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
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
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from pgdispatch.cli import main
4
+
5
+ sys.exit(main())
pgdispatch/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Adapters between pgdispatch and the application's database driver."""
2
+
3
+ from pgdispatch.backends.asyncpg import AsyncpgBackend
4
+ from pgdispatch.backends.base import Backend
5
+
6
+ __all__ = ["AsyncpgBackend", "Backend"]
@@ -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("%", "%%"))