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.
@@ -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