community-agent-framework-postgres 0.1.1__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.
- agent_framework_community_postgres/__init__.py +77 -0
- agent_framework_community_postgres/__main__.py +3 -0
- agent_framework_community_postgres/_checkpoint_storage.py +218 -0
- agent_framework_community_postgres/_cli.py +97 -0
- agent_framework_community_postgres/_client.py +390 -0
- agent_framework_community_postgres/_entra.py +178 -0
- agent_framework_community_postgres/_framework.py +148 -0
- agent_framework_community_postgres/_history_provider.py +261 -0
- agent_framework_community_postgres/_json.py +34 -0
- agent_framework_community_postgres/_leases.py +196 -0
- agent_framework_community_postgres/_migrations.py +235 -0
- agent_framework_community_postgres/_persistence.py +184 -0
- agent_framework_community_postgres/_retention.py +159 -0
- agent_framework_community_postgres/_session_store.py +135 -0
- agent_framework_community_postgres/_store.py +66 -0
- agent_framework_community_postgres/_thread_snapshot_store.py +162 -0
- agent_framework_community_postgres/py.typed +0 -0
- community_agent_framework_postgres-0.1.1.dist-info/METADATA +367 -0
- community_agent_framework_postgres-0.1.1.dist-info/RECORD +21 -0
- community_agent_framework_postgres-0.1.1.dist-info/WHEEL +4 -0
- community_agent_framework_postgres-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""Connection ownership, settings, table naming and this package's error types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import functools
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import weakref
|
|
10
|
+
from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping
|
|
11
|
+
from contextlib import asynccontextmanager
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import TYPE_CHECKING, Any, TypeAlias, TypedDict, cast
|
|
14
|
+
|
|
15
|
+
from agent_framework import SecretString, load_settings
|
|
16
|
+
from agent_framework.exceptions import IntegrationException
|
|
17
|
+
from psycopg import AsyncConnection, Error, OperationalError, sql
|
|
18
|
+
from psycopg.conninfo import conninfo_to_dict
|
|
19
|
+
from psycopg.pq import TransactionStatus
|
|
20
|
+
from psycopg_pool import AsyncConnectionPool, PoolClosed, PoolTimeout
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
# _entra imports this module's error type, so it is imported where a pool is built.
|
|
24
|
+
from ._entra import EntraCredential
|
|
25
|
+
|
|
26
|
+
PostgresClient: TypeAlias = AsyncConnection[Any] | AsyncConnectionPool[AsyncConnection[Any]]
|
|
27
|
+
|
|
28
|
+
_SCHEMA = re.compile(r"[a-z_][a-z0-9_]*")
|
|
29
|
+
_PREFIX = re.compile(r"[a-z0-9_]*")
|
|
30
|
+
# The longest table or index suffix this package creates; a migration adding a longer one must update it.
|
|
31
|
+
_LONGEST_IDENTIFIER = "history_messages_session_expiry_idx"
|
|
32
|
+
_MAX_IDENTIFIER_BYTES = 63
|
|
33
|
+
# Bounds one connection attempt and how long a caller waits for a pooled connection. The pool
|
|
34
|
+
# keeps retrying in the background, so a call fails fast and a later call recovers with the database.
|
|
35
|
+
_CONNECT_TIMEOUT_SECONDS = 10
|
|
36
|
+
# Connection parameters for an owned pool when the connection string does not set them. Keepalives
|
|
37
|
+
# and tcp_user_timeout bound a silently dead connection to about a minute instead of the operating
|
|
38
|
+
# system's retransmit timeout. No statement or lock timeout: history saves and replicas waiting on
|
|
39
|
+
# the migration lock are meant to wait.
|
|
40
|
+
_CONNECTION_DEFAULTS: dict[str, Any] = {
|
|
41
|
+
"connect_timeout": _CONNECT_TIMEOUT_SECONDS,
|
|
42
|
+
"keepalives": 1,
|
|
43
|
+
"keepalives_idle": 30,
|
|
44
|
+
"keepalives_interval": 10,
|
|
45
|
+
"keepalives_count": 3,
|
|
46
|
+
"tcp_user_timeout": 30000,
|
|
47
|
+
}
|
|
48
|
+
_POOL_DEFAULTS: dict[str, Any] = {"min_size": 1, "max_size": 10, "timeout": float(_CONNECT_TIMEOUT_SECONDS)}
|
|
49
|
+
# SQLSTATE classes a caller may retry on a fresh connection: connection failure, transaction rollback
|
|
50
|
+
# (serialization, deadlock), insufficient resources, operator intervention, lock not available.
|
|
51
|
+
_RETRYABLE_SQLSTATES = ("08", "40", "53", "57P0", "55P03")
|
|
52
|
+
_WEAK_SSLMODES = ("disable", "allow", "prefer")
|
|
53
|
+
# One lock per borrowed connection, shared by every handle over it: a psycopg connection runs one
|
|
54
|
+
# transaction block at a time, so overlapping calls from two tasks would nest out of order.
|
|
55
|
+
_CONNECTION_LOCKS: weakref.WeakKeyDictionary[AsyncConnection[Any], asyncio.Lock] = weakref.WeakKeyDictionary()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PostgresSettings(TypedDict, total=False):
|
|
59
|
+
"""Connection settings resolved from an explicit value, a selected ``.env`` file, or ``POSTGRES_*``."""
|
|
60
|
+
|
|
61
|
+
connection_string: SecretString | None
|
|
62
|
+
"""Psycopg conninfo or URI; from ``POSTGRES_CONNECTION_STRING`` when not given explicitly."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def is_retryable(sqlstate: str | None) -> bool:
|
|
66
|
+
"""Whether a driver error with this SQLSTATE is worth retrying on a fresh connection."""
|
|
67
|
+
return sqlstate is not None and sqlstate.startswith(_RETRYABLE_SQLSTATES)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class PostgresStorageError(IntegrationException):
|
|
71
|
+
"""A PostgreSQL operation failed; the driver exception is chained as the cause.
|
|
72
|
+
|
|
73
|
+
``sqlstate`` is the server's error code when there was one. ``retryable`` is ``True`` for
|
|
74
|
+
connection exceptions (class 08), transaction rollbacks such as serialization failures and
|
|
75
|
+
deadlocks (class 40), insufficient resources (class 53), server shutdown and admin
|
|
76
|
+
termination (57P0x), lock not available, which includes ``lock_timeout`` (55P03), pool
|
|
77
|
+
timeouts, and a connection lost without a SQLSTATE.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, message: str, *, sqlstate: str | None = None, retryable: bool = False) -> None:
|
|
81
|
+
super().__init__(message)
|
|
82
|
+
self.sqlstate = sqlstate
|
|
83
|
+
self.retryable = retryable
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _LeaseError(PostgresStorageError):
|
|
87
|
+
"""A lease error; the message names no resource, which ``resource`` carries."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, message: str, *, resource: str | None = None) -> None:
|
|
90
|
+
super().__init__(message)
|
|
91
|
+
self.resource = resource
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class LeaseUnavailable(_LeaseError):
|
|
95
|
+
"""Someone holds the lease and the wait budget ran out."""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class LeaseLost(_LeaseError):
|
|
99
|
+
"""The lease expired or was taken over before ``renew`` ran."""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def require_text(value: object, name: str) -> str:
|
|
103
|
+
"""Return ``value`` when it is a non-empty ``str``; raise ``ValueError`` otherwise."""
|
|
104
|
+
if not isinstance(value, str) or not value:
|
|
105
|
+
raise ValueError(f"{name} must be a non-empty string.")
|
|
106
|
+
return value
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def optional_text(value: object, name: str) -> str:
|
|
110
|
+
"""Map ``None`` to ``''`` for storage; reject an explicit ``''`` so absence and value never conflate."""
|
|
111
|
+
if value is None:
|
|
112
|
+
return ""
|
|
113
|
+
return require_text(value, name)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class TableNames:
|
|
118
|
+
"""Schema and prefix every table of this package is created under."""
|
|
119
|
+
|
|
120
|
+
schema: str = "public"
|
|
121
|
+
prefix: str = "af_"
|
|
122
|
+
|
|
123
|
+
def __post_init__(self) -> None:
|
|
124
|
+
if not _SCHEMA.fullmatch(self.schema):
|
|
125
|
+
raise ValueError("schema must be a lowercase PostgreSQL identifier (letters, digits, underscores).")
|
|
126
|
+
if self.schema.startswith("pg_"):
|
|
127
|
+
raise ValueError("schema must not start with 'pg_'; PostgreSQL reserves that prefix for system schemas.")
|
|
128
|
+
if not _PREFIX.fullmatch(self.prefix):
|
|
129
|
+
raise ValueError("table_prefix may contain only lowercase letters, digits and underscores.")
|
|
130
|
+
for name in (self.schema, f"{self.prefix}{_LONGEST_IDENTIFIER}"):
|
|
131
|
+
if len(name.encode("utf-8")) > _MAX_IDENTIFIER_BYTES:
|
|
132
|
+
raise ValueError("schema and table_prefix must keep every identifier within 63 bytes.")
|
|
133
|
+
|
|
134
|
+
def table(self, name: str) -> sql.Identifier:
|
|
135
|
+
"""The schema-qualified identifier of one of this package's tables."""
|
|
136
|
+
return sql.Identifier(self.schema, f"{self.prefix}{name}")
|
|
137
|
+
|
|
138
|
+
def index(self, name: str) -> sql.Identifier:
|
|
139
|
+
"""An unqualified index identifier (``CREATE INDEX`` takes the schema from the table)."""
|
|
140
|
+
return sql.Identifier(f"{self.prefix}{name}")
|
|
141
|
+
|
|
142
|
+
def qualified(self, name: str) -> str:
|
|
143
|
+
"""``schema.table`` as plain text, for catalog lookups."""
|
|
144
|
+
return f"{self.schema}.{self.prefix}{name}"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _refuse_weak_sslmode(options: Mapping[str, Any], kwargs: Mapping[str, Any]) -> None:
|
|
148
|
+
"""With a credential the password is a bearer token for every Azure server; refuse an unverified channel."""
|
|
149
|
+
# libpq's precedence: a keyword argument, then the connection string, then PGSSLMODE.
|
|
150
|
+
for source, value in (
|
|
151
|
+
("sslmode", kwargs.get("sslmode")),
|
|
152
|
+
("sslmode", options.get("sslmode")),
|
|
153
|
+
("PGSSLMODE", os.environ.get("PGSSLMODE")),
|
|
154
|
+
):
|
|
155
|
+
if not value:
|
|
156
|
+
continue
|
|
157
|
+
if value in _WEAK_SSLMODES:
|
|
158
|
+
raise ValueError(
|
|
159
|
+
f"{source}={value} sends the Microsoft Entra ID token without verifying the server;"
|
|
160
|
+
" use verify-full (the default with a credential) or require."
|
|
161
|
+
)
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@asynccontextmanager
|
|
166
|
+
async def _block(
|
|
167
|
+
connection: AsyncConnection[Any], transactional: bool, read_committed: bool, *, started: bool
|
|
168
|
+
) -> AsyncGenerator[None]:
|
|
169
|
+
"""A transaction (or savepoint) around the caller's statements, or nothing for an autocommit read."""
|
|
170
|
+
# A borrowed connection outside autocommit would leave the read's implicit transaction open, so
|
|
171
|
+
# its next write would become a savepoint nobody commits; such a read still gets a transaction.
|
|
172
|
+
if not transactional and (connection.autocommit or not started):
|
|
173
|
+
yield
|
|
174
|
+
return
|
|
175
|
+
async with connection.transaction():
|
|
176
|
+
if read_committed and started:
|
|
177
|
+
# Must be the transaction's first statement; a snapshot taken earlier would be kept.
|
|
178
|
+
await connection.execute("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")
|
|
179
|
+
yield
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class ClientHandle:
|
|
183
|
+
"""Own a lazily opened pool, or borrow a caller's pool or connection without closing it.
|
|
184
|
+
|
|
185
|
+
With ``credential``, the owned pool signs in to Azure Database for PostgreSQL with a Microsoft
|
|
186
|
+
Entra ID token per new connection (see ``_entra``).
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
def __init__(
|
|
190
|
+
self,
|
|
191
|
+
connection_string: SecretString | None,
|
|
192
|
+
client: PostgresClient | None,
|
|
193
|
+
*,
|
|
194
|
+
credential: EntraCredential | None = None,
|
|
195
|
+
pool_kwargs: Mapping[str, Any] | None = None,
|
|
196
|
+
) -> None:
|
|
197
|
+
if (connection_string is None) == (client is None):
|
|
198
|
+
raise ValueError("Supply exactly one of connection_string or client.")
|
|
199
|
+
if credential is not None and client is not None:
|
|
200
|
+
raise ValueError(
|
|
201
|
+
"credential cannot be combined with client; a pool or connection you pass signs in itself."
|
|
202
|
+
)
|
|
203
|
+
if pool_kwargs is not None and client is not None:
|
|
204
|
+
raise ValueError("pool_kwargs cannot be combined with client; it configures a pool this handle owns.")
|
|
205
|
+
if client is not None and not isinstance(client, (AsyncConnection, AsyncConnectionPool)): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
206
|
+
raise TypeError("client must be a psycopg AsyncConnection or AsyncConnectionPool.")
|
|
207
|
+
self.owned = client is None
|
|
208
|
+
self.client: PostgresClient
|
|
209
|
+
# Signs in once without connecting; set only for an owned pool with a credential.
|
|
210
|
+
self._check_credential: Callable[[], Awaitable[None]] | None = None
|
|
211
|
+
if connection_string is not None:
|
|
212
|
+
conninfo = connection_string.get_secret_value()
|
|
213
|
+
if not conninfo.strip():
|
|
214
|
+
raise ValueError("connection_string must not be empty.")
|
|
215
|
+
try:
|
|
216
|
+
options = conninfo_to_dict(conninfo)
|
|
217
|
+
except Error:
|
|
218
|
+
# from None: libpq's text can quote a token next to the password.
|
|
219
|
+
raise PostgresStorageError("Invalid connection string.") from None
|
|
220
|
+
overrides = dict(pool_kwargs or {})
|
|
221
|
+
connection_kwargs: dict[str, Any] = {"autocommit": True}
|
|
222
|
+
connection_kwargs.update({key: value for key, value in _CONNECTION_DEFAULTS.items() if key not in options})
|
|
223
|
+
connection_kwargs.update(overrides.pop("kwargs", {}))
|
|
224
|
+
pool_options = _POOL_DEFAULTS | overrides
|
|
225
|
+
if credential is not None:
|
|
226
|
+
from ._entra import check_credential, entra_connection_class
|
|
227
|
+
|
|
228
|
+
if "connection_class" in pool_options:
|
|
229
|
+
raise ValueError("pool_kwargs cannot set connection_class together with credential.")
|
|
230
|
+
_refuse_weak_sslmode(options, connection_kwargs)
|
|
231
|
+
pool_options["connection_class"] = entra_connection_class(credential)
|
|
232
|
+
self._check_credential = functools.partial(
|
|
233
|
+
check_credential, credential, conninfo, dict(connection_kwargs)
|
|
234
|
+
)
|
|
235
|
+
self.client = AsyncConnectionPool(conninfo, open=False, kwargs=connection_kwargs, **pool_options)
|
|
236
|
+
else:
|
|
237
|
+
self.client = cast(PostgresClient, client)
|
|
238
|
+
self.closed = False
|
|
239
|
+
self._parent: ClientHandle | None = None
|
|
240
|
+
|
|
241
|
+
@staticmethod
|
|
242
|
+
def _no_connection(pool: AsyncConnectionPool[AsyncConnection[Any]]) -> PostgresStorageError:
|
|
243
|
+
# psycopg_pool raises the same PoolTimeout for a database that is down and for a pool whose
|
|
244
|
+
# connections are all busy, so the message names both.
|
|
245
|
+
seconds = f"{pool.timeout:g} second{'' if pool.timeout == 1 else 's'}"
|
|
246
|
+
message = (
|
|
247
|
+
f"No PostgreSQL connection became available within {seconds}: the database is unreachable or"
|
|
248
|
+
" every pooled connection is busy; psycopg_pool logs the driver's reason."
|
|
249
|
+
)
|
|
250
|
+
# Set only on an Entra ID connection class (see _entra); the message names no token.
|
|
251
|
+
last_error = getattr(pool.connection_class, "entra_last_error", None)
|
|
252
|
+
if isinstance(last_error, str):
|
|
253
|
+
message += f" Last connection error: {last_error}"
|
|
254
|
+
return PostgresStorageError(message, retryable=True)
|
|
255
|
+
|
|
256
|
+
def __repr__(self) -> str:
|
|
257
|
+
kind = type(self.client).__name__
|
|
258
|
+
return f"ClientHandle(owned={self.owned}, client={kind}, closed={self.closed})"
|
|
259
|
+
|
|
260
|
+
async def open(self) -> None:
|
|
261
|
+
"""Open an owned pool; a no-op for a borrowed client.
|
|
262
|
+
|
|
263
|
+
With a credential, one token is fetched before the pool opens, so a failing credential raises
|
|
264
|
+
``PostgresStorageError`` here instead of a pool timeout later. Nothing is cached.
|
|
265
|
+
"""
|
|
266
|
+
self._ensure_open()
|
|
267
|
+
if self.owned and isinstance(self.client, AsyncConnectionPool):
|
|
268
|
+
if self._check_credential is not None and self.client.closed:
|
|
269
|
+
await self._check_credential()
|
|
270
|
+
await self.client.open()
|
|
271
|
+
|
|
272
|
+
def _root(self) -> ClientHandle:
|
|
273
|
+
handle = self
|
|
274
|
+
while handle._parent is not None:
|
|
275
|
+
handle = handle._parent
|
|
276
|
+
return handle
|
|
277
|
+
|
|
278
|
+
def child(self) -> ClientHandle:
|
|
279
|
+
"""A borrowed handle over the same client that stops working once this handle is closed."""
|
|
280
|
+
handle = ClientHandle(None, self.client)
|
|
281
|
+
handle._parent = self
|
|
282
|
+
return handle
|
|
283
|
+
|
|
284
|
+
def _is_closed(self) -> bool:
|
|
285
|
+
return self.closed or (self._parent is not None and self._parent._is_closed())
|
|
286
|
+
|
|
287
|
+
def _ensure_open(self) -> None:
|
|
288
|
+
if self._is_closed():
|
|
289
|
+
raise PostgresStorageError("The Postgres client is closed.")
|
|
290
|
+
|
|
291
|
+
@asynccontextmanager
|
|
292
|
+
async def connection(
|
|
293
|
+
self, *, transactional: bool = True, read_committed: bool = False
|
|
294
|
+
) -> AsyncGenerator[AsyncConnection[Any]]:
|
|
295
|
+
"""Yield a connection, inside a transaction unless ``transactional`` is ``False``.
|
|
296
|
+
|
|
297
|
+
A single-statement read passes ``transactional=False`` and runs in autocommit, saving two round
|
|
298
|
+
trips. ``read_committed`` makes ``READ COMMITTED`` the isolation level of a transaction this
|
|
299
|
+
handle starts, whatever the server default; inside a caller's own transaction the caller's
|
|
300
|
+
level applies. A pool is opened on first use; a borrowed pool its owner already closed raises
|
|
301
|
+
``PostgresStorageError``. A borrowed connection is held under a lock shared by every handle
|
|
302
|
+
over it, so calls on one connection run one at a time; inside the caller's own transaction a
|
|
303
|
+
transactional block is a savepoint. psycopg errors become ``PostgresStorageError``.
|
|
304
|
+
"""
|
|
305
|
+
self._ensure_open()
|
|
306
|
+
try:
|
|
307
|
+
if isinstance(self.client, AsyncConnectionPool):
|
|
308
|
+
root = self._root()
|
|
309
|
+
if root.owned:
|
|
310
|
+
# The handle that owns the pool opens it, so a hub's stores get its credential check too.
|
|
311
|
+
await root.open()
|
|
312
|
+
elif self.client.closed:
|
|
313
|
+
await self.client.open()
|
|
314
|
+
async with (
|
|
315
|
+
self.client.connection() as connection,
|
|
316
|
+
_block(connection, transactional, read_committed, started=True),
|
|
317
|
+
):
|
|
318
|
+
yield connection
|
|
319
|
+
else:
|
|
320
|
+
lock = _CONNECTION_LOCKS.setdefault(self.client, asyncio.Lock())
|
|
321
|
+
async with lock:
|
|
322
|
+
started = self.client.info.transaction_status == TransactionStatus.IDLE
|
|
323
|
+
async with _block(self.client, transactional, read_committed, started=started):
|
|
324
|
+
yield self.client
|
|
325
|
+
except PoolTimeout as exc:
|
|
326
|
+
if isinstance(self.client, AsyncConnectionPool):
|
|
327
|
+
raise self._no_connection(self.client) from exc
|
|
328
|
+
raise
|
|
329
|
+
except Error as exc:
|
|
330
|
+
sqlstate = exc.sqlstate
|
|
331
|
+
retryable = is_retryable(sqlstate) or (
|
|
332
|
+
sqlstate is None and isinstance(exc, OperationalError) and not isinstance(exc, PoolClosed)
|
|
333
|
+
)
|
|
334
|
+
raise PostgresStorageError(
|
|
335
|
+
"PostgreSQL operation failed; see the chained driver exception.", sqlstate=sqlstate, retryable=retryable
|
|
336
|
+
) from exc
|
|
337
|
+
|
|
338
|
+
async def close(self) -> None:
|
|
339
|
+
"""Close an owned pool once; borrowed clients are left to their owner."""
|
|
340
|
+
if not self.closed:
|
|
341
|
+
if self.owned and isinstance(self.client, AsyncConnectionPool):
|
|
342
|
+
await self.client.close()
|
|
343
|
+
self.closed = True
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def create_client(
|
|
347
|
+
connection_string: str | SecretString | None,
|
|
348
|
+
*,
|
|
349
|
+
client: PostgresClient | ClientHandle | None,
|
|
350
|
+
env_file_path: str | None,
|
|
351
|
+
env_file_encoding: str | None,
|
|
352
|
+
credential: EntraCredential | None = None,
|
|
353
|
+
pool_kwargs: Mapping[str, Any] | None = None,
|
|
354
|
+
) -> ClientHandle:
|
|
355
|
+
"""Resolve the connection the way every store does: explicit argument, ``.env`` file, environment.
|
|
356
|
+
|
|
357
|
+
A ``ClientHandle`` (how ``PostgresPersistence`` shares its pool) is borrowed as a child handle, so
|
|
358
|
+
closing the handle it came from stops this one too. ``credential`` signs the owned pool in with
|
|
359
|
+
Microsoft Entra ID and needs a connection string, not a client. ``pool_kwargs`` is merged over the
|
|
360
|
+
owned pool's defaults, its ``kwargs`` entry over the connection defaults.
|
|
361
|
+
"""
|
|
362
|
+
if client is not None:
|
|
363
|
+
if credential is not None:
|
|
364
|
+
raise ValueError(
|
|
365
|
+
"credential cannot be combined with client; a pool or connection you pass signs in itself."
|
|
366
|
+
)
|
|
367
|
+
if (
|
|
368
|
+
connection_string is not None
|
|
369
|
+
or env_file_path is not None
|
|
370
|
+
or env_file_encoding is not None
|
|
371
|
+
or pool_kwargs is not None
|
|
372
|
+
):
|
|
373
|
+
raise ValueError(
|
|
374
|
+
"client cannot be combined with connection_string, env_file_path, env_file_encoding or pool_kwargs."
|
|
375
|
+
)
|
|
376
|
+
if isinstance(client, ClientHandle):
|
|
377
|
+
return client.child()
|
|
378
|
+
return ClientHandle(None, client)
|
|
379
|
+
settings = load_settings(
|
|
380
|
+
PostgresSettings,
|
|
381
|
+
env_prefix="POSTGRES_",
|
|
382
|
+
required_fields=["connection_string"],
|
|
383
|
+
connection_string=connection_string,
|
|
384
|
+
env_file_path=env_file_path,
|
|
385
|
+
env_file_encoding=env_file_encoding,
|
|
386
|
+
)
|
|
387
|
+
resolved = settings.get("connection_string")
|
|
388
|
+
if not isinstance(resolved, SecretString):
|
|
389
|
+
raise TypeError("connection_string must be a string or SecretString.")
|
|
390
|
+
return ClientHandle(resolved, None, credential=credential, pool_kwargs=pool_kwargs)
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Microsoft Entra ID sign-in for Azure Database for PostgreSQL: a fresh token is the password of each new connection.
|
|
2
|
+
|
|
3
|
+
azure-identity stays optional: a credential is anything with ``get_token(*scopes)``, sync or async, so this
|
|
4
|
+
module never imports ``azure``. PostgreSQL checks the password only at login, so a connection stays
|
|
5
|
+
authenticated after its token expires; the pool's ``max_lifetime`` bounds how long such a connection lives.
|
|
6
|
+
The token is a bearer credential, so the connection verifies the server (``sslmode=verify-full`` against the
|
|
7
|
+
system trust store) unless the connection string or libpq's environment chooses otherwise.
|
|
8
|
+
|
|
9
|
+
``connect`` runs inside psycopg_pool's background workers, which log a failure and retry, so a sign-in
|
|
10
|
+
failure is logged here and remembered on the connection class for the pool-timeout message, and
|
|
11
|
+
``ClientHandle.open`` fetches one token up front so a broken credential fails the first call directly.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import base64
|
|
18
|
+
import inspect
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
from collections.abc import Awaitable, Mapping
|
|
23
|
+
from typing import Any, ClassVar, Protocol, Self, cast
|
|
24
|
+
|
|
25
|
+
from psycopg import AsyncConnection, AsyncCursor
|
|
26
|
+
from psycopg.abc import AdaptContext, ConnParam
|
|
27
|
+
from psycopg.conninfo import conninfo_to_dict
|
|
28
|
+
from psycopg.rows import AsyncRowFactory
|
|
29
|
+
|
|
30
|
+
from ._client import PostgresStorageError
|
|
31
|
+
|
|
32
|
+
ENTRA_SCOPE = "https://ossrdbms-aad.database.windows.net/.default"
|
|
33
|
+
"""The scope Azure Database for PostgreSQL accepts tokens for."""
|
|
34
|
+
|
|
35
|
+
# Claims that carry a user principal name, in the order a token is searched for one.
|
|
36
|
+
_USER_CLAIMS = ("upn", "preferred_username", "unique_name")
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class EntraAccessToken(Protocol):
|
|
42
|
+
"""What ``get_token`` returns; ``azure.core.credentials.AccessToken`` fits."""
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def token(self) -> str: ...
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def expires_on(self) -> int: ...
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class EntraCredential(Protocol):
|
|
52
|
+
"""A Microsoft Entra ID credential from ``azure.identity`` or ``azure.identity.aio``, or your own."""
|
|
53
|
+
|
|
54
|
+
def get_token(self, *scopes: str, **kwargs: Any) -> EntraAccessToken | Awaitable[EntraAccessToken]: ...
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _describe(exc: BaseException) -> str:
|
|
58
|
+
if not type(exc).__module__.startswith("azure."):
|
|
59
|
+
return f"{type(exc).__name__}: see the chained cause"
|
|
60
|
+
text = str(exc)
|
|
61
|
+
return f"{type(exc).__name__}: {text}" if text else type(exc).__name__
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def _fetch_token(credential: EntraCredential) -> str:
|
|
65
|
+
try:
|
|
66
|
+
if inspect.iscoroutinefunction(credential.get_token):
|
|
67
|
+
result = credential.get_token(ENTRA_SCOPE)
|
|
68
|
+
else:
|
|
69
|
+
# A sync credential can reach the network; run it off the event loop.
|
|
70
|
+
result = await asyncio.to_thread(credential.get_token, ENTRA_SCOPE)
|
|
71
|
+
if inspect.isawaitable(result):
|
|
72
|
+
result = await result
|
|
73
|
+
except Exception as exc:
|
|
74
|
+
# Keep Azure SDK diagnostics; other exception text stays in the chained cause, not our message or log.
|
|
75
|
+
raise PostgresStorageError(
|
|
76
|
+
f"Could not get a Microsoft Entra ID token for {ENTRA_SCOPE}: {_describe(exc)}"
|
|
77
|
+
) from exc
|
|
78
|
+
token: object = getattr(result, "token", None)
|
|
79
|
+
if not isinstance(token, str) or not token:
|
|
80
|
+
raise PostgresStorageError(f"The credential returned no access token for {ENTRA_SCOPE}.")
|
|
81
|
+
return token
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _user_from_token(token: str) -> str | None:
|
|
85
|
+
"""The user principal name in the token's unverified payload, or ``None``."""
|
|
86
|
+
parts = token.split(".")
|
|
87
|
+
if len(parts) != 3:
|
|
88
|
+
return None
|
|
89
|
+
payload = parts[1]
|
|
90
|
+
try:
|
|
91
|
+
claims: object = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
|
|
92
|
+
except ValueError: # binascii.Error, UnicodeDecodeError and JSONDecodeError are all ValueError
|
|
93
|
+
return None
|
|
94
|
+
if not isinstance(claims, dict):
|
|
95
|
+
return None
|
|
96
|
+
for claim in _USER_CLAIMS:
|
|
97
|
+
value = cast("dict[str, object]", claims).get(claim)
|
|
98
|
+
if isinstance(value, str) and value:
|
|
99
|
+
return value
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def entra_connection_kwargs(credential: EntraCredential, conninfo: str, kwargs: dict[str, ConnParam]) -> None:
|
|
104
|
+
"""Set ``password`` to a fresh token, ``user`` from the token, and ``sslmode=verify-full``.
|
|
105
|
+
|
|
106
|
+
``user``, ``sslmode`` and ``sslrootcert`` are set only when neither the connection string,
|
|
107
|
+
``kwargs`` nor libpq's ``PGUSER``, ``PGSSLMODE`` and ``PGSSLROOTCERT`` environment variables set
|
|
108
|
+
them. ``sslrootcert=system`` is added only together with the default ``sslmode``.
|
|
109
|
+
"""
|
|
110
|
+
params = conninfo_to_dict(conninfo)
|
|
111
|
+
token = await _fetch_token(credential)
|
|
112
|
+
kwargs["password"] = token
|
|
113
|
+
if not params.get("user") and not kwargs.get("user") and not os.environ.get("PGUSER"):
|
|
114
|
+
user = _user_from_token(token)
|
|
115
|
+
if user is None:
|
|
116
|
+
raise PostgresStorageError(
|
|
117
|
+
"The Microsoft Entra ID token names no user (upn, preferred_username or unique_name);"
|
|
118
|
+
" set user= in the connection string to the database role of this identity."
|
|
119
|
+
)
|
|
120
|
+
kwargs["user"] = user
|
|
121
|
+
if "sslmode" not in params and "sslmode" not in kwargs and not os.environ.get("PGSSLMODE"):
|
|
122
|
+
# The token is a bearer credential for every Azure Database for PostgreSQL server: verify the peer.
|
|
123
|
+
# ``sslrootcert=system`` uses the operating system's trust store (libpq 16+; psycopg's binary
|
|
124
|
+
# wheel bundles libpq 18).
|
|
125
|
+
kwargs["sslmode"] = "verify-full"
|
|
126
|
+
if "sslrootcert" not in params and "sslrootcert" not in kwargs and not os.environ.get("PGSSLROOTCERT"):
|
|
127
|
+
kwargs["sslrootcert"] = "system"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def check_credential(credential: EntraCredential, conninfo: str, kwargs: Mapping[str, Any]) -> None:
|
|
131
|
+
"""Sign in the way ``connect`` will, without connecting, so a credential failure reaches the caller.
|
|
132
|
+
|
|
133
|
+
Nothing is kept: each new connection still fetches its own token.
|
|
134
|
+
"""
|
|
135
|
+
await entra_connection_kwargs(credential, conninfo, dict(kwargs))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def entra_connection_class(credential: EntraCredential) -> type[AsyncConnection[Any]]:
|
|
139
|
+
"""An ``AsyncConnection`` subclass whose ``connect`` signs in with a token from ``credential``."""
|
|
140
|
+
if not callable(getattr(credential, "get_token", None)):
|
|
141
|
+
raise TypeError("credential must have a get_token(*scopes) method, like the azure.identity credentials.")
|
|
142
|
+
|
|
143
|
+
class EntraConnection(AsyncConnection[Any]):
|
|
144
|
+
# The last sign-in failure, for ClientHandle's pool-timeout message; cleared by a successful sign-in.
|
|
145
|
+
entra_last_error: ClassVar[str | None] = None
|
|
146
|
+
|
|
147
|
+
@classmethod
|
|
148
|
+
async def connect(
|
|
149
|
+
cls,
|
|
150
|
+
conninfo: str = "",
|
|
151
|
+
*,
|
|
152
|
+
autocommit: bool = False,
|
|
153
|
+
prepare_threshold: int | None = 5,
|
|
154
|
+
context: AdaptContext | None = None,
|
|
155
|
+
row_factory: AsyncRowFactory[Any] | None = None,
|
|
156
|
+
cursor_factory: type[AsyncCursor[Any]] | None = None,
|
|
157
|
+
**kwargs: ConnParam,
|
|
158
|
+
) -> Self:
|
|
159
|
+
try:
|
|
160
|
+
await entra_connection_kwargs(credential, conninfo, kwargs)
|
|
161
|
+
except PostgresStorageError as exc:
|
|
162
|
+
# psycopg_pool's worker swallows this and retries; log it where the application can see it.
|
|
163
|
+
message = str(exc)
|
|
164
|
+
logger.error("Microsoft Entra ID sign-in failed: %s", message)
|
|
165
|
+
EntraConnection.entra_last_error = message
|
|
166
|
+
raise
|
|
167
|
+
EntraConnection.entra_last_error = None
|
|
168
|
+
return await super().connect(
|
|
169
|
+
conninfo,
|
|
170
|
+
autocommit=autocommit,
|
|
171
|
+
prepare_threshold=prepare_threshold,
|
|
172
|
+
context=context,
|
|
173
|
+
row_factory=row_factory,
|
|
174
|
+
cursor_factory=cursor_factory,
|
|
175
|
+
**kwargs,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
return EntraConnection
|