xtr-webhook 4.0.2__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_webhook/.agents/skills/xtr-webhook/SKILL.md +350 -0
- xtr_webhook/.agents/skills/xtr-webhook/references/signature-formats.md +189 -0
- xtr_webhook/__init__.py +10 -0
- xtr_webhook/_endpoint_paths.py +22 -0
- xtr_webhook/_standard_webhooks_signature.py +76 -0
- xtr_webhook/bundle/__init__.py +15 -0
- xtr_webhook/bundle/_routed_parsers.py +70 -0
- xtr_webhook/bundle/no_private_network_config.py +59 -0
- xtr_webhook/bundle/route_config.py +34 -0
- xtr_webhook/bundle/webhook_bundle.py +262 -0
- xtr_webhook/bundle/webhook_config.py +80 -0
- xtr_webhook/client/__init__.py +23 -0
- xtr_webhook/client/abstract_request_parser.py +248 -0
- xtr_webhook/client/request_parser.py +272 -0
- xtr_webhook/client/request_parser_interface.py +90 -0
- xtr_webhook/controller/__init__.py +12 -0
- xtr_webhook/controller/_built_parsers.py +65 -0
- xtr_webhook/controller/webhook_controller.py +140 -0
- xtr_webhook/exception/__init__.py +25 -0
- xtr_webhook/exception/delivery_error.py +38 -0
- xtr_webhook/exception/invalid_argument_error.py +18 -0
- xtr_webhook/exception/logic_error.py +15 -0
- xtr_webhook/exception/reject_webhook_error.py +52 -0
- xtr_webhook/exception/webhook_error.py +9 -0
- xtr_webhook/exception/webhook_runtime_error.py +16 -0
- xtr_webhook/messenger/__init__.py +14 -0
- xtr_webhook/messenger/send_webhook_handler.py +49 -0
- xtr_webhook/messenger/send_webhook_message.py +36 -0
- xtr_webhook/py.typed +0 -0
- xtr_webhook/recipe/__init__.py +18 -0
- xtr_webhook/recipe/files/config/routes/webhook.py.tmpl +25 -0
- xtr_webhook/recipe/files/config/webhook.py.tmpl +33 -0
- xtr_webhook/recipe/manifest.toml +35 -0
- xtr_webhook/routing/__init__.py +11 -0
- xtr_webhook/routing/_endpoints.py +38 -0
- xtr_webhook/routing/webhook_route_loader.py +31 -0
- xtr_webhook/server/__init__.py +39 -0
- xtr_webhook/server/header_signature_configurator.py +128 -0
- xtr_webhook/server/headers_configurator.py +92 -0
- xtr_webhook/server/json_body_configurator.py +80 -0
- xtr_webhook/server/json_payload_serializer.py +45 -0
- xtr_webhook/server/outgoing_request.py +35 -0
- xtr_webhook/server/payload_serializer_interface.py +34 -0
- xtr_webhook/server/request_configurator_interface.py +39 -0
- xtr_webhook/server/signature_format.py +65 -0
- xtr_webhook/server/transport.py +106 -0
- xtr_webhook/server/transport_interface.py +40 -0
- xtr_webhook/subscriber.py +41 -0
- xtr_webhook/testing/__init__.py +18 -0
- xtr_webhook/testing/_parser_cases.py +141 -0
- xtr_webhook/testing/parser_case.py +37 -0
- xtr_webhook-4.0.2.dist-info/METADATA +704 -0
- xtr_webhook-4.0.2.dist-info/RECORD +56 -0
- xtr_webhook-4.0.2.dist-info/WHEEL +4 -0
- xtr_webhook-4.0.2.dist-info/entry_points.txt +6 -0
- xtr_webhook-4.0.2.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xtr-webhook
|
|
3
|
+
Version: 4.0.2
|
|
4
|
+
Summary: Receive webhooks — verified, replay-bounded, turned into events — and send them signed, in the legacy or the Standard Webhooks format.
|
|
5
|
+
Keywords: webhook,standard-webhooks,signature,hmac,remote-event,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: msgspec>=0.18
|
|
20
|
+
Requires-Dist: xtr-clock>=4.0,<5
|
|
21
|
+
Requires-Dist: xtr-http-client-contracts>=4.0,<5
|
|
22
|
+
Requires-Dist: xtr-http-foundation>=4.0,<5
|
|
23
|
+
Requires-Dist: xtr-http-message-contracts>=4.0,<5
|
|
24
|
+
Requires-Dist: xtr-messenger>=4.0,<5
|
|
25
|
+
Requires-Dist: xtr-remote-event>=4.0,<5
|
|
26
|
+
Requires-Dist: xtr-service-contracts>=4.0,<5
|
|
27
|
+
Requires-Dist: xtr-http-kernel>=4.0,<5
|
|
28
|
+
Requires-Dist: fastapi>=0.121.3
|
|
29
|
+
Requires-Dist: xtr-http-client>=4.0,<5 ; extra == 'client'
|
|
30
|
+
Requires-Dist: xtr-dependency-injection>=4.0,<5 ; extra == 'di'
|
|
31
|
+
Requires-Dist: xtr-http-message>=4.0,<5 ; extra == 'testing'
|
|
32
|
+
Requires-Python: >=3.11
|
|
33
|
+
Provides-Extra: client
|
|
34
|
+
Provides-Extra: di
|
|
35
|
+
Provides-Extra: testing
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
<div align="center">
|
|
39
|
+
|
|
40
|
+
# xtr-webhook
|
|
41
|
+
|
|
42
|
+
**Receive webhooks — verified, replay-bounded, turned into events — and send them signed, in the legacy or the Standard Webhooks format.**
|
|
43
|
+
|
|
44
|
+
<img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
|
|
45
|
+
<img alt="asyncio" src="https://img.shields.io/badge/asyncio-native-1f6feb">
|
|
46
|
+
<img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
|
|
47
|
+
<img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
|
|
48
|
+
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Why?
|
|
54
|
+
|
|
55
|
+
A webhook is an HTTP request from someone who is not a user, so the usual answers do not
|
|
56
|
+
apply: there is no session to trust, the body is the only thing signed, and a request captured
|
|
57
|
+
once can be sent again tomorrow. Handling one well means checking it against the right
|
|
58
|
+
secret, refusing anything outside a window, and getting off the request as fast as possible.
|
|
59
|
+
|
|
60
|
+
Sending one is the same problem from the other side: sign the exact bytes, say when they were
|
|
61
|
+
signed, and retry what the receiver refused.
|
|
62
|
+
|
|
63
|
+
This package does both ends, and turns what arrives into an
|
|
64
|
+
[xtr-remote-event](../xtr-remote-event) event, so the code that handles it never sees HTTP.
|
|
65
|
+
|
|
66
|
+
## Install
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
uv add xtr-webhook # signatures, parsers, the endpoint, the events a webhook becomes
|
|
70
|
+
uv add "xtr-webhook[client]" # + sending signed webhooks, through xtr-http-client
|
|
71
|
+
uv add "xtr-webhook[di]" # + the bundle
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Quick start
|
|
75
|
+
|
|
76
|
+
Receiving, with nothing but this package and a bus that handles in process. A controller is
|
|
77
|
+
given the parser for each type, the secret each is checked against, and where to hand on what it
|
|
78
|
+
read:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
import asyncio
|
|
82
|
+
import hmac
|
|
83
|
+
|
|
84
|
+
from xtr_http_message import Response, ServerRequest
|
|
85
|
+
from xtr_messenger import HandlersLocator, MessageBusConfig, MessageBusFactory, TransportConfig
|
|
86
|
+
from xtr_remote_event.messenger import ConsumeRemoteEventMessage
|
|
87
|
+
from xtr_webhook.client import RequestParser
|
|
88
|
+
from xtr_webhook.controller import WebhookController
|
|
89
|
+
|
|
90
|
+
SECRET = "the-secret"
|
|
91
|
+
BODY = b'{"total": 4200}'
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def consume(message: ConsumeRemoteEventMessage) -> None:
|
|
95
|
+
event = message.event
|
|
96
|
+
print(f"consumed {event.name} {event.id} {event.payload}")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def main() -> None:
|
|
100
|
+
handlers = HandlersLocator()
|
|
101
|
+
_ = handlers.register(ConsumeRemoteEventMessage, consume)
|
|
102
|
+
bus = MessageBusFactory(
|
|
103
|
+
MessageBusConfig(
|
|
104
|
+
transports={"sync": TransportConfig("sync://")},
|
|
105
|
+
routing={ConsumeRemoteEventMessage: "sync"},
|
|
106
|
+
),
|
|
107
|
+
handlers=handlers,
|
|
108
|
+
).bus()
|
|
109
|
+
|
|
110
|
+
controller = WebhookController({"acme": RequestParser()}, {"acme": SECRET}, bus)
|
|
111
|
+
|
|
112
|
+
signed = hmac.new(SECRET.encode(), b"order.paid" + b"evt_1" + BODY, "sha256").hexdigest()
|
|
113
|
+
arrived = ServerRequest(
|
|
114
|
+
"POST",
|
|
115
|
+
"/webhook/acme",
|
|
116
|
+
{
|
|
117
|
+
"Content-Type": "application/json",
|
|
118
|
+
"Webhook-Event": "order.paid",
|
|
119
|
+
"Webhook-Id": "evt_1",
|
|
120
|
+
"Webhook-Signature": f"sha256={signed}",
|
|
121
|
+
},
|
|
122
|
+
BODY,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
answer = await controller.index_action("acme", arrived, Response())
|
|
126
|
+
print(f"answered {answer.get_status_code()}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
asyncio.run(main())
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
consumed order.paid evt_1 {'total': 4200}
|
|
134
|
+
answered 202
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
In an application the endpoint and the controller are the bundle's — see
|
|
138
|
+
[Use in an application](#use-in-an-application).
|
|
139
|
+
|
|
140
|
+
## Receiving
|
|
141
|
+
|
|
142
|
+
`setup(app, kernel)` adds the endpoint — no `include_router` of your own:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from fastapi import FastAPI
|
|
146
|
+
from xtr_dependency_injection import Kernel
|
|
147
|
+
from xtr_http_kernel import setup
|
|
148
|
+
|
|
149
|
+
app = FastAPI()
|
|
150
|
+
setup(app, Kernel("app"))
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
That serves `POST /webhook/{type}`, contributed as the route resource `webhook`. A routes file in
|
|
154
|
+
`<app>/config/routes/` — `routes.import_("webhook").prefix("/hooks")` — moves it to
|
|
155
|
+
`/hooks/webhook/{type}`, for an application keeping everything a stranger may call under one
|
|
156
|
+
path. Whichever it is, build the
|
|
157
|
+
URL you hand a sender from the route's name, `"webhook"` — a sender that was told a URL keeps
|
|
158
|
+
sending to it long after the deploy that changed the path.
|
|
159
|
+
|
|
160
|
+
One endpoint serves every sender. The `type` segment tells them apart: it picks the parser that
|
|
161
|
+
reads the webhook and the secret it is checked against, both configuration, so a new sender
|
|
162
|
+
needs no new route.
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
# app/config/webhook.py
|
|
166
|
+
from xtr_dependency_injection import configure, env
|
|
167
|
+
|
|
168
|
+
from xtr_webhook.bundle import RouteConfig, WebhookConfig
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@configure
|
|
172
|
+
def webhook() -> WebhookConfig:
|
|
173
|
+
return WebhookConfig(
|
|
174
|
+
routing={
|
|
175
|
+
"acme": RouteConfig(secret=env("WEBHOOK_SECRET")),
|
|
176
|
+
"stripe": RouteConfig(parser=StripeRequestParser, secret=env("STRIPE_SECRET")),
|
|
177
|
+
},
|
|
178
|
+
)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Pass a secret through `env(...)`, never as a literal: a placeholder is read only when the
|
|
182
|
+
endpoint is built, and no container report prints it.
|
|
183
|
+
|
|
184
|
+
Routing keys are kept verbatim, dashes included. A type nothing is routed to is answered `404`
|
|
185
|
+
with `No webhook parser found for the type given in the URL.` — from the sender's side that URL
|
|
186
|
+
does not exist.
|
|
187
|
+
|
|
188
|
+
### What the endpoint answers
|
|
189
|
+
|
|
190
|
+
| Answer | When |
|
|
191
|
+
|---|---|
|
|
192
|
+
| `202` | the webhook was read, and every event it carried is on the bus |
|
|
193
|
+
| `404` | nothing is routed to that type |
|
|
194
|
+
| `406` | the parser refused it: a signature that does not match, a timestamp outside the window, a body that will not decode, a request the matcher does not claim |
|
|
195
|
+
| whatever the refusal carries | a parser raising `RejectWebhookError` with its own status and headers |
|
|
196
|
+
| `5xx` | something behind the endpoint failed — a bus with nothing to handle the event above all |
|
|
197
|
+
|
|
198
|
+
The answer goes out before the events are handled, which is the point: a sender waiting on a
|
|
199
|
+
handler is a sender that times out and sends the webhook again. The refusal body is plain text
|
|
200
|
+
naming the check that refused, never the values that failed it.
|
|
201
|
+
|
|
202
|
+
A `5xx` is deliberate rather than tidied away. The sender then sends the webhook again, which is
|
|
203
|
+
the only outcome that does not lose what it carried.
|
|
204
|
+
|
|
205
|
+
### Writing a parser
|
|
206
|
+
|
|
207
|
+
One parser speaks for one sender. Subclass `AbstractRequestParser` and fill in the two things
|
|
208
|
+
that differ per sender — which requests are that sender's, and how to check and read one:
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
from xtr_http_foundation import (
|
|
212
|
+
ChainRequestMatcher,
|
|
213
|
+
IsJsonRequestMatcher,
|
|
214
|
+
MethodRequestMatcher,
|
|
215
|
+
RequestMatcherInterface,
|
|
216
|
+
)
|
|
217
|
+
from xtr_http_message_contracts import ServerRequestInterface
|
|
218
|
+
from xtr_remote_event import RemoteEvent
|
|
219
|
+
|
|
220
|
+
from xtr_webhook.client import AbstractRequestParser
|
|
221
|
+
from xtr_webhook.exception import RejectWebhookError
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class AcmeRequestParser(AbstractRequestParser):
|
|
225
|
+
def get_request_matcher(self) -> RequestMatcherInterface:
|
|
226
|
+
return ChainRequestMatcher([MethodRequestMatcher("POST"), IsJsonRequestMatcher()])
|
|
227
|
+
|
|
228
|
+
async def do_parse(self, request: ServerRequestInterface, secret: str) -> RemoteEvent:
|
|
229
|
+
if request.get_header_line("X-Acme-Token") != secret:
|
|
230
|
+
raise RejectWebhookError(406, "Signature is wrong.")
|
|
231
|
+
payload = await self.read_json(request)
|
|
232
|
+
return RemoteEvent("acme", str(payload["id"]), payload)
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
The base class does what every parser would otherwise write again, and get wrong in the same
|
|
236
|
+
way: the matcher runs before anything is read out of the request, a body that will not decode
|
|
237
|
+
comes back as a `406` rather than as a `500`, and the two answers are the ones the sender's side
|
|
238
|
+
waits for.
|
|
239
|
+
|
|
240
|
+
- `await self.read_body(request)` returns every byte of the body from its first one, rewinding
|
|
241
|
+
the stream where it can — a signature covers exactly the bytes that arrived. Reading a body is
|
|
242
|
+
awaited, so a request whose body is still arriving is read as it comes.
|
|
243
|
+
- `await self.read_json(request)` decodes the body as a JSON object. An array or a bare value is
|
|
244
|
+
refused; bytes that are not JSON at all raise, and `parse()` turns that into the same `406`.
|
|
245
|
+
- `do_parse` returns one event, a sequence of them, or `None` for a webhook to accept and
|
|
246
|
+
ignore — a keep-alive, an event kind this parser does not speak for. `None` and an empty
|
|
247
|
+
sequence are both answered as unparsable rather than accepted and dropped.
|
|
248
|
+
- Override `create_successful_response` — an `async def`, so it can write a body — for a sender
|
|
249
|
+
that wants something of its own echoed back. `create_rejected_response` is the same for a
|
|
250
|
+
refusal. The response is handed in and given back modified, because an HTTP message is
|
|
251
|
+
immutable and the endpoint owns the response.
|
|
252
|
+
- Compare secrets with `hmac.compare_digest`, never `==`.
|
|
253
|
+
|
|
254
|
+
The matchers come from [xtr-http-foundation](../xtr-http-foundation), so a webhook endpoint
|
|
255
|
+
says which requests are its sender's the same way a firewall or a rate limiter does. They are
|
|
256
|
+
small and compose: `MethodRequestMatcher(["POST", "PUT"])`, `IsJsonRequestMatcher()`,
|
|
257
|
+
`ChainRequestMatcher([...])` (all must match), and the rest — the path, the host, the port, the
|
|
258
|
+
scheme, a header, a query parameter, the address it came from. `matches()` is awaited, because
|
|
259
|
+
a rule may be about the body.
|
|
260
|
+
|
|
261
|
+
`IsJsonRequestMatcher` reads the body, not the `Content-Type` header: a sender that announces
|
|
262
|
+
nothing still sent JSON, and one that announces `application/json` and sent something else did
|
|
263
|
+
not — so that request is refused by the matcher, with `Request does not match.`, before the
|
|
264
|
+
parser is handed anything.
|
|
265
|
+
|
|
266
|
+
### Signature formats
|
|
267
|
+
|
|
268
|
+
`RequestParser` is this library's own parser, the mirror of its sender, and reads either of two
|
|
269
|
+
signature schemes. `SignatureFormat` picks which a request must carry:
|
|
270
|
+
|
|
271
|
+
| Format | Signature header | Signed over | Event name in | Replay bounded |
|
|
272
|
+
|---|---|---|---|---|
|
|
273
|
+
| `SignatureFormat.LEGACY` | `sha256=<hex>` | event name + id + body | the `Webhook-Event` header | no |
|
|
274
|
+
| `SignatureFormat.STANDARD` | `v1,<base64>` | `<id>.<timestamp>.` + body | the payload's `type` key | yes |
|
|
275
|
+
| `SignatureFormat.TRANSITIONAL` | both, space separated | both of the above | both | for the Standard Webhooks entry |
|
|
276
|
+
|
|
277
|
+
The members *are* their strings, so a configuration carries `"standard"` and is understood with
|
|
278
|
+
no conversion step. Anything else is refused by `SignatureFormat.parse` with
|
|
279
|
+
`InvalidArgumentError`.
|
|
280
|
+
|
|
281
|
+
Both ends must have made the same choice, which is why the sender's configurators and the
|
|
282
|
+
receiver's parser each take it. `TRANSITIONAL` exists so they need not change on the same day: a
|
|
283
|
+
receiver accepts whichever scheme it already understands.
|
|
284
|
+
|
|
285
|
+
Under `STANDARD` the event name travels in the payload because that scheme's signature covers no
|
|
286
|
+
header at all — a name sent as one is a value the receiver cannot trust. A payload with no
|
|
287
|
+
`type` string names no event and is refused.
|
|
288
|
+
|
|
289
|
+
### Rotating a secret
|
|
290
|
+
|
|
291
|
+
The signature header may carry several whitespace-separated entries, one per secret in play, and
|
|
292
|
+
any one that verifies is enough. So a rotation is: the sender starts sending both signatures, the
|
|
293
|
+
receiver's secret is changed, the sender drops the old one. Each expectation is computed once
|
|
294
|
+
rather than per entry, and every comparison is `hmac.compare_digest`, so the time a refusal takes
|
|
295
|
+
says nothing about how much of it was right.
|
|
296
|
+
|
|
297
|
+
### The replay window
|
|
298
|
+
|
|
299
|
+
A delivery captured once can be sent again tomorrow, so a signed timestamp is held to a window
|
|
300
|
+
around now — `timestamp_tolerance`, 300 seconds by default. Zero or less turns the bound off, for
|
|
301
|
+
when clocks that cannot be kept close enough matter more than a replayed capture.
|
|
302
|
+
|
|
303
|
+
Plainly: **a `LEGACY` signature carries no timestamp, so the window protects only `STANDARD` and
|
|
304
|
+
`TRANSITIONAL`.** Under `LEGACY` the timestamp header is still sent, but nothing signs it, so
|
|
305
|
+
refusing on it would mean trusting a value an attacker sets. The check runs only after a Standard
|
|
306
|
+
Webhooks entry verified, which is when the timestamp is known to be the sender's. A timestamp
|
|
307
|
+
that does not read as an integer is refused too — a value that cannot be bounded is not accepted
|
|
308
|
+
on faith.
|
|
309
|
+
|
|
310
|
+
A request carrying both schemes is tried Standard-first, so it is replay-bounded whatever order
|
|
311
|
+
its entries arrive in.
|
|
312
|
+
|
|
313
|
+
### An empty secret
|
|
314
|
+
|
|
315
|
+
`RequestParser` raises `InvalidArgumentError` rather than refusing the request: an empty secret
|
|
316
|
+
is this side's configuration, not the sender's doing, so it surfaces as a `5xx`. A type with no
|
|
317
|
+
`secret` in its `RouteConfig` is checked against `""`, so set one in production — a parser of
|
|
318
|
+
your own that verifies nothing would otherwise accept every sender.
|
|
319
|
+
|
|
320
|
+
## Sending
|
|
321
|
+
|
|
322
|
+
Install `xtr-webhook[client]`. A webhook goes out as a message, so the delivery is not in the
|
|
323
|
+
request that caused it, and a refusal is retried by the bus:
|
|
324
|
+
|
|
325
|
+
```python
|
|
326
|
+
from xtr_remote_event import RemoteEvent
|
|
327
|
+
|
|
328
|
+
from xtr_webhook.messenger import SendWebhookMessage
|
|
329
|
+
from xtr_webhook.subscriber import Subscriber
|
|
330
|
+
|
|
331
|
+
_ = await bus.dispatch(
|
|
332
|
+
SendWebhookMessage(
|
|
333
|
+
Subscriber("https://example.com/hook", secret),
|
|
334
|
+
RemoteEvent("order.paid", "evt_1", {"total": 4200}),
|
|
335
|
+
)
|
|
336
|
+
)
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
A `Subscriber` is where the webhook goes and what signs it; the pair travels together because
|
|
340
|
+
neither is useful alone. Its `secret` is kept out of the `repr`, and an empty one is refused with
|
|
341
|
+
`InvalidArgumentError` — it would still sign, over the empty key, and send perfectly formed
|
|
342
|
+
webhooks no receiver can accept.
|
|
343
|
+
|
|
344
|
+
The message carries the subscriber whole rather than an id, because this library stores no
|
|
345
|
+
subscribers: the application knows where a webhook goes and says so once, here.
|
|
346
|
+
|
|
347
|
+
### What goes out
|
|
348
|
+
|
|
349
|
+
A webhook is built by a chain of configurators, each writing one part of the same
|
|
350
|
+
`OutgoingRequest`: `HeadersConfigurator` the headers naming the event, `JsonBodyConfigurator` the
|
|
351
|
+
payload as the body, `HeaderSignatureConfigurator` the signature over what the other two wrote.
|
|
352
|
+
It runs last, because it signs their output. They are separate because receivers disagree about
|
|
353
|
+
all three, so an unusual one means adding or replacing a step rather than the sender.
|
|
354
|
+
|
|
355
|
+
`Transport` runs the chain and posts what it built through an `HttpClientInterface` — the
|
|
356
|
+
contract from [xtr-http-client-contracts](../xtr-http-client-contracts), so whatever stands
|
|
357
|
+
behind it does: the client the bundle is told to use, a scoped one, a client wrapped in the guard
|
|
358
|
+
below, or a `MockHttpClient` in a test. It asks the client for the request alone, with no
|
|
359
|
+
redirect followed; everything else — how long a delivery may take, above all — is the client's
|
|
360
|
+
configuration.
|
|
361
|
+
|
|
362
|
+
Under `LEGACY`, `RemoteEvent("order.paid", "evt_1", {"total": 4200})`:
|
|
363
|
+
|
|
364
|
+
```http
|
|
365
|
+
POST /hook HTTP/1.1
|
|
366
|
+
Content-Type: application/json
|
|
367
|
+
Webhook-Id: evt_1
|
|
368
|
+
Webhook-Timestamp: 1767225600
|
|
369
|
+
Webhook-Event: order.paid
|
|
370
|
+
Webhook-Signature: sha256=0f8c...
|
|
371
|
+
|
|
372
|
+
{"total":4200}
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
Under `STANDARD` the `Webhook-Event` header is not sent at all and the name is written into the
|
|
376
|
+
payload instead:
|
|
377
|
+
|
|
378
|
+
```http
|
|
379
|
+
POST /hook HTTP/1.1
|
|
380
|
+
Content-Type: application/json
|
|
381
|
+
Webhook-Id: evt_1
|
|
382
|
+
Webhook-Timestamp: 1767225600
|
|
383
|
+
Webhook-Signature: v1,dGhlLXNpZ25hdHVyZQ==
|
|
384
|
+
|
|
385
|
+
{"total":4200,"type":"order.paid"}
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
Under `TRANSITIONAL` both headers are sent and `Webhook-Signature` carries both entries, space
|
|
389
|
+
separated.
|
|
390
|
+
|
|
391
|
+
A payload that already says what it is keeps what it says: the application knows its own events
|
|
392
|
+
better than their source name does. The payload is copied before the key is added, so dispatching
|
|
393
|
+
a webhook never edits the dict the caller is still holding.
|
|
394
|
+
|
|
395
|
+
The body is written as compact JSON with nothing reordered — the bytes are the signed message, so
|
|
396
|
+
two runs over the same payload have to produce the same ones. A value JSON has no form for raises
|
|
397
|
+
`TypeError` from the serializer, unwrapped: what it refuses is its own vocabulary.
|
|
398
|
+
|
|
399
|
+
Header names and the legacy digest are all configurable:
|
|
400
|
+
|
|
401
|
+
| Option | Default |
|
|
402
|
+
|---|---|
|
|
403
|
+
| `event_header_name` | `"Webhook-Event"` |
|
|
404
|
+
| `id_header_name` | `"Webhook-Id"` |
|
|
405
|
+
| `timestamp_header_name` | `"Webhook-Timestamp"` |
|
|
406
|
+
| `signature_header_name` | `"Webhook-Signature"` |
|
|
407
|
+
| `signing_algorithm` | `"sha256"` |
|
|
408
|
+
| `signature_format` | `SignatureFormat.LEGACY` |
|
|
409
|
+
| `timestamp_tolerance` | `300` |
|
|
410
|
+
|
|
411
|
+
An algorithm this runtime cannot make is refused where it is configured, not at the first
|
|
412
|
+
delivery. The Standard Webhooks signature is always SHA-256; the scheme fixes it.
|
|
413
|
+
|
|
414
|
+
### Delivery errors and retries
|
|
415
|
+
|
|
416
|
+
Any answer outside 200-299 raises `DeliveryError`, carrying `.status_code` and `.url`. Nothing is
|
|
417
|
+
caught in the handler: the error is what makes the bus retry the delivery and, once the attempts
|
|
418
|
+
are spent, keep the message instead of dropping it. Swallowing it would turn every refused
|
|
419
|
+
webhook into one silently never sent. How many attempts, and how long between them, is the
|
|
420
|
+
messenger transport's — see [xtr-messenger](../xtr-messenger).
|
|
421
|
+
|
|
422
|
+
A delivery nothing answered — a connection refused, a receiver that never replied in time —
|
|
423
|
+
raises the client's own `TransportError` (`TransportTimeoutError` for time), and is retried the
|
|
424
|
+
same way. How long is too long is set on the client: `default_options.timeout` in the HTTP client
|
|
425
|
+
configuration, or a scoped client of its own that `http_client` names — see
|
|
426
|
+
[Kernel / bundle](#kernel--bundle).
|
|
427
|
+
|
|
428
|
+
A redirect counts as refused. Redirects are never followed, whatever the client was built with —
|
|
429
|
+
the transport asks for none: following one would carry a signed body to an address nobody
|
|
430
|
+
registered, and would hollow out the guard below.
|
|
431
|
+
|
|
432
|
+
## Private network guard
|
|
433
|
+
|
|
434
|
+
**Opt-in, and off by default.** A subscriber URL is usually data — chosen by whoever registered
|
|
435
|
+
it — so without a guard a webhook is a way to ask this process to fetch an address only this
|
|
436
|
+
process can see: another service on the same network, an admin port, a cloud provider's metadata
|
|
437
|
+
endpoint handing out credentials.
|
|
438
|
+
|
|
439
|
+
The guard is xtr-http-client's `NoPrivateNetworkHttpClient`, a client wrapping another one —
|
|
440
|
+
whichever the transport is given:
|
|
441
|
+
|
|
442
|
+
```python
|
|
443
|
+
from xtr_http_client import HttpClient, NoPrivateNetworkHttpClient
|
|
444
|
+
|
|
445
|
+
from xtr_webhook.server import (
|
|
446
|
+
HeadersConfigurator,
|
|
447
|
+
HeaderSignatureConfigurator,
|
|
448
|
+
JsonBodyConfigurator,
|
|
449
|
+
JsonPayloadSerializer,
|
|
450
|
+
Transport,
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
transport = Transport(
|
|
454
|
+
NoPrivateNetworkHttpClient(HttpClient.create()),
|
|
455
|
+
HeadersConfigurator(),
|
|
456
|
+
JsonBodyConfigurator(JsonPayloadSerializer()),
|
|
457
|
+
HeaderSignatureConfigurator(),
|
|
458
|
+
)
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
The check runs on what the host *resolves to*, not on how it is written, so neither a name
|
|
462
|
+
pointing inwards nor an address spelled unusually gets through. One refused address refuses the
|
|
463
|
+
whole delivery. The request is then pinned to the address that was checked, so a name whose
|
|
464
|
+
records change between the check and the connection — a rebinding attack — connects to what was
|
|
465
|
+
vetted or not at all.
|
|
466
|
+
|
|
467
|
+
A refusal is the client's `TransportError`, raised before anything is sent:
|
|
468
|
+
`Host "<host>" is blocked for "<url>".`, or `IP "<ip>" is blocked for "<url>".` for an address
|
|
469
|
+
written out. A retry will not get through either, since nothing about the network changes by
|
|
470
|
+
trying again.
|
|
471
|
+
|
|
472
|
+
With no `subnets` it refuses the networks only this one can reach — loopback, private,
|
|
473
|
+
link-local, carrier-grade NAT, unique-local, multicast, reserved and unspecified alike. Give
|
|
474
|
+
`subnets` to replace that set, and `allow_list` to open one receiver without opening the network
|
|
475
|
+
it sits in. Both take IP networks or single addresses, never hostnames — the check runs on
|
|
476
|
+
resolved addresses, and a name could point anywhere by the time it is used.
|
|
477
|
+
|
|
478
|
+
In the bundle it is `no_private_network`, and it wraps the client `http_client` names — the
|
|
479
|
+
default one when that is unset:
|
|
480
|
+
|
|
481
|
+
```python
|
|
482
|
+
WebhookConfig(
|
|
483
|
+
no_private_network=NoPrivateNetworkConfig(enabled=True, allow_list=("10.1.2.3",)),
|
|
484
|
+
)
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
Naming `subnets` or an `allow_list` while `enabled` is `False` is refused rather than silently
|
|
488
|
+
ignored — a configuration that looks like protection and is not. Turning it on without
|
|
489
|
+
xtr-http-client installed is refused too, with `LogicError`, when the container is built: there
|
|
490
|
+
is no client for it to wrap.
|
|
491
|
+
|
|
492
|
+
## Testing your parsers
|
|
493
|
+
|
|
494
|
+
`xtr_webhook.testing` turns a directory of recorded payloads into tests. Install the helpers
|
|
495
|
+
with the `testing` extra first — they build requests with xtr-http-message:
|
|
496
|
+
|
|
497
|
+
```sh
|
|
498
|
+
uv add --dev "xtr-webhook[testing]"
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
Each `*.json` fixture has a sibling `*.py` defining a module-level `EXPECTED`, the event — or list
|
|
502
|
+
of events — it must parse to:
|
|
503
|
+
|
|
504
|
+
```python
|
|
505
|
+
from pathlib import Path
|
|
506
|
+
|
|
507
|
+
import pytest
|
|
508
|
+
|
|
509
|
+
from xtr_webhook.testing import ParserCase, assert_parses, parser_cases
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
@pytest.mark.anyio
|
|
513
|
+
@pytest.mark.parametrize(
|
|
514
|
+
"case", parser_cases(Path(__file__).parent / "fixtures"), ids=lambda case: case.id
|
|
515
|
+
)
|
|
516
|
+
async def test_the_parser_reads_every_recorded_payload(case: ParserCase) -> None:
|
|
517
|
+
await assert_parses(AcmeRequestParser(), case, secret="the-secret")
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
`parser_cases(fixtures_dir, extension="json")` walks the directory recursively and returns a
|
|
521
|
+
`ParserCase(id, payload, expected)` per fixture, sorted by id, so adding a sender's payload to
|
|
522
|
+
the directory adds a test. A fixture with no expectation file beside it raises
|
|
523
|
+
`InvalidArgumentError` naming both paths — a payload nobody wrote an expectation for is a test
|
|
524
|
+
that silently is not run.
|
|
525
|
+
|
|
526
|
+
`assert_parses(parser, case, *, secret="", headers=None, method="POST")` builds the request with
|
|
527
|
+
`Content-Type: application/json` plus whatever `headers` adds, parses it, and asserts the
|
|
528
|
+
events match. Pass the signature headers a real delivery carries through `headers`.
|
|
529
|
+
|
|
530
|
+
Nothing here imports pytest, so a fixture directory is usable from any runner.
|
|
531
|
+
|
|
532
|
+
## Security notes
|
|
533
|
+
|
|
534
|
+
- **Subscriber secrets travel inside the queued message body.** `SendWebhookMessage` carries the
|
|
535
|
+
subscriber whole, so **the queue storage must be trusted**. The bus itself adds nothing to
|
|
536
|
+
that: its logging middleware records the message type, the transport, the message id, the
|
|
537
|
+
handler name and the stamps — never the body.
|
|
538
|
+
- **Receiving secrets belong in the environment**, passed as `env("...")`. A `RouteConfig`'s
|
|
539
|
+
`secret` is kept out of its `repr`, and a placeholder is resolved only when the endpoint is
|
|
540
|
+
built.
|
|
541
|
+
- **A `LEGACY` signature is not replay-bounded.** Use `STANDARD` where that matters.
|
|
542
|
+
- **An empty secret signs.** Both ends refuse one rather than send or accept webhooks nobody can
|
|
543
|
+
verify.
|
|
544
|
+
- **Refusals say as little as the sender needs.** Name the check that refused, never the values
|
|
545
|
+
that failed it.
|
|
546
|
+
- **Signatures are compared with `hmac.compare_digest`.** Do the same in a parser of your own.
|
|
547
|
+
- **Nothing follows a redirect**, so a signed body cannot be bounced to an address nobody
|
|
548
|
+
registered.
|
|
549
|
+
|
|
550
|
+
## Use in an application
|
|
551
|
+
|
|
552
|
+
Everything adding this package to an application on
|
|
553
|
+
[xtr-dependency-injection](../xtr-dependency-injection) takes — and, read backwards, what
|
|
554
|
+
removing it undoes.
|
|
555
|
+
|
|
556
|
+
- **Install** — `uv add "xtr-webhook[client]"`; add `di` for the bundle. The endpoint that
|
|
557
|
+
receives comes with the package; `client` is what sends: it brings
|
|
558
|
+
[xtr-http-client](../xtr-http-client) — the client, the guard and the client's bundle.
|
|
559
|
+
Without `client`, no transport and no send handler are registered, dispatching
|
|
560
|
+
`SendWebhookMessage` fails with the bus's no-handler error, and turning `no_private_network`
|
|
561
|
+
on is refused with `LogicError`.
|
|
562
|
+
- **Recipe** — `uv run xtr-recipes recipes:sync` does the *Activate*, *Configure* and
|
|
563
|
+
*Environment* steps below: it lists `WebhookBundle`, writes a starting
|
|
564
|
+
`<app>/config/webhook.py` with one route, `<app>/config/routes/webhook.py` serving the
|
|
565
|
+
endpoint at its default path, and `WEBHOOK_SECRET` (commented out) in `.env`. There is no
|
|
566
|
+
ignore line to write. It prints the steps to call `setup` and to name your own types, neither
|
|
567
|
+
of which a recipe can make for you.
|
|
568
|
+
- **Activate** — `WebhookBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`, imported
|
|
569
|
+
from `xtr_webhook.bundle`.
|
|
570
|
+
- **Brings along** — the remote-event bundle, the messenger bundle through it, and the HTTP
|
|
571
|
+
kernel's bundle, always; the HTTP client bundle, `http_client`, when xtr-http-client is
|
|
572
|
+
installed.
|
|
573
|
+
- **Configure** — needed to receive anything: with no routing the endpoint knows no type and
|
|
574
|
+
answers every webhook `404`. Routing, header names, the signature format, the client webhooks
|
|
575
|
+
are sent through and the guard go in `<app>/config/webhook.py`, a `@configure` function
|
|
576
|
+
returning `WebhookConfig` — see [Kernel / bundle](#kernel--bundle).
|
|
577
|
+
`<app>/config/routes/webhook.py` imports the `webhook` route resource; move it under a prefix
|
|
578
|
+
there. A delivery's timeout is the client's, set in `<app>/config/http_client.py`.
|
|
579
|
+
- **Environment** — `WEBHOOK_SECRET`, the secret the written route verifies against, read when
|
|
580
|
+
the endpoint is built; one per sender in practice.
|
|
581
|
+
- **Ignore** — nothing.
|
|
582
|
+
- **Load** — call `setup(app, kernel)` where the application is built; it adds the endpoint,
|
|
583
|
+
`POST /webhook/{type}`.
|
|
584
|
+
- **Remove** — drop the `BUNDLES` entry, delete `<app>/config/webhook.py` and
|
|
585
|
+
`<app>/config/routes/webhook.py`, then `uv remove xtr-webhook`.
|
|
586
|
+
- **Check** — `debug:bundles` shows `webhook` and `remote_event` as `listed` and `active`, and
|
|
587
|
+
`http_client` with the `client` extra; `debug:config webhook` shows the resolved routing,
|
|
588
|
+
header names and signature format.
|
|
589
|
+
|
|
590
|
+
## Kernel / bundle
|
|
591
|
+
|
|
592
|
+
```python
|
|
593
|
+
# app/bundles.py
|
|
594
|
+
from xtr_webhook.bundle import WebhookBundle
|
|
595
|
+
|
|
596
|
+
BUNDLES = {WebhookBundle: {"all": True}}
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
```python
|
|
600
|
+
# app/config/webhook.py
|
|
601
|
+
from xtr_dependency_injection import configure, env
|
|
602
|
+
|
|
603
|
+
from xtr_webhook.bundle import NoPrivateNetworkConfig, RouteConfig, WebhookConfig
|
|
604
|
+
from xtr_webhook.server import SignatureFormat
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
@configure
|
|
608
|
+
def webhook() -> WebhookConfig:
|
|
609
|
+
return WebhookConfig(
|
|
610
|
+
signature_format=SignatureFormat.STANDARD,
|
|
611
|
+
timestamp_tolerance=300,
|
|
612
|
+
http_client="webhook",
|
|
613
|
+
no_private_network=NoPrivateNetworkConfig(enabled=True),
|
|
614
|
+
routing={
|
|
615
|
+
"acme": RouteConfig(secret=env("WEBHOOK_SECRET")),
|
|
616
|
+
"stripe": RouteConfig(parser=StripeRequestParser, secret=env("STRIPE_SECRET")),
|
|
617
|
+
},
|
|
618
|
+
)
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
`http_client` names a client of the HTTP client configuration; this one gives webhooks a
|
|
622
|
+
timeout of their own. Leave `http_client` unset to send through the default client, whose
|
|
623
|
+
timeout is `default_options.timeout` — see [xtr-http-client](../xtr-http-client#kernel--bundle).
|
|
624
|
+
|
|
625
|
+
```python
|
|
626
|
+
# app/config/http_client.py
|
|
627
|
+
from xtr_dependency_injection import configure
|
|
628
|
+
from xtr_http_client.bundle import HttpClientConfig, ScopedClientConfig
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
@configure
|
|
632
|
+
def http_client() -> HttpClientConfig:
|
|
633
|
+
return HttpClientConfig(
|
|
634
|
+
scoped_clients={"webhook": ScopedClientConfig(scope="https?://", timeout=10)},
|
|
635
|
+
)
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
`WebhookConfig`:
|
|
639
|
+
|
|
640
|
+
| Field | Default | What it sets |
|
|
641
|
+
|---|---|---|
|
|
642
|
+
| `routing` | `{}` | what the endpoint does with each type, by the type in the URL |
|
|
643
|
+
| `signature_format` | `SignatureFormat.LEGACY` | the scheme both ends share; a string is accepted |
|
|
644
|
+
| `timestamp_tolerance` | `300` | seconds either side of now a signed timestamp may be; `0` turns the bound off |
|
|
645
|
+
| `signing_algorithm` | `"sha256"` | the digest the legacy signature is made with |
|
|
646
|
+
| `event_header_name` | `"Webhook-Event"` | where the event's name travels, where the scheme sends one |
|
|
647
|
+
| `id_header_name` | `"Webhook-Id"` | where the event's id travels |
|
|
648
|
+
| `timestamp_header_name` | `"Webhook-Timestamp"` | where the send time travels |
|
|
649
|
+
| `signature_header_name` | `"Webhook-Signature"` | where the signature entries travel |
|
|
650
|
+
| `http_client` | `None` | the qualifier of the `HttpClientInterface` webhooks are sent through — a scoped client's name; `None` means the default client |
|
|
651
|
+
| `no_private_network` | `NoPrivateNetworkConfig()` | whether that client is wrapped in the guard refusing to deliver inwards, and what counts as inwards |
|
|
652
|
+
|
|
653
|
+
`RouteConfig(parser=None, secret="")`: `parser` is the class reading that type's webhooks, built
|
|
654
|
+
by the container with its dependencies on the first webhook for it; `None` means the bundle's
|
|
655
|
+
own `RequestParser`, configured by the fields above. Two routes naming one class share one
|
|
656
|
+
instance.
|
|
657
|
+
|
|
658
|
+
The bundle registers the configurator chain, its own parser under the `webhook` qualifier — so
|
|
659
|
+
`Annotated[RequestParserInterface, Target("webhook")]` resolves to it — every custom parser the
|
|
660
|
+
routing names, the endpoint's route loader, and the controller behind it. The controller gets its parsers as a
|
|
661
|
+
service locator, so it never holds the container and a route's parser is built only when the
|
|
662
|
+
first webhook for it arrives.
|
|
663
|
+
|
|
664
|
+
The sending side is registered only when xtr-http-client is installed: the transport, over the
|
|
665
|
+
client `http_client` names — wrapped in `NoPrivateNetworkHttpClient` when `no_private_network` is
|
|
666
|
+
on — and `SendWebhookHandler` with it. The handler is registered beside the transport it needs,
|
|
667
|
+
so a container never holds a handler it cannot build. The client belongs to the HTTP client
|
|
668
|
+
bundle, which builds it, opens its connections when a delivery first needs one, and closes them
|
|
669
|
+
with the container.
|
|
670
|
+
|
|
671
|
+
The zero-config path routes nothing, signs with the legacy scheme under the default header names,
|
|
672
|
+
and does no I/O at build or boot.
|
|
673
|
+
|
|
674
|
+
## Errors
|
|
675
|
+
|
|
676
|
+
Everything this library raises derives from `WebhookError`, and carries what went wrong as typed
|
|
677
|
+
attributes rather than forcing a message to be parsed. None of them repeats a secret.
|
|
678
|
+
|
|
679
|
+
| Error | Raised when | Carries |
|
|
680
|
+
|---|---|---|
|
|
681
|
+
| `InvalidArgumentError` | a value cannot be used — an unknown signature format, an empty secret, an unavailable algorithm; also a `ValueError` | — |
|
|
682
|
+
| `LogicError` | this library was used in a way that cannot work — a chain that wrote no body, a signer running before the timestamp; also a `RuntimeError` | — |
|
|
683
|
+
| `RejectWebhookError` | an incoming webhook is refused; it carries the answer the sender gets | `.status_code`, `.headers` |
|
|
684
|
+
| `WebhookRuntimeError` | something a webhook depends on failed while it was handled; also a `RuntimeError` | — |
|
|
685
|
+
| `DeliveryError` | a receiver answered an outgoing webhook outside 200-299 | `.status_code`, `.url` |
|
|
686
|
+
|
|
687
|
+
An outgoing webhook nothing answered raises the client's own error instead, unwrapped:
|
|
688
|
+
`TransportError` from xtr-http-client-contracts, or its `TransportTimeoutError`. The
|
|
689
|
+
private-network guard refuses with a `TransportError` too.
|
|
690
|
+
|
|
691
|
+
## Development
|
|
692
|
+
|
|
693
|
+
Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
|
|
694
|
+
`packages/xtr-webhook`; run the commands below from there. The `python-xtr-webhook`
|
|
695
|
+
repository is a read-only copy, so send issues and pull requests to the monorepo.
|
|
696
|
+
|
|
697
|
+
```sh
|
|
698
|
+
uv sync --all-extras
|
|
699
|
+
uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
## License
|
|
703
|
+
|
|
704
|
+
MIT — see [LICENSE](LICENSE).
|