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.
@@ -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