vintasend-twilio 3.6.0__tar.gz
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.
- vintasend_twilio-3.6.0/LICENSE.txt +21 -0
- vintasend_twilio-3.6.0/PKG-INFO +167 -0
- vintasend_twilio-3.6.0/README.md +147 -0
- vintasend_twilio-3.6.0/pyproject.toml +135 -0
- vintasend_twilio-3.6.0/vintasend_twilio/__init__.py +16 -0
- vintasend_twilio-3.6.0/vintasend_twilio/adapter.py +293 -0
- vintasend_twilio-3.6.0/vintasend_twilio/py.typed +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2017 Vinta Serviços e Soluções Tecnológicas Ltda
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vintasend-twilio
|
|
3
|
+
Version: 3.6.0
|
|
4
|
+
Summary: Twilio SMS adapter for VintaSend
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE.txt
|
|
7
|
+
Author: Hugo bessa
|
|
8
|
+
Author-email: hugo@vinta.com.br
|
|
9
|
+
Requires-Python: >=3.10,<3.15
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Requires-Dist: twilio (>=9.0.0,<10.0.0)
|
|
17
|
+
Requires-Dist: vintasend (==3.6.0)
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# vintasend-twilio
|
|
21
|
+
|
|
22
|
+
A [Twilio](https://www.twilio.com/) SMS adapter for
|
|
23
|
+
[vintasend](https://github.com/vintasoftware/vintasend).
|
|
24
|
+
|
|
25
|
+
VintaSend records every notification, renders it from a template at send time, and hands the
|
|
26
|
+
result to an **adapter** for delivery. This package is an adapter for the `SMS` notification type:
|
|
27
|
+
it renders the notification with any `BaseTemplatedSMSRenderer` and posts the body to Twilio's
|
|
28
|
+
Messages API. Pair it with any vintasend backend and an SMS renderer (for example
|
|
29
|
+
`vintasend-jinja`, or `vintasend-managed-templates`' `ManagedTemplateSMSRenderer`).
|
|
30
|
+
|
|
31
|
+
Both service flavours are covered:
|
|
32
|
+
|
|
33
|
+
| Class | For |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `TwilioSMSNotificationAdapter` | `NotificationService` (sync) |
|
|
36
|
+
| `TwilioAsyncIOSMSNotificationAdapter` | `AsyncIONotificationService` (awaits Twilio through its async HTTP client) |
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
poetry add vintasend-twilio
|
|
42
|
+
# or
|
|
43
|
+
pip install vintasend-twilio
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`twilio` comes as a dependency.
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from vintasend.services.notification_service import NotificationService
|
|
52
|
+
from vintasend_twilio import TwilioSMSNotificationAdapter
|
|
53
|
+
|
|
54
|
+
adapter = TwilioSMSNotificationAdapter(
|
|
55
|
+
my_sms_renderer, # any BaseTemplatedSMSRenderer
|
|
56
|
+
my_backend, # any vintasend backend
|
|
57
|
+
account_sid="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
58
|
+
auth_token="...",
|
|
59
|
+
from_phone_number="+15550001234",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
service = NotificationService(notification_adapters=[adapter], notification_backend=my_backend)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Or let the service build it from settings. Every option below is an adapter keyword argument,
|
|
66
|
+
so it goes in the kwargs half of a `NOTIFICATION_ADAPTERS` entry:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
NOTIFICATION_ADAPTERS = [
|
|
70
|
+
(
|
|
71
|
+
("vintasend_twilio.TwilioSMSNotificationAdapter", {"from_phone_number": "+15550001234"}),
|
|
72
|
+
"vintasend_jinja.services.notification_template_renderers.jinja_template_renderer.JinjaTemplatedSMSRenderer",
|
|
73
|
+
),
|
|
74
|
+
]
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
With credentials left out of the kwargs, they are read from the environment (see below), which
|
|
78
|
+
keeps the auth token out of your settings module.
|
|
79
|
+
|
|
80
|
+
### Options
|
|
81
|
+
|
|
82
|
+
| Keyword argument | Environment fallback | Meaning |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `account_sid` | `TWILIO_ACCOUNT_SID` | Twilio account SID. Required unless `client` is given. |
|
|
85
|
+
| `auth_token` | `TWILIO_AUTH_TOKEN` | Twilio auth token. Required unless `client` is given. |
|
|
86
|
+
| `from_phone_number` | `TWILIO_FROM_PHONE_NUMBER` | E.164 number to send from. Exactly one of this and `messaging_service_sid` is required. |
|
|
87
|
+
| `messaging_service_sid` | `TWILIO_MESSAGING_SERVICE_SID` | Twilio Messaging Service to send through instead of a fixed number. |
|
|
88
|
+
| `phone_number_context_key` | — | Context key holding the recipient's number for regular notifications. Default `"phone_number"`. |
|
|
89
|
+
| `status_callback` | — | URL Twilio calls with delivery status updates. |
|
|
90
|
+
| `client` | — | A prebuilt `twilio.rest.Client`. Overrides `account_sid`, `auth_token` and `client_kwargs`. |
|
|
91
|
+
| `client_kwargs` | — | Extra keyword arguments for `twilio.rest.Client` (`region`, `edge`, `http_client`, ...). |
|
|
92
|
+
|
|
93
|
+
A missing sender or missing credentials raises `TwilioConfigurationError` when the adapter is
|
|
94
|
+
constructed, not at the first send.
|
|
95
|
+
|
|
96
|
+
### Where the recipient's number comes from
|
|
97
|
+
|
|
98
|
+
- A **one-off notification** carries its destination on itself (`email_or_phone`), so nothing
|
|
99
|
+
else is needed.
|
|
100
|
+
- A **regular notification** only knows the user, and vintasend's backend seam has no phone
|
|
101
|
+
lookup. The adapter reads the number from the context the notification's context generator
|
|
102
|
+
returns, under `phone_number_context_key` (default `phone_number`):
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from vintasend.services.dataclasses import NotificationContextDict
|
|
106
|
+
from vintasend.services.notification_service import register_context
|
|
107
|
+
|
|
108
|
+
@register_context("sms_verification")
|
|
109
|
+
def sms_verification(user_id: str) -> NotificationContextDict:
|
|
110
|
+
user = get_user(user_id)
|
|
111
|
+
return {"phone_number": user.phone_number, "code": user.pending_code}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
A regular notification whose context has no non-empty number raises
|
|
115
|
+
`MissingRecipientPhoneNumberError` before anything is sent.
|
|
116
|
+
|
|
117
|
+
Like the email adapters, the adapter also adds `base_url` to the context handed to the renderer
|
|
118
|
+
(from `NOTIFICATION_DEFAULT_BASE_URL_PROTOCOL` and `NOTIFICATION_DEFAULT_BASE_URL_DOMAIN`), so an
|
|
119
|
+
SMS template can build absolute links.
|
|
120
|
+
|
|
121
|
+
### Errors
|
|
122
|
+
|
|
123
|
+
Every exception derives from vintasend's `NotificationError`:
|
|
124
|
+
|
|
125
|
+
- `TwilioConfigurationError` -- no usable sender or credentials, raised at construction.
|
|
126
|
+
- `MissingRecipientPhoneNumberError` -- a regular notification's context has no number.
|
|
127
|
+
- `TwilioSendError` -- Twilio rejected the message. It carries Twilio's HTTP `status` and
|
|
128
|
+
error `code`, names the notification id, and chains the original `TwilioRestException` as
|
|
129
|
+
`__cause__`. Its own message never includes the recipient's number.
|
|
130
|
+
|
|
131
|
+
Both send errors subclass `NotificationSendError`, so the service marks the notification as
|
|
132
|
+
failed the same way it does for any other adapter.
|
|
133
|
+
|
|
134
|
+
### AsyncIO
|
|
135
|
+
|
|
136
|
+
`TwilioAsyncIOSMSNotificationAdapter` takes the same options. Unless you inject a `client`, it
|
|
137
|
+
builds Twilio's `Client` with `AsyncTwilioHttpClient` (aiohttp-backed) and awaits
|
|
138
|
+
`messages.create_async`, so the request never blocks the event loop. The client is built on first
|
|
139
|
+
send rather than in the constructor, because the aiohttp session needs a running event loop and
|
|
140
|
+
`AsyncIONotificationService` instantiates its adapters synchronously. An injected `client` must
|
|
141
|
+
have been built with an async `http_client`.
|
|
142
|
+
|
|
143
|
+
### Injecting a client
|
|
144
|
+
|
|
145
|
+
For a shared client, an API key instead of the auth token, a region or edge, or a custom HTTP
|
|
146
|
+
client:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
from twilio.rest import Client
|
|
150
|
+
|
|
151
|
+
client = Client(api_key_sid, api_key_secret, account_sid, region="ie1")
|
|
152
|
+
TwilioSMSNotificationAdapter(renderer, backend, from_phone_number="+15550001234", client=client)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Development
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
poetry install --with dev
|
|
159
|
+
poetry run pytest
|
|
160
|
+
poetry run ruff check .
|
|
161
|
+
poetry run mypy
|
|
162
|
+
poetry run tox # the suite across Python 3.10-3.13
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
The tests drive the real adapter against a recording Twilio HTTP client, so nothing touches the
|
|
166
|
+
network.
|
|
167
|
+
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# vintasend-twilio
|
|
2
|
+
|
|
3
|
+
A [Twilio](https://www.twilio.com/) SMS adapter for
|
|
4
|
+
[vintasend](https://github.com/vintasoftware/vintasend).
|
|
5
|
+
|
|
6
|
+
VintaSend records every notification, renders it from a template at send time, and hands the
|
|
7
|
+
result to an **adapter** for delivery. This package is an adapter for the `SMS` notification type:
|
|
8
|
+
it renders the notification with any `BaseTemplatedSMSRenderer` and posts the body to Twilio's
|
|
9
|
+
Messages API. Pair it with any vintasend backend and an SMS renderer (for example
|
|
10
|
+
`vintasend-jinja`, or `vintasend-managed-templates`' `ManagedTemplateSMSRenderer`).
|
|
11
|
+
|
|
12
|
+
Both service flavours are covered:
|
|
13
|
+
|
|
14
|
+
| Class | For |
|
|
15
|
+
|---|---|
|
|
16
|
+
| `TwilioSMSNotificationAdapter` | `NotificationService` (sync) |
|
|
17
|
+
| `TwilioAsyncIOSMSNotificationAdapter` | `AsyncIONotificationService` (awaits Twilio through its async HTTP client) |
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
poetry add vintasend-twilio
|
|
23
|
+
# or
|
|
24
|
+
pip install vintasend-twilio
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`twilio` comes as a dependency.
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from vintasend.services.notification_service import NotificationService
|
|
33
|
+
from vintasend_twilio import TwilioSMSNotificationAdapter
|
|
34
|
+
|
|
35
|
+
adapter = TwilioSMSNotificationAdapter(
|
|
36
|
+
my_sms_renderer, # any BaseTemplatedSMSRenderer
|
|
37
|
+
my_backend, # any vintasend backend
|
|
38
|
+
account_sid="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
39
|
+
auth_token="...",
|
|
40
|
+
from_phone_number="+15550001234",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
service = NotificationService(notification_adapters=[adapter], notification_backend=my_backend)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Or let the service build it from settings. Every option below is an adapter keyword argument,
|
|
47
|
+
so it goes in the kwargs half of a `NOTIFICATION_ADAPTERS` entry:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
NOTIFICATION_ADAPTERS = [
|
|
51
|
+
(
|
|
52
|
+
("vintasend_twilio.TwilioSMSNotificationAdapter", {"from_phone_number": "+15550001234"}),
|
|
53
|
+
"vintasend_jinja.services.notification_template_renderers.jinja_template_renderer.JinjaTemplatedSMSRenderer",
|
|
54
|
+
),
|
|
55
|
+
]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
With credentials left out of the kwargs, they are read from the environment (see below), which
|
|
59
|
+
keeps the auth token out of your settings module.
|
|
60
|
+
|
|
61
|
+
### Options
|
|
62
|
+
|
|
63
|
+
| Keyword argument | Environment fallback | Meaning |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| `account_sid` | `TWILIO_ACCOUNT_SID` | Twilio account SID. Required unless `client` is given. |
|
|
66
|
+
| `auth_token` | `TWILIO_AUTH_TOKEN` | Twilio auth token. Required unless `client` is given. |
|
|
67
|
+
| `from_phone_number` | `TWILIO_FROM_PHONE_NUMBER` | E.164 number to send from. Exactly one of this and `messaging_service_sid` is required. |
|
|
68
|
+
| `messaging_service_sid` | `TWILIO_MESSAGING_SERVICE_SID` | Twilio Messaging Service to send through instead of a fixed number. |
|
|
69
|
+
| `phone_number_context_key` | — | Context key holding the recipient's number for regular notifications. Default `"phone_number"`. |
|
|
70
|
+
| `status_callback` | — | URL Twilio calls with delivery status updates. |
|
|
71
|
+
| `client` | — | A prebuilt `twilio.rest.Client`. Overrides `account_sid`, `auth_token` and `client_kwargs`. |
|
|
72
|
+
| `client_kwargs` | — | Extra keyword arguments for `twilio.rest.Client` (`region`, `edge`, `http_client`, ...). |
|
|
73
|
+
|
|
74
|
+
A missing sender or missing credentials raises `TwilioConfigurationError` when the adapter is
|
|
75
|
+
constructed, not at the first send.
|
|
76
|
+
|
|
77
|
+
### Where the recipient's number comes from
|
|
78
|
+
|
|
79
|
+
- A **one-off notification** carries its destination on itself (`email_or_phone`), so nothing
|
|
80
|
+
else is needed.
|
|
81
|
+
- A **regular notification** only knows the user, and vintasend's backend seam has no phone
|
|
82
|
+
lookup. The adapter reads the number from the context the notification's context generator
|
|
83
|
+
returns, under `phone_number_context_key` (default `phone_number`):
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from vintasend.services.dataclasses import NotificationContextDict
|
|
87
|
+
from vintasend.services.notification_service import register_context
|
|
88
|
+
|
|
89
|
+
@register_context("sms_verification")
|
|
90
|
+
def sms_verification(user_id: str) -> NotificationContextDict:
|
|
91
|
+
user = get_user(user_id)
|
|
92
|
+
return {"phone_number": user.phone_number, "code": user.pending_code}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
A regular notification whose context has no non-empty number raises
|
|
96
|
+
`MissingRecipientPhoneNumberError` before anything is sent.
|
|
97
|
+
|
|
98
|
+
Like the email adapters, the adapter also adds `base_url` to the context handed to the renderer
|
|
99
|
+
(from `NOTIFICATION_DEFAULT_BASE_URL_PROTOCOL` and `NOTIFICATION_DEFAULT_BASE_URL_DOMAIN`), so an
|
|
100
|
+
SMS template can build absolute links.
|
|
101
|
+
|
|
102
|
+
### Errors
|
|
103
|
+
|
|
104
|
+
Every exception derives from vintasend's `NotificationError`:
|
|
105
|
+
|
|
106
|
+
- `TwilioConfigurationError` -- no usable sender or credentials, raised at construction.
|
|
107
|
+
- `MissingRecipientPhoneNumberError` -- a regular notification's context has no number.
|
|
108
|
+
- `TwilioSendError` -- Twilio rejected the message. It carries Twilio's HTTP `status` and
|
|
109
|
+
error `code`, names the notification id, and chains the original `TwilioRestException` as
|
|
110
|
+
`__cause__`. Its own message never includes the recipient's number.
|
|
111
|
+
|
|
112
|
+
Both send errors subclass `NotificationSendError`, so the service marks the notification as
|
|
113
|
+
failed the same way it does for any other adapter.
|
|
114
|
+
|
|
115
|
+
### AsyncIO
|
|
116
|
+
|
|
117
|
+
`TwilioAsyncIOSMSNotificationAdapter` takes the same options. Unless you inject a `client`, it
|
|
118
|
+
builds Twilio's `Client` with `AsyncTwilioHttpClient` (aiohttp-backed) and awaits
|
|
119
|
+
`messages.create_async`, so the request never blocks the event loop. The client is built on first
|
|
120
|
+
send rather than in the constructor, because the aiohttp session needs a running event loop and
|
|
121
|
+
`AsyncIONotificationService` instantiates its adapters synchronously. An injected `client` must
|
|
122
|
+
have been built with an async `http_client`.
|
|
123
|
+
|
|
124
|
+
### Injecting a client
|
|
125
|
+
|
|
126
|
+
For a shared client, an API key instead of the auth token, a region or edge, or a custom HTTP
|
|
127
|
+
client:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from twilio.rest import Client
|
|
131
|
+
|
|
132
|
+
client = Client(api_key_sid, api_key_secret, account_sid, region="ie1")
|
|
133
|
+
TwilioSMSNotificationAdapter(renderer, backend, from_phone_number="+15550001234", client=client)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Development
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
poetry install --with dev
|
|
140
|
+
poetry run pytest
|
|
141
|
+
poetry run ruff check .
|
|
142
|
+
poetry run mypy
|
|
143
|
+
poetry run tox # the suite across Python 3.10-3.13
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The tests drive the real adapter against a recording Twilio HTTP client, so nothing touches the
|
|
147
|
+
network.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "vintasend-twilio"
|
|
3
|
+
version = "3.6.0"
|
|
4
|
+
description = "Twilio SMS adapter for VintaSend"
|
|
5
|
+
authors = [{ name = "Hugo bessa", email = "hugo@vinta.com.br" }]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
requires-python = ">=3.10,<3.15"
|
|
9
|
+
# Constraints stay under [tool.poetry.dependencies] to keep caret syntax, which
|
|
10
|
+
# is not valid PEP 621. 'dynamic' tells build tooling the real list lives there.
|
|
11
|
+
dynamic = ["dependencies"]
|
|
12
|
+
|
|
13
|
+
[tool.poetry]
|
|
14
|
+
packages = [{ include = "vintasend_twilio" }]
|
|
15
|
+
include = [
|
|
16
|
+
{ path = "vintasend_twilio/py.typed", format = "wheel" }
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[tool.poetry.dependencies]
|
|
20
|
+
vintasend = "3.6.0"
|
|
21
|
+
twilio = "^9.0.0"
|
|
22
|
+
|
|
23
|
+
[tool.poetry.group.dev.dependencies]
|
|
24
|
+
coverage = "^7.6.4"
|
|
25
|
+
tox = "^4.23.2"
|
|
26
|
+
# Makes the [gh] section in tox.ini effective: each CI job then runs only the
|
|
27
|
+
# envs mapped to its interpreter instead of every env it can find one for.
|
|
28
|
+
tox-gh = "^1.8.0"
|
|
29
|
+
pytest = "^9.0.3"
|
|
30
|
+
pytest-xdist = {version = "^3.6.1", extras=["psutil"]}
|
|
31
|
+
pytest-cov = "^7.1.0"
|
|
32
|
+
pytest-asyncio = "^1.4.0"
|
|
33
|
+
ruff = "^0.17.3"
|
|
34
|
+
mypy = "^2.3.0"
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["poetry-core"]
|
|
38
|
+
build-backend = "poetry.core.masonry.api"
|
|
39
|
+
|
|
40
|
+
[tool.ruff]
|
|
41
|
+
line-length = 100
|
|
42
|
+
indent-width = 4
|
|
43
|
+
# Must track the MINIMUM Python this package supports. Setting this higher lets
|
|
44
|
+
# pyupgrade rewrite code into syntax that breaks older interpreters.
|
|
45
|
+
target-version = "py310"
|
|
46
|
+
|
|
47
|
+
[tool.ruff.lint]
|
|
48
|
+
select = [
|
|
49
|
+
# pycodestyle
|
|
50
|
+
"E",
|
|
51
|
+
# Pyflakes
|
|
52
|
+
"F",
|
|
53
|
+
# pep8-naming
|
|
54
|
+
"N",
|
|
55
|
+
# pyupgrade
|
|
56
|
+
"UP",
|
|
57
|
+
# flake8-bugbear
|
|
58
|
+
"B",
|
|
59
|
+
# flake8-bandit
|
|
60
|
+
"S",
|
|
61
|
+
# flake8-blind-except
|
|
62
|
+
"BLE",
|
|
63
|
+
# flake8-builtins
|
|
64
|
+
"A",
|
|
65
|
+
# isort
|
|
66
|
+
"I",
|
|
67
|
+
# flake8-logging-format
|
|
68
|
+
"G",
|
|
69
|
+
# flake8-no-pep420
|
|
70
|
+
"INP",
|
|
71
|
+
# Ruff-specific rules
|
|
72
|
+
"RUF",
|
|
73
|
+
]
|
|
74
|
+
ignore = [
|
|
75
|
+
# Disable Conflicting lint rules,
|
|
76
|
+
# see https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules
|
|
77
|
+
"W191",
|
|
78
|
+
"E501",
|
|
79
|
+
"E111",
|
|
80
|
+
"E117",
|
|
81
|
+
"D206",
|
|
82
|
+
"D300",
|
|
83
|
+
"Q000",
|
|
84
|
+
"Q001",
|
|
85
|
+
"Q002",
|
|
86
|
+
"Q003",
|
|
87
|
+
"COM812",
|
|
88
|
+
"COM819",
|
|
89
|
+
"ISC001",
|
|
90
|
+
"ISC002",
|
|
91
|
+
# Allow `except Exception`:
|
|
92
|
+
"BLE001",
|
|
93
|
+
# Disable unused `noqa` directive
|
|
94
|
+
"RUF100",
|
|
95
|
+
]
|
|
96
|
+
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
|
|
97
|
+
|
|
98
|
+
[tool.ruff.lint.pycodestyle]
|
|
99
|
+
ignore-overlong-task-comments = true
|
|
100
|
+
|
|
101
|
+
[tool.ruff.lint.isort]
|
|
102
|
+
section-order = [
|
|
103
|
+
"future",
|
|
104
|
+
"standard-library",
|
|
105
|
+
"third-party",
|
|
106
|
+
"first-party",
|
|
107
|
+
"local-folder",
|
|
108
|
+
]
|
|
109
|
+
lines-after-imports = 2
|
|
110
|
+
|
|
111
|
+
[tool.ruff.lint.per-file-ignores]
|
|
112
|
+
"__init__.py" = ["E402", "F403", "F405"]
|
|
113
|
+
"**/{tests,docs}/*" = ["E402", "F403", "F405", "S", "N802"]
|
|
114
|
+
"**/*test*.py" = ["E402", "F403", "F405", "S", "N802"]
|
|
115
|
+
|
|
116
|
+
[tool.pytest.ini_options]
|
|
117
|
+
python_files = ["test_*.py"]
|
|
118
|
+
testpaths = ["tests"]
|
|
119
|
+
addopts = "--dist=loadscope"
|
|
120
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
121
|
+
|
|
122
|
+
[tool.mypy]
|
|
123
|
+
files = ["vintasend_twilio", "tests"]
|
|
124
|
+
# Must track the MINIMUM Python this package supports.
|
|
125
|
+
python_version = "3.10"
|
|
126
|
+
show_error_codes = true
|
|
127
|
+
no_implicit_reexport = true
|
|
128
|
+
strict_equality = true
|
|
129
|
+
warn_no_return = true
|
|
130
|
+
|
|
131
|
+
# twilio ships no py.typed marker, so its modules are treated as untyped rather than failing
|
|
132
|
+
# the check.
|
|
133
|
+
[[tool.mypy.overrides]]
|
|
134
|
+
module = "twilio.*"
|
|
135
|
+
ignore_missing_imports = true
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from vintasend_twilio.adapter import (
|
|
2
|
+
MissingRecipientPhoneNumberError,
|
|
3
|
+
TwilioAsyncIOSMSNotificationAdapter,
|
|
4
|
+
TwilioConfigurationError,
|
|
5
|
+
TwilioSendError,
|
|
6
|
+
TwilioSMSNotificationAdapter,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"MissingRecipientPhoneNumberError",
|
|
12
|
+
"TwilioAsyncIOSMSNotificationAdapter",
|
|
13
|
+
"TwilioConfigurationError",
|
|
14
|
+
"TwilioSMSNotificationAdapter",
|
|
15
|
+
"TwilioSendError",
|
|
16
|
+
]
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Twilio SMS delivery for VintaSend.
|
|
2
|
+
|
|
3
|
+
Two adapters, one per service flavour:
|
|
4
|
+
|
|
5
|
+
- ``TwilioSMSNotificationAdapter`` for ``NotificationService`` (sync).
|
|
6
|
+
- ``TwilioAsyncIOSMSNotificationAdapter`` for ``AsyncIONotificationService``. It drives the same
|
|
7
|
+
Twilio ``Client`` through Twilio's own ``AsyncTwilioHttpClient`` and awaits ``create_async``.
|
|
8
|
+
|
|
9
|
+
Both render the notification with a ``BaseTemplatedSMSRenderer`` and post the resulting body to
|
|
10
|
+
Twilio's Messages API. See ``TwilioConfigMixin`` for how credentials, the sender, and the
|
|
11
|
+
recipient are resolved.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
16
|
+
|
|
17
|
+
from twilio.base.exceptions import TwilioRestException
|
|
18
|
+
from twilio.http.async_http_client import AsyncTwilioHttpClient
|
|
19
|
+
from twilio.rest import Client
|
|
20
|
+
from vintasend.app_settings import NotificationSettings
|
|
21
|
+
from vintasend.constants import NotificationTypes
|
|
22
|
+
from vintasend.exceptions import NotificationError, NotificationSendError
|
|
23
|
+
from vintasend.services.dataclasses import Notification, OneOffNotification
|
|
24
|
+
from vintasend.services.notification_adapters.asyncio_base import AsyncIOBaseNotificationAdapter
|
|
25
|
+
from vintasend.services.notification_adapters.base import BaseNotificationAdapter
|
|
26
|
+
from vintasend.services.notification_backends.asyncio_base import AsyncIOBaseNotificationBackend
|
|
27
|
+
from vintasend.services.notification_backends.base import BaseNotificationBackend
|
|
28
|
+
from vintasend.services.notification_template_renderers.base_templated_sms_renderer import (
|
|
29
|
+
BaseTemplatedSMSRenderer,
|
|
30
|
+
TemplatedSMS,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from vintasend.services.dataclasses import NotificationContextDict
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
B = TypeVar("B", bound=BaseNotificationBackend)
|
|
39
|
+
BAIO = TypeVar("BAIO", bound=AsyncIOBaseNotificationBackend)
|
|
40
|
+
T = TypeVar("T", bound=BaseTemplatedSMSRenderer)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
ACCOUNT_SID_ENV_VAR = "TWILIO_ACCOUNT_SID"
|
|
44
|
+
AUTH_TOKEN_ENV_VAR = "TWILIO_AUTH_TOKEN" # noqa: S105 -- the variable's name, not a secret
|
|
45
|
+
FROM_PHONE_NUMBER_ENV_VAR = "TWILIO_FROM_PHONE_NUMBER"
|
|
46
|
+
MESSAGING_SERVICE_SID_ENV_VAR = "TWILIO_MESSAGING_SERVICE_SID"
|
|
47
|
+
|
|
48
|
+
DEFAULT_PHONE_NUMBER_CONTEXT_KEY = "phone_number"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TwilioConfigurationError(NotificationError):
|
|
52
|
+
"""Raised at construction when the adapter has no usable sender or credentials."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class MissingRecipientPhoneNumberError(NotificationSendError):
|
|
56
|
+
"""Raised when a regular notification's context carries no phone number to send to."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TwilioSendError(NotificationSendError):
|
|
60
|
+
"""Raised when Twilio rejects a message.
|
|
61
|
+
|
|
62
|
+
The message names the notification and Twilio's HTTP status and error code, never the
|
|
63
|
+
recipient's number. The original ``TwilioRestException`` is chained as ``__cause__``.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
status: int | None
|
|
67
|
+
code: int | None
|
|
68
|
+
|
|
69
|
+
def __init__(self, message: str, status: int | None = None, code: int | None = None):
|
|
70
|
+
super().__init__(message)
|
|
71
|
+
self.status = status
|
|
72
|
+
self.code = code
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TwilioConfigMixin:
|
|
76
|
+
"""Configuration shared by the sync and AsyncIO adapters.
|
|
77
|
+
|
|
78
|
+
Every option is an adapter keyword argument, so it can be given either in code
|
|
79
|
+
(``TwilioSMSNotificationAdapter(renderer, backend, from_phone_number="+15550001234")``) or in
|
|
80
|
+
the ``NOTIFICATION_ADAPTERS`` setting as the kwargs half of a ``(dotted_path, kwargs)`` tuple.
|
|
81
|
+
|
|
82
|
+
:param account_sid: Twilio account SID. Falls back to ``TWILIO_ACCOUNT_SID``.
|
|
83
|
+
:param auth_token: Twilio auth token. Falls back to ``TWILIO_AUTH_TOKEN``.
|
|
84
|
+
:param from_phone_number: The E.164 number messages are sent from. Falls back to
|
|
85
|
+
``TWILIO_FROM_PHONE_NUMBER``. Mutually exclusive with ``messaging_service_sid``.
|
|
86
|
+
:param messaging_service_sid: A Twilio Messaging Service to send through instead of a fixed
|
|
87
|
+
number. Falls back to ``TWILIO_MESSAGING_SERVICE_SID``.
|
|
88
|
+
:param phone_number_context_key: Which key of the rendered context holds the recipient's
|
|
89
|
+
number for a regular (non one-off) notification. Defaults to ``"phone_number"``.
|
|
90
|
+
:param status_callback: Optional URL Twilio calls with delivery status updates.
|
|
91
|
+
:param client: A prebuilt ``twilio.rest.Client`` to use instead of building one. When given,
|
|
92
|
+
``account_sid``, ``auth_token`` and ``client_kwargs`` are ignored. The AsyncIO adapter
|
|
93
|
+
needs one built with an async ``http_client``.
|
|
94
|
+
:param client_kwargs: Extra keyword arguments for ``twilio.rest.Client`` (``region``,
|
|
95
|
+
``edge``, ``http_client``, ...). Ignored when ``client`` is given.
|
|
96
|
+
|
|
97
|
+
The Twilio client is built on first use rather than in ``__init__``. Twilio's async HTTP
|
|
98
|
+
client opens an aiohttp session as it is constructed, which needs a running event loop, and
|
|
99
|
+
``AsyncIONotificationService`` instantiates its adapters synchronously -- so an eager build
|
|
100
|
+
would fail for the AsyncIO adapter whenever it is configured through settings.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
from_phone_number: str | None
|
|
104
|
+
messaging_service_sid: str | None
|
|
105
|
+
phone_number_context_key: str
|
|
106
|
+
status_callback: str | None
|
|
107
|
+
|
|
108
|
+
_client: Client | None
|
|
109
|
+
_account_sid: str | None
|
|
110
|
+
_auth_token: str | None
|
|
111
|
+
_client_kwargs: dict[str, Any]
|
|
112
|
+
_use_async_http_client: bool
|
|
113
|
+
|
|
114
|
+
def _configure_twilio(self, kwargs: dict[str, Any], *, use_async_http_client: bool) -> None:
|
|
115
|
+
self.from_phone_number = kwargs.get("from_phone_number") or os.environ.get(
|
|
116
|
+
FROM_PHONE_NUMBER_ENV_VAR
|
|
117
|
+
)
|
|
118
|
+
self.messaging_service_sid = kwargs.get("messaging_service_sid") or os.environ.get(
|
|
119
|
+
MESSAGING_SERVICE_SID_ENV_VAR
|
|
120
|
+
)
|
|
121
|
+
if self.from_phone_number and self.messaging_service_sid:
|
|
122
|
+
raise TwilioConfigurationError(
|
|
123
|
+
f"{type(self).__name__} takes either from_phone_number or messaging_service_sid, "
|
|
124
|
+
"not both."
|
|
125
|
+
)
|
|
126
|
+
if not self.from_phone_number and not self.messaging_service_sid:
|
|
127
|
+
raise TwilioConfigurationError(
|
|
128
|
+
f"{type(self).__name__} needs a sender: pass from_phone_number or "
|
|
129
|
+
f"messaging_service_sid, or set {FROM_PHONE_NUMBER_ENV_VAR} or "
|
|
130
|
+
f"{MESSAGING_SERVICE_SID_ENV_VAR}."
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
self.phone_number_context_key = (
|
|
134
|
+
kwargs.get("phone_number_context_key") or DEFAULT_PHONE_NUMBER_CONTEXT_KEY
|
|
135
|
+
)
|
|
136
|
+
self.status_callback = kwargs.get("status_callback")
|
|
137
|
+
self._use_async_http_client = use_async_http_client
|
|
138
|
+
|
|
139
|
+
client = kwargs.get("client")
|
|
140
|
+
if client is not None:
|
|
141
|
+
if not isinstance(client, Client):
|
|
142
|
+
raise TwilioConfigurationError(
|
|
143
|
+
f"{type(self).__name__} expects `client` to be a twilio.rest.Client, "
|
|
144
|
+
f"got {type(client).__name__}."
|
|
145
|
+
)
|
|
146
|
+
self._client = client
|
|
147
|
+
self._account_sid = None
|
|
148
|
+
self._auth_token = None
|
|
149
|
+
self._client_kwargs = {}
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
self._client = None
|
|
153
|
+
self._account_sid = kwargs.get("account_sid") or os.environ.get(ACCOUNT_SID_ENV_VAR)
|
|
154
|
+
self._auth_token = kwargs.get("auth_token") or os.environ.get(AUTH_TOKEN_ENV_VAR)
|
|
155
|
+
self._client_kwargs = dict(kwargs.get("client_kwargs") or {})
|
|
156
|
+
if not self._account_sid or not self._auth_token:
|
|
157
|
+
raise TwilioConfigurationError(
|
|
158
|
+
f"{type(self).__name__} needs Twilio credentials: pass account_sid and "
|
|
159
|
+
f"auth_token, or set {ACCOUNT_SID_ENV_VAR} and {AUTH_TOKEN_ENV_VAR}."
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def client(self) -> Client:
|
|
164
|
+
"""The Twilio client, built on first access unless one was injected."""
|
|
165
|
+
if self._client is None:
|
|
166
|
+
client_kwargs = dict(self._client_kwargs)
|
|
167
|
+
if self._use_async_http_client:
|
|
168
|
+
client_kwargs.setdefault("http_client", AsyncTwilioHttpClient())
|
|
169
|
+
self._client = Client(self._account_sid, self._auth_token, **client_kwargs)
|
|
170
|
+
return self._client
|
|
171
|
+
|
|
172
|
+
def _recipient_phone_number(
|
|
173
|
+
self,
|
|
174
|
+
notification: "Notification | OneOffNotification",
|
|
175
|
+
context: "NotificationContextDict",
|
|
176
|
+
) -> str:
|
|
177
|
+
"""Resolve the destination number for either notification flavour.
|
|
178
|
+
|
|
179
|
+
A one-off notification carries the number on itself. A regular one only knows the user,
|
|
180
|
+
and the backend seam has no phone lookup, so the number has to come from the context the
|
|
181
|
+
notification's context generator produced -- under ``phone_number_context_key``.
|
|
182
|
+
"""
|
|
183
|
+
if isinstance(notification, OneOffNotification):
|
|
184
|
+
return notification.email_or_phone
|
|
185
|
+
phone_number = context.get(self.phone_number_context_key)
|
|
186
|
+
if not isinstance(phone_number, str) or not phone_number.strip():
|
|
187
|
+
raise MissingRecipientPhoneNumberError(
|
|
188
|
+
f"Notification {notification.id} has no recipient phone number: its context has "
|
|
189
|
+
f"no non-empty {self.phone_number_context_key!r} key. Have its context generator "
|
|
190
|
+
"return one, or point phone_number_context_key at the key it does set."
|
|
191
|
+
)
|
|
192
|
+
return phone_number.strip()
|
|
193
|
+
|
|
194
|
+
def _message_kwargs(self, to: str, body: str) -> dict[str, Any]:
|
|
195
|
+
"""The keyword arguments for ``client.messages.create`` / ``create_async``."""
|
|
196
|
+
message_kwargs: dict[str, Any] = {"to": to, "body": body}
|
|
197
|
+
if self.from_phone_number:
|
|
198
|
+
message_kwargs["from_"] = self.from_phone_number
|
|
199
|
+
else:
|
|
200
|
+
message_kwargs["messaging_service_sid"] = self.messaging_service_sid
|
|
201
|
+
if self.status_callback:
|
|
202
|
+
message_kwargs["status_callback"] = self.status_callback
|
|
203
|
+
return message_kwargs
|
|
204
|
+
|
|
205
|
+
def _context_with_base_url(
|
|
206
|
+
self, context: "NotificationContextDict"
|
|
207
|
+
) -> "NotificationContextDict":
|
|
208
|
+
"""Add ``base_url`` the way the email adapters do, so SMS templates can build links."""
|
|
209
|
+
notification_settings = NotificationSettings()
|
|
210
|
+
context_with_base_url: NotificationContextDict = context.copy()
|
|
211
|
+
context_with_base_url["base_url"] = (
|
|
212
|
+
f"{notification_settings.NOTIFICATION_DEFAULT_BASE_URL_PROTOCOL}://"
|
|
213
|
+
f"{notification_settings.NOTIFICATION_DEFAULT_BASE_URL_DOMAIN}"
|
|
214
|
+
)
|
|
215
|
+
return context_with_base_url
|
|
216
|
+
|
|
217
|
+
def _send_error(
|
|
218
|
+
self, notification: "Notification | OneOffNotification", exc: TwilioRestException
|
|
219
|
+
) -> TwilioSendError:
|
|
220
|
+
return TwilioSendError(
|
|
221
|
+
f"Twilio rejected the SMS for notification {notification.id} "
|
|
222
|
+
f"(HTTP {exc.status}, Twilio error code {exc.code}).",
|
|
223
|
+
status=exc.status,
|
|
224
|
+
code=exc.code,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class TwilioSMSNotificationAdapter( # noqa: UP046
|
|
229
|
+
TwilioConfigMixin, Generic[B, T], BaseNotificationAdapter[B, T]
|
|
230
|
+
):
|
|
231
|
+
"""Sends SMS notifications through Twilio from a ``NotificationService``."""
|
|
232
|
+
|
|
233
|
+
notification_type = NotificationTypes.SMS
|
|
234
|
+
backend: B
|
|
235
|
+
template_renderer: T
|
|
236
|
+
|
|
237
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
238
|
+
super().__init__(*args, **kwargs)
|
|
239
|
+
self._configure_twilio(kwargs, use_async_http_client=False)
|
|
240
|
+
|
|
241
|
+
def send(
|
|
242
|
+
self, notification: "Notification | OneOffNotification", context: "NotificationContextDict"
|
|
243
|
+
) -> TemplatedSMS:
|
|
244
|
+
"""Render the notification and post it to Twilio's Messages API.
|
|
245
|
+
|
|
246
|
+
Returns the rendered SMS so the service can record which template version went out.
|
|
247
|
+
|
|
248
|
+
:raises MissingRecipientPhoneNumberError: a regular notification's context has no number.
|
|
249
|
+
:raises TwilioSendError: Twilio rejected the message.
|
|
250
|
+
"""
|
|
251
|
+
to = self._recipient_phone_number(notification, context)
|
|
252
|
+
template = self.template_renderer.render(notification, self._context_with_base_url(context))
|
|
253
|
+
try:
|
|
254
|
+
self.client.messages.create(**self._message_kwargs(to, template.body))
|
|
255
|
+
except TwilioRestException as exc:
|
|
256
|
+
raise self._send_error(notification, exc) from exc
|
|
257
|
+
return template
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class TwilioAsyncIOSMSNotificationAdapter( # noqa: UP046
|
|
261
|
+
TwilioConfigMixin, Generic[BAIO, T], AsyncIOBaseNotificationAdapter[BAIO, T]
|
|
262
|
+
):
|
|
263
|
+
"""Sends SMS notifications through Twilio from an ``AsyncIONotificationService``.
|
|
264
|
+
|
|
265
|
+
Unless a ``client`` is injected, the Twilio client is built with Twilio's
|
|
266
|
+
``AsyncTwilioHttpClient`` so the request is awaited rather than blocking the event loop.
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
notification_type = NotificationTypes.SMS
|
|
270
|
+
backend: BAIO
|
|
271
|
+
template_renderer: T
|
|
272
|
+
|
|
273
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
274
|
+
super().__init__(*args, **kwargs)
|
|
275
|
+
self._configure_twilio(kwargs, use_async_http_client=True)
|
|
276
|
+
|
|
277
|
+
async def send(
|
|
278
|
+
self, notification: "Notification | OneOffNotification", context: "NotificationContextDict"
|
|
279
|
+
) -> TemplatedSMS:
|
|
280
|
+
"""Render the notification and post it to Twilio's Messages API.
|
|
281
|
+
|
|
282
|
+
Returns the rendered SMS so the service can record which template version went out.
|
|
283
|
+
|
|
284
|
+
:raises MissingRecipientPhoneNumberError: a regular notification's context has no number.
|
|
285
|
+
:raises TwilioSendError: Twilio rejected the message.
|
|
286
|
+
"""
|
|
287
|
+
to = self._recipient_phone_number(notification, context)
|
|
288
|
+
template = self.template_renderer.render(notification, self._context_with_base_url(context))
|
|
289
|
+
try:
|
|
290
|
+
await self.client.messages.create_async(**self._message_kwargs(to, template.body))
|
|
291
|
+
except TwilioRestException as exc:
|
|
292
|
+
raise self._send_error(notification, exc) from exc
|
|
293
|
+
return template
|
|
File without changes
|