xtr-notifier 5.0.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.
Files changed (105) hide show
  1. xtr_notifier-5.0.0/LICENSE +22 -0
  2. xtr_notifier-5.0.0/PKG-INFO +300 -0
  3. xtr_notifier-5.0.0/README.md +270 -0
  4. xtr_notifier-5.0.0/pyproject.toml +186 -0
  5. xtr_notifier-5.0.0/pyproject.toml.orig +197 -0
  6. xtr_notifier-5.0.0/src/xtr_notifier/.agents/skills/xtr-notifier/SKILL.md +143 -0
  7. xtr_notifier-5.0.0/src/xtr_notifier/.agents/skills/xtr-notifier/references/bridges.md +200 -0
  8. xtr_notifier-5.0.0/src/xtr_notifier/__init__.py +28 -0
  9. xtr_notifier-5.0.0/src/xtr_notifier/_message_sender.py +64 -0
  10. xtr_notifier-5.0.0/src/xtr_notifier/admin_recipients_provider_interface.py +26 -0
  11. xtr_notifier-5.0.0/src/xtr_notifier/bundle/__init__.py +21 -0
  12. xtr_notifier-5.0.0/src/xtr_notifier/bundle/_chat_message_handler.py +34 -0
  13. xtr_notifier-5.0.0/src/xtr_notifier/bundle/_failed_messages.py +15 -0
  14. xtr_notifier-5.0.0/src/xtr_notifier/bundle/_message_bus.py +39 -0
  15. xtr_notifier-5.0.0/src/xtr_notifier/bundle/_peer_factories.py +46 -0
  16. xtr_notifier-5.0.0/src/xtr_notifier/bundle/_peer_wiring.py +102 -0
  17. xtr_notifier-5.0.0/src/xtr_notifier/bundle/_peers.py +52 -0
  18. xtr_notifier-5.0.0/src/xtr_notifier/bundle/_services.py +221 -0
  19. xtr_notifier-5.0.0/src/xtr_notifier/bundle/_text_message_handler.py +35 -0
  20. xtr_notifier-5.0.0/src/xtr_notifier/bundle/admin_recipient_config.py +35 -0
  21. xtr_notifier-5.0.0/src/xtr_notifier/bundle/notifier_bundle.py +233 -0
  22. xtr_notifier-5.0.0/src/xtr_notifier/bundle/notifier_config.py +74 -0
  23. xtr_notifier-5.0.0/src/xtr_notifier/channel/__init__.py +21 -0
  24. xtr_notifier-5.0.0/src/xtr_notifier/channel/abstract_channel.py +59 -0
  25. xtr_notifier-5.0.0/src/xtr_notifier/channel/channel_interface.py +41 -0
  26. xtr_notifier-5.0.0/src/xtr_notifier/channel/channel_policy.py +51 -0
  27. xtr_notifier-5.0.0/src/xtr_notifier/channel/channel_policy_interface.py +19 -0
  28. xtr_notifier-5.0.0/src/xtr_notifier/channel/chat_channel.py +51 -0
  29. xtr_notifier-5.0.0/src/xtr_notifier/channel/push_channel.py +51 -0
  30. xtr_notifier-5.0.0/src/xtr_notifier/channel/sms_channel.py +58 -0
  31. xtr_notifier-5.0.0/src/xtr_notifier/chatter.py +28 -0
  32. xtr_notifier-5.0.0/src/xtr_notifier/chatter_interface.py +28 -0
  33. xtr_notifier-5.0.0/src/xtr_notifier/event/__init__.py +10 -0
  34. xtr_notifier-5.0.0/src/xtr_notifier/event/failed_message_event.py +39 -0
  35. xtr_notifier-5.0.0/src/xtr_notifier/event/message_event.py +39 -0
  36. xtr_notifier-5.0.0/src/xtr_notifier/event/notification_events.py +49 -0
  37. xtr_notifier-5.0.0/src/xtr_notifier/event/sent_message_event.py +28 -0
  38. xtr_notifier-5.0.0/src/xtr_notifier/event_listener/__init__.py +11 -0
  39. xtr_notifier-5.0.0/src/xtr_notifier/event_listener/notification_logger_listener.py +60 -0
  40. xtr_notifier-5.0.0/src/xtr_notifier/event_listener/send_failed_message_to_notifier_listener.py +61 -0
  41. xtr_notifier-5.0.0/src/xtr_notifier/exception/__init__.py +31 -0
  42. xtr_notifier-5.0.0/src/xtr_notifier/exception/incomplete_dsn_error.py +31 -0
  43. xtr_notifier-5.0.0/src/xtr_notifier/exception/invalid_argument_error.py +15 -0
  44. xtr_notifier-5.0.0/src/xtr_notifier/exception/length_error.py +15 -0
  45. xtr_notifier-5.0.0/src/xtr_notifier/exception/logic_error.py +16 -0
  46. xtr_notifier-5.0.0/src/xtr_notifier/exception/missing_required_option_error.py +27 -0
  47. xtr_notifier-5.0.0/src/xtr_notifier/exception/multiple_exclusive_options_used_error.py +42 -0
  48. xtr_notifier-5.0.0/src/xtr_notifier/exception/notifier_error.py +9 -0
  49. xtr_notifier-5.0.0/src/xtr_notifier/exception/notifier_runtime_error.py +15 -0
  50. xtr_notifier-5.0.0/src/xtr_notifier/exception/transport_error.py +43 -0
  51. xtr_notifier-5.0.0/src/xtr_notifier/exception/unsupported_message_type_error.py +37 -0
  52. xtr_notifier-5.0.0/src/xtr_notifier/exception/unsupported_options_error.py +37 -0
  53. xtr_notifier-5.0.0/src/xtr_notifier/exception/unsupported_scheme_error.py +77 -0
  54. xtr_notifier-5.0.0/src/xtr_notifier/message/__init__.py +23 -0
  55. xtr_notifier-5.0.0/src/xtr_notifier/message/chat_message.py +62 -0
  56. xtr_notifier-5.0.0/src/xtr_notifier/message/message_interface.py +35 -0
  57. xtr_notifier-5.0.0/src/xtr_notifier/message/message_options.py +45 -0
  58. xtr_notifier-5.0.0/src/xtr_notifier/message/message_options_interface.py +30 -0
  59. xtr_notifier-5.0.0/src/xtr_notifier/message/null_message.py +59 -0
  60. xtr_notifier-5.0.0/src/xtr_notifier/message/push_message.py +69 -0
  61. xtr_notifier-5.0.0/src/xtr_notifier/message/sent_message.py +76 -0
  62. xtr_notifier-5.0.0/src/xtr_notifier/message/sms_message.py +95 -0
  63. xtr_notifier-5.0.0/src/xtr_notifier/messenger/__init__.py +36 -0
  64. xtr_notifier-5.0.0/src/xtr_notifier/messenger/message_handler.py +38 -0
  65. xtr_notifier-5.0.0/src/xtr_notifier/notification/__init__.py +16 -0
  66. xtr_notifier-5.0.0/src/xtr_notifier/notification/chat_notification_interface.py +33 -0
  67. xtr_notifier-5.0.0/src/xtr_notifier/notification/importance.py +20 -0
  68. xtr_notifier-5.0.0/src/xtr_notifier/notification/notification.py +171 -0
  69. xtr_notifier-5.0.0/src/xtr_notifier/notification/notification_summary.py +42 -0
  70. xtr_notifier-5.0.0/src/xtr_notifier/notification/push_notification_interface.py +33 -0
  71. xtr_notifier-5.0.0/src/xtr_notifier/notification/sms_notification_interface.py +33 -0
  72. xtr_notifier-5.0.0/src/xtr_notifier/notifier.py +137 -0
  73. xtr_notifier-5.0.0/src/xtr_notifier/notifier_interface.py +24 -0
  74. xtr_notifier-5.0.0/src/xtr_notifier/py.typed +0 -0
  75. xtr_notifier-5.0.0/src/xtr_notifier/recipe/__init__.py +15 -0
  76. xtr_notifier-5.0.0/src/xtr_notifier/recipe/files/config/notifier.py.tmpl +29 -0
  77. xtr_notifier-5.0.0/src/xtr_notifier/recipe/manifest.toml +31 -0
  78. xtr_notifier-5.0.0/src/xtr_notifier/recipient/__init__.py +17 -0
  79. xtr_notifier-5.0.0/src/xtr_notifier/recipient/email_recipient_interface.py +24 -0
  80. xtr_notifier-5.0.0/src/xtr_notifier/recipient/no_recipient.py +20 -0
  81. xtr_notifier-5.0.0/src/xtr_notifier/recipient/recipient.py +66 -0
  82. xtr_notifier-5.0.0/src/xtr_notifier/recipient/recipient_interface.py +18 -0
  83. xtr_notifier-5.0.0/src/xtr_notifier/recipient/sms_recipient_interface.py +19 -0
  84. xtr_notifier-5.0.0/src/xtr_notifier/testing/__init__.py +52 -0
  85. xtr_notifier-5.0.0/src/xtr_notifier/testing/_factory_assertions.py +86 -0
  86. xtr_notifier-5.0.0/src/xtr_notifier/testing/_failures.py +21 -0
  87. xtr_notifier-5.0.0/src/xtr_notifier/testing/_notification_assertions.py +120 -0
  88. xtr_notifier-5.0.0/src/xtr_notifier/testing/_transport_assertions.py +98 -0
  89. xtr_notifier-5.0.0/src/xtr_notifier/testing/factory_cases.py +56 -0
  90. xtr_notifier-5.0.0/src/xtr_notifier/testing/transport_cases.py +48 -0
  91. xtr_notifier-5.0.0/src/xtr_notifier/texter.py +28 -0
  92. xtr_notifier-5.0.0/src/xtr_notifier/texter_interface.py +28 -0
  93. xtr_notifier-5.0.0/src/xtr_notifier/transport/__init__.py +31 -0
  94. xtr_notifier-5.0.0/src/xtr_notifier/transport/abstract_transport.py +123 -0
  95. xtr_notifier-5.0.0/src/xtr_notifier/transport/abstract_transport_factory.py +93 -0
  96. xtr_notifier-5.0.0/src/xtr_notifier/transport/dsn.py +187 -0
  97. xtr_notifier-5.0.0/src/xtr_notifier/transport/failover_transport.py +66 -0
  98. xtr_notifier-5.0.0/src/xtr_notifier/transport/null_transport.py +56 -0
  99. xtr_notifier-5.0.0/src/xtr_notifier/transport/null_transport_factory.py +66 -0
  100. xtr_notifier-5.0.0/src/xtr_notifier/transport/round_robin_transport.py +135 -0
  101. xtr_notifier-5.0.0/src/xtr_notifier/transport/transport.py +106 -0
  102. xtr_notifier-5.0.0/src/xtr_notifier/transport/transport_factory_discovery.py +154 -0
  103. xtr_notifier-5.0.0/src/xtr_notifier/transport/transport_factory_interface.py +32 -0
  104. xtr_notifier-5.0.0/src/xtr_notifier/transport/transport_interface.py +33 -0
  105. xtr_notifier-5.0.0/src/xtr_notifier/transport/transports.py +71 -0
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xterr
4
+ Copyright (c) 2019-present Fabien Potencier
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -0,0 +1,300 @@
1
+ Metadata-Version: 2.4
2
+ Name: xtr-notifier
3
+ Version: 5.0.0
4
+ Summary: Send notifications to chat, SMS and push channels — one notification, the channels its importance or its recipient call for, any provider configured by DSN.
5
+ Keywords: notifier,notification,chat,sms,push,dsn,asyncio
6
+ Author: Xterr
7
+ Author-email: Xterr <me@xterr.dev>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: AsyncIO
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Typing :: Typed
18
+ Requires-Dist: typing-extensions>=4.4
19
+ Requires-Dist: xtr-clock>=5.0,<6
20
+ Requires-Dist: xtr-event-dispatcher-contracts>=5.0,<6
21
+ Requires-Dist: xtr-http-client-contracts>=5.0,<6
22
+ Requires-Dist: xtr-logging-contracts>=5.0,<6
23
+ Requires-Dist: xtr-service-contracts>=5.0,<6
24
+ Requires-Dist: xtr-dependency-injection>=5.0,<6 ; extra == 'di'
25
+ Requires-Dist: xtr-messenger>=5.0,<6 ; extra == 'messenger'
26
+ Requires-Python: >=3.11
27
+ Provides-Extra: di
28
+ Provides-Extra: messenger
29
+ Description-Content-Type: text/markdown
30
+
31
+ <div align="center">
32
+
33
+ # xtr-notifier
34
+
35
+ **Send notifications to chat, SMS and push channels — one notification, the channels its importance or its recipient call for, any provider configured by DSN.**
36
+
37
+ <img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
38
+ <img alt="asyncio" src="https://img.shields.io/badge/asyncio-native-1f6feb">
39
+ <img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
40
+ <img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
41
+
42
+ </div>
43
+
44
+ ---
45
+
46
+ ## Why?
47
+
48
+ "The disk is almost full" has to reach someone: a chat room during the day, a phone at night,
49
+ both when it is urgent. Code that says *what happened* should not also decide *which provider,
50
+ which channel, which token*. Here a notification carries a subject, a body and an importance;
51
+ the notifier picks the channels — from the notification, or from a policy keyed by its
52
+ importance — and each channel hands a message to a transport chosen by a DSN
53
+ (`slack://TOKEN@default?channel=ops`). Changing provider is changing a string.
54
+
55
+ - **Channels** — `chat`, `sms` and `push`, each built from the notification or rendered by it.
56
+ - **Transports by DSN** — one string per provider; `a || b` falls over, `a && b` takes turns.
57
+ - **Providers are libraries** — each installed beside this one and found by its DSN scheme.
58
+ - **Queueable** — messages are frozen values, so they cross a message bus and are sent by a
59
+ worker.
60
+ - **Testable** — a logger listener records every message; `xtr_notifier.testing` asserts on it,
61
+ and holds a provider library's transport and factory to what every provider owes.
62
+
63
+ ## Install
64
+
65
+ ```sh
66
+ uv add xtr-notifier
67
+ uv add "xtr-notifier[messenger]" # queue messages through xtr-messenger
68
+ ```
69
+
70
+ A provider's transport reaches it through an HTTP client: install
71
+ [xtr-http-client](../xtr-http-client), or bring any
72
+ [xtr-http-client-contracts](../xtr-http-client-contracts) implementation.
73
+
74
+ ## Quick start
75
+
76
+ ```python
77
+ from xtr_notifier import Notifier, Transport
78
+ from xtr_notifier.channel import ChatChannel, ChannelPolicy, SmsChannel
79
+ from xtr_notifier.notification import Importance, Notification
80
+ from xtr_notifier.recipient import Recipient
81
+ from xtr_notifier.transport import TransportFactoryDiscovery
82
+
83
+ chat = Transport(
84
+ discovery=TransportFactoryDiscovery(TransportFactoryDiscovery.CHATTER_GROUP, http_client=client)
85
+ ).from_strings({"ops": "null://null"})
86
+ texts = Transport(
87
+ discovery=TransportFactoryDiscovery(TransportFactoryDiscovery.TEXTER_GROUP, http_client=client)
88
+ ).from_strings({"sms": "null://null"})
89
+
90
+ notifier = Notifier(
91
+ {"chat": ChatChannel(chat), "sms": SmsChannel(texts)},
92
+ ChannelPolicy({"urgent": ["sms", "chat"], "high": ["chat"]}),
93
+ )
94
+
95
+ await notifier.send(
96
+ Notification("Disk almost full", content="98% used on db-1", importance=Importance.URGENT),
97
+ Recipient(phone="+3312345678"),
98
+ )
99
+ ```
100
+
101
+ `null://null` sends nowhere, and needs no HTTP client: swap it for a provider's DSN once its
102
+ library is installed.
103
+
104
+ ## Notifications
105
+
106
+ `Notification(subject, *, content="", importance=Importance.HIGH, emoji="", channels=(),
107
+ exception=None)` is built in one call and read through properties.
108
+
109
+ - **Channels** — `channels=["chat/slack", "sms"]` names them; the part after `/` is the
110
+ transport. Left empty, the notifier asks its `ChannelPolicy` for the importance's channels.
111
+ - **Importance** — `Importance.URGENT`, `HIGH`, `MEDIUM`, `LOW`, a `StrEnum` whose values key
112
+ the policy. `Notification.importance_from_level("critical")` maps a log level: critical and
113
+ above is urgent, error high, the rest low.
114
+ - **Exceptions** — `Notification.from_exception(error, channels=...)` names the subject after
115
+ the error and keeps its traceback; the emoji follows the importance.
116
+ - **Per recipient** — override `get_channels(recipient)`, or implement
117
+ `as_chat_message(recipient, transport_name)`, `as_sms_message(...)` or
118
+ `as_push_message(...)` to render a channel's message yourself (`ChatNotificationInterface`
119
+ and friends, in `xtr_notifier.notification.<kind>_notification_interface`).
120
+
121
+ Recipients are anything with an `email` or a `phone`; `Recipient(email, phone)` is the plain one,
122
+ and `NoRecipient()` — what `send` uses with no recipient — is a chat room or a topic.
123
+
124
+ ## Messages, the chatter and the texter
125
+
126
+ A channel turns a notification into a message: `ChatMessage(subject)`,
127
+ `SmsMessage(phone, subject, from_="")`, `PushMessage(subject, content)`. Messages are frozen;
128
+ `with_transport(...)`, `with_options(...)` and friends return copies. Provider options — blocks,
129
+ buttons, a parse mode — are a frozen `MessageOptions(kind, recipient_id, values)` that a
130
+ provider's builder renders, so a message survives a trip through a bus.
131
+
132
+ When the code has a message rather than a notification, send it directly:
133
+
134
+ ```python
135
+ from xtr_notifier import Chatter, Texter
136
+
137
+ await Chatter(chat).send(ChatMessage("Deployed 4.1.0", transport="ops"))
138
+ await Texter(texts).send(SmsMessage("+3312345678", "Your code is 4812"))
139
+ ```
140
+
141
+ Given a message bus, the chatter, the texter and the channels queue instead of sending, and
142
+ return `None`; `xtr_notifier.messenger.MessageHandler(transport)` sends what the bus hands back.
143
+
144
+ ## Transports and DSNs
145
+
146
+ `Transport(factories, discovery)` builds transports from DSNs: one DSN gives one transport,
147
+ `a || b` a `FailoverTransport` (sticks to the first that works), `a && b` a
148
+ `RoundRobinTransport` (takes turns). A transport that raises `TransportError` is skipped for 60
149
+ seconds. `from_strings({...})` gives `Transports`, which sends a message through the transport
150
+ it names, or the first that supports it.
151
+
152
+ A DSN is `scheme://user:password@host:port/path?option=value`; `host` is `default` for the
153
+ provider's own API host. A factory serves a scheme: the ones given first, then those installed
154
+ libraries advertise in the group the builder reads — the chatter's
155
+ (`xtr_notifier.chatter_transport_factories`) or the texter's
156
+ (`xtr_notifier.texter_transport_factories`). A scheme nothing serves raises
157
+ `UnsupportedSchemeError`, naming the library to install when it is a known one.
158
+
159
+ ## Providers
160
+
161
+ Each provider is a library of its own, installed beside this one; its factory is found by its
162
+ DSN scheme with no wiring. This package ships one transport, `null`, in both groups.
163
+
164
+ | Library | Scheme | Kind | DSN |
165
+ |---|---|---|---|
166
+ | xtr-notifier | `null` | chat, SMS, push | `null://null` |
167
+ | xtr-telegram-notifier | `telegram` | chat | `telegram://BOT_ID:TOKEN@default?channel=CHAT_ID` |
168
+ | xtr-slack-notifier | `slack` | chat | `slack://TOKEN@default?channel=CHANNEL` |
169
+
170
+ Writing one is a package with an `AbstractTransport`, an `AbstractTransportFactory` and an entry
171
+ point; the agent skill's `references/bridges.md` walks through it.
172
+
173
+ ## Testing
174
+
175
+ Record what was sent with a `NotificationLoggerListener` (the bundle registers one when the
176
+ event dispatcher is active) and assert on it:
177
+
178
+ ```python
179
+ from xtr_notifier.testing import assert_notification_count, assert_notification_subject_contains
180
+
181
+ assert_notification_count(listener.events, 1, "ops")
182
+ assert_notification_subject_contains(listener.events.get_messages()[0], "Disk")
183
+ ```
184
+
185
+ A provider library checks its transport and factory with `TransportCases` /
186
+ `assert_transport_cases` and `FactoryCases` / `assert_factory_cases`, which report every case
187
+ that fails at once. None of it imports a test framework.
188
+
189
+ ## Use in an application
190
+
191
+ Everything adding this package to an application on
192
+ [xtr-dependency-injection](../xtr-dependency-injection) takes — and, read backwards, what
193
+ removing it undoes.
194
+
195
+ - **Install** — `uv add "xtr-notifier[messenger]"`, or `uv add xtr-notifier` to send in-process
196
+ only. Each provider is a library of its own (see [Providers](#providers)). It works with
197
+ [xtr-http-client](../xtr-http-client), which providers are reached through,
198
+ [xtr-event-dispatcher](../xtr-event-dispatcher), which announces every message and records
199
+ them for tests, and [xtr-messenger](../xtr-messenger), which queues them — each used when
200
+ installed.
201
+ - **Recipe** — `uv run xtr-recipes recipes:sync` does the *Activate*, *Configure* and
202
+ *Environment* steps below: it lists `NotifierBundle`, writes a starting
203
+ `<app>/config/notifier.py` with one chat and one SMS-and-push transport, and
204
+ `NOTIFIER_CHATTER_DSN` and `NOTIFIER_TEXTER_DSN`, both `null://null`, in `.env`. There is no
205
+ ignore line to write. It prints the steps to install a provider and to route the messages to
206
+ a worker, which a recipe cannot make for you.
207
+ - **Activate** — `NotifierBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`, imported
208
+ from `xtr_notifier.bundle`.
209
+ - **Brings along** — the HTTP client, messenger and event dispatcher bundles, each when its
210
+ package is installed.
211
+ - **Configure** — the zero-config notifier has no channel and refuses every notification with
212
+ `The "chat" channel does not exist.`; transports, the channel policy and the administrators go
213
+ in `<app>/config/notifier.py`, a `@configure` function returning `NotifierConfig` — see
214
+ [Kernel / bundle](#kernel--bundle). To queue messages, route `ChatMessage`, `SmsMessage` and
215
+ `PushMessage` in `<app>/config/messenger.py`.
216
+ - **Environment** — `NOTIFIER_CHATTER_DSN` and `NOTIFIER_TEXTER_DSN`, the DSNs the written
217
+ configuration reads when a message is first sent. A provider's DSN carries its token.
218
+ - **Ignore** — nothing.
219
+ - **Remove** — drop the `BUNDLES` entry, delete `<app>/config/notifier.py` and the two variables,
220
+ then `uv remove xtr-notifier`.
221
+ - **Check** — `debug:bundles` shows `notifier` as `listed` and `active`; `debug:config notifier`
222
+ shows the transports, the channel policy and the administrators.
223
+
224
+ ## Kernel / bundle
225
+
226
+ ```python
227
+ # app/config/notifier.py
228
+ from xtr_dependency_injection import configure, env
229
+ from xtr_notifier.bundle import AdminRecipientConfig, NotifierConfig
230
+
231
+
232
+ @configure
233
+ def notifier() -> NotifierConfig:
234
+ return NotifierConfig(
235
+ chatter_transports={"slack": env("SLACK_DSN"), "ops": "telegram://... || slack://..."},
236
+ texter_transports={"sms": env("SMS_DSN")},
237
+ channel_policy={"urgent": ["sms", "chat/slack"], "high": ["chat/slack"]},
238
+ admin_recipients=[AdminRecipientConfig("ops@example.com", "+3312345678")],
239
+ notification_on_failed_messages=True,
240
+ )
241
+ ```
242
+
243
+ | Field | Default | What it does |
244
+ |---|---|---|
245
+ | `chatter_transports` | `{}` | Chat transports by name; any brings the `chat` channel and `ChatterInterface` |
246
+ | `texter_transports` | `{}` | SMS and push transports; any brings the `sms` and `push` channels and `TexterInterface` |
247
+ | `channel_policy` | `{}` | Channels by importance value, for a notification naming none |
248
+ | `admin_recipients` | `()` | Who the application's own failures are reported to (`email` required) |
249
+ | `notification_on_failed_messages` | `False` | Report a message the bus gave up on to the administrators |
250
+ | `http_client` | `None` | Qualifier of the HTTP client providers are reached through |
251
+
252
+ What the bundle registers:
253
+
254
+ - `NotifierInterface` / `AdminRecipientsProviderInterface` — always.
255
+ - A `Transport` builder and `Transports` per kind, under the qualifiers `chatter` and `texter`,
256
+ plus the chatter, the texter and the channels — only for a kind with transports. Nothing reads
257
+ a DSN before a message is sent, so a DSN given through `env()` is checked where it is used.
258
+ - With the messenger bundle: a message whose type the bus routes (by class, by its wire name, or
259
+ `*`) is queued, and a handler sends it through the same transports; an unrouted one is sent at
260
+ once. The texter queues only when both SMS and push messages are routed.
261
+ - With the event dispatcher bundle: a `NotificationLoggerListener`, and every message announced.
262
+ - `notification_on_failed_messages=True` without the messenger or the event dispatcher bundle
263
+ fails the build with `LogicError`.
264
+
265
+ Extension points, for an application or another bundle:
266
+
267
+ - Any class deriving from `TransportFactoryInterface` the application registers serves both
268
+ kinds; tag one `CHATTER_TRANSPORT_FACTORY_TAG` or `TEXTER_TRANSPORT_FACTORY_TAG` to serve one.
269
+ A factory keeping `AbstractTransportFactory`'s constructor is handed the HTTP client and the
270
+ event dispatcher; one with a constructor of its own is built by the container.
271
+ - Tag a `ChannelInterface` service `CHANNEL_TAG` with `channel="<name>"` to add a channel.
272
+
273
+ ## Errors
274
+
275
+ Every error derives from `NotifierError`.
276
+
277
+ | Error | Raised when |
278
+ |---|---|
279
+ | `InvalidArgumentError` | A DSN cannot be read, an option is not a boolean, a transport name is unknown |
280
+ | `IncompleteDsnError` | A DSN lacks a part its provider needs — a token, a user |
281
+ | `MissingRequiredOptionError` | A DSN lacks a required option |
282
+ | `UnsupportedSchemeError` | No factory serves a DSN's scheme |
283
+ | `LogicError` | A channel does not exist or cannot reach the recipient; no transport supports a message |
284
+ | `UnsupportedMessageTypeError` | A transport is handed a message of another kind |
285
+ | `UnsupportedOptionsError` | A transport is handed another provider's options |
286
+ | `MultipleExclusiveOptionsUsedError` | Options a provider takes only one of were set together |
287
+ | `LengthError` | A value is longer than the provider accepts |
288
+ | `TransportError` | A provider could not be reached or refused a message (carries the response, when one came) |
289
+ | `NotifierRuntimeError` | Every transport of a failover or round robin failed |
290
+
291
+ ## Development
292
+
293
+ ```sh
294
+ cd packages/xtr-notifier
295
+ uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
296
+ ```
297
+
298
+ ## License
299
+
300
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,270 @@
1
+ <div align="center">
2
+
3
+ # xtr-notifier
4
+
5
+ **Send notifications to chat, SMS and push channels — one notification, the channels its importance or its recipient call for, any provider configured by DSN.**
6
+
7
+ <img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
8
+ <img alt="asyncio" src="https://img.shields.io/badge/asyncio-native-1f6feb">
9
+ <img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
10
+ <img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
11
+
12
+ </div>
13
+
14
+ ---
15
+
16
+ ## Why?
17
+
18
+ "The disk is almost full" has to reach someone: a chat room during the day, a phone at night,
19
+ both when it is urgent. Code that says *what happened* should not also decide *which provider,
20
+ which channel, which token*. Here a notification carries a subject, a body and an importance;
21
+ the notifier picks the channels — from the notification, or from a policy keyed by its
22
+ importance — and each channel hands a message to a transport chosen by a DSN
23
+ (`slack://TOKEN@default?channel=ops`). Changing provider is changing a string.
24
+
25
+ - **Channels** — `chat`, `sms` and `push`, each built from the notification or rendered by it.
26
+ - **Transports by DSN** — one string per provider; `a || b` falls over, `a && b` takes turns.
27
+ - **Providers are libraries** — each installed beside this one and found by its DSN scheme.
28
+ - **Queueable** — messages are frozen values, so they cross a message bus and are sent by a
29
+ worker.
30
+ - **Testable** — a logger listener records every message; `xtr_notifier.testing` asserts on it,
31
+ and holds a provider library's transport and factory to what every provider owes.
32
+
33
+ ## Install
34
+
35
+ ```sh
36
+ uv add xtr-notifier
37
+ uv add "xtr-notifier[messenger]" # queue messages through xtr-messenger
38
+ ```
39
+
40
+ A provider's transport reaches it through an HTTP client: install
41
+ [xtr-http-client](../xtr-http-client), or bring any
42
+ [xtr-http-client-contracts](../xtr-http-client-contracts) implementation.
43
+
44
+ ## Quick start
45
+
46
+ ```python
47
+ from xtr_notifier import Notifier, Transport
48
+ from xtr_notifier.channel import ChatChannel, ChannelPolicy, SmsChannel
49
+ from xtr_notifier.notification import Importance, Notification
50
+ from xtr_notifier.recipient import Recipient
51
+ from xtr_notifier.transport import TransportFactoryDiscovery
52
+
53
+ chat = Transport(
54
+ discovery=TransportFactoryDiscovery(TransportFactoryDiscovery.CHATTER_GROUP, http_client=client)
55
+ ).from_strings({"ops": "null://null"})
56
+ texts = Transport(
57
+ discovery=TransportFactoryDiscovery(TransportFactoryDiscovery.TEXTER_GROUP, http_client=client)
58
+ ).from_strings({"sms": "null://null"})
59
+
60
+ notifier = Notifier(
61
+ {"chat": ChatChannel(chat), "sms": SmsChannel(texts)},
62
+ ChannelPolicy({"urgent": ["sms", "chat"], "high": ["chat"]}),
63
+ )
64
+
65
+ await notifier.send(
66
+ Notification("Disk almost full", content="98% used on db-1", importance=Importance.URGENT),
67
+ Recipient(phone="+3312345678"),
68
+ )
69
+ ```
70
+
71
+ `null://null` sends nowhere, and needs no HTTP client: swap it for a provider's DSN once its
72
+ library is installed.
73
+
74
+ ## Notifications
75
+
76
+ `Notification(subject, *, content="", importance=Importance.HIGH, emoji="", channels=(),
77
+ exception=None)` is built in one call and read through properties.
78
+
79
+ - **Channels** — `channels=["chat/slack", "sms"]` names them; the part after `/` is the
80
+ transport. Left empty, the notifier asks its `ChannelPolicy` for the importance's channels.
81
+ - **Importance** — `Importance.URGENT`, `HIGH`, `MEDIUM`, `LOW`, a `StrEnum` whose values key
82
+ the policy. `Notification.importance_from_level("critical")` maps a log level: critical and
83
+ above is urgent, error high, the rest low.
84
+ - **Exceptions** — `Notification.from_exception(error, channels=...)` names the subject after
85
+ the error and keeps its traceback; the emoji follows the importance.
86
+ - **Per recipient** — override `get_channels(recipient)`, or implement
87
+ `as_chat_message(recipient, transport_name)`, `as_sms_message(...)` or
88
+ `as_push_message(...)` to render a channel's message yourself (`ChatNotificationInterface`
89
+ and friends, in `xtr_notifier.notification.<kind>_notification_interface`).
90
+
91
+ Recipients are anything with an `email` or a `phone`; `Recipient(email, phone)` is the plain one,
92
+ and `NoRecipient()` — what `send` uses with no recipient — is a chat room or a topic.
93
+
94
+ ## Messages, the chatter and the texter
95
+
96
+ A channel turns a notification into a message: `ChatMessage(subject)`,
97
+ `SmsMessage(phone, subject, from_="")`, `PushMessage(subject, content)`. Messages are frozen;
98
+ `with_transport(...)`, `with_options(...)` and friends return copies. Provider options — blocks,
99
+ buttons, a parse mode — are a frozen `MessageOptions(kind, recipient_id, values)` that a
100
+ provider's builder renders, so a message survives a trip through a bus.
101
+
102
+ When the code has a message rather than a notification, send it directly:
103
+
104
+ ```python
105
+ from xtr_notifier import Chatter, Texter
106
+
107
+ await Chatter(chat).send(ChatMessage("Deployed 4.1.0", transport="ops"))
108
+ await Texter(texts).send(SmsMessage("+3312345678", "Your code is 4812"))
109
+ ```
110
+
111
+ Given a message bus, the chatter, the texter and the channels queue instead of sending, and
112
+ return `None`; `xtr_notifier.messenger.MessageHandler(transport)` sends what the bus hands back.
113
+
114
+ ## Transports and DSNs
115
+
116
+ `Transport(factories, discovery)` builds transports from DSNs: one DSN gives one transport,
117
+ `a || b` a `FailoverTransport` (sticks to the first that works), `a && b` a
118
+ `RoundRobinTransport` (takes turns). A transport that raises `TransportError` is skipped for 60
119
+ seconds. `from_strings({...})` gives `Transports`, which sends a message through the transport
120
+ it names, or the first that supports it.
121
+
122
+ A DSN is `scheme://user:password@host:port/path?option=value`; `host` is `default` for the
123
+ provider's own API host. A factory serves a scheme: the ones given first, then those installed
124
+ libraries advertise in the group the builder reads — the chatter's
125
+ (`xtr_notifier.chatter_transport_factories`) or the texter's
126
+ (`xtr_notifier.texter_transport_factories`). A scheme nothing serves raises
127
+ `UnsupportedSchemeError`, naming the library to install when it is a known one.
128
+
129
+ ## Providers
130
+
131
+ Each provider is a library of its own, installed beside this one; its factory is found by its
132
+ DSN scheme with no wiring. This package ships one transport, `null`, in both groups.
133
+
134
+ | Library | Scheme | Kind | DSN |
135
+ |---|---|---|---|
136
+ | xtr-notifier | `null` | chat, SMS, push | `null://null` |
137
+ | xtr-telegram-notifier | `telegram` | chat | `telegram://BOT_ID:TOKEN@default?channel=CHAT_ID` |
138
+ | xtr-slack-notifier | `slack` | chat | `slack://TOKEN@default?channel=CHANNEL` |
139
+
140
+ Writing one is a package with an `AbstractTransport`, an `AbstractTransportFactory` and an entry
141
+ point; the agent skill's `references/bridges.md` walks through it.
142
+
143
+ ## Testing
144
+
145
+ Record what was sent with a `NotificationLoggerListener` (the bundle registers one when the
146
+ event dispatcher is active) and assert on it:
147
+
148
+ ```python
149
+ from xtr_notifier.testing import assert_notification_count, assert_notification_subject_contains
150
+
151
+ assert_notification_count(listener.events, 1, "ops")
152
+ assert_notification_subject_contains(listener.events.get_messages()[0], "Disk")
153
+ ```
154
+
155
+ A provider library checks its transport and factory with `TransportCases` /
156
+ `assert_transport_cases` and `FactoryCases` / `assert_factory_cases`, which report every case
157
+ that fails at once. None of it imports a test framework.
158
+
159
+ ## Use in an application
160
+
161
+ Everything adding this package to an application on
162
+ [xtr-dependency-injection](../xtr-dependency-injection) takes — and, read backwards, what
163
+ removing it undoes.
164
+
165
+ - **Install** — `uv add "xtr-notifier[messenger]"`, or `uv add xtr-notifier` to send in-process
166
+ only. Each provider is a library of its own (see [Providers](#providers)). It works with
167
+ [xtr-http-client](../xtr-http-client), which providers are reached through,
168
+ [xtr-event-dispatcher](../xtr-event-dispatcher), which announces every message and records
169
+ them for tests, and [xtr-messenger](../xtr-messenger), which queues them — each used when
170
+ installed.
171
+ - **Recipe** — `uv run xtr-recipes recipes:sync` does the *Activate*, *Configure* and
172
+ *Environment* steps below: it lists `NotifierBundle`, writes a starting
173
+ `<app>/config/notifier.py` with one chat and one SMS-and-push transport, and
174
+ `NOTIFIER_CHATTER_DSN` and `NOTIFIER_TEXTER_DSN`, both `null://null`, in `.env`. There is no
175
+ ignore line to write. It prints the steps to install a provider and to route the messages to
176
+ a worker, which a recipe cannot make for you.
177
+ - **Activate** — `NotifierBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`, imported
178
+ from `xtr_notifier.bundle`.
179
+ - **Brings along** — the HTTP client, messenger and event dispatcher bundles, each when its
180
+ package is installed.
181
+ - **Configure** — the zero-config notifier has no channel and refuses every notification with
182
+ `The "chat" channel does not exist.`; transports, the channel policy and the administrators go
183
+ in `<app>/config/notifier.py`, a `@configure` function returning `NotifierConfig` — see
184
+ [Kernel / bundle](#kernel--bundle). To queue messages, route `ChatMessage`, `SmsMessage` and
185
+ `PushMessage` in `<app>/config/messenger.py`.
186
+ - **Environment** — `NOTIFIER_CHATTER_DSN` and `NOTIFIER_TEXTER_DSN`, the DSNs the written
187
+ configuration reads when a message is first sent. A provider's DSN carries its token.
188
+ - **Ignore** — nothing.
189
+ - **Remove** — drop the `BUNDLES` entry, delete `<app>/config/notifier.py` and the two variables,
190
+ then `uv remove xtr-notifier`.
191
+ - **Check** — `debug:bundles` shows `notifier` as `listed` and `active`; `debug:config notifier`
192
+ shows the transports, the channel policy and the administrators.
193
+
194
+ ## Kernel / bundle
195
+
196
+ ```python
197
+ # app/config/notifier.py
198
+ from xtr_dependency_injection import configure, env
199
+ from xtr_notifier.bundle import AdminRecipientConfig, NotifierConfig
200
+
201
+
202
+ @configure
203
+ def notifier() -> NotifierConfig:
204
+ return NotifierConfig(
205
+ chatter_transports={"slack": env("SLACK_DSN"), "ops": "telegram://... || slack://..."},
206
+ texter_transports={"sms": env("SMS_DSN")},
207
+ channel_policy={"urgent": ["sms", "chat/slack"], "high": ["chat/slack"]},
208
+ admin_recipients=[AdminRecipientConfig("ops@example.com", "+3312345678")],
209
+ notification_on_failed_messages=True,
210
+ )
211
+ ```
212
+
213
+ | Field | Default | What it does |
214
+ |---|---|---|
215
+ | `chatter_transports` | `{}` | Chat transports by name; any brings the `chat` channel and `ChatterInterface` |
216
+ | `texter_transports` | `{}` | SMS and push transports; any brings the `sms` and `push` channels and `TexterInterface` |
217
+ | `channel_policy` | `{}` | Channels by importance value, for a notification naming none |
218
+ | `admin_recipients` | `()` | Who the application's own failures are reported to (`email` required) |
219
+ | `notification_on_failed_messages` | `False` | Report a message the bus gave up on to the administrators |
220
+ | `http_client` | `None` | Qualifier of the HTTP client providers are reached through |
221
+
222
+ What the bundle registers:
223
+
224
+ - `NotifierInterface` / `AdminRecipientsProviderInterface` — always.
225
+ - A `Transport` builder and `Transports` per kind, under the qualifiers `chatter` and `texter`,
226
+ plus the chatter, the texter and the channels — only for a kind with transports. Nothing reads
227
+ a DSN before a message is sent, so a DSN given through `env()` is checked where it is used.
228
+ - With the messenger bundle: a message whose type the bus routes (by class, by its wire name, or
229
+ `*`) is queued, and a handler sends it through the same transports; an unrouted one is sent at
230
+ once. The texter queues only when both SMS and push messages are routed.
231
+ - With the event dispatcher bundle: a `NotificationLoggerListener`, and every message announced.
232
+ - `notification_on_failed_messages=True` without the messenger or the event dispatcher bundle
233
+ fails the build with `LogicError`.
234
+
235
+ Extension points, for an application or another bundle:
236
+
237
+ - Any class deriving from `TransportFactoryInterface` the application registers serves both
238
+ kinds; tag one `CHATTER_TRANSPORT_FACTORY_TAG` or `TEXTER_TRANSPORT_FACTORY_TAG` to serve one.
239
+ A factory keeping `AbstractTransportFactory`'s constructor is handed the HTTP client and the
240
+ event dispatcher; one with a constructor of its own is built by the container.
241
+ - Tag a `ChannelInterface` service `CHANNEL_TAG` with `channel="<name>"` to add a channel.
242
+
243
+ ## Errors
244
+
245
+ Every error derives from `NotifierError`.
246
+
247
+ | Error | Raised when |
248
+ |---|---|
249
+ | `InvalidArgumentError` | A DSN cannot be read, an option is not a boolean, a transport name is unknown |
250
+ | `IncompleteDsnError` | A DSN lacks a part its provider needs — a token, a user |
251
+ | `MissingRequiredOptionError` | A DSN lacks a required option |
252
+ | `UnsupportedSchemeError` | No factory serves a DSN's scheme |
253
+ | `LogicError` | A channel does not exist or cannot reach the recipient; no transport supports a message |
254
+ | `UnsupportedMessageTypeError` | A transport is handed a message of another kind |
255
+ | `UnsupportedOptionsError` | A transport is handed another provider's options |
256
+ | `MultipleExclusiveOptionsUsedError` | Options a provider takes only one of were set together |
257
+ | `LengthError` | A value is longer than the provider accepts |
258
+ | `TransportError` | A provider could not be reached or refused a message (carries the response, when one came) |
259
+ | `NotifierRuntimeError` | Every transport of a failover or round robin failed |
260
+
261
+ ## Development
262
+
263
+ ```sh
264
+ cd packages/xtr-notifier
265
+ uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
266
+ ```
267
+
268
+ ## License
269
+
270
+ MIT — see [LICENSE](LICENSE).