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.
- swiftpy/.gitignore +0 -0
- swiftpy/__init__.py +572 -0
- swiftpy/admin/__init__.py +50 -0
- swiftpy/admin/auth.py +522 -0
- swiftpy/admin/registry.py +567 -0
- swiftpy/admin/views.py +1583 -0
- swiftpy/ai/__init__.py +199 -0
- swiftpy/ai/_deterministic.py +40 -0
- swiftpy/ai/_fake.py +604 -0
- swiftpy/ai/agents/__init__.py +30 -0
- swiftpy/ai/agents/base_agent.py +506 -0
- swiftpy/ai/agents/memory.py +864 -0
- swiftpy/ai/client.py +1011 -0
- swiftpy/ai/conversation.py +171 -0
- swiftpy/ai/embeddings.py +321 -0
- swiftpy/ai/exceptions.py +75 -0
- swiftpy/ai/history_strategies.py +157 -0
- swiftpy/ai/middleware.py +918 -0
- swiftpy/ai/observability/__init__.py +28 -0
- swiftpy/ai/observability/drivers.py +763 -0
- swiftpy/ai/observability/observer.py +364 -0
- swiftpy/ai/pool.py +102 -0
- swiftpy/ai/pricing.py +101 -0
- swiftpy/ai/prompts/__init__.py +6 -0
- swiftpy/ai/prompts/loader.py +343 -0
- swiftpy/ai/providers/__init__.py +22 -0
- swiftpy/ai/providers/anthropic_provider.py +353 -0
- swiftpy/ai/providers/base.py +273 -0
- swiftpy/ai/providers/openai_provider.py +275 -0
- swiftpy/ai/rag/__init__.py +53 -0
- swiftpy/ai/rag/context_formatter.py +263 -0
- swiftpy/ai/rag/pipeline_builder.py +544 -0
- swiftpy/ai/rag/reranker.py +484 -0
- swiftpy/ai/rag/result.py +78 -0
- swiftpy/ai/rag/retriever.py +315 -0
- swiftpy/ai/redis_store.py +227 -0
- swiftpy/ai/streaming.py +115 -0
- swiftpy/ai/tools/__init__.py +22 -0
- swiftpy/ai/tools/base_tool.py +489 -0
- swiftpy/ai/tools/first_party.py +384 -0
- swiftpy/ai/tools/tool_loop.py +417 -0
- swiftpy/ai/vectorstore/__init__.py +46 -0
- swiftpy/ai/vectorstore/base.py +542 -0
- swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
- swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
- swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
- swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
- swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
- swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
- swiftpy/auth/__init__.py +50 -0
- swiftpy/auth/api_key_guard.py +544 -0
- swiftpy/auth/auth_manager.py +259 -0
- swiftpy/auth/exceptions.py +71 -0
- swiftpy/auth/jwt_guard.py +230 -0
- swiftpy/auth/jwt_issuer.py +658 -0
- swiftpy/auth/token_blacklist.py +300 -0
- swiftpy/cache/__init__.py +14 -0
- swiftpy/cache/cache.py +251 -0
- swiftpy/cache/driver.py +216 -0
- swiftpy/cache/drivers/memory.py +199 -0
- swiftpy/cache/drivers/redis.py +421 -0
- swiftpy/console/__init__.py +0 -0
- swiftpy/console/cli.py +68 -0
- swiftpy/console/commands/__init__.py +0 -0
- swiftpy/console/commands/cache.py +164 -0
- swiftpy/console/commands/make.py +935 -0
- swiftpy/console/commands/migrate.py +280 -0
- swiftpy/console/commands/new.py +1806 -0
- swiftpy/console/commands/queue.py +306 -0
- swiftpy/console/commands/seed.py +252 -0
- swiftpy/console/commands/start.py +171 -0
- swiftpy/console/commands/storage.py +104 -0
- swiftpy/console/loader.py +21 -0
- swiftpy/console/stubs/__init__.py +26 -0
- swiftpy/console/stubs/controller.stub +14 -0
- swiftpy/console/stubs/event.stub +14 -0
- swiftpy/console/stubs/job.stub +22 -0
- swiftpy/console/stubs/listener.stub +14 -0
- swiftpy/console/stubs/mail.stub +20 -0
- swiftpy/console/stubs/middleware.stub +16 -0
- swiftpy/console/stubs/migration.alter.stub +17 -0
- swiftpy/console/stubs/migration.api_keys.stub +22 -0
- swiftpy/console/stubs/migration.blank.stub +11 -0
- swiftpy/console/stubs/migration.create.stub +15 -0
- swiftpy/console/stubs/migration.stub +11 -0
- swiftpy/console/stubs/migration.update.stub +17 -0
- swiftpy/console/stubs/model.stub +17 -0
- swiftpy/console/stubs/provider.stub +18 -0
- swiftpy/console/stubs/repository.stub +10 -0
- swiftpy/console/stubs/request.stub +16 -0
- swiftpy/console/stubs/resource.stub +21 -0
- swiftpy/console/stubs/schema.stub +12 -0
- swiftpy/console/stubs/seeder.stub +14 -0
- swiftpy/console/stubs/service.stub +10 -0
- swiftpy/core/__init__.py +0 -0
- swiftpy/core/application.py +1306 -0
- swiftpy/core/bootstrap.py +30 -0
- swiftpy/core/config.py +432 -0
- swiftpy/core/container.py +596 -0
- swiftpy/core/container_exceptions.py +18 -0
- swiftpy/core/context.py +148 -0
- swiftpy/core/exceptions.py +352 -0
- swiftpy/core/providers/base.py +35 -0
- swiftpy/database/__init__.py +41 -0
- swiftpy/database/exceptions.py +42 -0
- swiftpy/database/identifiers.py +105 -0
- swiftpy/database/migrations/__init__.py +36 -0
- swiftpy/database/migrations/blueprint.py +431 -0
- swiftpy/database/migrations/lock.py +104 -0
- swiftpy/database/migrations/migration.py +22 -0
- swiftpy/database/migrations/migrator.py +507 -0
- swiftpy/database/migrations/schema.py +435 -0
- swiftpy/database/orm/__init__.py +33 -0
- swiftpy/database/orm/fields.py +92 -0
- swiftpy/database/orm/model.py +2034 -0
- swiftpy/database/orm/registry.py +33 -0
- swiftpy/database/orm/relationships.py +836 -0
- swiftpy/database/pagination/__init__.py +16 -0
- swiftpy/database/pagination/contracts.py +60 -0
- swiftpy/database/pagination/paginator.py +112 -0
- swiftpy/database/pagination/simple_paginator.py +97 -0
- swiftpy/database/pool.py +218 -0
- swiftpy/database/query_builder.py +1803 -0
- swiftpy/database/seeder.py +39 -0
- swiftpy/database/transaction.py +276 -0
- swiftpy/events/__init__.py +10 -0
- swiftpy/events/dispatcher.py +308 -0
- swiftpy/events/redis_bus.py +552 -0
- swiftpy/filesystem/__init__.py +227 -0
- swiftpy/filesystem/disk.py +378 -0
- swiftpy/filesystem/drivers/__init__.py +11 -0
- swiftpy/filesystem/drivers/base.py +156 -0
- swiftpy/filesystem/drivers/local.py +559 -0
- swiftpy/filesystem/drivers/s3.py +655 -0
- swiftpy/filesystem/events.py +37 -0
- swiftpy/filesystem/exceptions.py +127 -0
- swiftpy/filesystem/manager.py +138 -0
- swiftpy/filesystem/metadata.py +22 -0
- swiftpy/filesystem/paths.py +99 -0
- swiftpy/filesystem/service_provider.py +17 -0
- swiftpy/filesystem/uploads/__init__.py +13 -0
- swiftpy/filesystem/uploads/handlers.py +362 -0
- swiftpy/filesystem/uploads/uploaded_file.py +222 -0
- swiftpy/filesystem/uploads/validators.py +186 -0
- swiftpy/filesystem/visibility.py +22 -0
- swiftpy/helpers/__init__.py +42 -0
- swiftpy/helpers/helper.py +180 -0
- swiftpy/http/exceptions.py +41 -0
- swiftpy/http/middleware.py +307 -0
- swiftpy/http/request.py +375 -0
- swiftpy/http/response.py +442 -0
- swiftpy/http/router.py +1050 -0
- swiftpy/mail/__init__.py +58 -0
- swiftpy/mail/attachments.py +255 -0
- swiftpy/mail/config.py +71 -0
- swiftpy/mail/envelope.py +183 -0
- swiftpy/mail/exceptions.py +90 -0
- swiftpy/mail/facade.py +312 -0
- swiftpy/mail/mailable.py +53 -0
- swiftpy/mail/manager.py +240 -0
- swiftpy/mail/message.py +186 -0
- swiftpy/mail/models.py +19 -0
- swiftpy/mail/queue/__init__.py +5 -0
- swiftpy/mail/queue/job.py +279 -0
- swiftpy/mail/service_provider.py +19 -0
- swiftpy/mail/templates/__init__.py +5 -0
- swiftpy/mail/templates/renderer.py +173 -0
- swiftpy/mail/transports/__init__.py +21 -0
- swiftpy/mail/transports/api/__init__.py +13 -0
- swiftpy/mail/transports/api/base.py +194 -0
- swiftpy/mail/transports/api/mailgun.py +158 -0
- swiftpy/mail/transports/api/resend.py +156 -0
- swiftpy/mail/transports/api/sendgrid.py +172 -0
- swiftpy/mail/transports/base.py +24 -0
- swiftpy/mail/transports/console.py +91 -0
- swiftpy/mail/transports/sendmail.py +128 -0
- swiftpy/mail/transports/smtp.py +466 -0
- swiftpy/queue/__init__.py +13 -0
- swiftpy/queue/dispatcher.py +813 -0
- swiftpy/queue/job.py +541 -0
- swiftpy/queue/worker.py +556 -0
- swiftpy/support/__init__.py +12 -0
- swiftpy/support/keys.py +108 -0
- swiftpy/support/redis_config.py +223 -0
- swiftpy/support/strings.py +114 -0
- swiftpy/testing/__init__.py +20 -0
- swiftpy/testing/fakes/__init__.py +11 -0
- swiftpy/testing/fakes/ai_fake.py +21 -0
- swiftpy/testing/fakes/vectorstore_fake.py +194 -0
- swiftpy/testing/fixtures.py +124 -0
- swiftpy/testing/http_client.py +730 -0
- swiftpy/testing/test_case.py +126 -0
- swiftpy/validation/__init__.py +97 -0
- swiftpy/validation/exceptions.py +26 -0
- swiftpy/validation/form_request.py +302 -0
- swiftpy/validation/protocols.py +33 -0
- swiftpy/validation/rules/__init__.py +89 -0
- swiftpy/validation/rules/builtin.py +391 -0
- swiftpy/validation/rules/exists.py +86 -0
- swiftpy/validation/rules/registry.py +290 -0
- swiftpy/validation/rules/required.py +55 -0
- swiftpy/validation/rules/unique.py +138 -0
- swiftpy/validation/validator.py +612 -0
- swiftpy/websockets/__init__.py +19 -0
- swiftpy/websockets/connection.py +317 -0
- swiftpy/websockets/manager.py +271 -0
- swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
- swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
- swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
- swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
- 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"]
|
swiftpy/mail/mailable.py
ADDED
|
@@ -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"]
|
swiftpy/mail/manager.py
ADDED
|
@@ -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"]
|