swiftpy-framework 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 (211) hide show
  1. swiftpy/.gitignore +0 -0
  2. swiftpy/__init__.py +572 -0
  3. swiftpy/admin/__init__.py +50 -0
  4. swiftpy/admin/auth.py +522 -0
  5. swiftpy/admin/registry.py +567 -0
  6. swiftpy/admin/views.py +1583 -0
  7. swiftpy/ai/__init__.py +199 -0
  8. swiftpy/ai/_deterministic.py +40 -0
  9. swiftpy/ai/_fake.py +604 -0
  10. swiftpy/ai/agents/__init__.py +30 -0
  11. swiftpy/ai/agents/base_agent.py +506 -0
  12. swiftpy/ai/agents/memory.py +864 -0
  13. swiftpy/ai/client.py +1011 -0
  14. swiftpy/ai/conversation.py +171 -0
  15. swiftpy/ai/embeddings.py +321 -0
  16. swiftpy/ai/exceptions.py +75 -0
  17. swiftpy/ai/history_strategies.py +157 -0
  18. swiftpy/ai/middleware.py +918 -0
  19. swiftpy/ai/observability/__init__.py +28 -0
  20. swiftpy/ai/observability/drivers.py +763 -0
  21. swiftpy/ai/observability/observer.py +364 -0
  22. swiftpy/ai/pool.py +102 -0
  23. swiftpy/ai/pricing.py +101 -0
  24. swiftpy/ai/prompts/__init__.py +6 -0
  25. swiftpy/ai/prompts/loader.py +343 -0
  26. swiftpy/ai/providers/__init__.py +22 -0
  27. swiftpy/ai/providers/anthropic_provider.py +353 -0
  28. swiftpy/ai/providers/base.py +273 -0
  29. swiftpy/ai/providers/openai_provider.py +275 -0
  30. swiftpy/ai/rag/__init__.py +53 -0
  31. swiftpy/ai/rag/context_formatter.py +263 -0
  32. swiftpy/ai/rag/pipeline_builder.py +544 -0
  33. swiftpy/ai/rag/reranker.py +484 -0
  34. swiftpy/ai/rag/result.py +78 -0
  35. swiftpy/ai/rag/retriever.py +315 -0
  36. swiftpy/ai/redis_store.py +227 -0
  37. swiftpy/ai/streaming.py +115 -0
  38. swiftpy/ai/tools/__init__.py +22 -0
  39. swiftpy/ai/tools/base_tool.py +489 -0
  40. swiftpy/ai/tools/first_party.py +384 -0
  41. swiftpy/ai/tools/tool_loop.py +417 -0
  42. swiftpy/ai/vectorstore/__init__.py +46 -0
  43. swiftpy/ai/vectorstore/base.py +542 -0
  44. swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
  45. swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
  46. swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
  47. swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
  48. swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
  49. swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
  50. swiftpy/auth/__init__.py +50 -0
  51. swiftpy/auth/api_key_guard.py +544 -0
  52. swiftpy/auth/auth_manager.py +259 -0
  53. swiftpy/auth/exceptions.py +71 -0
  54. swiftpy/auth/jwt_guard.py +230 -0
  55. swiftpy/auth/jwt_issuer.py +658 -0
  56. swiftpy/auth/token_blacklist.py +300 -0
  57. swiftpy/cache/__init__.py +14 -0
  58. swiftpy/cache/cache.py +251 -0
  59. swiftpy/cache/driver.py +216 -0
  60. swiftpy/cache/drivers/memory.py +199 -0
  61. swiftpy/cache/drivers/redis.py +421 -0
  62. swiftpy/console/__init__.py +0 -0
  63. swiftpy/console/cli.py +68 -0
  64. swiftpy/console/commands/__init__.py +0 -0
  65. swiftpy/console/commands/cache.py +164 -0
  66. swiftpy/console/commands/make.py +935 -0
  67. swiftpy/console/commands/migrate.py +280 -0
  68. swiftpy/console/commands/new.py +1806 -0
  69. swiftpy/console/commands/queue.py +306 -0
  70. swiftpy/console/commands/seed.py +252 -0
  71. swiftpy/console/commands/start.py +171 -0
  72. swiftpy/console/commands/storage.py +104 -0
  73. swiftpy/console/loader.py +21 -0
  74. swiftpy/console/stubs/__init__.py +26 -0
  75. swiftpy/console/stubs/controller.stub +14 -0
  76. swiftpy/console/stubs/event.stub +14 -0
  77. swiftpy/console/stubs/job.stub +22 -0
  78. swiftpy/console/stubs/listener.stub +14 -0
  79. swiftpy/console/stubs/mail.stub +20 -0
  80. swiftpy/console/stubs/middleware.stub +16 -0
  81. swiftpy/console/stubs/migration.alter.stub +17 -0
  82. swiftpy/console/stubs/migration.api_keys.stub +22 -0
  83. swiftpy/console/stubs/migration.blank.stub +11 -0
  84. swiftpy/console/stubs/migration.create.stub +15 -0
  85. swiftpy/console/stubs/migration.stub +11 -0
  86. swiftpy/console/stubs/migration.update.stub +17 -0
  87. swiftpy/console/stubs/model.stub +17 -0
  88. swiftpy/console/stubs/provider.stub +18 -0
  89. swiftpy/console/stubs/repository.stub +10 -0
  90. swiftpy/console/stubs/request.stub +16 -0
  91. swiftpy/console/stubs/resource.stub +21 -0
  92. swiftpy/console/stubs/schema.stub +12 -0
  93. swiftpy/console/stubs/seeder.stub +14 -0
  94. swiftpy/console/stubs/service.stub +10 -0
  95. swiftpy/core/__init__.py +0 -0
  96. swiftpy/core/application.py +1306 -0
  97. swiftpy/core/bootstrap.py +30 -0
  98. swiftpy/core/config.py +432 -0
  99. swiftpy/core/container.py +596 -0
  100. swiftpy/core/container_exceptions.py +18 -0
  101. swiftpy/core/context.py +148 -0
  102. swiftpy/core/exceptions.py +352 -0
  103. swiftpy/core/providers/base.py +35 -0
  104. swiftpy/database/__init__.py +41 -0
  105. swiftpy/database/exceptions.py +42 -0
  106. swiftpy/database/identifiers.py +105 -0
  107. swiftpy/database/migrations/__init__.py +36 -0
  108. swiftpy/database/migrations/blueprint.py +431 -0
  109. swiftpy/database/migrations/lock.py +104 -0
  110. swiftpy/database/migrations/migration.py +22 -0
  111. swiftpy/database/migrations/migrator.py +507 -0
  112. swiftpy/database/migrations/schema.py +435 -0
  113. swiftpy/database/orm/__init__.py +33 -0
  114. swiftpy/database/orm/fields.py +92 -0
  115. swiftpy/database/orm/model.py +2034 -0
  116. swiftpy/database/orm/registry.py +33 -0
  117. swiftpy/database/orm/relationships.py +836 -0
  118. swiftpy/database/pagination/__init__.py +16 -0
  119. swiftpy/database/pagination/contracts.py +60 -0
  120. swiftpy/database/pagination/paginator.py +112 -0
  121. swiftpy/database/pagination/simple_paginator.py +97 -0
  122. swiftpy/database/pool.py +218 -0
  123. swiftpy/database/query_builder.py +1803 -0
  124. swiftpy/database/seeder.py +39 -0
  125. swiftpy/database/transaction.py +276 -0
  126. swiftpy/events/__init__.py +10 -0
  127. swiftpy/events/dispatcher.py +308 -0
  128. swiftpy/events/redis_bus.py +552 -0
  129. swiftpy/filesystem/__init__.py +227 -0
  130. swiftpy/filesystem/disk.py +378 -0
  131. swiftpy/filesystem/drivers/__init__.py +11 -0
  132. swiftpy/filesystem/drivers/base.py +156 -0
  133. swiftpy/filesystem/drivers/local.py +559 -0
  134. swiftpy/filesystem/drivers/s3.py +655 -0
  135. swiftpy/filesystem/events.py +37 -0
  136. swiftpy/filesystem/exceptions.py +127 -0
  137. swiftpy/filesystem/manager.py +138 -0
  138. swiftpy/filesystem/metadata.py +22 -0
  139. swiftpy/filesystem/paths.py +99 -0
  140. swiftpy/filesystem/service_provider.py +17 -0
  141. swiftpy/filesystem/uploads/__init__.py +13 -0
  142. swiftpy/filesystem/uploads/handlers.py +362 -0
  143. swiftpy/filesystem/uploads/uploaded_file.py +222 -0
  144. swiftpy/filesystem/uploads/validators.py +186 -0
  145. swiftpy/filesystem/visibility.py +22 -0
  146. swiftpy/helpers/__init__.py +42 -0
  147. swiftpy/helpers/helper.py +180 -0
  148. swiftpy/http/exceptions.py +41 -0
  149. swiftpy/http/middleware.py +307 -0
  150. swiftpy/http/request.py +375 -0
  151. swiftpy/http/response.py +442 -0
  152. swiftpy/http/router.py +1050 -0
  153. swiftpy/mail/__init__.py +58 -0
  154. swiftpy/mail/attachments.py +255 -0
  155. swiftpy/mail/config.py +71 -0
  156. swiftpy/mail/envelope.py +183 -0
  157. swiftpy/mail/exceptions.py +90 -0
  158. swiftpy/mail/facade.py +312 -0
  159. swiftpy/mail/mailable.py +53 -0
  160. swiftpy/mail/manager.py +240 -0
  161. swiftpy/mail/message.py +186 -0
  162. swiftpy/mail/models.py +19 -0
  163. swiftpy/mail/queue/__init__.py +5 -0
  164. swiftpy/mail/queue/job.py +279 -0
  165. swiftpy/mail/service_provider.py +19 -0
  166. swiftpy/mail/templates/__init__.py +5 -0
  167. swiftpy/mail/templates/renderer.py +173 -0
  168. swiftpy/mail/transports/__init__.py +21 -0
  169. swiftpy/mail/transports/api/__init__.py +13 -0
  170. swiftpy/mail/transports/api/base.py +194 -0
  171. swiftpy/mail/transports/api/mailgun.py +158 -0
  172. swiftpy/mail/transports/api/resend.py +156 -0
  173. swiftpy/mail/transports/api/sendgrid.py +172 -0
  174. swiftpy/mail/transports/base.py +24 -0
  175. swiftpy/mail/transports/console.py +91 -0
  176. swiftpy/mail/transports/sendmail.py +128 -0
  177. swiftpy/mail/transports/smtp.py +466 -0
  178. swiftpy/queue/__init__.py +13 -0
  179. swiftpy/queue/dispatcher.py +813 -0
  180. swiftpy/queue/job.py +541 -0
  181. swiftpy/queue/worker.py +556 -0
  182. swiftpy/support/__init__.py +12 -0
  183. swiftpy/support/keys.py +108 -0
  184. swiftpy/support/redis_config.py +223 -0
  185. swiftpy/support/strings.py +114 -0
  186. swiftpy/testing/__init__.py +20 -0
  187. swiftpy/testing/fakes/__init__.py +11 -0
  188. swiftpy/testing/fakes/ai_fake.py +21 -0
  189. swiftpy/testing/fakes/vectorstore_fake.py +194 -0
  190. swiftpy/testing/fixtures.py +124 -0
  191. swiftpy/testing/http_client.py +730 -0
  192. swiftpy/testing/test_case.py +126 -0
  193. swiftpy/validation/__init__.py +97 -0
  194. swiftpy/validation/exceptions.py +26 -0
  195. swiftpy/validation/form_request.py +302 -0
  196. swiftpy/validation/protocols.py +33 -0
  197. swiftpy/validation/rules/__init__.py +89 -0
  198. swiftpy/validation/rules/builtin.py +391 -0
  199. swiftpy/validation/rules/exists.py +86 -0
  200. swiftpy/validation/rules/registry.py +290 -0
  201. swiftpy/validation/rules/required.py +55 -0
  202. swiftpy/validation/rules/unique.py +138 -0
  203. swiftpy/validation/validator.py +612 -0
  204. swiftpy/websockets/__init__.py +19 -0
  205. swiftpy/websockets/connection.py +317 -0
  206. swiftpy/websockets/manager.py +271 -0
  207. swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
  208. swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
  209. swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
  210. swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
  211. swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
swiftpy/mail/facade.py ADDED
@@ -0,0 +1,312 @@
1
+ from __future__ import annotations
2
+
3
+ import types
4
+ from collections.abc import Callable
5
+ from contextvars import ContextVar, Token
6
+ from typing import Any, Self, TypedDict
7
+
8
+ from swiftpy.mail.mailable import Mailable
9
+ from swiftpy.mail.manager import MailManager
10
+ from swiftpy.mail.message import MailMessage
11
+ from swiftpy.mail.models import MailSendResult
12
+ from swiftpy.mail.queue.job import SendQueuedMailableJob
13
+ from swiftpy.mail.transports.base import MailTransport
14
+
15
+
16
+ class _SentMailRecord(TypedDict):
17
+ """Correlated record of a message captured by MailFake."""
18
+
19
+ message: MailMessage
20
+ mailable: Mailable | None
21
+ mailer: str | None
22
+
23
+
24
+ class MailFake(MailTransport):
25
+ """
26
+ In-memory test double for SwiftPY Mail subsystem.
27
+ Records sent messages and queued mailables with expressive assertion helpers.
28
+ Supports task-local isolation via ContextVar and synchronous/asynchronous context managers.
29
+ """
30
+
31
+ def __init__(self) -> None:
32
+ self.sent_messages: list[MailMessage] = []
33
+ self.sent_mailables: list[Mailable] = []
34
+ self.sent_records: list[_SentMailRecord] = []
35
+ self.queued_mailables: list[dict[str, Any]] = []
36
+ self._token: Token[MailFake | None] | None = None
37
+
38
+ def __enter__(self) -> Self:
39
+ return self
40
+
41
+ def __exit__(
42
+ self,
43
+ exc_type: type[BaseException] | None,
44
+ exc_val: BaseException | None,
45
+ exc_tb: types.TracebackType | None,
46
+ ) -> None:
47
+ Mail.restore()
48
+
49
+ async def __aenter__(self) -> Self:
50
+ return self
51
+
52
+ async def __aexit__(
53
+ self,
54
+ exc_type: type[BaseException] | None,
55
+ exc_val: BaseException | None,
56
+ exc_tb: types.TracebackType | None,
57
+ ) -> None:
58
+ Mail.restore()
59
+
60
+ async def send(
61
+ self,
62
+ message: MailMessage,
63
+ *,
64
+ mailable: Mailable | None = None,
65
+ mailer: str | None = None,
66
+ ) -> MailSendResult:
67
+ self.sent_messages.append(message)
68
+ if mailable is not None:
69
+ self.sent_mailables.append(mailable)
70
+ self.sent_records.append(
71
+ {
72
+ "message": message,
73
+ "mailable": mailable,
74
+ "mailer": mailer,
75
+ }
76
+ )
77
+ return MailSendResult(
78
+ success=True,
79
+ message_id=f"fake-{len(self.sent_messages)}",
80
+ provider="fake",
81
+ accepted=message.all_recipient_addresses(),
82
+ rejected=[],
83
+ )
84
+
85
+ def record_queued(
86
+ self,
87
+ mailable: Mailable,
88
+ queue: str | None = None,
89
+ delay: float = 0,
90
+ after_commit: bool = False,
91
+ ) -> None:
92
+ self.queued_mailables.append(
93
+ {
94
+ "mailable": mailable,
95
+ "queue": queue or mailable.queue,
96
+ "delay": delay,
97
+ "after_commit": after_commit,
98
+ }
99
+ )
100
+
101
+ @staticmethod
102
+ def _record_matches(
103
+ record: _SentMailRecord,
104
+ mailable_cls_or_filter: type[Mailable]
105
+ | Callable[[MailMessage], bool]
106
+ | None,
107
+ recipient: str | None,
108
+ ) -> tuple[bool, str | None]:
109
+ msg = record["message"]
110
+ if recipient and recipient not in msg.all_recipient_addresses():
111
+ return False, None
112
+
113
+ if mailable_cls_or_filter is not None:
114
+ if isinstance(mailable_cls_or_filter, type) and issubclass(
115
+ mailable_cls_or_filter, Mailable
116
+ ):
117
+ if not isinstance(record["mailable"], mailable_cls_or_filter):
118
+ return False, None
119
+ suffix = f" to recipient '{recipient}'" if recipient else ""
120
+ return (
121
+ True,
122
+ f"Unexpected mailable of type '{mailable_cls_or_filter.__name__}' was sent{suffix}",
123
+ )
124
+ if not isinstance(mailable_cls_or_filter, type) and callable(
125
+ mailable_cls_or_filter
126
+ ):
127
+ if not mailable_cls_or_filter(msg):
128
+ return False, None
129
+ suffix = f" to recipient '{recipient}'" if recipient else ""
130
+ return True, f"Unexpected email matching predicate was sent{suffix}"
131
+
132
+ if recipient:
133
+ return True, f"Unexpected email sent to recipient '{recipient}'"
134
+ return True, "Unexpected email was sent"
135
+
136
+ def assert_sent(
137
+ self,
138
+ mailable_cls_or_filter: type[Mailable]
139
+ | Callable[[MailMessage], bool]
140
+ | None = None,
141
+ recipient: str | None = None,
142
+ count: int | None = None,
143
+ ) -> None:
144
+ """Assert that matching emails were sent."""
145
+ matches = [
146
+ record["message"]
147
+ for record in self.sent_records
148
+ if self._record_matches(record, mailable_cls_or_filter, recipient)[0]
149
+ ]
150
+
151
+ if count is not None:
152
+ assert len(matches) == count, (
153
+ f"Expected {count} emails matching criteria, found {len(matches)}."
154
+ )
155
+ else:
156
+ assert len(matches) > 0, "Expected email was not sent."
157
+
158
+ def assert_not_sent(
159
+ self,
160
+ mailable_cls_or_filter: type[Mailable]
161
+ | Callable[[MailMessage], bool]
162
+ | None = None,
163
+ recipient: str | None = None,
164
+ ) -> None:
165
+ """Assert that no matching emails were sent."""
166
+ for record in self.sent_records:
167
+ matched, reason = self._record_matches(
168
+ record, mailable_cls_or_filter, recipient
169
+ )
170
+ if matched:
171
+ raise AssertionError(reason)
172
+
173
+ def assert_queued(
174
+ self,
175
+ mailable_cls: type[Mailable],
176
+ count: int | None = None,
177
+ ) -> None:
178
+ """Assert that a mailable of the given class was queued."""
179
+ matches = [
180
+ item
181
+ for item in self.queued_mailables
182
+ if isinstance(item["mailable"], mailable_cls)
183
+ ]
184
+ if count is not None:
185
+ assert len(matches) == count, (
186
+ f"Expected {count} queued mailables of type '{mailable_cls.__name__}', found {len(matches)}."
187
+ )
188
+ else:
189
+ assert len(matches) > 0, (
190
+ f"Expected mailable '{mailable_cls.__name__}' was not queued."
191
+ )
192
+
193
+ def assert_nothing_sent(self) -> None:
194
+ """Assert that zero emails were sent or queued."""
195
+ assert len(self.sent_messages) == 0, (
196
+ f"Expected no emails to be sent, but {len(self.sent_messages)} were sent."
197
+ )
198
+ assert len(self.queued_mailables) == 0, (
199
+ f"Expected no mailables to be queued, but {len(self.queued_mailables)} were queued."
200
+ )
201
+
202
+ def reset(self) -> None:
203
+ self.sent_messages.clear()
204
+ self.sent_mailables.clear()
205
+ self.sent_records.clear()
206
+ self.queued_mailables.clear()
207
+
208
+
209
+ class Mail:
210
+ """Class-level facade for SwiftPY Mail Subsystem."""
211
+
212
+ _fake_var: ContextVar[MailFake | None] = ContextVar(
213
+ "swiftpy_mail_fake", default=None
214
+ )
215
+
216
+ @classmethod
217
+ def _manager(cls) -> MailManager:
218
+ return MailManager.get_instance()
219
+
220
+ @classmethod
221
+ def fake(cls) -> MailFake:
222
+ """Swap real delivery with the MailFake test double in the current task/context."""
223
+ current = cls._fake_var.get()
224
+ if current is None:
225
+ fake_instance = MailFake()
226
+ fake_instance._token = cls._fake_var.set(fake_instance)
227
+ return fake_instance
228
+ current.reset()
229
+ return current
230
+
231
+ @classmethod
232
+ def restore(cls) -> None:
233
+ """Restore real mail delivery after testing."""
234
+ cls._fake_var.set(None)
235
+
236
+ @classmethod
237
+ def is_faked(cls) -> bool:
238
+ return cls._fake_var.get() is not None
239
+
240
+ @classmethod
241
+ def mailer(cls, name: str | None = None) -> MailTransport:
242
+ """Resolve a configured mail transport instance."""
243
+ fake_instance = cls._fake_var.get()
244
+ if fake_instance is not None:
245
+ return fake_instance
246
+ return cls._manager().mailer(name)
247
+
248
+ @classmethod
249
+ def extend(
250
+ cls,
251
+ driver_name: str,
252
+ factory: Callable[[dict[str, Any]], MailTransport],
253
+ ) -> None:
254
+ """Register a custom mail transport."""
255
+ cls._manager().extend(driver_name, factory)
256
+
257
+ @classmethod
258
+ async def send(
259
+ cls,
260
+ mailable_or_message: Mailable | MailMessage,
261
+ mailer: str | None = None,
262
+ ) -> MailSendResult:
263
+ """Deliver an email immediately."""
264
+ if isinstance(mailable_or_message, Mailable):
265
+ msg = await mailable_or_message.build_message()
266
+ target_mailer = mailer or mailable_or_message.mailer_name
267
+ sent_mailable: Mailable | None = mailable_or_message
268
+ else:
269
+ msg = mailable_or_message
270
+ target_mailer = mailer
271
+ sent_mailable = None
272
+
273
+ fake_instance = cls._fake_var.get()
274
+ if fake_instance is not None:
275
+ return await fake_instance.send(
276
+ msg, mailable=sent_mailable, mailer=target_mailer
277
+ )
278
+
279
+ return await cls._manager().send(msg, mailer=target_mailer)
280
+
281
+ @classmethod
282
+ async def queue(
283
+ cls,
284
+ mailable: Mailable,
285
+ queue: str | None = None,
286
+ delay: float = 0,
287
+ after_commit: bool = False,
288
+ ) -> str:
289
+ """Dispatch a mailable onto the background queue."""
290
+ fake_instance = cls._fake_var.get()
291
+ if fake_instance is not None:
292
+ fake_instance.record_queued(
293
+ mailable,
294
+ queue=queue,
295
+ delay=delay,
296
+ after_commit=after_commit,
297
+ )
298
+ return f"fake-job-{len(fake_instance.queued_mailables)}"
299
+
300
+ job = SendQueuedMailableJob(
301
+ mailable=mailable,
302
+ queue=queue or mailable.queue,
303
+ delay=delay,
304
+ )
305
+ return await job.dispatch(
306
+ queue=queue or mailable.queue,
307
+ delay=delay,
308
+ after_commit=after_commit,
309
+ )
310
+
311
+
312
+ __all__ = ["Mail", "MailFake"]
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from abc import ABC, abstractmethod
5
+ from typing import Any
6
+
7
+ from swiftpy.mail.message import MailMessage
8
+ from swiftpy.mail.templates.renderer import MailTemplateRenderer
9
+
10
+
11
+ class Mailable(ABC):
12
+ """
13
+ Abstract base class for reusable email definitions.
14
+ Separates email composition and rendering from business logic.
15
+ """
16
+
17
+ mailer_name: str | None = None
18
+ queue: str = "emails"
19
+ tries: int = 3
20
+ timeout: float | None = 60.0
21
+ backoff: int | float = 5
22
+
23
+ @abstractmethod
24
+ def build(self) -> MailMessage | Any:
25
+ """Construct the MailMessage representing this email."""
26
+
27
+ async def build_message(self) -> MailMessage:
28
+ """
29
+ Execute build(), awaiting if asynchronous, apply template rendering if needed,
30
+ and return the validated MailMessage.
31
+ """
32
+ result = self.build()
33
+ if inspect.isawaitable(result):
34
+ result = await result
35
+
36
+ if not isinstance(result, MailMessage):
37
+ raise TypeError(
38
+ f"Mailable {self.__class__.__name__}.build() must return MailMessage, "
39
+ f"got {type(result).__name__}"
40
+ )
41
+
42
+ if result.template:
43
+ renderer = MailTemplateRenderer.get_instance()
44
+ rendered = await renderer.render(result.template, result.context)
45
+ if rendered.html and not result.html_body:
46
+ result.html_body = rendered.html
47
+ if rendered.text and not result.text_body:
48
+ result.text_body = rendered.text
49
+
50
+ return result
51
+
52
+
53
+ __all__ = ["Mailable"]
@@ -0,0 +1,240 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import replace
5
+ from typing import Any
6
+
7
+ from swiftpy.core.config import Config
8
+ from swiftpy.core.container import get_container
9
+ from swiftpy.mail.config import MailConfig
10
+ from swiftpy.mail.exceptions import (
11
+ AttachmentTooLargeError,
12
+ MailConfigurationError,
13
+ MailerNotFoundError,
14
+ )
15
+ from swiftpy.mail.message import MailMessage, _validate_no_crlf, parse_mail_address
16
+ from swiftpy.mail.models import MailSendResult
17
+ from swiftpy.mail.transports.api.mailgun import MailgunTransport
18
+ from swiftpy.mail.transports.api.resend import ResendTransport
19
+ from swiftpy.mail.transports.api.sendgrid import SendGridTransport
20
+ from swiftpy.mail.transports.base import MailTransport
21
+ from swiftpy.mail.transports.console import ConsoleTransport
22
+ from swiftpy.mail.transports.sendmail import SendmailTransport
23
+ from swiftpy.mail.transports.smtp import SMTPTransport
24
+
25
+
26
+ class MailManager:
27
+ """
28
+ Registry and factory for configured MailTransport instances.
29
+ Integrates with SwiftPY's configuration repository and DI container.
30
+ """
31
+
32
+ _instance: MailManager | None = None
33
+
34
+ def __init__(self, config: Config | None = None) -> None:
35
+ if config is None:
36
+ try:
37
+ container = get_container()
38
+ if container.has(Config):
39
+ self.config = container.resolve(Config)
40
+ else:
41
+ self.config = Config.get_instance()
42
+ except RuntimeError:
43
+ self.config = Config.get_instance()
44
+ else:
45
+ self.config = config
46
+
47
+ self._transports: dict[str, MailTransport] = {}
48
+ self._custom_creators: dict[str, Callable[[dict[str, Any]], MailTransport]] = {}
49
+
50
+ @classmethod
51
+ def get_instance(cls) -> MailManager:
52
+ if cls._instance is not None:
53
+ return cls._instance
54
+
55
+ try:
56
+ container = get_container()
57
+ if container.has(cls):
58
+ return container.resolve(cls)
59
+ except RuntimeError:
60
+ pass
61
+
62
+ cls._instance = cls()
63
+ return cls._instance
64
+
65
+ @property
66
+ def mail_config(self) -> MailConfig:
67
+ raw_mail = self.config.get("mail", {})
68
+ if isinstance(raw_mail, dict):
69
+ return MailConfig.from_dict(raw_mail)
70
+ return MailConfig()
71
+
72
+ @property
73
+ def default_mailer_name(self) -> str:
74
+ return self.mail_config.default
75
+
76
+ @property
77
+ def default_from(self) -> tuple[str, str | None]:
78
+ cfg = self.mail_config
79
+ return cfg.from_address, cfg.from_name
80
+
81
+ def extend(
82
+ self,
83
+ driver_name: str,
84
+ factory: Callable[[dict[str, Any]], MailTransport],
85
+ ) -> None:
86
+ """Register a custom mail transport creator."""
87
+ self._custom_creators[driver_name.lower()] = factory
88
+
89
+ def mailer(self, name: str | None = None) -> MailTransport:
90
+ """Resolve a mail transport instance by name, or return the default mailer."""
91
+ mailer_name = name or self.default_mailer_name
92
+
93
+ if mailer_name in self._transports:
94
+ return self._transports[mailer_name]
95
+
96
+ transport = self._resolve_transport(mailer_name)
97
+ self._transports[mailer_name] = transport
98
+ return transport
99
+
100
+ def _resolve_transport(self, name: str) -> MailTransport:
101
+ mailers = self.mail_config.mailers
102
+ if name not in mailers:
103
+ # Fallback check directly in config if not parsed into mailers dict
104
+ raw_mailers = self.config.get("mail.mailers", {})
105
+ if isinstance(raw_mailers, dict) and name in raw_mailers:
106
+ mailer_config = raw_mailers[name]
107
+ else:
108
+ raise MailerNotFoundError(f"Mailer '{name}' is not configured.")
109
+ else:
110
+ mailer_config = mailers[name]
111
+
112
+ if not isinstance(mailer_config, dict):
113
+ raise MailConfigurationError(
114
+ f"Configuration for mailer '{name}' must be a dictionary."
115
+ )
116
+
117
+ driver_type = str(mailer_config.get("transport", "")).lower()
118
+
119
+ cfg = self.mail_config
120
+ max_att_val = mailer_config.get(
121
+ "max_attachment_size_bytes", cfg.max_attachment_size_bytes
122
+ )
123
+ max_msg_val = mailer_config.get(
124
+ "max_message_size_bytes", cfg.max_message_size_bytes
125
+ )
126
+ max_att = int(max_att_val) if max_att_val is not None else None
127
+ max_msg = int(max_msg_val) if max_msg_val is not None else None
128
+
129
+ if driver_type in self._custom_creators:
130
+ return self._custom_creators[driver_type](mailer_config)
131
+
132
+ if driver_type == "smtp":
133
+ return SMTPTransport.from_config(
134
+ mailer_config,
135
+ default_from=self.default_from,
136
+ max_attachment_size_bytes=max_att,
137
+ max_message_size_bytes=max_msg,
138
+ )
139
+
140
+ if driver_type in ("console", "log"):
141
+ return ConsoleTransport.from_config(
142
+ mailer_config,
143
+ max_attachment_size_bytes=max_att,
144
+ max_message_size_bytes=max_msg,
145
+ )
146
+
147
+ if driver_type == "sendmail":
148
+ return SendmailTransport.from_config(
149
+ mailer_config,
150
+ default_from=self.default_from,
151
+ max_attachment_size_bytes=max_att,
152
+ max_message_size_bytes=max_msg,
153
+ )
154
+
155
+ if driver_type == "resend":
156
+ return ResendTransport.from_config(
157
+ mailer_config,
158
+ default_from=self.default_from,
159
+ max_attachment_size_bytes=max_att,
160
+ max_message_size_bytes=max_msg,
161
+ )
162
+
163
+ if driver_type == "sendgrid":
164
+ return SendGridTransport.from_config(
165
+ mailer_config,
166
+ default_from=self.default_from,
167
+ max_attachment_size_bytes=max_att,
168
+ max_message_size_bytes=max_msg,
169
+ )
170
+
171
+ if driver_type == "mailgun":
172
+ return MailgunTransport.from_config(
173
+ mailer_config,
174
+ default_from=self.default_from,
175
+ max_attachment_size_bytes=max_att,
176
+ max_message_size_bytes=max_msg,
177
+ )
178
+
179
+ raise MailConfigurationError(f"Unsupported mail transport: '{driver_type}'")
180
+
181
+ async def send(
182
+ self,
183
+ message: MailMessage,
184
+ mailer: str | None = None,
185
+ ) -> MailSendResult:
186
+ """Deliver an email message using the specified or default mailer."""
187
+ cfg = self.mail_config
188
+ default_addr, default_name = cfg.from_address, cfg.from_name
189
+ if default_addr:
190
+ parse_mail_address(default_addr, field_name="default_from")
191
+ if default_name:
192
+ _validate_no_crlf(default_name, "default_from_name")
193
+
194
+ outbound_from_addr = message.from_address or default_addr or None
195
+ outbound_from_name = (
196
+ message.from_name
197
+ if message.from_address
198
+ else (message.from_name or default_name)
199
+ )
200
+ outbound = replace(
201
+ message,
202
+ to=list(message.to),
203
+ cc=list(message.cc),
204
+ bcc=list(message.bcc),
205
+ from_address=outbound_from_addr,
206
+ from_name=outbound_from_name,
207
+ context=dict(message.context),
208
+ attachments=list(message.attachments),
209
+ headers=dict(message.headers),
210
+ tags=list(message.tags),
211
+ metadata=dict(message.metadata),
212
+ )
213
+
214
+ # Enforce upfront limits for known sizes across all transports
215
+ total_known = len((outbound.text_body or "").encode("utf-8")) + len(
216
+ (outbound.html_body or "").encode("utf-8")
217
+ )
218
+ for att in outbound.attachments:
219
+ if att.size is not None:
220
+ if att.size > cfg.max_attachment_size_bytes:
221
+ raise AttachmentTooLargeError(
222
+ f"Attachment '{att.filename}' ({att.size} bytes) exceeds size limit ({cfg.max_attachment_size_bytes} bytes)"
223
+ )
224
+ total_known += att.size
225
+ if total_known > cfg.max_message_size_bytes:
226
+ raise AttachmentTooLargeError(
227
+ f"Total email size ({total_known} bytes) exceeds configured limit ({cfg.max_message_size_bytes} bytes)"
228
+ )
229
+
230
+ transport = self.mailer(mailer)
231
+ return await transport.send(outbound)
232
+
233
+ async def close(self) -> None:
234
+ """Close and clean up all instantiated mail transports."""
235
+ for transport in self._transports.values():
236
+ await transport.close()
237
+ self._transports.clear()
238
+
239
+
240
+ __all__ = ["MailManager"]