xtr-notifier 5.0.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.
- xtr_notifier/.agents/skills/xtr-notifier/SKILL.md +143 -0
- xtr_notifier/.agents/skills/xtr-notifier/references/bridges.md +200 -0
- xtr_notifier/__init__.py +28 -0
- xtr_notifier/_message_sender.py +64 -0
- xtr_notifier/admin_recipients_provider_interface.py +26 -0
- xtr_notifier/bundle/__init__.py +21 -0
- xtr_notifier/bundle/_chat_message_handler.py +34 -0
- xtr_notifier/bundle/_failed_messages.py +15 -0
- xtr_notifier/bundle/_message_bus.py +39 -0
- xtr_notifier/bundle/_peer_factories.py +46 -0
- xtr_notifier/bundle/_peer_wiring.py +102 -0
- xtr_notifier/bundle/_peers.py +52 -0
- xtr_notifier/bundle/_services.py +221 -0
- xtr_notifier/bundle/_text_message_handler.py +35 -0
- xtr_notifier/bundle/admin_recipient_config.py +35 -0
- xtr_notifier/bundle/notifier_bundle.py +233 -0
- xtr_notifier/bundle/notifier_config.py +74 -0
- xtr_notifier/channel/__init__.py +21 -0
- xtr_notifier/channel/abstract_channel.py +59 -0
- xtr_notifier/channel/channel_interface.py +41 -0
- xtr_notifier/channel/channel_policy.py +51 -0
- xtr_notifier/channel/channel_policy_interface.py +19 -0
- xtr_notifier/channel/chat_channel.py +51 -0
- xtr_notifier/channel/push_channel.py +51 -0
- xtr_notifier/channel/sms_channel.py +58 -0
- xtr_notifier/chatter.py +28 -0
- xtr_notifier/chatter_interface.py +28 -0
- xtr_notifier/event/__init__.py +10 -0
- xtr_notifier/event/failed_message_event.py +39 -0
- xtr_notifier/event/message_event.py +39 -0
- xtr_notifier/event/notification_events.py +49 -0
- xtr_notifier/event/sent_message_event.py +28 -0
- xtr_notifier/event_listener/__init__.py +11 -0
- xtr_notifier/event_listener/notification_logger_listener.py +60 -0
- xtr_notifier/event_listener/send_failed_message_to_notifier_listener.py +61 -0
- xtr_notifier/exception/__init__.py +31 -0
- xtr_notifier/exception/incomplete_dsn_error.py +31 -0
- xtr_notifier/exception/invalid_argument_error.py +15 -0
- xtr_notifier/exception/length_error.py +15 -0
- xtr_notifier/exception/logic_error.py +16 -0
- xtr_notifier/exception/missing_required_option_error.py +27 -0
- xtr_notifier/exception/multiple_exclusive_options_used_error.py +42 -0
- xtr_notifier/exception/notifier_error.py +9 -0
- xtr_notifier/exception/notifier_runtime_error.py +15 -0
- xtr_notifier/exception/transport_error.py +43 -0
- xtr_notifier/exception/unsupported_message_type_error.py +37 -0
- xtr_notifier/exception/unsupported_options_error.py +37 -0
- xtr_notifier/exception/unsupported_scheme_error.py +77 -0
- xtr_notifier/message/__init__.py +23 -0
- xtr_notifier/message/chat_message.py +62 -0
- xtr_notifier/message/message_interface.py +35 -0
- xtr_notifier/message/message_options.py +45 -0
- xtr_notifier/message/message_options_interface.py +30 -0
- xtr_notifier/message/null_message.py +59 -0
- xtr_notifier/message/push_message.py +69 -0
- xtr_notifier/message/sent_message.py +76 -0
- xtr_notifier/message/sms_message.py +95 -0
- xtr_notifier/messenger/__init__.py +36 -0
- xtr_notifier/messenger/message_handler.py +38 -0
- xtr_notifier/notification/__init__.py +16 -0
- xtr_notifier/notification/chat_notification_interface.py +33 -0
- xtr_notifier/notification/importance.py +20 -0
- xtr_notifier/notification/notification.py +171 -0
- xtr_notifier/notification/notification_summary.py +42 -0
- xtr_notifier/notification/push_notification_interface.py +33 -0
- xtr_notifier/notification/sms_notification_interface.py +33 -0
- xtr_notifier/notifier.py +137 -0
- xtr_notifier/notifier_interface.py +24 -0
- xtr_notifier/py.typed +0 -0
- xtr_notifier/recipe/__init__.py +15 -0
- xtr_notifier/recipe/files/config/notifier.py.tmpl +29 -0
- xtr_notifier/recipe/manifest.toml +31 -0
- xtr_notifier/recipient/__init__.py +17 -0
- xtr_notifier/recipient/email_recipient_interface.py +24 -0
- xtr_notifier/recipient/no_recipient.py +20 -0
- xtr_notifier/recipient/recipient.py +66 -0
- xtr_notifier/recipient/recipient_interface.py +18 -0
- xtr_notifier/recipient/sms_recipient_interface.py +19 -0
- xtr_notifier/testing/__init__.py +52 -0
- xtr_notifier/testing/_factory_assertions.py +86 -0
- xtr_notifier/testing/_failures.py +21 -0
- xtr_notifier/testing/_notification_assertions.py +120 -0
- xtr_notifier/testing/_transport_assertions.py +98 -0
- xtr_notifier/testing/factory_cases.py +56 -0
- xtr_notifier/testing/transport_cases.py +48 -0
- xtr_notifier/texter.py +28 -0
- xtr_notifier/texter_interface.py +28 -0
- xtr_notifier/transport/__init__.py +31 -0
- xtr_notifier/transport/abstract_transport.py +123 -0
- xtr_notifier/transport/abstract_transport_factory.py +93 -0
- xtr_notifier/transport/dsn.py +187 -0
- xtr_notifier/transport/failover_transport.py +66 -0
- xtr_notifier/transport/null_transport.py +56 -0
- xtr_notifier/transport/null_transport_factory.py +66 -0
- xtr_notifier/transport/round_robin_transport.py +135 -0
- xtr_notifier/transport/transport.py +106 -0
- xtr_notifier/transport/transport_factory_discovery.py +154 -0
- xtr_notifier/transport/transport_factory_interface.py +32 -0
- xtr_notifier/transport/transport_interface.py +33 -0
- xtr_notifier/transport/transports.py +71 -0
- xtr_notifier-5.0.0.dist-info/METADATA +300 -0
- xtr_notifier-5.0.0.dist-info/RECORD +105 -0
- xtr_notifier-5.0.0.dist-info/WHEEL +4 -0
- xtr_notifier-5.0.0.dist-info/entry_points.txt +12 -0
- xtr_notifier-5.0.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: xtr-notifier
|
|
3
|
+
description: How to send notifications with xtr-notifier — a notification sent to chat, SMS and push channels picked by the notification or by an importance policy, through provider transports configured by DSN, with failover and round robin, sent in-process or queued through xtr-messenger. Use when an application must alert a chat room or a phone, send an SMS code or a push message, configure a provider by DSN, fall over between providers, route notifications by importance, report failed queued messages to administrators, assert in a test what was notified, add NotifierBundle to an application, or write a provider library (a transport and its factory) for the notifier.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# xtr-notifier
|
|
7
|
+
|
|
8
|
+
A notification says what happened and how much it matters; the notifier picks its channels and
|
|
9
|
+
each channel sends a message through a transport chosen by a DSN. Providers are libraries of
|
|
10
|
+
their own, found by their DSN scheme.
|
|
11
|
+
|
|
12
|
+
## Quick reference
|
|
13
|
+
|
|
14
|
+
- Notify — `await notifier.send(Notification(subject, content=..., importance=...,
|
|
15
|
+
channels=["chat/slack", "sms"]), *recipients)`; no recipients means `NoRecipient()`.
|
|
16
|
+
- Channels — `ChatChannel`, `SmsChannel` (needs a recipient with a `phone`), `PushChannel`, each
|
|
17
|
+
built with a transport or a `message_bus=`. `"chat/slack"` sends on `chat` through `slack`.
|
|
18
|
+
- Policy — `ChannelPolicy({"urgent": ["sms", "chat"]})` picks channels when a notification names
|
|
19
|
+
none; `Importance` is a `StrEnum` (`urgent`, `high`, `medium`, `low`).
|
|
20
|
+
- Direct — `Chatter(transport).send(ChatMessage(...))`, `Texter(transport).send(SmsMessage(...))`;
|
|
21
|
+
with a bus they queue and return `None`.
|
|
22
|
+
- Transports — `Transport(factories, discovery).from_string("a || b")` (failover) or
|
|
23
|
+
`"a && b"` (round robin); `from_strings({...})` gives named `Transports`.
|
|
24
|
+
- Discovery — `TransportFactoryDiscovery(TransportFactoryDiscovery.CHATTER_GROUP |
|
|
25
|
+
TEXTER_GROUP, http_client=..., event_dispatcher=...)`; `null://null` is built in.
|
|
26
|
+
- Messages are frozen: `with_transport`, `with_options`, `with_subject` return copies.
|
|
27
|
+
- Bus — `xtr_notifier.messenger.MessageHandler(transport)`; importing `xtr_notifier.messenger`
|
|
28
|
+
declares the messages' wire names.
|
|
29
|
+
- Every error derives from `NotifierError`.
|
|
30
|
+
|
|
31
|
+
## Send a notification
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from xtr_notifier import Notifier, Transport
|
|
35
|
+
from xtr_notifier.channel import ChannelPolicy, ChatChannel
|
|
36
|
+
from xtr_notifier.notification import Importance, Notification
|
|
37
|
+
from xtr_notifier.transport import TransportFactoryDiscovery
|
|
38
|
+
|
|
39
|
+
discovery = TransportFactoryDiscovery(TransportFactoryDiscovery.CHATTER_GROUP)
|
|
40
|
+
chat = Transport(discovery=discovery).from_strings({"ops": "null://null"})
|
|
41
|
+
notifier = Notifier({"chat": ChatChannel(chat)}, ChannelPolicy({"urgent": ["chat/ops"]}))
|
|
42
|
+
|
|
43
|
+
await notifier.send(Notification("Disk almost full", importance=Importance.URGENT))
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Report an error with `Notification.from_exception(error, channels=["chat"])`; read an importance
|
|
47
|
+
from a log level with `Notification.importance_from_level("error")`.
|
|
48
|
+
|
|
49
|
+
## Render a channel's message yourself
|
|
50
|
+
|
|
51
|
+
Implement `as_chat_message` (or `as_sms_message`, `as_push_message`) on a `Notification`
|
|
52
|
+
subclass; return `None` to fall back to the default message:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from xtr_notifier.message import ChatMessage, MessageOptions
|
|
56
|
+
from xtr_notifier.notification import Notification
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Deployed(Notification):
|
|
60
|
+
def as_chat_message(self, recipient, transport_name):
|
|
61
|
+
return ChatMessage(self.subject, MessageOptions("slack", "deploys", {"unfurl": False}))
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`MessageOptions(kind, recipient_id, values)` holds JSON data only; a provider sends options
|
|
65
|
+
only when `kind` is its own.
|
|
66
|
+
|
|
67
|
+
## Configure providers by DSN
|
|
68
|
+
|
|
69
|
+
A DSN is `scheme://user:password@host?options`; `host` is `default` for the provider's own API.
|
|
70
|
+
Install the provider library (`uv add xtr-telegram-notifier`) and its scheme is served. A scheme
|
|
71
|
+
nothing serves raises `UnsupportedSchemeError` naming the library to install.
|
|
72
|
+
|
|
73
|
+
## Write a provider library
|
|
74
|
+
|
|
75
|
+
See [references/bridges.md](references/bridges.md): an `AbstractTransport`, an
|
|
76
|
+
`AbstractTransportFactory`, one entry point per scheme, and the `xtr_notifier.testing` cases.
|
|
77
|
+
|
|
78
|
+
## Testing
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from xtr_notifier.event_listener import NotificationLoggerListener
|
|
82
|
+
from xtr_notifier.testing import (
|
|
83
|
+
assert_notification_count,
|
|
84
|
+
assert_notification_subject_contains,
|
|
85
|
+
assert_queued_notification_count,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
listener = NotificationLoggerListener() # listen to MessageEvent with on_notification
|
|
89
|
+
assert_notification_count(listener.events, 1, "ops")
|
|
90
|
+
assert_queued_notification_count(listener.events, 0)
|
|
91
|
+
assert_notification_subject_contains(listener.events.get_messages()[0], "Disk")
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Use `null://null` to send nowhere; under the bundle a `NotificationLoggerListener` is registered
|
|
95
|
+
when the event dispatcher bundle is active — get it from the container.
|
|
96
|
+
|
|
97
|
+
## Use in an application
|
|
98
|
+
|
|
99
|
+
`uv run xtr-recipes recipes:sync` applies the recipe shipped with this package: it lists
|
|
100
|
+
`NotifierBundle`, writes `<app>/config/notifier.py`, and `NOTIFIER_CHATTER_DSN` and
|
|
101
|
+
`NOTIFIER_TEXTER_DSN` (both `null://null`) in `.env`. It prints the steps it cannot make.
|
|
102
|
+
|
|
103
|
+
1. **Install** — `uv add "xtr-notifier[messenger]"` (or `xtr-notifier` alone); a provider
|
|
104
|
+
library per provider. xtr-http-client, xtr-event-dispatcher and xtr-messenger are used when
|
|
105
|
+
installed.
|
|
106
|
+
2. **Activate** — `NotifierBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`
|
|
107
|
+
(`from xtr_notifier.bundle import NotifierBundle`).
|
|
108
|
+
3. **Brings along** — the HTTP client, messenger and event dispatcher bundles, each when its
|
|
109
|
+
package is installed.
|
|
110
|
+
4. **Configure** — `<app>/config/notifier.py`, a `@configure` function returning
|
|
111
|
+
`NotifierConfig`: `chatter_transports`, `texter_transports`, `channel_policy`,
|
|
112
|
+
`admin_recipients` (`AdminRecipientConfig`), `notification_on_failed_messages`, `http_client`.
|
|
113
|
+
With no transports there is no channel: sending raises `The "chat" channel does not exist.`
|
|
114
|
+
5. **Queue** — route `ChatMessage`, `SmsMessage`, `PushMessage` in `<app>/config/messenger.py`;
|
|
115
|
+
an unrouted message is sent at once.
|
|
116
|
+
6. **Environment** — `NOTIFIER_CHATTER_DSN`, `NOTIFIER_TEXTER_DSN`. **Ignore** — nothing.
|
|
117
|
+
7. **Use** — inject `NotifierInterface`, `ChatterInterface` or `TexterInterface`.
|
|
118
|
+
8. **Extend** — tag a factory `CHATTER_TRANSPORT_FACTORY_TAG` / `TEXTER_TRANSPORT_FACTORY_TAG`,
|
|
119
|
+
or a channel `CHANNEL_TAG` with `channel="<name>"`.
|
|
120
|
+
9. **Check** — `debug:bundles` shows `notifier` active; `debug:config notifier`.
|
|
121
|
+
10. **Remove** — drop the `BUNDLES` entry, delete `<app>/config/notifier.py` and the two
|
|
122
|
+
variables, then `uv remove xtr-notifier`.
|
|
123
|
+
|
|
124
|
+
## Errors
|
|
125
|
+
|
|
126
|
+
| Error | When |
|
|
127
|
+
|---|---|
|
|
128
|
+
| `LogicError` | No channel can be determined, a channel does not exist or cannot reach the recipient, no transport supports the message, failed-message reporting without its peer bundles |
|
|
129
|
+
| `InvalidArgumentError` | Unreadable DSN, non-boolean option, unknown transport name, policy without the importance |
|
|
130
|
+
| `IncompleteDsnError` / `MissingRequiredOptionError` | A DSN lacks a token or a required option |
|
|
131
|
+
| `UnsupportedSchemeError` | No factory serves the scheme |
|
|
132
|
+
| `UnsupportedMessageTypeError` | A transport got a message of another kind |
|
|
133
|
+
| `TransportError` | The provider was unreachable or refused; the one error failover moves on from |
|
|
134
|
+
| `NotifierRuntimeError` | Every transport of a failover or round robin failed |
|
|
135
|
+
|
|
136
|
+
## Do not
|
|
137
|
+
|
|
138
|
+
- Do not write a DSN with a token into code or configuration; read it with `env("...")`.
|
|
139
|
+
- Do not mutate a message; use its `with_*` copies.
|
|
140
|
+
- Do not put objects in `MessageOptions.values` — JSON data only, or the message will not cross
|
|
141
|
+
the bus.
|
|
142
|
+
- Do not create an HTTP client inside a transport; it takes the application's.
|
|
143
|
+
- Do not catch `TransportError` inside a transport to retry; failover and the bus do that.
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# Writing a provider library
|
|
2
|
+
|
|
3
|
+
A provider library — a *bridge* — teaches the notifier one provider: its transport, the factory
|
|
4
|
+
that builds it from a DSN, and an options builder. It is a package of its own, named
|
|
5
|
+
`xtr-<provider>-notifier` / `xtr_<provider>_notifier`, installed beside `xtr-notifier`.
|
|
6
|
+
|
|
7
|
+
## Layout
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
packages/xtr-acme-notifier/
|
|
11
|
+
├── pyproject.toml
|
|
12
|
+
├── README.md
|
|
13
|
+
├── LICENSE
|
|
14
|
+
├── src/xtr_acme_notifier/
|
|
15
|
+
│ ├── __init__.py
|
|
16
|
+
│ ├── py.typed
|
|
17
|
+
│ ├── acme_transport.py
|
|
18
|
+
│ ├── acme_transport_factory.py
|
|
19
|
+
│ ├── acme_options.py
|
|
20
|
+
│ ├── recipe/ # manifest.toml: [env] ACME_DSN, a notes step
|
|
21
|
+
│ └── .agents/skills/xtr-acme-notifier/SKILL.md
|
|
22
|
+
└── tests/
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Dependencies: `xtr-notifier`, `xtr-http-client-contracts`, `typing-extensions`. The dev group
|
|
26
|
+
adds `xtr-http-client`, for `MockHttpClient`. A bridge ships no bundle.
|
|
27
|
+
|
|
28
|
+
## Advertise the factory
|
|
29
|
+
|
|
30
|
+
One entry point per scheme, in the chatter's group for a chat provider or the texter's for an
|
|
31
|
+
SMS or push provider — both when it serves both:
|
|
32
|
+
|
|
33
|
+
```toml
|
|
34
|
+
[project.entry-points."xtr_notifier.chatter_transport_factories"]
|
|
35
|
+
acme = "xtr_acme_notifier:AcmeTransportFactory"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The entry point names the factory **class**. Discovery builds it with
|
|
39
|
+
`http_client=` and `event_dispatcher=` — the constructor of `AbstractTransportFactory`. A
|
|
40
|
+
factory needing anything else cannot be discovered; such a bridge ships a bundle that registers
|
|
41
|
+
it under `CHATTER_TRANSPORT_FACTORY_TAG` or `TEXTER_TRANSPORT_FACTORY_TAG`.
|
|
42
|
+
|
|
43
|
+
## The transport
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from typing import ClassVar
|
|
47
|
+
|
|
48
|
+
from typing_extensions import override
|
|
49
|
+
from xtr_http_client_contracts.exception import TransportError as HttpTransportError
|
|
50
|
+
|
|
51
|
+
from xtr_notifier.exception import TransportError, UnsupportedMessageTypeError
|
|
52
|
+
from xtr_notifier.message import ChatMessage, MessageInterface, SentMessage
|
|
53
|
+
from xtr_notifier.transport import AbstractTransport
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AcmeTransport(AbstractTransport):
|
|
57
|
+
HOST: ClassVar[str] = "api.acme.test"
|
|
58
|
+
|
|
59
|
+
def __init__(self, token: str, channel: str, http_client, event_dispatcher=None) -> None:
|
|
60
|
+
super().__init__(http_client, event_dispatcher)
|
|
61
|
+
self._token = token
|
|
62
|
+
self._channel = channel
|
|
63
|
+
|
|
64
|
+
@override
|
|
65
|
+
def __str__(self) -> str:
|
|
66
|
+
return f"acme://{self._get_endpoint()}?channel={self._channel}" # never the token
|
|
67
|
+
|
|
68
|
+
@override
|
|
69
|
+
def supports(self, message: MessageInterface) -> bool:
|
|
70
|
+
return isinstance(message, ChatMessage) and (
|
|
71
|
+
message.options is None or message.options.kind == "acme"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
@override
|
|
75
|
+
async def _do_send(self, message: MessageInterface) -> SentMessage:
|
|
76
|
+
if not isinstance(message, ChatMessage):
|
|
77
|
+
raise UnsupportedMessageTypeError(str(self), "ChatMessage", message)
|
|
78
|
+
url = f"{self._get_http_scheme()}://{self._get_endpoint()}/post"
|
|
79
|
+
try:
|
|
80
|
+
response = await self._http_client.request("POST", url, json={"text": message.subject})
|
|
81
|
+
except HttpTransportError as error:
|
|
82
|
+
raise TransportError("Could not reach the remote Acme server.") from error
|
|
83
|
+
async with response:
|
|
84
|
+
answer = await response.to_array(throw=False)
|
|
85
|
+
if response.get_status_code() != 200:
|
|
86
|
+
raise TransportError(
|
|
87
|
+
f"Unable to post the Acme message: {answer.get('error')}.", response
|
|
88
|
+
)
|
|
89
|
+
return SentMessage(message, str(self), message_id=str(answer.get("id")))
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
- `send` is inherited: it announces the message, calls `_do_send`, and announces the outcome.
|
|
93
|
+
- Raise `TransportError` for anything the provider refused or never answered — it is what
|
|
94
|
+
failover moves on from — and `UnsupportedMessageTypeError` for a message of another kind.
|
|
95
|
+
A provider that never answered leaves no response: the client raised its own
|
|
96
|
+
`TransportError`, which becomes the cause.
|
|
97
|
+
- Read the answer inside `async with response`, so the connection and the kept body are let go.
|
|
98
|
+
- A file to upload stays a path in the options; open it `"rb"` in `_do_send`, send the fields as
|
|
99
|
+
`body=` — the client sends a mapping holding a file as `multipart/form-data` — and close it
|
|
100
|
+
in a `finally`.
|
|
101
|
+
- The string form names the endpoint and the options, never a credential.
|
|
102
|
+
|
|
103
|
+
## The factory
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from collections.abc import Sequence
|
|
107
|
+
|
|
108
|
+
from typing_extensions import override
|
|
109
|
+
|
|
110
|
+
from xtr_notifier.exception import UnsupportedSchemeError
|
|
111
|
+
from xtr_notifier.transport import AbstractTransportFactory, Dsn
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class AcmeTransportFactory(AbstractTransportFactory):
|
|
115
|
+
@override
|
|
116
|
+
def create(self, dsn: Dsn) -> AcmeTransport:
|
|
117
|
+
if not self.supports(dsn):
|
|
118
|
+
raise UnsupportedSchemeError(dsn.scheme, "acme", self._get_supported_schemes())
|
|
119
|
+
transport = AcmeTransport(
|
|
120
|
+
self._get_user(dsn), # IncompleteDsnError when missing
|
|
121
|
+
dsn.get_required_option("channel"), # MissingRequiredOptionError
|
|
122
|
+
self._http_client,
|
|
123
|
+
self._event_dispatcher,
|
|
124
|
+
)
|
|
125
|
+
host = None if dsn.host == "default" else dsn.host
|
|
126
|
+
return transport.set_host(host).set_port(dsn.port).set_ssl(self._get_ssl(dsn))
|
|
127
|
+
|
|
128
|
+
@override
|
|
129
|
+
def _get_supported_schemes(self) -> Sequence[str]:
|
|
130
|
+
return ("acme",)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Options
|
|
134
|
+
|
|
135
|
+
A builder implementing `MessageOptionsInterface` — a `recipient_id` property and
|
|
136
|
+
`to_options()` — renders a frozen `MessageOptions("acme", recipient_id, values)`. Keep `values`
|
|
137
|
+
JSON data, so a message carrying it crosses a message bus. The transport reads
|
|
138
|
+
`message.options.values`.
|
|
139
|
+
|
|
140
|
+
## Tests
|
|
141
|
+
|
|
142
|
+
Hold the transport and the factory to what every provider owes:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
import pytest
|
|
146
|
+
from xtr_http_client import MockHttpClient
|
|
147
|
+
|
|
148
|
+
from xtr_notifier.message import ChatMessage, SmsMessage
|
|
149
|
+
from xtr_notifier.testing import (
|
|
150
|
+
FactoryCases,
|
|
151
|
+
TransportCases,
|
|
152
|
+
assert_factory_cases,
|
|
153
|
+
assert_transport_cases,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _transport() -> AcmeTransport:
|
|
158
|
+
return AcmeTransport("TOKEN", "ops", MockHttpClient())
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@pytest.mark.anyio
|
|
162
|
+
async def test_the_transport_does_what_every_transport_owes() -> None:
|
|
163
|
+
await assert_transport_cases(
|
|
164
|
+
TransportCases(
|
|
165
|
+
create_transport=_transport,
|
|
166
|
+
to_string=[("acme://api.acme.test?channel=ops", _transport())],
|
|
167
|
+
supported=[ChatMessage("Hello")],
|
|
168
|
+
unsupported=[SmsMessage("+33", "Hello")],
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_the_factory_does_what_every_factory_owes() -> None:
|
|
174
|
+
assert_factory_cases(
|
|
175
|
+
FactoryCases(
|
|
176
|
+
create_factory=lambda: AcmeTransportFactory(MockHttpClient()),
|
|
177
|
+
supports=[(True, "acme://TOKEN@default"), (False, "other://TOKEN@default")],
|
|
178
|
+
create=[("acme://api.acme.test?channel=ops", "acme://TOKEN@default?channel=ops")],
|
|
179
|
+
unsupported_scheme=[("other://TOKEN@default?channel=ops", None)],
|
|
180
|
+
incomplete_dsn=[("acme://default?channel=ops", "User is not set.")],
|
|
181
|
+
missing_required_option=[("acme://TOKEN@default", 'The option "channel"')],
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Then pin the exact request with a `MockHttpClient` whose callable checks the method, URL,
|
|
187
|
+
headers and body, and every error the provider answers mapped to `TransportError`.
|
|
188
|
+
|
|
189
|
+
## Outside the package
|
|
190
|
+
|
|
191
|
+
- A row in the core's `UnsupportedSchemeError` table (`scheme` → distribution), so a missing
|
|
192
|
+
install is named.
|
|
193
|
+
- The root `pyproject.toml` workspace source, `CREDITS.md`, the root README table and graph.
|
|
194
|
+
- A recipe: `[env] ACME_DSN = "acme://TOKEN@default?channel=CHANNEL"` and a `notes.steps` line
|
|
195
|
+
adding `acme=env("ACME_DSN")` to `chatter_transports` in `config/notifier.py` — no `[bundles]`,
|
|
196
|
+
no `[files]` — advertised as `[project.entry-points."xtr_recipes"] acme =
|
|
197
|
+
"xtr_acme_notifier.recipe"`. The README's *Use in an application* lists Install, Recipe,
|
|
198
|
+
Configure, Environment, Remove and Check, and points to xtr-notifier's for the rest.
|
|
199
|
+
|
|
200
|
+
xtr-telegram-notifier is a complete example of all of the above.
|
xtr_notifier/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Notifications sent to chat, SMS and push channels.
|
|
2
|
+
|
|
3
|
+
A notification says what happened and how much it matters. The notifier picks the channels
|
|
4
|
+
it goes to — from the notification itself or from a policy keyed by its importance — and each
|
|
5
|
+
channel hands a message to a transport configured by DSN, or to a message bus.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .admin_recipients_provider_interface import AdminRecipientsProviderInterface
|
|
11
|
+
from .chatter import Chatter
|
|
12
|
+
from .chatter_interface import ChatterInterface
|
|
13
|
+
from .notifier import Notifier
|
|
14
|
+
from .notifier_interface import NotifierInterface
|
|
15
|
+
from .texter import Texter
|
|
16
|
+
from .texter_interface import TexterInterface
|
|
17
|
+
from .transport import Transport
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"AdminRecipientsProviderInterface",
|
|
21
|
+
"Chatter",
|
|
22
|
+
"ChatterInterface",
|
|
23
|
+
"Notifier",
|
|
24
|
+
"NotifierInterface",
|
|
25
|
+
"Texter",
|
|
26
|
+
"TexterInterface",
|
|
27
|
+
"Transport",
|
|
28
|
+
]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""What the chatter and the texter share: send now, or queue on a bus."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
6
|
+
|
|
7
|
+
from xtr_notifier.event import MessageEvent
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from xtr_event_dispatcher_contracts import EventDispatcherInterface
|
|
11
|
+
from xtr_messenger import MessageBusInterface
|
|
12
|
+
|
|
13
|
+
from xtr_notifier.message import MessageInterface, SentMessage
|
|
14
|
+
from xtr_notifier.transport import TransportInterface
|
|
15
|
+
|
|
16
|
+
__all__ = ["MessageSender"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MessageSender:
|
|
20
|
+
"""Sends through a transport, or — given a message bus — queues on it instead.
|
|
21
|
+
|
|
22
|
+
With a bus the message is dispatched there and a handler sends it through
|
|
23
|
+
the same transport later, possibly in another process; ``send`` then
|
|
24
|
+
returns ``None``, since nothing was sent yet. The queued message is
|
|
25
|
+
announced as a :class:`~xtr_notifier.event.MessageEvent` with ``queued``
|
|
26
|
+
set, so a listener sees it once whichever way it leaves.
|
|
27
|
+
|
|
28
|
+
The bus is only ever called, so the messenger package is needed only by an
|
|
29
|
+
application that hands one over.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
__slots__: ClassVar[tuple[str, ...]] = ("_event_dispatcher", "_message_bus", "_transport")
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
transport: TransportInterface,
|
|
37
|
+
message_bus: MessageBusInterface | None = None,
|
|
38
|
+
event_dispatcher: EventDispatcherInterface | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Send through ``transport``, or queue on ``message_bus`` when given one.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
transport: What sends the messages, now or when the bus hands them back.
|
|
44
|
+
message_bus: Where messages are queued instead of sent now; ``None``
|
|
45
|
+
sends every message at once.
|
|
46
|
+
event_dispatcher: Where a queued message is announced; ``None``
|
|
47
|
+
announces nothing.
|
|
48
|
+
"""
|
|
49
|
+
self._transport: TransportInterface = transport
|
|
50
|
+
self._message_bus: MessageBusInterface | None = message_bus
|
|
51
|
+
self._event_dispatcher: EventDispatcherInterface | None = event_dispatcher
|
|
52
|
+
|
|
53
|
+
def supports(self, message: MessageInterface) -> bool:
|
|
54
|
+
"""Tell whether the transport sends ``message``."""
|
|
55
|
+
return self._transport.supports(message)
|
|
56
|
+
|
|
57
|
+
async def send(self, message: MessageInterface) -> SentMessage | None:
|
|
58
|
+
"""Send ``message`` and report it, or queue it on the bus and return ``None``."""
|
|
59
|
+
if self._message_bus is None:
|
|
60
|
+
return await self._transport.send(message)
|
|
61
|
+
if self._event_dispatcher is not None:
|
|
62
|
+
_ = await self._event_dispatcher.dispatch(MessageEvent(message, queued=True))
|
|
63
|
+
_ = await self._message_bus.dispatch(message)
|
|
64
|
+
return None
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Who to tell when something goes wrong."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
|
|
10
|
+
from xtr_notifier.recipient import RecipientInterface
|
|
11
|
+
|
|
12
|
+
__all__ = ["AdminRecipientsProviderInterface"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@runtime_checkable
|
|
16
|
+
class AdminRecipientsProviderInterface(Protocol):
|
|
17
|
+
"""Names the administrators of the application, to send its own failures to.
|
|
18
|
+
|
|
19
|
+
A failed queued message, for one, is reported to them; a notifier without
|
|
20
|
+
administrators reports it to nobody in particular.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def admin_recipients(self) -> Sequence[RecipientInterface]:
|
|
25
|
+
"""The administrators, in the order they were configured."""
|
|
26
|
+
...
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""The xtr-dependency-injection bundle for xtr-notifier."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .admin_recipient_config import AdminRecipientConfig
|
|
6
|
+
from .notifier_bundle import (
|
|
7
|
+
CHANNEL_TAG,
|
|
8
|
+
CHATTER_TRANSPORT_FACTORY_TAG,
|
|
9
|
+
TEXTER_TRANSPORT_FACTORY_TAG,
|
|
10
|
+
NotifierBundle,
|
|
11
|
+
)
|
|
12
|
+
from .notifier_config import NotifierConfig
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"CHANNEL_TAG",
|
|
16
|
+
"CHATTER_TRANSPORT_FACTORY_TAG",
|
|
17
|
+
"TEXTER_TRANSPORT_FACTORY_TAG",
|
|
18
|
+
"AdminRecipientConfig",
|
|
19
|
+
"NotifierBundle",
|
|
20
|
+
"NotifierConfig",
|
|
21
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Sends the chat messages queued on the bus through the chatter's transports.
|
|
2
|
+
|
|
3
|
+
Scanned by the bundle only when the messenger bundle is active and chat
|
|
4
|
+
transports are configured, so the bus never holds a handler it cannot build.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Annotated, final
|
|
10
|
+
|
|
11
|
+
from xtr_dependency_injection import Target
|
|
12
|
+
from xtr_messenger import as_message_handler
|
|
13
|
+
|
|
14
|
+
from xtr_notifier.message import ChatMessage, SentMessage
|
|
15
|
+
from xtr_notifier.messenger import MessageHandler
|
|
16
|
+
from xtr_notifier.transport import Transports
|
|
17
|
+
|
|
18
|
+
__all__ = ["ChatMessageHandler"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@as_message_handler(ChatMessage)
|
|
22
|
+
@final
|
|
23
|
+
class ChatMessageHandler:
|
|
24
|
+
"""Hands each queued chat message to the chatter's transports."""
|
|
25
|
+
|
|
26
|
+
__slots__ = ("_handler",)
|
|
27
|
+
|
|
28
|
+
def __init__(self, transports: Annotated[Transports, Target("chatter")]) -> None:
|
|
29
|
+
"""Send through ``transports``."""
|
|
30
|
+
self._handler = MessageHandler(transports)
|
|
31
|
+
|
|
32
|
+
async def __call__(self, message: ChatMessage) -> SentMessage:
|
|
33
|
+
"""Send ``message``; a provider's refusal makes the bus retry it."""
|
|
34
|
+
return await self._handler(message)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""The listener reporting failed messages, built only when the bus is there to fail them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from xtr_notifier.event_listener.send_failed_message_to_notifier_listener import (
|
|
6
|
+
SendFailedMessageToNotifierListener,
|
|
7
|
+
)
|
|
8
|
+
from xtr_notifier.notifier_interface import NotifierInterface
|
|
9
|
+
|
|
10
|
+
__all__ = ["failed_message_listener"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def failed_message_listener(notifier: NotifierInterface) -> SendFailedMessageToNotifierListener:
|
|
14
|
+
"""Report every message the bus gives up on through ``notifier``."""
|
|
15
|
+
return SendFailedMessageToNotifierListener(notifier)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""The bus side of the bundle: which messages are queued, and the bus they go to.
|
|
2
|
+
|
|
3
|
+
Imported only when the messenger bundle is active.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from xtr_messenger import MessageBusConfig, MessageBusInterface
|
|
9
|
+
|
|
10
|
+
from xtr_notifier.messenger import MESSAGE_NAMES
|
|
11
|
+
|
|
12
|
+
from ._peers import MessageBusPeer
|
|
13
|
+
|
|
14
|
+
__all__ = ["message_bus_peer", "routed"]
|
|
15
|
+
|
|
16
|
+
_WILDCARD = "*"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def message_bus_peer(message_bus: MessageBusInterface) -> MessageBusPeer:
|
|
20
|
+
"""Hold the application's message bus."""
|
|
21
|
+
return MessageBusPeer(message_bus)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def routed(config: MessageBusConfig, message_type: type) -> bool:
|
|
25
|
+
"""Tell whether the bus does something with ``message_type`` when it is dispatched.
|
|
26
|
+
|
|
27
|
+
A message the routing names — by its class, its wire name or the ``*``
|
|
28
|
+
catch-all — goes to a transport; with ``handle_unrouted`` any message is
|
|
29
|
+
handled in-process. Anything else would be dropped quietly by the bus, so
|
|
30
|
+
it is sent at once instead: the behaviour stays the same until the
|
|
31
|
+
application routes the message.
|
|
32
|
+
"""
|
|
33
|
+
if config.handle_unrouted:
|
|
34
|
+
return True
|
|
35
|
+
names = {_WILDCARD, MESSAGE_NAMES[message_type]}
|
|
36
|
+
return any(
|
|
37
|
+
key in names if isinstance(key, str) else issubclass(message_type, key)
|
|
38
|
+
for key in config.routing
|
|
39
|
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""The factories of the peer holders, registered only when their peer is active.
|
|
2
|
+
|
|
3
|
+
Imported by the bundle only once the peer is known to be active, since each
|
|
4
|
+
names a type of that peer's package.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING, Annotated
|
|
10
|
+
|
|
11
|
+
from xtr_dependency_injection import Target
|
|
12
|
+
from xtr_event_dispatcher_contracts import EventDispatcherInterface
|
|
13
|
+
from xtr_http_client_contracts import HttpClientInterface
|
|
14
|
+
|
|
15
|
+
from ._peers import EventDispatcherPeer, HttpClientPeer
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
|
|
20
|
+
__all__ = ["event_dispatcher_peer", "http_client_peer"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def event_dispatcher_peer(event_dispatcher: EventDispatcherInterface) -> EventDispatcherPeer:
|
|
24
|
+
"""Hold the application's default event dispatcher."""
|
|
25
|
+
return EventDispatcherPeer(event_dispatcher)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def http_client_peer(qualifier: str | None) -> Callable[..., HttpClientPeer]:
|
|
29
|
+
"""Return the factory holding the HTTP client ``qualifier`` names; ``None`` the default.
|
|
30
|
+
|
|
31
|
+
The qualifier is configuration, so the client's annotation is written onto
|
|
32
|
+
the factory at runtime rather than in its signature.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def build(http_client: HttpClientInterface) -> HttpClientPeer:
|
|
36
|
+
return HttpClientPeer(http_client)
|
|
37
|
+
|
|
38
|
+
build.__annotations__ = {
|
|
39
|
+
"http_client": (
|
|
40
|
+
HttpClientInterface
|
|
41
|
+
if qualifier is None
|
|
42
|
+
else Annotated[HttpClientInterface, Target(qualifier)]
|
|
43
|
+
),
|
|
44
|
+
"return": HttpClientPeer,
|
|
45
|
+
}
|
|
46
|
+
return build
|