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.
Files changed (105) hide show
  1. xtr_notifier/.agents/skills/xtr-notifier/SKILL.md +143 -0
  2. xtr_notifier/.agents/skills/xtr-notifier/references/bridges.md +200 -0
  3. xtr_notifier/__init__.py +28 -0
  4. xtr_notifier/_message_sender.py +64 -0
  5. xtr_notifier/admin_recipients_provider_interface.py +26 -0
  6. xtr_notifier/bundle/__init__.py +21 -0
  7. xtr_notifier/bundle/_chat_message_handler.py +34 -0
  8. xtr_notifier/bundle/_failed_messages.py +15 -0
  9. xtr_notifier/bundle/_message_bus.py +39 -0
  10. xtr_notifier/bundle/_peer_factories.py +46 -0
  11. xtr_notifier/bundle/_peer_wiring.py +102 -0
  12. xtr_notifier/bundle/_peers.py +52 -0
  13. xtr_notifier/bundle/_services.py +221 -0
  14. xtr_notifier/bundle/_text_message_handler.py +35 -0
  15. xtr_notifier/bundle/admin_recipient_config.py +35 -0
  16. xtr_notifier/bundle/notifier_bundle.py +233 -0
  17. xtr_notifier/bundle/notifier_config.py +74 -0
  18. xtr_notifier/channel/__init__.py +21 -0
  19. xtr_notifier/channel/abstract_channel.py +59 -0
  20. xtr_notifier/channel/channel_interface.py +41 -0
  21. xtr_notifier/channel/channel_policy.py +51 -0
  22. xtr_notifier/channel/channel_policy_interface.py +19 -0
  23. xtr_notifier/channel/chat_channel.py +51 -0
  24. xtr_notifier/channel/push_channel.py +51 -0
  25. xtr_notifier/channel/sms_channel.py +58 -0
  26. xtr_notifier/chatter.py +28 -0
  27. xtr_notifier/chatter_interface.py +28 -0
  28. xtr_notifier/event/__init__.py +10 -0
  29. xtr_notifier/event/failed_message_event.py +39 -0
  30. xtr_notifier/event/message_event.py +39 -0
  31. xtr_notifier/event/notification_events.py +49 -0
  32. xtr_notifier/event/sent_message_event.py +28 -0
  33. xtr_notifier/event_listener/__init__.py +11 -0
  34. xtr_notifier/event_listener/notification_logger_listener.py +60 -0
  35. xtr_notifier/event_listener/send_failed_message_to_notifier_listener.py +61 -0
  36. xtr_notifier/exception/__init__.py +31 -0
  37. xtr_notifier/exception/incomplete_dsn_error.py +31 -0
  38. xtr_notifier/exception/invalid_argument_error.py +15 -0
  39. xtr_notifier/exception/length_error.py +15 -0
  40. xtr_notifier/exception/logic_error.py +16 -0
  41. xtr_notifier/exception/missing_required_option_error.py +27 -0
  42. xtr_notifier/exception/multiple_exclusive_options_used_error.py +42 -0
  43. xtr_notifier/exception/notifier_error.py +9 -0
  44. xtr_notifier/exception/notifier_runtime_error.py +15 -0
  45. xtr_notifier/exception/transport_error.py +43 -0
  46. xtr_notifier/exception/unsupported_message_type_error.py +37 -0
  47. xtr_notifier/exception/unsupported_options_error.py +37 -0
  48. xtr_notifier/exception/unsupported_scheme_error.py +77 -0
  49. xtr_notifier/message/__init__.py +23 -0
  50. xtr_notifier/message/chat_message.py +62 -0
  51. xtr_notifier/message/message_interface.py +35 -0
  52. xtr_notifier/message/message_options.py +45 -0
  53. xtr_notifier/message/message_options_interface.py +30 -0
  54. xtr_notifier/message/null_message.py +59 -0
  55. xtr_notifier/message/push_message.py +69 -0
  56. xtr_notifier/message/sent_message.py +76 -0
  57. xtr_notifier/message/sms_message.py +95 -0
  58. xtr_notifier/messenger/__init__.py +36 -0
  59. xtr_notifier/messenger/message_handler.py +38 -0
  60. xtr_notifier/notification/__init__.py +16 -0
  61. xtr_notifier/notification/chat_notification_interface.py +33 -0
  62. xtr_notifier/notification/importance.py +20 -0
  63. xtr_notifier/notification/notification.py +171 -0
  64. xtr_notifier/notification/notification_summary.py +42 -0
  65. xtr_notifier/notification/push_notification_interface.py +33 -0
  66. xtr_notifier/notification/sms_notification_interface.py +33 -0
  67. xtr_notifier/notifier.py +137 -0
  68. xtr_notifier/notifier_interface.py +24 -0
  69. xtr_notifier/py.typed +0 -0
  70. xtr_notifier/recipe/__init__.py +15 -0
  71. xtr_notifier/recipe/files/config/notifier.py.tmpl +29 -0
  72. xtr_notifier/recipe/manifest.toml +31 -0
  73. xtr_notifier/recipient/__init__.py +17 -0
  74. xtr_notifier/recipient/email_recipient_interface.py +24 -0
  75. xtr_notifier/recipient/no_recipient.py +20 -0
  76. xtr_notifier/recipient/recipient.py +66 -0
  77. xtr_notifier/recipient/recipient_interface.py +18 -0
  78. xtr_notifier/recipient/sms_recipient_interface.py +19 -0
  79. xtr_notifier/testing/__init__.py +52 -0
  80. xtr_notifier/testing/_factory_assertions.py +86 -0
  81. xtr_notifier/testing/_failures.py +21 -0
  82. xtr_notifier/testing/_notification_assertions.py +120 -0
  83. xtr_notifier/testing/_transport_assertions.py +98 -0
  84. xtr_notifier/testing/factory_cases.py +56 -0
  85. xtr_notifier/testing/transport_cases.py +48 -0
  86. xtr_notifier/texter.py +28 -0
  87. xtr_notifier/texter_interface.py +28 -0
  88. xtr_notifier/transport/__init__.py +31 -0
  89. xtr_notifier/transport/abstract_transport.py +123 -0
  90. xtr_notifier/transport/abstract_transport_factory.py +93 -0
  91. xtr_notifier/transport/dsn.py +187 -0
  92. xtr_notifier/transport/failover_transport.py +66 -0
  93. xtr_notifier/transport/null_transport.py +56 -0
  94. xtr_notifier/transport/null_transport_factory.py +66 -0
  95. xtr_notifier/transport/round_robin_transport.py +135 -0
  96. xtr_notifier/transport/transport.py +106 -0
  97. xtr_notifier/transport/transport_factory_discovery.py +154 -0
  98. xtr_notifier/transport/transport_factory_interface.py +32 -0
  99. xtr_notifier/transport/transport_interface.py +33 -0
  100. xtr_notifier/transport/transports.py +71 -0
  101. xtr_notifier-5.0.0.dist-info/METADATA +300 -0
  102. xtr_notifier-5.0.0.dist-info/RECORD +105 -0
  103. xtr_notifier-5.0.0.dist-info/WHEEL +4 -0
  104. xtr_notifier-5.0.0.dist-info/entry_points.txt +12 -0
  105. 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.
@@ -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