pgdispatch 0.1.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 (54) hide show
  1. pgdispatch/__init__.py +69 -0
  2. pgdispatch/__main__.py +5 -0
  3. pgdispatch/_version.py +1 -0
  4. pgdispatch/backends/__init__.py +6 -0
  5. pgdispatch/backends/asyncpg.py +103 -0
  6. pgdispatch/backends/base.py +142 -0
  7. pgdispatch/cli.py +420 -0
  8. pgdispatch/client.py +490 -0
  9. pgdispatch/config.py +223 -0
  10. pgdispatch/core/__init__.py +1 -0
  11. pgdispatch/core/circuit.py +94 -0
  12. pgdispatch/core/common.py +87 -0
  13. pgdispatch/core/deliveries.py +216 -0
  14. pgdispatch/core/endpoints.py +338 -0
  15. pgdispatch/core/events.py +65 -0
  16. pgdispatch/core/maintenance.py +121 -0
  17. pgdispatch/core/matching.py +76 -0
  18. pgdispatch/core/payload.py +108 -0
  19. pgdispatch/core/publisher.py +232 -0
  20. pgdispatch/core/retry.py +90 -0
  21. pgdispatch/core/secrets.py +138 -0
  22. pgdispatch/core/signing.py +141 -0
  23. pgdispatch/django/__init__.py +37 -0
  24. pgdispatch/django/apps.py +10 -0
  25. pgdispatch/django/client.py +402 -0
  26. pgdispatch/django/management/__init__.py +0 -0
  27. pgdispatch/django/management/commands/__init__.py +0 -0
  28. pgdispatch/django/management/commands/pgdispatch_worker.py +32 -0
  29. pgdispatch/django/migrations/0001_initial.py +30 -0
  30. pgdispatch/django/migrations/__init__.py +0 -0
  31. pgdispatch/errors.py +43 -0
  32. pgdispatch/fastapi/__init__.py +23 -0
  33. pgdispatch/fastapi/router.py +243 -0
  34. pgdispatch/hooks.py +80 -0
  35. pgdispatch/metrics.py +80 -0
  36. pgdispatch/migrations/0001_initial.sql +104 -0
  37. pgdispatch/migrations/__init__.py +93 -0
  38. pgdispatch/models.py +208 -0
  39. pgdispatch/py.typed +0 -0
  40. pgdispatch/security/__init__.py +1 -0
  41. pgdispatch/security/ssrf.py +141 -0
  42. pgdispatch/sqlalchemy.py +130 -0
  43. pgdispatch/worker/__init__.py +5 -0
  44. pgdispatch/worker/fetcher.py +183 -0
  45. pgdispatch/worker/ratelimit.py +47 -0
  46. pgdispatch/worker/recorder.py +435 -0
  47. pgdispatch/worker/runner.py +36 -0
  48. pgdispatch/worker/sender.py +160 -0
  49. pgdispatch/worker/worker.py +557 -0
  50. pgdispatch-0.1.0.dist-info/METADATA +329 -0
  51. pgdispatch-0.1.0.dist-info/RECORD +54 -0
  52. pgdispatch-0.1.0.dist-info/WHEEL +4 -0
  53. pgdispatch-0.1.0.dist-info/entry_points.txt +2 -0
  54. pgdispatch-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,141 @@
1
+ """Request signing according to the `Standard Webhooks`_ specification.
2
+
3
+ Every request carries three headers:
4
+
5
+ ``webhook-id``
6
+ The event id. It is identical across retries, so receivers can deduplicate.
7
+ ``webhook-timestamp``
8
+ Unix time of this attempt, in seconds.
9
+ ``webhook-signature``
10
+ ``v1,<base64 HMAC-SHA256(secret, "{id}.{timestamp}.{body}")>``. During a
11
+ secret rotation several space-separated signatures are sent, one per
12
+ active secret, and a receiver accepts the request if any of them matches.
13
+
14
+ The implementation is a few lines of standard-library code; the test-suite
15
+ checks it against the reference ``standardwebhooks`` package, so receivers can
16
+ use any official Standard Webhooks SDK.
17
+
18
+ .. _Standard Webhooks: https://www.standardwebhooks.com/
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import base64
24
+ import binascii
25
+ import hashlib
26
+ import hmac
27
+ import secrets
28
+ import time
29
+ from collections.abc import Iterable, Mapping
30
+
31
+ from pgdispatch.errors import InvalidSignatureError, ValidationError
32
+
33
+ __all__ = [
34
+ "SECRET_PREFIX",
35
+ "generate_secret",
36
+ "sign",
37
+ "signature_header",
38
+ "validate_secret",
39
+ "verify",
40
+ ]
41
+
42
+ SECRET_PREFIX = "whsec_" # noqa: S105 - a prefix, not a password
43
+ SIGNATURE_VERSION = "v1"
44
+ DEFAULT_TOLERANCE_SECONDS = 5 * 60
45
+
46
+ _MIN_SECRET_BYTES = 24
47
+ _MAX_SECRET_BYTES = 64
48
+
49
+
50
+ def generate_secret(num_bytes: int = 32) -> str:
51
+ """Create a new random signing secret such as ``whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw``."""
52
+ if not _MIN_SECRET_BYTES <= num_bytes <= _MAX_SECRET_BYTES:
53
+ msg = f"secrets must be {_MIN_SECRET_BYTES}-{_MAX_SECRET_BYTES} bytes long"
54
+ raise ValueError(msg)
55
+ return SECRET_PREFIX + base64.b64encode(secrets.token_bytes(num_bytes)).decode("ascii")
56
+
57
+
58
+ def validate_secret(secret: str) -> str:
59
+ """Return ``secret`` if it is a well-formed ``whsec_`` secret.
60
+
61
+ :raises ValidationError: if it is not.
62
+ """
63
+ try:
64
+ key = _decode_secret(secret)
65
+ except ValueError:
66
+ key = b""
67
+ if (
68
+ not secret.startswith(SECRET_PREFIX)
69
+ or not _MIN_SECRET_BYTES <= len(key) <= _MAX_SECRET_BYTES
70
+ ):
71
+ msg = (
72
+ f"a signing secret must look like '{SECRET_PREFIX}<base64>' and encode "
73
+ f"{_MIN_SECRET_BYTES}-{_MAX_SECRET_BYTES} random bytes"
74
+ )
75
+ raise ValidationError(msg)
76
+ return secret
77
+
78
+
79
+ def sign(secret: str, msg_id: str, timestamp: int, body: bytes) -> str:
80
+ """Return a single ``v1,<signature>`` value for ``body``."""
81
+ signed_content = f"{msg_id}.{timestamp}.".encode() + body
82
+ digest = hmac.new(_decode_secret(secret), signed_content, hashlib.sha256).digest()
83
+ return f"{SIGNATURE_VERSION},{base64.b64encode(digest).decode('ascii')}"
84
+
85
+
86
+ def signature_header(secrets_: Iterable[str], msg_id: str, timestamp: int, body: bytes) -> str:
87
+ """Build the ``webhook-signature`` header value, one signature per active secret."""
88
+ return " ".join(sign(secret, msg_id, timestamp, body) for secret in secrets_)
89
+
90
+
91
+ def verify(
92
+ secret: str,
93
+ headers: Mapping[str, str],
94
+ body: bytes,
95
+ *,
96
+ tolerance: int = DEFAULT_TOLERANCE_SECONDS,
97
+ now: float | None = None,
98
+ ) -> None:
99
+ """Check a received webhook; raise :class:`InvalidSignatureError` if it is not authentic.
100
+
101
+ Useful in tests and in Python receivers::
102
+
103
+ from pgdispatch import verify_signature
104
+
105
+ verify_signature(secret, request.headers, await request.body())
106
+ """
107
+ lowered = {name.lower(): value for name, value in headers.items()}
108
+ try:
109
+ msg_id = lowered["webhook-id"]
110
+ raw_timestamp = lowered["webhook-timestamp"]
111
+ received = lowered["webhook-signature"]
112
+ except KeyError as exc:
113
+ msg = f"missing required header {exc.args[0]!r}"
114
+ raise InvalidSignatureError(msg) from None
115
+
116
+ try:
117
+ timestamp = int(raw_timestamp)
118
+ except ValueError:
119
+ msg = "invalid webhook-timestamp header"
120
+ raise InvalidSignatureError(msg) from None
121
+ current = time.time() if now is None else now
122
+ if abs(current - timestamp) > tolerance:
123
+ msg = "webhook timestamp is outside the allowed tolerance"
124
+ raise InvalidSignatureError(msg)
125
+
126
+ expected = sign(secret, msg_id, timestamp, body).encode()
127
+ for candidate in received.split():
128
+ version, _, _ = candidate.partition(",")
129
+ if version == SIGNATURE_VERSION and hmac.compare_digest(candidate.encode(), expected):
130
+ return
131
+ msg = "no matching signature found"
132
+ raise InvalidSignatureError(msg)
133
+
134
+
135
+ def _decode_secret(secret: str) -> bytes:
136
+ encoded = secret.removeprefix(SECRET_PREFIX)
137
+ try:
138
+ return base64.b64decode(encoded, validate=True)
139
+ except binascii.Error as exc:
140
+ msg = "secret is not valid base64"
141
+ raise ValueError(msg) from exc
@@ -0,0 +1,37 @@
1
+ """Django integration.
2
+
3
+ Add the app and publish events inside ``transaction.atomic()``::
4
+
5
+ INSTALLED_APPS = [..., "pgdispatch.django"]
6
+ PGDISPATCH = {"concurrency": 50} # optional, any WebhooksConfig field
7
+
8
+ from pgdispatch.django import webhooks
9
+
10
+ with transaction.atomic():
11
+ order.save()
12
+ webhooks.publish("order.shipped", {"id": order.id}, owner_id=order.shop_id)
13
+
14
+ ``python manage.py migrate`` creates the tables and ``python manage.py
15
+ pgdispatch_worker`` delivers the webhooks.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import TYPE_CHECKING, Any
21
+
22
+ if TYPE_CHECKING:
23
+ from pgdispatch.django.client import DjangoWebhooks
24
+
25
+ webhooks: DjangoWebhooks
26
+
27
+ __all__ = ["webhooks"]
28
+
29
+
30
+ def __getattr__(name: str) -> Any:
31
+ # Lazy, so that importing this package does not require configured settings.
32
+ if name == "webhooks":
33
+ from pgdispatch.django.client import get_webhooks
34
+
35
+ return get_webhooks()
36
+ msg = f"module {__name__!r} has no attribute {name!r}"
37
+ raise AttributeError(msg)
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+ from django.apps import AppConfig
4
+
5
+
6
+ class PgDispatchConfig(AppConfig):
7
+ name = "pgdispatch.django"
8
+ label = "pgdispatch"
9
+ verbose_name = "pgdispatch webhooks"
10
+ default_auto_field = "django.db.models.BigAutoField"
@@ -0,0 +1,402 @@
1
+ """The synchronous Django facade: ``pgdispatch.django.webhooks``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import builtins
6
+ import uuid
7
+ from collections.abc import Iterable, Mapping
8
+ from datetime import datetime, timedelta
9
+ from functools import cache
10
+ from typing import Any, TypeVar
11
+
12
+ import asyncpg
13
+ from django.conf import settings
14
+ from django.db import connections, transaction
15
+ from django.db.backends.base.base import BaseDatabaseWrapper
16
+
17
+ from pgdispatch.backends.asyncpg import WORKER_POOL_SIZE
18
+ from pgdispatch.backends.base import Operation, Row, run_sync, to_pyformat_style
19
+ from pgdispatch.config import WebhooksConfig
20
+ from pgdispatch.core import deliveries as delivery_ops
21
+ from pgdispatch.core import endpoints as endpoint_ops
22
+ from pgdispatch.core import events as event_ops
23
+ from pgdispatch.core import publisher
24
+ from pgdispatch.core.common import UNSET, OwnerFilter, Unset
25
+ from pgdispatch.core.maintenance import QueueStats, queue_stats
26
+ from pgdispatch.core.secrets import SecretStore, fernet_cipher
27
+ from pgdispatch.errors import ConfigurationError
28
+ from pgdispatch.hooks import Hooks
29
+ from pgdispatch.metrics import get_metrics
30
+ from pgdispatch.migrations import drop_schema as drop_schema_operation
31
+ from pgdispatch.migrations import migrate
32
+ from pgdispatch.models import Attempt, Delivery, DeliveryStatus, Endpoint, Event, PublishResult
33
+ from pgdispatch.worker.worker import Worker
34
+
35
+ __all__ = ["DjangoWebhooks", "drop_schema", "get_webhooks", "migrate_schema"]
36
+
37
+ T = TypeVar("T")
38
+
39
+
40
+ class DjangoExecutor:
41
+ """Runs statements through a Django database connection."""
42
+
43
+ def __init__(self, connection: BaseDatabaseWrapper) -> None:
44
+ if connection.vendor != "postgresql":
45
+ msg = (
46
+ f"pgdispatch requires PostgreSQL, "
47
+ f"but database {connection.alias!r} is {connection.vendor}"
48
+ )
49
+ raise ConfigurationError(msg)
50
+ self._connection = connection
51
+
52
+ def fetch(self, sql: str, params: Mapping[str, Any]) -> list[Row]:
53
+ with self._connection.cursor() as cursor:
54
+ cursor.execute(to_pyformat_style(sql), dict(params))
55
+ if cursor.description is None:
56
+ return []
57
+ names = [column[0] for column in cursor.description]
58
+ return [dict(zip(names, row, strict=True)) for row in cursor.fetchall()]
59
+
60
+
61
+ class DjangoWebhooks:
62
+ """Blocking counterpart of :class:`pgdispatch.Webhooks` for Django projects.
63
+
64
+ Every call runs in ``transaction.atomic(using=...)``, which becomes a
65
+ savepoint when you are already inside a transaction, so ``publish`` joins
66
+ your business transaction.
67
+ """
68
+
69
+ def __init__(self, config: WebhooksConfig | None = None, *, using: str = "default") -> None:
70
+ self.config = config or WebhooksConfig()
71
+ self.using = using
72
+ self.hooks = Hooks()
73
+ keys = [key.get_secret_value() for key in self.config.secret_encryption_keys]
74
+ self._secrets = SecretStore(fernet_cipher(keys) if keys else None)
75
+ self._metrics = get_metrics()
76
+ self._url_policy = endpoint_ops.UrlPolicy(
77
+ allow_http=self.config.allow_http, allow_private_ips=self.config.allow_private_ips
78
+ )
79
+ self.endpoints = DjangoEndpoints(self)
80
+ self.deliveries = DjangoDeliveries(self)
81
+ self.events = DjangoEvents(self)
82
+
83
+ def publish(
84
+ self,
85
+ event_type: str,
86
+ payload: Any,
87
+ *,
88
+ owner_id: str | None = None,
89
+ ordering_key: str | None = None,
90
+ idempotency_key: str | None = None,
91
+ using: str | None = None,
92
+ ) -> PublishResult:
93
+ """Record an event in the current transaction; see :meth:`pgdispatch.Webhooks.publish`."""
94
+ result = self._run(
95
+ publisher.publish(
96
+ event_type=event_type,
97
+ payload=payload,
98
+ owner_id=owner_id,
99
+ ordering_key=ordering_key,
100
+ idempotency_key=idempotency_key,
101
+ channel=self.config.notify_channel,
102
+ max_payload_bytes=self.config.max_payload_bytes,
103
+ initial_delay=self.config.retry_schedule[0],
104
+ ),
105
+ using=using,
106
+ )
107
+ if not result.duplicate:
108
+ self._metrics.events_published.labels(event_type).inc()
109
+ return result
110
+
111
+ def status(self) -> QueueStats:
112
+ return self._run(queue_stats(worker_ttl=self.config.heartbeat_interval * 3))
113
+
114
+ def worker(self, **overrides: Any) -> Worker:
115
+ """A :class:`Worker` connected to the same database (used by ``pgdispatch_worker``)."""
116
+ config = (
117
+ WebhooksConfig(**{**self.config.model_dump(), **overrides})
118
+ if overrides
119
+ else self.config
120
+ )
121
+ connect_kwargs = asyncpg_connect_kwargs(settings.DATABASES[self.using])
122
+
123
+ async def create_pool() -> asyncpg.Pool:
124
+ pool: asyncpg.Pool = await asyncpg.create_pool(
125
+ min_size=1, max_size=WORKER_POOL_SIZE, **connect_kwargs
126
+ )
127
+ return pool
128
+
129
+ return Worker(
130
+ create_pool, config, hooks=self.hooks, metrics=self._metrics, secret_store=self._secrets
131
+ )
132
+
133
+ def _run(self, operation: Operation[T], *, using: str | None = None) -> T:
134
+ alias = using or self.using
135
+ with transaction.atomic(using=alias):
136
+ return run_sync(
137
+ operation, DjangoExecutor(connections[alias]), schema=self.config.db_schema
138
+ )
139
+
140
+
141
+ class DjangoEndpoints:
142
+ def __init__(self, webhooks: DjangoWebhooks) -> None:
143
+ self._webhooks = webhooks
144
+
145
+ def create(
146
+ self,
147
+ url: str,
148
+ *,
149
+ event_types: Iterable[str] = ("*",),
150
+ owner_id: str | None = None,
151
+ description: str | None = None,
152
+ headers: Mapping[str, str] | None = None,
153
+ rate_limit_rps: float | None = None,
154
+ secret: str | None = None,
155
+ enabled: bool = True,
156
+ ) -> Endpoint:
157
+ w = self._webhooks
158
+ return w._run(
159
+ endpoint_ops.create_endpoint(
160
+ url=url,
161
+ event_types=event_types,
162
+ owner_id=owner_id,
163
+ description=description,
164
+ headers=headers,
165
+ rate_limit_rps=rate_limit_rps,
166
+ secret=secret,
167
+ enabled=enabled,
168
+ url_policy=w._url_policy,
169
+ secret_store=w._secrets,
170
+ )
171
+ )
172
+
173
+ def get(self, endpoint_id: uuid.UUID, *, owner_id: OwnerFilter = UNSET) -> Endpoint:
174
+ return self._webhooks._run(endpoint_ops.get_endpoint(endpoint_id, owner_id=owner_id))
175
+
176
+ def list(
177
+ self,
178
+ *,
179
+ owner_id: OwnerFilter = UNSET,
180
+ enabled: bool | None = None,
181
+ limit: int = 100,
182
+ after: uuid.UUID | None = None,
183
+ ) -> builtins.list[Endpoint]:
184
+ return self._webhooks._run(
185
+ endpoint_ops.list_endpoints(
186
+ owner_id=owner_id, enabled=enabled, limit=limit, after=after
187
+ )
188
+ )
189
+
190
+ def update(
191
+ self,
192
+ endpoint_id: uuid.UUID,
193
+ *,
194
+ url: str | Unset = UNSET,
195
+ event_types: Iterable[str] | Unset = UNSET,
196
+ description: str | Unset | None = UNSET,
197
+ headers: Mapping[str, str] | Unset = UNSET,
198
+ rate_limit_rps: float | Unset | None = UNSET,
199
+ owner_id: OwnerFilter = UNSET,
200
+ ) -> Endpoint:
201
+ w = self._webhooks
202
+ return w._run(
203
+ endpoint_ops.update_endpoint(
204
+ endpoint_id,
205
+ owner_id=owner_id,
206
+ url=url,
207
+ event_types=event_types,
208
+ description=description,
209
+ headers=headers,
210
+ rate_limit_rps=rate_limit_rps,
211
+ url_policy=w._url_policy,
212
+ )
213
+ )
214
+
215
+ def enable(self, endpoint_id: uuid.UUID, *, owner_id: OwnerFilter = UNSET) -> Endpoint:
216
+ return self._webhooks._run(endpoint_ops.enable_endpoint(endpoint_id, owner_id=owner_id))
217
+
218
+ def disable(
219
+ self, endpoint_id: uuid.UUID, *, reason: str | None = None, owner_id: OwnerFilter = UNSET
220
+ ) -> Endpoint:
221
+ return self._webhooks._run(
222
+ endpoint_ops.disable_endpoint(endpoint_id, reason=reason, owner_id=owner_id)
223
+ )
224
+
225
+ def delete(self, endpoint_id: uuid.UUID, *, owner_id: OwnerFilter = UNSET) -> None:
226
+ self._webhooks._run(endpoint_ops.delete_endpoint(endpoint_id, owner_id=owner_id))
227
+
228
+ def rotate_secret(
229
+ self,
230
+ endpoint_id: uuid.UUID,
231
+ *,
232
+ grace_period: timedelta = timedelta(hours=24),
233
+ secret: str | None = None,
234
+ owner_id: OwnerFilter = UNSET,
235
+ ) -> Endpoint:
236
+ w = self._webhooks
237
+ return w._run(
238
+ endpoint_ops.rotate_secret(
239
+ endpoint_id,
240
+ grace_period=grace_period,
241
+ secret=secret,
242
+ owner_id=owner_id,
243
+ secret_store=w._secrets,
244
+ )
245
+ )
246
+
247
+ def get_secret(self, endpoint_id: uuid.UUID, *, owner_id: OwnerFilter = UNSET) -> str:
248
+ w = self._webhooks
249
+ return w._run(
250
+ endpoint_ops.get_secret(endpoint_id, owner_id=owner_id, secret_store=w._secrets)
251
+ )
252
+
253
+ def send_test(self, endpoint_id: uuid.UUID, *, owner_id: OwnerFilter = UNSET) -> PublishResult:
254
+ w = self._webhooks
255
+ return w._run(
256
+ publisher.send_test(endpoint_id, owner_id=owner_id, channel=w.config.notify_channel)
257
+ )
258
+
259
+
260
+ class DjangoDeliveries:
261
+ def __init__(self, webhooks: DjangoWebhooks) -> None:
262
+ self._webhooks = webhooks
263
+
264
+ def list(
265
+ self,
266
+ *,
267
+ endpoint_id: uuid.UUID | None = None,
268
+ event_id: uuid.UUID | None = None,
269
+ status: DeliveryStatus | str | None = None,
270
+ since: datetime | None = None,
271
+ before: int | None = None,
272
+ limit: int = 50,
273
+ owner_id: OwnerFilter = UNSET,
274
+ ) -> builtins.list[Delivery]:
275
+ return self._webhooks._run(
276
+ delivery_ops.list_deliveries(
277
+ endpoint_id=endpoint_id,
278
+ event_id=event_id,
279
+ status=status,
280
+ since=since,
281
+ before=before,
282
+ limit=limit,
283
+ owner_id=owner_id,
284
+ )
285
+ )
286
+
287
+ def get(self, delivery_id: int, *, owner_id: OwnerFilter = UNSET) -> Delivery:
288
+ return self._webhooks._run(delivery_ops.get_delivery(delivery_id, owner_id=owner_id))
289
+
290
+ def attempts(
291
+ self, delivery_id: int, *, owner_id: OwnerFilter = UNSET
292
+ ) -> builtins.list[Attempt]:
293
+ return self._webhooks._run(delivery_ops.list_attempts(delivery_id, owner_id=owner_id))
294
+
295
+ def retry(self, delivery_id: int, *, owner_id: OwnerFilter = UNSET) -> Delivery:
296
+ w = self._webhooks
297
+ return w._run(
298
+ delivery_ops.retry_delivery(
299
+ delivery_id, owner_id=owner_id, channel=w.config.notify_channel
300
+ )
301
+ )
302
+
303
+ def retry_all(
304
+ self,
305
+ *,
306
+ endpoint_id: uuid.UUID | None = None,
307
+ status: DeliveryStatus | str = DeliveryStatus.FAILED,
308
+ since: datetime | None = None,
309
+ owner_id: OwnerFilter = UNSET,
310
+ ) -> int:
311
+ w = self._webhooks
312
+ return w._run(
313
+ delivery_ops.retry_deliveries(
314
+ endpoint_id=endpoint_id,
315
+ status=status,
316
+ since=since,
317
+ owner_id=owner_id,
318
+ channel=w.config.notify_channel,
319
+ )
320
+ )
321
+
322
+ def cancel(self, delivery_id: int, *, owner_id: OwnerFilter = UNSET) -> Delivery:
323
+ return self._webhooks._run(delivery_ops.cancel_delivery(delivery_id, owner_id=owner_id))
324
+
325
+
326
+ class DjangoEvents:
327
+ def __init__(self, webhooks: DjangoWebhooks) -> None:
328
+ self._webhooks = webhooks
329
+
330
+ def get(self, event_id: uuid.UUID, *, owner_id: OwnerFilter = UNSET) -> Event:
331
+ return self._webhooks._run(event_ops.get_event(event_id, owner_id=owner_id))
332
+
333
+ def list(
334
+ self,
335
+ *,
336
+ owner_id: OwnerFilter = UNSET,
337
+ event_type: str | None = None,
338
+ since: datetime | None = None,
339
+ before: uuid.UUID | None = None,
340
+ limit: int = 50,
341
+ ) -> builtins.list[Event]:
342
+ return self._webhooks._run(
343
+ event_ops.list_events(
344
+ owner_id=owner_id, event_type=event_type, since=since, before=before, limit=limit
345
+ )
346
+ )
347
+
348
+ def replay(
349
+ self,
350
+ event_id: uuid.UUID,
351
+ *,
352
+ endpoint_id: uuid.UUID | None = None,
353
+ owner_id: OwnerFilter = UNSET,
354
+ ) -> builtins.list[int]:
355
+ w = self._webhooks
356
+ return w._run(
357
+ publisher.replay(
358
+ event_id,
359
+ endpoint_id=endpoint_id,
360
+ owner_id=owner_id,
361
+ channel=w.config.notify_channel,
362
+ )
363
+ )
364
+
365
+
366
+ @cache
367
+ def get_webhooks() -> DjangoWebhooks:
368
+ """The project-wide instance, configured from ``settings.PGDISPATCH``."""
369
+ options = dict(getattr(settings, "PGDISPATCH", {}))
370
+ using = options.pop("database", "default")
371
+ return DjangoWebhooks(WebhooksConfig(**options), using=using)
372
+
373
+
374
+ def migrate_schema(connection: BaseDatabaseWrapper, *, target: int | None = None) -> list[int]:
375
+ """Apply pgdispatch SQL migrations through a Django connection."""
376
+ return run_sync(
377
+ migrate(target=target), DjangoExecutor(connection), schema=get_webhooks().config.db_schema
378
+ )
379
+
380
+
381
+ def drop_schema(connection: BaseDatabaseWrapper) -> None:
382
+ run_sync(
383
+ drop_schema_operation(), DjangoExecutor(connection), schema=get_webhooks().config.db_schema
384
+ )
385
+
386
+
387
+ def asyncpg_connect_kwargs(database: Mapping[str, Any]) -> dict[str, Any]:
388
+ """Translate a Django ``DATABASES`` entry into asyncpg connection arguments."""
389
+ if "postgresql" not in database.get("ENGINE", ""):
390
+ msg = f"pgdispatch requires PostgreSQL, got ENGINE={database.get('ENGINE')!r}"
391
+ raise ConfigurationError(msg)
392
+ options = database.get("OPTIONS", {})
393
+ kwargs: dict[str, Any] = {
394
+ "host": database.get("HOST") or None,
395
+ "port": int(database["PORT"]) if database.get("PORT") else None,
396
+ "user": database.get("USER") or None,
397
+ "password": database.get("PASSWORD") or None,
398
+ "database": database.get("NAME") or None,
399
+ }
400
+ if options.get("sslmode"):
401
+ kwargs["ssl"] = options["sslmode"]
402
+ return {key: value for key, value in kwargs.items() if value is not None}
File without changes
File without changes
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from typing import Any
6
+
7
+ from django.core.management.base import BaseCommand, CommandParser
8
+
9
+ from pgdispatch.django.client import get_webhooks
10
+ from pgdispatch.worker.runner import run_until_signalled
11
+
12
+
13
+ class Command(BaseCommand):
14
+ help = "Deliver pgdispatch webhooks until interrupted (SIGINT/SIGTERM)."
15
+
16
+ def add_arguments(self, parser: CommandParser) -> None:
17
+ parser.add_argument("--concurrency", type=int, help="Simultaneous HTTP requests.")
18
+ parser.add_argument("--batch-size", type=int, help="Deliveries claimed per query.")
19
+ parser.add_argument(
20
+ "--metrics-port", type=int, help="Serve Prometheus metrics on this port."
21
+ )
22
+
23
+ def handle(self, *args: Any, **options: Any) -> None:
24
+ if not logging.getLogger().handlers:
25
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
26
+ overrides = {
27
+ name: options[name]
28
+ for name in ("concurrency", "batch_size")
29
+ if options.get(name) is not None
30
+ }
31
+ worker = get_webhooks().worker(**overrides)
32
+ asyncio.run(run_until_signalled(worker, metrics_port=options.get("metrics_port")))
@@ -0,0 +1,30 @@
1
+ """Create the pgdispatch schema.
2
+
3
+ The tables are defined once, in pgdispatch's own SQL migrations; this Django
4
+ migration simply runs them, so ``migrate`` and ``pgdispatch migrate`` always
5
+ produce the same schema. Future versions add a Django migration per SQL one.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from django.db import migrations
13
+
14
+
15
+ def forwards(apps: Any, schema_editor: Any) -> None:
16
+ from pgdispatch.django.client import migrate_schema
17
+
18
+ migrate_schema(schema_editor.connection, target=1)
19
+
20
+
21
+ def backwards(apps: Any, schema_editor: Any) -> None:
22
+ from pgdispatch.django.client import drop_schema
23
+
24
+ drop_schema(schema_editor.connection)
25
+
26
+
27
+ class Migration(migrations.Migration):
28
+ initial = True
29
+ dependencies = []
30
+ operations = [migrations.RunPython(forwards, backwards, elidable=False)]
File without changes