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.
- pgdispatch/__init__.py +69 -0
- pgdispatch/__main__.py +5 -0
- pgdispatch/_version.py +1 -0
- pgdispatch/backends/__init__.py +6 -0
- pgdispatch/backends/asyncpg.py +103 -0
- pgdispatch/backends/base.py +142 -0
- pgdispatch/cli.py +420 -0
- pgdispatch/client.py +490 -0
- pgdispatch/config.py +223 -0
- pgdispatch/core/__init__.py +1 -0
- pgdispatch/core/circuit.py +94 -0
- pgdispatch/core/common.py +87 -0
- pgdispatch/core/deliveries.py +216 -0
- pgdispatch/core/endpoints.py +338 -0
- pgdispatch/core/events.py +65 -0
- pgdispatch/core/maintenance.py +121 -0
- pgdispatch/core/matching.py +76 -0
- pgdispatch/core/payload.py +108 -0
- pgdispatch/core/publisher.py +232 -0
- pgdispatch/core/retry.py +90 -0
- pgdispatch/core/secrets.py +138 -0
- pgdispatch/core/signing.py +141 -0
- pgdispatch/django/__init__.py +37 -0
- pgdispatch/django/apps.py +10 -0
- pgdispatch/django/client.py +402 -0
- pgdispatch/django/management/__init__.py +0 -0
- pgdispatch/django/management/commands/__init__.py +0 -0
- pgdispatch/django/management/commands/pgdispatch_worker.py +32 -0
- pgdispatch/django/migrations/0001_initial.py +30 -0
- pgdispatch/django/migrations/__init__.py +0 -0
- pgdispatch/errors.py +43 -0
- pgdispatch/fastapi/__init__.py +23 -0
- pgdispatch/fastapi/router.py +243 -0
- pgdispatch/hooks.py +80 -0
- pgdispatch/metrics.py +80 -0
- pgdispatch/migrations/0001_initial.sql +104 -0
- pgdispatch/migrations/__init__.py +93 -0
- pgdispatch/models.py +208 -0
- pgdispatch/py.typed +0 -0
- pgdispatch/security/__init__.py +1 -0
- pgdispatch/security/ssrf.py +141 -0
- pgdispatch/sqlalchemy.py +130 -0
- pgdispatch/worker/__init__.py +5 -0
- pgdispatch/worker/fetcher.py +183 -0
- pgdispatch/worker/ratelimit.py +47 -0
- pgdispatch/worker/recorder.py +435 -0
- pgdispatch/worker/runner.py +36 -0
- pgdispatch/worker/sender.py +160 -0
- pgdispatch/worker/worker.py +557 -0
- pgdispatch-0.1.0.dist-info/METADATA +329 -0
- pgdispatch-0.1.0.dist-info/RECORD +54 -0
- pgdispatch-0.1.0.dist-info/WHEEL +4 -0
- pgdispatch-0.1.0.dist-info/entry_points.txt +2 -0
- pgdispatch-0.1.0.dist-info/licenses/LICENSE +21 -0
pgdispatch/__init__.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""pgdispatch: reliable webhook delivery using only the PostgreSQL you already have.
|
|
2
|
+
|
|
3
|
+
Publish events inside your database transaction, and a background worker
|
|
4
|
+
delivers them to every subscribed endpoint, signed according to Standard
|
|
5
|
+
Webhooks, with retries, circuit breaking and a full attempt log.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pgdispatch._version import __version__
|
|
9
|
+
from pgdispatch.backends.asyncpg import AsyncpgBackend
|
|
10
|
+
from pgdispatch.client import Webhooks
|
|
11
|
+
from pgdispatch.config import WebhooksConfig
|
|
12
|
+
from pgdispatch.core.common import UNSET
|
|
13
|
+
from pgdispatch.core.maintenance import QueueStats
|
|
14
|
+
from pgdispatch.core.retry import DEFAULT_RETRY_SCHEDULE, exponential_backoff
|
|
15
|
+
from pgdispatch.core.signing import generate_secret
|
|
16
|
+
from pgdispatch.core.signing import verify as verify_signature
|
|
17
|
+
from pgdispatch.errors import (
|
|
18
|
+
ConfigurationError,
|
|
19
|
+
ConflictError,
|
|
20
|
+
InvalidSignatureError,
|
|
21
|
+
MigrationError,
|
|
22
|
+
NotFoundError,
|
|
23
|
+
PayloadTooLargeError,
|
|
24
|
+
PgDispatchError,
|
|
25
|
+
UnsafeURLError,
|
|
26
|
+
ValidationError,
|
|
27
|
+
)
|
|
28
|
+
from pgdispatch.hooks import Hooks
|
|
29
|
+
from pgdispatch.models import (
|
|
30
|
+
Attempt,
|
|
31
|
+
CircuitState,
|
|
32
|
+
Delivery,
|
|
33
|
+
DeliveryStatus,
|
|
34
|
+
Endpoint,
|
|
35
|
+
Event,
|
|
36
|
+
PublishResult,
|
|
37
|
+
)
|
|
38
|
+
from pgdispatch.worker.worker import Worker
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"DEFAULT_RETRY_SCHEDULE",
|
|
42
|
+
"UNSET",
|
|
43
|
+
"AsyncpgBackend",
|
|
44
|
+
"Attempt",
|
|
45
|
+
"CircuitState",
|
|
46
|
+
"ConfigurationError",
|
|
47
|
+
"ConflictError",
|
|
48
|
+
"Delivery",
|
|
49
|
+
"DeliveryStatus",
|
|
50
|
+
"Endpoint",
|
|
51
|
+
"Event",
|
|
52
|
+
"Hooks",
|
|
53
|
+
"InvalidSignatureError",
|
|
54
|
+
"MigrationError",
|
|
55
|
+
"NotFoundError",
|
|
56
|
+
"PayloadTooLargeError",
|
|
57
|
+
"PgDispatchError",
|
|
58
|
+
"PublishResult",
|
|
59
|
+
"QueueStats",
|
|
60
|
+
"UnsafeURLError",
|
|
61
|
+
"ValidationError",
|
|
62
|
+
"Webhooks",
|
|
63
|
+
"WebhooksConfig",
|
|
64
|
+
"Worker",
|
|
65
|
+
"__version__",
|
|
66
|
+
"exponential_backoff",
|
|
67
|
+
"generate_secret",
|
|
68
|
+
"verify_signature",
|
|
69
|
+
]
|
pgdispatch/__main__.py
ADDED
pgdispatch/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Backend for applications that talk to PostgreSQL with plain asyncpg."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator, Mapping
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import asyncpg
|
|
10
|
+
from asyncpg.pool import PoolConnectionProxy
|
|
11
|
+
|
|
12
|
+
from pgdispatch.backends.base import Row, to_numeric_style
|
|
13
|
+
from pgdispatch.errors import ConfigurationError
|
|
14
|
+
|
|
15
|
+
__all__ = ["AsyncpgBackend", "AsyncpgConnection", "AsyncpgExecutor", "normalize_dsn"]
|
|
16
|
+
|
|
17
|
+
AsyncpgConnection = asyncpg.Connection | PoolConnectionProxy
|
|
18
|
+
"""A plain connection or one acquired from a pool."""
|
|
19
|
+
|
|
20
|
+
WORKER_POOL_SIZE = 5
|
|
21
|
+
"""Connections used by a worker: LISTEN, claiming, recording results and housekeeping."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AsyncpgExecutor:
|
|
25
|
+
"""Runs statements on an asyncpg connection (or pool)."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, connection: AsyncpgConnection | asyncpg.Pool) -> None:
|
|
28
|
+
self._connection = connection
|
|
29
|
+
|
|
30
|
+
async def fetch(self, sql: str, params: Mapping[str, Any]) -> list[Row]:
|
|
31
|
+
statement, names = to_numeric_style(sql)
|
|
32
|
+
records = await self._connection.fetch(statement, *(params[name] for name in names))
|
|
33
|
+
return [dict(record) for record in records]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AsyncpgBackend:
|
|
37
|
+
"""Use pgdispatch with asyncpg.
|
|
38
|
+
|
|
39
|
+
``AsyncpgBackend("postgresql://...")`` creates and owns a pool. Pass an
|
|
40
|
+
existing pool with ``AsyncpgBackend(pool=pool)`` to share yours; the worker
|
|
41
|
+
always opens a small pool of its own because it keeps a connection busy
|
|
42
|
+
with ``LISTEN``.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
dsn: str | None = None,
|
|
48
|
+
*,
|
|
49
|
+
pool: asyncpg.Pool | None = None,
|
|
50
|
+
**connect_kwargs: Any,
|
|
51
|
+
) -> None:
|
|
52
|
+
if dsn is None and pool is None and not connect_kwargs:
|
|
53
|
+
msg = "pass a DSN, an asyncpg pool or asyncpg connection arguments"
|
|
54
|
+
raise ValueError(msg)
|
|
55
|
+
self._dsn = normalize_dsn(dsn) if dsn else None
|
|
56
|
+
self._connect_kwargs = connect_kwargs
|
|
57
|
+
self._pool = pool
|
|
58
|
+
self._owns_pool = pool is None
|
|
59
|
+
|
|
60
|
+
def executor(self, connection: Any) -> AsyncpgExecutor:
|
|
61
|
+
if not isinstance(connection, (asyncpg.Connection, PoolConnectionProxy, asyncpg.Pool)):
|
|
62
|
+
kind = type(connection).__name__
|
|
63
|
+
msg = f"AsyncpgBackend.publish() expects an asyncpg connection, got {kind}"
|
|
64
|
+
raise TypeError(msg)
|
|
65
|
+
return AsyncpgExecutor(connection)
|
|
66
|
+
|
|
67
|
+
@asynccontextmanager
|
|
68
|
+
async def transaction(self) -> AsyncIterator[AsyncpgExecutor]:
|
|
69
|
+
pool = await self._get_pool()
|
|
70
|
+
async with pool.acquire() as connection, connection.transaction():
|
|
71
|
+
yield AsyncpgExecutor(connection)
|
|
72
|
+
|
|
73
|
+
async def create_worker_pool(self) -> asyncpg.Pool:
|
|
74
|
+
if self._dsn is None and not self._connect_kwargs:
|
|
75
|
+
msg = (
|
|
76
|
+
"the worker needs its own connections: pass the DSN as well, "
|
|
77
|
+
"e.g. AsyncpgBackend(dsn, pool=pool)"
|
|
78
|
+
)
|
|
79
|
+
raise ConfigurationError(msg)
|
|
80
|
+
kwargs = {"min_size": 1, "max_size": WORKER_POOL_SIZE, **self._connect_kwargs}
|
|
81
|
+
pool: asyncpg.Pool = await asyncpg.create_pool(self._dsn, **kwargs)
|
|
82
|
+
return pool
|
|
83
|
+
|
|
84
|
+
async def close(self) -> None:
|
|
85
|
+
if self._owns_pool and self._pool is not None:
|
|
86
|
+
await self._pool.close()
|
|
87
|
+
self._pool = None
|
|
88
|
+
|
|
89
|
+
async def _get_pool(self) -> asyncpg.Pool:
|
|
90
|
+
if self._pool is None:
|
|
91
|
+
self._pool = await asyncpg.create_pool(self._dsn, **self._connect_kwargs)
|
|
92
|
+
return self._pool
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def normalize_dsn(dsn: str) -> str:
|
|
96
|
+
"""Accept SQLAlchemy-style URLs such as ``postgresql+asyncpg://`` as well."""
|
|
97
|
+
scheme, separator, rest = dsn.partition("://")
|
|
98
|
+
if not separator:
|
|
99
|
+
return dsn
|
|
100
|
+
base = scheme.split("+", 1)[0]
|
|
101
|
+
if base == "postgres":
|
|
102
|
+
base = "postgresql"
|
|
103
|
+
return f"{base}://{rest}"
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Driver-independent database access.
|
|
2
|
+
|
|
3
|
+
pgdispatch has to run the same SQL through asyncpg, SQLAlchemy (any async
|
|
4
|
+
driver) and Django (psycopg, synchronously). Instead of writing every operation
|
|
5
|
+
three times, operations are written once as *generators* that yield
|
|
6
|
+
:class:`Query` objects and receive the resulting rows::
|
|
7
|
+
|
|
8
|
+
def get_endpoint(endpoint_id: UUID) -> Operation[Endpoint]:
|
|
9
|
+
rows = yield Query("SELECT ... WHERE id = CAST(:id AS uuid)", {"id": str(endpoint_id)})
|
|
10
|
+
return Endpoint.from_row(rows[0])
|
|
11
|
+
|
|
12
|
+
A small runner (:func:`run_async` or :func:`run_sync`) drives the generator
|
|
13
|
+
against an executor. Executors only know how to send one statement to their
|
|
14
|
+
driver; they never contain business logic.
|
|
15
|
+
|
|
16
|
+
SQL conventions:
|
|
17
|
+
|
|
18
|
+
* Parameters use the ``:name`` style and are always wrapped in ``CAST`` so that
|
|
19
|
+
every driver binds them with the same PostgreSQL type.
|
|
20
|
+
* ``{schema}`` is replaced with the configured, quoted schema name.
|
|
21
|
+
* ``jsonb`` values are selected as ``text`` and decoded in Python.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
from collections.abc import Generator, Mapping
|
|
28
|
+
from contextlib import AbstractAsyncContextManager
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from functools import lru_cache
|
|
31
|
+
from typing import TYPE_CHECKING, Any, Protocol, TypeVar
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
import asyncpg
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"AsyncExecutor",
|
|
38
|
+
"Backend",
|
|
39
|
+
"Operation",
|
|
40
|
+
"Query",
|
|
41
|
+
"Row",
|
|
42
|
+
"SyncExecutor",
|
|
43
|
+
"run_async",
|
|
44
|
+
"run_sync",
|
|
45
|
+
"to_numeric_style",
|
|
46
|
+
"to_pyformat_style",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
T = TypeVar("T")
|
|
50
|
+
Row = dict[str, Any]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class Query:
|
|
55
|
+
sql: str
|
|
56
|
+
params: Mapping[str, Any] = field(default_factory=dict)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
Operation = Generator[Query, list[Row], T]
|
|
60
|
+
"""A database operation: yields queries, receives their rows, returns a result."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class AsyncExecutor(Protocol):
|
|
64
|
+
async def fetch(self, sql: str, params: Mapping[str, Any]) -> list[Row]:
|
|
65
|
+
"""Execute one statement and return its rows (an empty list if it returns none)."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SyncExecutor(Protocol):
|
|
69
|
+
def fetch(self, sql: str, params: Mapping[str, Any]) -> list[Row]:
|
|
70
|
+
"""Execute one statement and return its rows (an empty list if it returns none)."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Backend(Protocol):
|
|
74
|
+
"""Connects :class:`~pgdispatch.Webhooks` to the application's database layer."""
|
|
75
|
+
|
|
76
|
+
def executor(self, connection: Any) -> AsyncExecutor:
|
|
77
|
+
"""Wrap a connection or session owned by the application (used by ``publish``)."""
|
|
78
|
+
|
|
79
|
+
def transaction(self) -> AbstractAsyncContextManager[AsyncExecutor]:
|
|
80
|
+
"""Run management operations in a new transaction."""
|
|
81
|
+
|
|
82
|
+
async def create_worker_pool(self) -> asyncpg.Pool:
|
|
83
|
+
"""Create the asyncpg pool used by the delivery worker."""
|
|
84
|
+
|
|
85
|
+
async def close(self) -> None:
|
|
86
|
+
"""Release resources owned by the backend."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def run_async(operation: Operation[T], executor: AsyncExecutor, *, schema: str) -> T:
|
|
90
|
+
"""Drive ``operation`` to completion against an async executor."""
|
|
91
|
+
try:
|
|
92
|
+
query = next(operation)
|
|
93
|
+
while True:
|
|
94
|
+
rows = await executor.fetch(render_schema(query.sql, schema), query.params)
|
|
95
|
+
query = operation.send(rows)
|
|
96
|
+
except StopIteration as stop:
|
|
97
|
+
result: T = stop.value
|
|
98
|
+
return result
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def run_sync(operation: Operation[T], executor: SyncExecutor, *, schema: str) -> T:
|
|
102
|
+
"""Drive ``operation`` to completion against a blocking executor."""
|
|
103
|
+
try:
|
|
104
|
+
query = next(operation)
|
|
105
|
+
while True:
|
|
106
|
+
rows = executor.fetch(render_schema(query.sql, schema), query.params)
|
|
107
|
+
query = operation.send(rows)
|
|
108
|
+
except StopIteration as stop:
|
|
109
|
+
result: T = stop.value
|
|
110
|
+
return result
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ``:name`` placeholders; ``::type`` casts are left alone.
|
|
114
|
+
_PARAMETER = re.compile(r"(?<![:\w]):(\w+)(?!:)")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@lru_cache(maxsize=512)
|
|
118
|
+
def render_schema(sql: str, schema: str) -> str:
|
|
119
|
+
return sql.replace("{schema}", f'"{schema}"')
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@lru_cache(maxsize=512)
|
|
123
|
+
def to_numeric_style(sql: str) -> tuple[str, tuple[str, ...]]:
|
|
124
|
+
"""Convert ``:name`` placeholders to asyncpg's ``$1`` style.
|
|
125
|
+
|
|
126
|
+
Returns the new SQL and the parameter names in positional order.
|
|
127
|
+
"""
|
|
128
|
+
names: list[str] = []
|
|
129
|
+
|
|
130
|
+
def replace(match: re.Match[str]) -> str:
|
|
131
|
+
name = match.group(1)
|
|
132
|
+
if name not in names:
|
|
133
|
+
names.append(name)
|
|
134
|
+
return f"${names.index(name) + 1}"
|
|
135
|
+
|
|
136
|
+
return _PARAMETER.sub(replace, sql), tuple(names)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@lru_cache(maxsize=512)
|
|
140
|
+
def to_pyformat_style(sql: str) -> str:
|
|
141
|
+
"""Convert ``:name`` placeholders to the DB-API ``%(name)s`` style used by psycopg."""
|
|
142
|
+
return _PARAMETER.sub(r"%(\1)s", sql.replace("%", "%%"))
|