pgtern 0.3.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.
pgtern/aio.py ADDED
@@ -0,0 +1,1287 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Native asyncio API: ``AsyncConnection``, ``AsyncCursor``, ``AsyncConnectionPool``.
3
+
4
+ Same wire protocol, authentication, TLS and type handling as the blocking driver;
5
+ only the I/O is different. If a task is cancelled (or a timeout fires) while a
6
+ statement is in flight the connection is closed, because its protocol state is then unknown.
7
+
8
+ async with await pgtern.aio.connect("postgresql://...") as conn:
9
+ async with conn.transaction():
10
+ cur = await conn.execute("SELECT %s::int", [1])
11
+ print(await cur.fetchall())
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import collections
17
+ import hashlib
18
+ import itertools
19
+ import logging
20
+ import time
21
+ from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager
22
+ from typing import Any, AsyncIterator, Callable, Iterable, Sequence
23
+
24
+ from . import _binary
25
+ from . import _protocol as P
26
+ from . import _query, _types, errors, hooks
27
+ from ._conn import (
28
+ _BROKEN, _IDLE, PRIME_SQL, ConnectionInfo, _ssl_context,
29
+ )
30
+ from ._copy import format_row, parse_rows
31
+ from ._cursor import Cursor
32
+ from ._dsn import ConnParams, build_params
33
+ from ._scram import MECHANISM, ScramClient
34
+ from .pipeline import _enter_hooks, _encode, _exit_hooks, _Item
35
+ from .pool import PoolClosed, PoolTimeout
36
+ from .rows import tuple_row
37
+ from .sql import SQL, Composable, quote_ident
38
+
39
+ log = logging.getLogger("pgtern.aio")
40
+
41
+ __all__ = ["connect", "AsyncConnection", "AsyncCursor", "AsyncServerCursor", "AsyncTransaction",
42
+ "AsyncCopy", "AsyncConnectionPool"]
43
+
44
+
45
+ class AsyncCursor(Cursor):
46
+ """Like :class:`pgtern.Cursor`; fetching is in-memory (so synchronous), executing is awaited."""
47
+
48
+ async def execute(self, query: Any, params: Any = None, *, prepare: bool | None = None, # type: ignore[override]
49
+ binary: bool | None = None) -> "AsyncCursor":
50
+ if self._closed:
51
+ raise errors.InterfaceError("the cursor is closed")
52
+ self._set_results(await self.connection._execute(query, params, bool(binary)))
53
+ return self
54
+
55
+ async def executemany(self, query: Any, params_seq: Sequence[Any]) -> None: # type: ignore[override]
56
+ """Run ``query`` once per parameter set as one pipelined batch, in one transaction
57
+ (see ``AsyncConnection._run_many``). ``rowcount`` is the total of the statements' rowcounts."""
58
+ if self._closed:
59
+ raise errors.InterfaceError("the cursor is closed")
60
+ total = await self.connection._run_many(query, params_seq)
61
+ self._set_results([])
62
+ self._total_rowcount = total
63
+
64
+ def copy(self, query: Any, params: Any = None) -> "AsyncCopy": # type: ignore[override]
65
+ if self._closed:
66
+ raise errors.InterfaceError("the cursor is closed")
67
+ return AsyncCopy(self, query, params)
68
+
69
+ async def close(self) -> None: # type: ignore[override]
70
+ Cursor.close(self)
71
+
72
+ async def __aenter__(self) -> "AsyncCursor":
73
+ return self
74
+
75
+ async def __aexit__(self, *exc: Any) -> None:
76
+ await self.close()
77
+
78
+ def __aiter__(self) -> AsyncIterator:
79
+ return self._aiter()
80
+
81
+ async def _aiter(self) -> AsyncIterator:
82
+ while True:
83
+ row = self.fetchone()
84
+ if row is None:
85
+ return
86
+ yield row
87
+
88
+
89
+ class AsyncServerCursor(AsyncCursor):
90
+ """Named server-side cursor (``DECLARE``); rows are fetched in batches with ``await``."""
91
+
92
+ itersize = 100
93
+
94
+ def __init__(self, connection: Any, name: str, *, row_factory: Callable | None = None,
95
+ scrollable: bool | None = None, withhold: bool = False) -> None:
96
+ super().__init__(connection, row_factory=row_factory)
97
+ self.name = name
98
+ self._scrollable, self._withhold = scrollable, withhold
99
+ self._binary = False
100
+ self._declared = False
101
+ self._columns = None
102
+ self._fetched = 0
103
+ self._batch = None
104
+
105
+ @property
106
+ def description(self):
107
+ return list(self._columns) if self._columns is not None else None
108
+
109
+ @property
110
+ def rowcount(self) -> int:
111
+ return self._fetched if self._declared else -1
112
+
113
+ @property
114
+ def rownumber(self) -> int | None:
115
+ return self._fetched if self._declared else None
116
+
117
+ async def execute(self, query: Any, params: Any = None, *, binary: bool | None = None, # type: ignore[override]
118
+ **_: Any) -> "AsyncServerCursor":
119
+ if self._closed:
120
+ raise errors.InterfaceError("the cursor is closed")
121
+ if self._declared:
122
+ raise errors.ProgrammingError("the server-side cursor was already executed; create a new one")
123
+ self._binary = bool(binary)
124
+ prefix = f"DECLARE {quote_ident(self.name)} "
125
+ if self._scrollable is not None:
126
+ prefix += "SCROLL " if self._scrollable else "NO SCROLL "
127
+ prefix += "CURSOR " + ("WITH HOLD " if self._withhold else "") + "FOR "
128
+ body = query if isinstance(query, Composable) else SQL(query if isinstance(query, str) else bytes(query).decode())
129
+ await self.connection._execute(SQL(prefix) + body, params)
130
+ self._declared = True
131
+ first = (await self.connection._execute(f"FETCH FORWARD 0 FROM {quote_ident(self.name)}", None, self._binary))[0]
132
+ self._columns = first.columns
133
+ self._set_results([first])
134
+ return self
135
+
136
+ async def _fetch(self, command: str):
137
+ if not self._declared:
138
+ raise errors.ProgrammingError("the cursor has not been executed")
139
+ result = (await self.connection._execute(f"{command} FROM {quote_ident(self.name)}", None, self._binary))[0]
140
+ if result.columns is None:
141
+ result.columns = self._columns
142
+ self._set_results([result])
143
+ return result
144
+
145
+ async def fetchmany(self, size: int | None = None) -> list: # type: ignore[override]
146
+ result = await self._fetch(f"FETCH FORWARD {int(self.arraysize if size is None else size)}")
147
+ self._fetched += len(result.rows)
148
+ return Cursor.fetchall(self)
149
+
150
+ async def fetchone(self) -> Any: # type: ignore[override]
151
+ rows = await self.fetchmany(1)
152
+ return rows[0] if rows else None
153
+
154
+ async def fetchall(self) -> list: # type: ignore[override]
155
+ result = await self._fetch("FETCH FORWARD ALL")
156
+ self._fetched += len(result.rows)
157
+ return Cursor.fetchall(self)
158
+
159
+ async def scroll(self, value: int, mode: str = "relative") -> None:
160
+ if mode not in ("relative", "absolute"):
161
+ raise ValueError(f"bad mode: {mode}; it should be 'relative' or 'absolute'")
162
+ await self._fetch(f"MOVE {mode.upper()} {int(value)}")
163
+ self._fetched = (self._fetched + value) if mode == "relative" else value
164
+
165
+ def _aiter(self) -> AsyncIterator:
166
+ return self._server_iter()
167
+
168
+ async def _server_iter(self) -> AsyncIterator:
169
+ while True:
170
+ rows = await self.fetchmany(self.itersize)
171
+ for row in rows:
172
+ yield row
173
+ if len(rows) < self.itersize:
174
+ return
175
+
176
+ async def close(self) -> None: # type: ignore[override]
177
+ if self._declared and not self.connection.closed and self.connection._tx_status != "E":
178
+ try:
179
+ await self.connection._execute(f"CLOSE {quote_ident(self.name)}", None)
180
+ except errors.Error:
181
+ pass
182
+ self._declared = False
183
+ Cursor.close(self)
184
+
185
+
186
+ class AsyncTransaction:
187
+ def __init__(self, conn: "AsyncConnection", savepoint_name: str | None = None,
188
+ force_rollback: bool = False) -> None:
189
+ self._conn, self._name, self._force_rollback = conn, savepoint_name, force_rollback
190
+ self._savepoint: str | None = None
191
+
192
+ async def __aenter__(self) -> "AsyncTransaction":
193
+ conn = self._conn
194
+ async with conn._lock:
195
+ conn._check_open()
196
+ if conn._tx_status == _IDLE:
197
+ self._savepoint = None
198
+ await conn._simple("BEGIN")
199
+ else:
200
+ self._savepoint = self._name or f"_pgtern_{next(conn._savepoint_ids)}"
201
+ await conn._simple(f"SAVEPOINT {quote_ident(self._savepoint)}")
202
+ conn._tx_depth += 1
203
+ return self
204
+
205
+ async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
206
+ conn = self._conn
207
+ async with conn._lock:
208
+ conn._tx_depth = max(0, conn._tx_depth - 1)
209
+ if conn.closed:
210
+ return False
211
+ commit = exc_type is None and not self._force_rollback
212
+ try:
213
+ if self._savepoint is None:
214
+ await conn._simple("COMMIT" if commit else "ROLLBACK")
215
+ else:
216
+ sp = quote_ident(self._savepoint)
217
+ await conn._simple(f"RELEASE {sp}" if commit else f"ROLLBACK TO {sp}; RELEASE {sp}")
218
+ except Exception:
219
+ if exc_type is None:
220
+ raise
221
+ log.warning("error while ending a failed transaction block", exc_info=True)
222
+ return False
223
+
224
+
225
+ class AsyncCopy:
226
+ """``async with cur.copy("COPY t FROM STDIN") as copy: await copy.write_row(...)``."""
227
+
228
+ def __init__(self, cursor: AsyncCursor, query: Any, params: Any = None) -> None:
229
+ self._cursor, self._conn, self._query, self._params = cursor, cursor.connection, query, params
230
+ self._kind = ""
231
+ self._stack = AsyncExitStack()
232
+ self._done = False
233
+ self._buffer = bytearray()
234
+ self._pending_error: dict[str, str] | None = None
235
+
236
+ async def __aenter__(self) -> "AsyncCopy":
237
+ conn = self._conn
238
+ await conn._lock.acquire()
239
+ try:
240
+ self._kind = await conn._start_copy(self._query, self._params, self._stack)
241
+ except BaseException:
242
+ await self._stack.aclose()
243
+ conn._lock.release()
244
+ raise
245
+ return self
246
+
247
+ async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
248
+ conn = self._conn
249
+ try:
250
+ if conn.closed:
251
+ return
252
+ if exc_type is asyncio.CancelledError:
253
+ conn._mark_broken()
254
+ return
255
+ if self._kind == "in":
256
+ if exc_type is not None:
257
+ await conn._send(P.copy_fail(f"error in Python: {exc_type.__name__}"))
258
+ try:
259
+ await conn._read_cycle()
260
+ except errors.Error:
261
+ pass
262
+ else:
263
+ await conn._send(P.COPY_DONE)
264
+ results = await conn._read_cycle()
265
+ self._cursor._set_results(results)
266
+ else:
267
+ while not self._done:
268
+ await self._next_chunk()
269
+ err = self._pending_error
270
+ results = await conn._read_cycle()
271
+ if err is not None:
272
+ raise errors.error_from_fields(err)
273
+ if exc_type is None:
274
+ self._cursor._set_results(results)
275
+ finally:
276
+ try:
277
+ await self._stack.__aexit__(exc_type, exc, tb)
278
+ finally:
279
+ conn._lock.release()
280
+
281
+ async def write(self, data: bytes | bytearray | memoryview | str) -> None:
282
+ if self._kind != "in":
283
+ raise errors.ProgrammingError("write() needs a COPY ... FROM STDIN statement")
284
+ data = data.encode("utf-8") if isinstance(data, str) else bytes(data)
285
+ for i in range(0, max(len(data), 1), 1 << 20):
286
+ await self._conn._send(P.copy_data(data[i:i + (1 << 20)]))
287
+
288
+ async def write_row(self, row: Sequence[Any]) -> None:
289
+ await self.write(format_row(row))
290
+
291
+ async def _next_chunk(self) -> bytes | None:
292
+ conn = self._conn
293
+ while True:
294
+ tag, payload = await conn._next_message()
295
+ if tag == 0x64:
296
+ return payload
297
+ if tag == 0x63:
298
+ self._done = True
299
+ return None
300
+ if tag == 0x45:
301
+ self._done = True
302
+ self._pending_error = P.parse_error_fields(payload)
303
+ return None
304
+ conn._handle_async_message(tag, payload)
305
+
306
+ async def read(self) -> bytes:
307
+ if self._kind != "out":
308
+ raise errors.ProgrammingError("read() needs a COPY ... TO STDOUT statement")
309
+ if self._done:
310
+ return b""
311
+ chunk = await self._next_chunk()
312
+ return chunk if chunk is not None else b""
313
+
314
+ async def __aiter__(self) -> AsyncIterator[bytes]:
315
+ while True:
316
+ chunk = await self.read()
317
+ if not chunk:
318
+ return
319
+ yield chunk
320
+
321
+ async def rows(self) -> AsyncIterator[tuple]:
322
+ async for chunk in self:
323
+ self._buffer += chunk
324
+ for row in parse_rows(self._buffer):
325
+ yield row
326
+ for row in parse_rows(self._buffer, final=True):
327
+ yield row
328
+
329
+
330
+ class AsyncConnection:
331
+ """An asyncio connection. Create it with :func:`pgtern.aio.connect`."""
332
+
333
+ def __init__(self, params: ConnParams, *, autocommit: bool = False,
334
+ row_factory: Callable | None = None) -> None:
335
+ self._params = params
336
+ self._autocommit = autocommit
337
+ self.row_factory = row_factory or tuple_row
338
+ self._lock = asyncio.Lock()
339
+ self._reader: asyncio.StreamReader | None = None
340
+ self._writer: asyncio.StreamWriter | None = None
341
+ self._closed = True
342
+ self._parser = P.MessageParser()
343
+ self._pending: collections.deque[tuple[int, bytes]] = collections.deque()
344
+ self._server_params: dict[str, str] = {}
345
+ self._backend_pid = 0
346
+ self._tx_status = _BROKEN
347
+ self._tx_depth = 0
348
+ self._savepoint_ids = itertools.count(1)
349
+ self._host, self._port = "", 0
350
+ self._dates_ok = True
351
+ self._notice_handlers: list[Callable] = []
352
+ self._notify_handlers: list[Callable] = []
353
+ self._notifies: collections.deque[P.Notify] = collections.deque()
354
+ self._loader_cache: dict[int, Any] = {}
355
+ self._pipeline: Any = None
356
+ self.info = ConnectionInfo(self) # type: ignore[arg-type]
357
+ self.created = time.monotonic()
358
+
359
+ # ---------------------------------------------------------------- opening
360
+ @classmethod
361
+ async def connect(cls, conninfo: str = "", *, autocommit: bool = False,
362
+ row_factory: Callable | None = None, **kwargs: Any) -> "AsyncConnection":
363
+ conn = cls(build_params(conninfo, **kwargs), autocommit=autocommit, row_factory=row_factory)
364
+ await conn._open()
365
+ return conn
366
+
367
+ async def _open(self) -> None:
368
+ modes = ["disable", "require"] if self._params.sslmode == "allow" else [self._params.sslmode]
369
+ last: errors.OperationalError | None = None
370
+ for host, port in self._params.hosts:
371
+ for mode in modes:
372
+ try:
373
+ await asyncio.wait_for(self._connect_host(host, port, mode), self._params.connect_timeout)
374
+ return
375
+ except (errors.OperationalError, asyncio.TimeoutError, OSError) as exc:
376
+ self._drop()
377
+ last = exc if isinstance(exc, errors.OperationalError) else errors.OperationalError(
378
+ f'connection to server at "{host}", port {port} failed: {exc or "timeout expired"}')
379
+ assert last is not None
380
+ raise last
381
+
382
+ def _drop(self) -> None:
383
+ writer, self._writer, self._reader = self._writer, None, None
384
+ self._closed = True
385
+ self._parser = P.MessageParser()
386
+ self._pending.clear()
387
+ if writer is not None:
388
+ writer.close()
389
+
390
+ async def _connect_host(self, host: str, port: int, mode: str) -> None:
391
+ unix = host.startswith("/")
392
+ try:
393
+ if unix:
394
+ reader, writer = await asyncio.open_unix_connection(f"{host}/.s.PGSQL.{port}")
395
+ else:
396
+ reader, writer = await asyncio.open_connection(host, port)
397
+ sock = writer.get_extra_info("socket")
398
+ if sock is not None:
399
+ import socket as _s
400
+ sock.setsockopt(_s.IPPROTO_TCP, _s.TCP_NODELAY, 1)
401
+ sock.setsockopt(_s.SOL_SOCKET, _s.SO_KEEPALIVE, 1)
402
+ except OSError as exc:
403
+ raise errors.OperationalError(f'connection to server at "{host}", port {port} failed: {exc}') from exc
404
+ self._reader, self._writer = reader, writer
405
+ self._host, self._port = host, port
406
+ self._closed = False
407
+ self._parser = P.MessageParser()
408
+ self._pending.clear()
409
+ self._server_params = {}
410
+ if not unix and mode != "disable":
411
+ try:
412
+ writer.write(P.SSL_REQUEST)
413
+ await writer.drain()
414
+ answer = await reader.readexactly(1)
415
+ if answer == b"S":
416
+ await writer.start_tls(_ssl_context(self._params, mode), server_hostname=host)
417
+ elif answer == b"N":
418
+ if mode in ("require", "verify-ca", "verify-full"):
419
+ raise errors.OperationalError("server does not support SSL, but SSL was required")
420
+ else:
421
+ raise errors.OperationalError(f"received an invalid response to the SSL request: {answer!r}")
422
+ except (OSError, asyncio.IncompleteReadError) as exc:
423
+ self._drop()
424
+ if mode == "prefer":
425
+ return await self._connect_host(host, port, "disable")
426
+ raise errors.OperationalError(
427
+ f'SSL connection to server at "{host}", port {port} failed: {exc}') from exc
428
+ await self._startup()
429
+ self._dates_ok = self._server_params.get("DateStyle", "ISO").upper().startswith("ISO")
430
+
431
+ def _password(self) -> str:
432
+ if self._params.password is None:
433
+ raise errors.OperationalError("the server requested a password, but none was provided")
434
+ return self._params.password
435
+
436
+ async def _startup(self) -> None:
437
+ p = self._params
438
+ startup = {"user": p.user, "database": p.dbname, "client_encoding": "UTF8", "DateStyle": "ISO"}
439
+ if p.application_name:
440
+ startup["application_name"] = p.application_name
441
+ if p.options:
442
+ startup["options"] = p.options
443
+ await self._send(P.startup_message(startup))
444
+ scram: ScramClient | None = None
445
+ while True:
446
+ tag, payload = await self._next_message()
447
+ if tag == 0x52:
448
+ code = int.from_bytes(payload[:4], "big")
449
+ if code == 0:
450
+ continue
451
+ if code == 3:
452
+ await self._send(P.password_message(self._password().encode()))
453
+ elif code == 5:
454
+ inner = hashlib.md5((self._password() + p.user).encode(), usedforsecurity=False).hexdigest()
455
+ outer = hashlib.md5(inner.encode() + payload[4:8], usedforsecurity=False).hexdigest()
456
+ await self._send(P.password_message(b"md5" + outer.encode()))
457
+ elif code == 10:
458
+ mechanisms = [m for m in payload[4:].split(b"\0") if m]
459
+ if MECHANISM.encode() not in mechanisms:
460
+ raise errors.NotSupportedError(
461
+ "the server offers no supported SASL mechanism: " + ", ".join(m.decode() for m in mechanisms))
462
+ scram = ScramClient(self._password())
463
+ await self._send(P.sasl_initial_response(MECHANISM, scram.client_first()))
464
+ elif code == 11 and scram is not None:
465
+ await self._send(P.sasl_response(scram.client_final(payload[4:])))
466
+ elif code == 12 and scram is not None:
467
+ scram.verify_server_final(payload[4:])
468
+ else:
469
+ raise errors.NotSupportedError(f"authentication method {code} is not supported")
470
+ elif tag == 0x45:
471
+ raise errors.error_from_fields(P.parse_error_fields(payload))
472
+ elif tag == 0x4B:
473
+ self._backend_pid = int.from_bytes(payload[:4], "big")
474
+ elif tag == 0x5A:
475
+ self._tx_status = chr(payload[0])
476
+ return
477
+ else:
478
+ self._handle_async_message(tag, payload)
479
+
480
+ # ---------------------------------------------------------------- I/O
481
+ def _mark_broken(self) -> None:
482
+ self._closed = True
483
+ self._tx_status = _BROKEN
484
+ writer, self._writer, self._reader = self._writer, None, None
485
+ if writer is not None:
486
+ writer.close()
487
+
488
+ async def _send(self, data: bytes) -> None:
489
+ writer = self._writer
490
+ if writer is None:
491
+ raise errors.OperationalError("the connection is closed")
492
+ try:
493
+ writer.write(data)
494
+ await writer.drain()
495
+ except (OSError, ConnectionError) as exc:
496
+ self._mark_broken()
497
+ raise errors.OperationalError(f"connection failed while sending: {exc}") from exc
498
+ except BaseException:
499
+ self._mark_broken() # cancelled mid-write: the stream is in an unknown state
500
+ raise
501
+
502
+ def _write(self, data: bytes) -> None:
503
+ """Buffer bytes for the socket without waiting: pipelines drain at their flush points."""
504
+ writer = self._writer
505
+ if writer is None:
506
+ raise errors.OperationalError("the connection is closed")
507
+ writer.write(data)
508
+
509
+ async def _drain(self) -> None:
510
+ writer = self._writer
511
+ if writer is None:
512
+ raise errors.OperationalError("the connection is closed")
513
+ try:
514
+ await writer.drain()
515
+ except (OSError, ConnectionError) as exc:
516
+ self._mark_broken()
517
+ raise errors.OperationalError(f"connection failed while sending: {exc}") from exc
518
+ except BaseException:
519
+ self._mark_broken()
520
+ raise
521
+
522
+ async def _next_message(self) -> tuple[int, bytes]:
523
+ while not self._pending:
524
+ reader = self._reader
525
+ if reader is None:
526
+ raise errors.OperationalError("the connection is closed")
527
+ try:
528
+ data = await reader.read(65536)
529
+ except (OSError, ConnectionError) as exc:
530
+ self._mark_broken()
531
+ raise errors.OperationalError(f"connection failed while receiving: {exc}") from exc
532
+ except BaseException:
533
+ self._mark_broken() # cancelled mid-statement
534
+ raise
535
+ if not data:
536
+ self._mark_broken()
537
+ raise errors.OperationalError("server closed the connection unexpectedly")
538
+ self._pending.extend(self._parser.feed(data))
539
+ return self._pending.popleft()
540
+
541
+ def _notice(self, payload: bytes) -> None:
542
+ diag = errors.Diagnostic(P.parse_error_fields(payload))
543
+ for handler in self._notice_handlers:
544
+ handler(diag)
545
+ if not self._notice_handlers:
546
+ log.debug("server notice: %s", diag.message_primary)
547
+
548
+ def add_notice_handler(self, handler: Callable) -> None:
549
+ self._notice_handlers.append(handler)
550
+
551
+ def add_notify_handler(self, handler: Callable) -> None:
552
+ self._notify_handlers.append(handler)
553
+
554
+ def _handle_async_message(self, tag: int, payload: bytes) -> None:
555
+ if tag == 0x4E:
556
+ self._notice(payload)
557
+ elif tag == 0x53:
558
+ name, value = P.parse_parameter_status(payload)
559
+ self._server_params[name] = value
560
+ elif tag == 0x41:
561
+ note = P.parse_notification(payload)
562
+ if self._notify_handlers:
563
+ for handler in self._notify_handlers:
564
+ handler(note)
565
+ else:
566
+ self._notifies.append(note)
567
+
568
+ async def notifies(self, *, timeout: float | None = None, stop_after: int | None = None) -> AsyncIterator[P.Notify]:
569
+ """``async for note in conn.notifies(timeout=5): ...`` (after ``LISTEN``)."""
570
+ count = 0
571
+ while stop_after is None or count < stop_after:
572
+ async with self._lock:
573
+ self._check_open()
574
+ if not self._notifies:
575
+ try:
576
+ await asyncio.wait_for(self._wait_notify(), timeout)
577
+ except asyncio.TimeoutError:
578
+ pass
579
+ note = self._notifies.popleft() if self._notifies else None
580
+ if note is None:
581
+ return
582
+ count += 1
583
+ yield note
584
+
585
+ async def _wait_notify(self) -> None:
586
+ # Never leaves a partial message behind: messages are only taken whole from the parser.
587
+ while not self._notifies:
588
+ tag, payload = await self._next_message_keep_open()
589
+ if tag == 0x45:
590
+ self._mark_broken()
591
+ raise errors.error_from_fields(P.parse_error_fields(payload))
592
+ self._handle_async_message(tag, payload)
593
+
594
+ async def _next_message_keep_open(self) -> tuple[int, bytes]:
595
+ """Like _next_message, but a cancelled wait (timeout) does not poison the connection."""
596
+ while not self._pending:
597
+ reader = self._reader
598
+ if reader is None:
599
+ raise errors.OperationalError("the connection is closed")
600
+ try:
601
+ data = await reader.read(65536)
602
+ except (OSError, ConnectionError) as exc:
603
+ self._mark_broken()
604
+ raise errors.OperationalError(f"connection failed while receiving: {exc}") from exc
605
+ if not data:
606
+ self._mark_broken()
607
+ raise errors.OperationalError("server closed the connection unexpectedly")
608
+ self._pending.extend(self._parser.feed(data))
609
+ return self._pending.popleft()
610
+
611
+ async def _read_cycle(self) -> list[P.Result]:
612
+ collector = P.QueryCollector()
613
+ error: dict[str, str] | None = None
614
+ unsupported = False
615
+ while True:
616
+ tag, payload = await self._next_message()
617
+ if tag == 0x5A:
618
+ self._tx_status = chr(payload[0])
619
+ break
620
+ if tag == 0x45:
621
+ fields = P.parse_error_fields(payload)
622
+ error = error or fields
623
+ if fields.get("V", fields.get("S")) in ("FATAL", "PANIC"):
624
+ self._mark_broken()
625
+ raise errors.error_from_fields(fields)
626
+ elif tag in (0x4E, 0x53, 0x41):
627
+ self._handle_async_message(tag, payload)
628
+ elif tag == 0x47:
629
+ unsupported = True
630
+ await self._send(P.copy_fail("COPY needs cursor.copy()"))
631
+ elif tag in (0x48, 0x57, 0x64, 0x63):
632
+ unsupported = True
633
+ else:
634
+ collector.handle(tag, payload)
635
+ if unsupported:
636
+ raise errors.NotSupportedError("COPY must be run with cursor.copy(), not execute()")
637
+ if error is not None:
638
+ raise errors.error_from_fields(error)
639
+ return collector.results
640
+
641
+ def _check_open(self) -> None:
642
+ if self._closed:
643
+ raise errors.OperationalError("the connection is closed")
644
+
645
+ def _check_alive(self) -> bool:
646
+ reader, writer = self._reader, self._writer
647
+ if self._closed or reader is None or writer is None:
648
+ return False
649
+ if reader.at_eof() or writer.is_closing() or reader.exception() is not None:
650
+ self._mark_broken()
651
+ return False
652
+ # An idle connection must have nothing buffered. If the server shut it down, its FATAL
653
+ # ErrorResponse is sitting in the buffer (and at_eof() is False until it is read).
654
+ buffered = bytes(getattr(reader, "_buffer", b""))
655
+ if buffered:
656
+ try:
657
+ if any(tag == 0x45 for tag, _ in P.MessageParser().feed(buffered)):
658
+ self._mark_broken()
659
+ return False
660
+ except errors.Error:
661
+ self._mark_broken()
662
+ return False
663
+ return True
664
+
665
+ # ---------------------------------------------------------------- execution
666
+ async def _simple(self, sql: str) -> list[P.Result]:
667
+ await self._send(P.query_message(sql))
668
+ return await self._read_cycle()
669
+
670
+ async def _send_query(self, sql: str, params: Any, *, inline: bool = False) -> None:
671
+ begin = not self._autocommit and self._tx_status == _IDLE
672
+ if inline and params is not None:
673
+ sql, params = _query.translate(sql, params, _types.render_literal)[0], None
674
+ body = P.build_query(sql, params, _query.translate, _types.dump)
675
+ if begin:
676
+ await self._send(P.query_message("BEGIN") + body)
677
+ try:
678
+ await self._read_cycle()
679
+ except BaseException:
680
+ self._mark_broken()
681
+ raise
682
+ else:
683
+ await self._send(body)
684
+
685
+ def _query_text(self, query: Any) -> tuple[str, str]:
686
+ if isinstance(query, Composable):
687
+ return query.as_string(), query.as_redacted()
688
+ if isinstance(query, str):
689
+ return query, query
690
+ if isinstance(query, (bytes, bytearray)):
691
+ text = bytes(query).decode()
692
+ return text, text
693
+ raise TypeError(f"the query must be str, bytes or sql.Composable, not {type(query).__name__}")
694
+
695
+ async def _start_copy(self, query: Any, params: Any, stack: AsyncExitStack) -> str:
696
+ sql, redacted = self._query_text(query)
697
+ self._check_open()
698
+ if hooks._HOOKS:
699
+ info = hooks.QueryInfo(redacted, self._host, self._port, self._params.dbname, self._params.user)
700
+ for hook in tuple(hooks._HOOKS):
701
+ try:
702
+ cm = hook(info)
703
+ if isinstance(cm, AbstractAsyncContextManager):
704
+ await stack.enter_async_context(cm)
705
+ else:
706
+ stack.enter_context(cm)
707
+ except Exception:
708
+ log.exception("a pgtern query hook failed on entry; ignoring it")
709
+ await self._send_query(sql, params, inline=True)
710
+ error: dict[str, str] | None = None
711
+ while True:
712
+ tag, payload = await self._next_message()
713
+ if error is None and tag in (0x47, 0x48):
714
+ return "in" if tag == 0x47 else "out"
715
+ if tag == 0x45:
716
+ fields = P.parse_error_fields(payload)
717
+ if fields.get("V", fields.get("S")) in ("FATAL", "PANIC"):
718
+ self._mark_broken()
719
+ raise errors.error_from_fields(fields)
720
+ error = error or fields
721
+ elif tag == 0x5A:
722
+ self._tx_status = chr(payload[0])
723
+ if error is not None:
724
+ raise errors.error_from_fields(error)
725
+ raise errors.ProgrammingError("the statement is not a COPY ... FROM STDIN / TO STDOUT")
726
+ else:
727
+ self._handle_async_message(tag, payload)
728
+
729
+ async def _prime_oids(self, oids: Iterable[int]) -> None:
730
+ if not _types.has_loaders():
731
+ return
732
+ wanted = set(oids) - self._loader_cache.keys()
733
+ if not wanted:
734
+ return
735
+ ids = ", ".join(str(int(o)) for o in sorted(wanted))
736
+ rows = (await self._simple(PRIME_SQL.format(ids=ids)))[0].rows
737
+ _types.resolve_loaders(rows, self._loader_cache, sorted(wanted))
738
+
739
+ async def _prime_loaders(self, results: list[P.Result]) -> None:
740
+ await self._prime_oids(c.type_code for r in results if r.columns for c in r.columns)
741
+
742
+ async def _describe(self, sql: str) -> tuple[list[int], list[P.Column] | None]:
743
+ await self._send(P.describe_statement(sql))
744
+ param_oids: list[int] = []
745
+ columns: list[P.Column] | None = None
746
+ error: dict[str, str] | None = None
747
+ while True:
748
+ tag, payload = await self._next_message()
749
+ if tag == 0x5A:
750
+ self._tx_status = chr(payload[0])
751
+ break
752
+ if tag == 0x74:
753
+ param_oids = P.parse_param_description(payload)
754
+ elif tag == 0x54:
755
+ columns = P.parse_row_description(payload)
756
+ elif tag == 0x45:
757
+ fields = P.parse_error_fields(payload)
758
+ if fields.get("V", fields.get("S")) in ("FATAL", "PANIC"):
759
+ self._mark_broken()
760
+ raise errors.error_from_fields(fields)
761
+ error = error or fields
762
+ elif tag in (0x31, 0x6E):
763
+ pass
764
+ else:
765
+ self._handle_async_message(tag, payload)
766
+ if error is not None:
767
+ raise errors.error_from_fields(error)
768
+ return param_oids, columns
769
+
770
+ async def _run_binary(self, sql: str, params: Any) -> list[P.Result]:
771
+ begin = not self._autocommit and self._tx_status == _IDLE
772
+ if begin:
773
+ await self._simple("BEGIN")
774
+ values: list[Any]
775
+ if params is None:
776
+ pg_sql, values = sql, []
777
+ else:
778
+ pg_sql, values = _query.translate(sql, params)
779
+ param_oids, columns = await self._describe(pg_sql)
780
+ if len(param_oids) != len(values):
781
+ raise errors.ProgrammingError(
782
+ f"the query has {len(param_oids)} parameters but {len(values)} were passed")
783
+ pformats, payloads = _types.binary_param_plan(param_oids, values)
784
+ rformats = None
785
+ if columns is not None:
786
+ await self._prime_oids(c.type_code for c in columns)
787
+ rformats = [1 if _binary.scalar_decoder(c.type_code) is not None and self._loader_cache.get(c.type_code) is None
788
+ else 0 for c in columns]
789
+ await self._send(P.extended_query(pg_sql, param_oids, payloads, pformats, rformats))
790
+ results = await self._read_cycle()
791
+ for r in results:
792
+ if r.columns is not None:
793
+ r.formats = rformats
794
+ return results
795
+
796
+ async def _run_text(self, sql: str, params: Any) -> list[P.Result]:
797
+ await self._send_query(sql, params)
798
+ return await self._read_cycle()
799
+
800
+ def _check_no_pipeline(self) -> None:
801
+ if self._pipeline is not None:
802
+ raise errors.ProgrammingError(
803
+ "a pipeline is active on this connection: queue statements with pipe.execute() "
804
+ "or leave the pipeline block first")
805
+
806
+ async def _execute(self, query: Any, params: Any, binary: bool = False) -> list[P.Result]:
807
+ sql, redacted = self._query_text(query)
808
+ run = self._run_binary if binary else self._run_text
809
+ async with self._lock:
810
+ self._check_open()
811
+ self._check_no_pipeline()
812
+ if not hooks._HOOKS:
813
+ results = await run(sql, params)
814
+ else:
815
+ info = hooks.QueryInfo(redacted, self._host, self._port, self._params.dbname, self._params.user)
816
+ from contextlib import ExitStack
817
+ with ExitStack() as stack:
818
+ for hook in tuple(hooks._HOOKS):
819
+ try:
820
+ stack.enter_context(hook(info))
821
+ except Exception:
822
+ log.exception("a pgtern query hook failed on entry; ignoring it")
823
+ results = await run(sql, params)
824
+ info.rowcount = results[-1].rowcount if results else -1
825
+ await self._prime_loaders(results)
826
+ return results
827
+
828
+ async def _run_many(self, sql: str, params_seq: Iterable[Any]) -> int:
829
+ """``executemany`` as one pipeline (async twin of ``Connection._run_many``): every statement is
830
+ sent before its reply is read, one transaction per batch (BEGIN..COMMIT under autocommit,
831
+ the implicit transaction otherwise), replies read through a sliding window with Flush."""
832
+ from contextlib import ExitStack
833
+
834
+ from .pipeline import _encode # internal helper; imported here to avoid a cycle
835
+
836
+ window = 64
837
+ pg_sql, redacted = self._query_text(sql)
838
+ async with self._lock:
839
+ self._check_open()
840
+ self._check_no_pipeline()
841
+ own_tx = self._autocommit and self._tx_status == _IDLE
842
+ if own_tx or (not self._autocommit and self._tx_status == _IDLE):
843
+ await self._send(P.query_message("BEGIN"))
844
+ await self._read_cycle()
845
+ total = 0
846
+ in_flight: collections.deque = collections.deque()
847
+ failure: BaseException | None = None
848
+ sent_sync = False
849
+
850
+ def enter_hooks() -> tuple[ExitStack, Any]:
851
+ stack = ExitStack()
852
+ info = None
853
+ if hooks._HOOKS:
854
+ info = hooks.QueryInfo(redacted, self._host, self._port, self._params.dbname, self._params.user)
855
+ for hook in tuple(hooks._HOOKS):
856
+ try:
857
+ stack.enter_context(hook(info))
858
+ except Exception:
859
+ log.exception("a pgtern query hook failed on entry; ignoring it")
860
+ return stack, info
861
+
862
+ async def read_one() -> errors.Error | None:
863
+ nonlocal total
864
+ stack, info = in_flight.popleft()
865
+ results, error = await AsyncPipeline._read_reply(self)
866
+ if results is not None:
867
+ rc = results[-1].rowcount if results else -1
868
+ if info is not None:
869
+ info.rowcount = rc
870
+ if rc > 0:
871
+ total += rc
872
+ if error is None:
873
+ stack.__exit__(None, None, None)
874
+ else:
875
+ stack.__exit__(type(error), error, None)
876
+ return error
877
+
878
+ try:
879
+ pending: list[bytes] = [] # encoded statements not yet handed to the socket
880
+ for params in params_seq:
881
+ pending.append(_encode(pg_sql, params)) # no Sync: the batch ends with one
882
+ in_flight.append(enter_hooks())
883
+ if len(pending) >= window: # coalesce: one socket write per window, not per statement
884
+ self._write(b"".join(pending))
885
+ pending.clear()
886
+ if len(in_flight) > window:
887
+ self._write(b"".join(pending) + P.FLUSH) # Flush: the server sends replies so far
888
+ pending.clear()
889
+ await self._drain()
890
+ while len(in_flight) > window // 2:
891
+ failure = await read_one()
892
+ if failure is not None:
893
+ break # statements sent after a failure are skipped by the server
894
+ if failure is not None:
895
+ break
896
+ if failure is not None:
897
+ pending.clear() # the server would skip these anyway
898
+ self._write(b"".join(pending) + P.SYNC)
899
+ pending.clear()
900
+ await self._drain()
901
+ sent_sync = True
902
+ while failure is None and in_flight:
903
+ failure = await read_one()
904
+ except BaseException as exc:
905
+ failure = exc
906
+ if not self._closed:
907
+ try:
908
+ if not sent_sync:
909
+ await self._send(P.SYNC)
910
+ while True:
911
+ tag, payload = await self._next_message()
912
+ if tag == 0x5A:
913
+ self._tx_status = chr(payload[0])
914
+ break
915
+ if own_tx and self._tx_status != _IDLE:
916
+ await self._simple("ROLLBACK")
917
+ except Exception:
918
+ self._mark_broken()
919
+ raise
920
+ finally:
921
+ while in_flight:
922
+ stack, _ = in_flight.popleft()
923
+ if failure is None:
924
+ stack.__exit__(None, None, None)
925
+ else:
926
+ stack.__exit__(type(failure), failure, None)
927
+ while True:
928
+ tag, payload = await self._next_message()
929
+ if tag == 0x5A:
930
+ self._tx_status = chr(payload[0])
931
+ break
932
+ if tag == 0x45:
933
+ fields = P.parse_error_fields(payload)
934
+ if fields.get("V", fields.get("S")) in ("FATAL", "PANIC"):
935
+ self._mark_broken()
936
+ raise errors.error_from_fields(fields)
937
+ else:
938
+ self._handle_async_message(tag, payload)
939
+ if own_tx:
940
+ await self._simple("ROLLBACK" if failure is not None else "COMMIT")
941
+ if failure is not None:
942
+ raise failure
943
+ return total
944
+
945
+ # ---------------------------------------------------------------- public API
946
+ def cursor(self, name: str | None = None, *, row_factory: Callable | None = None,
947
+ scrollable: bool | None = None, withhold: bool = False) -> AsyncCursor:
948
+ if name is not None:
949
+ return AsyncServerCursor(self, name, row_factory=row_factory, scrollable=scrollable, withhold=withhold)
950
+ return AsyncCursor(self, row_factory=row_factory)
951
+
952
+ async def execute(self, query: Any, params: Any = None, *, prepare: bool | None = None,
953
+ binary: bool | None = None) -> AsyncCursor:
954
+ return await self.cursor().execute(query, params, binary=bool(binary))
955
+
956
+ @property
957
+ def closed(self) -> bool:
958
+ return self._closed
959
+
960
+ @property
961
+ def autocommit(self) -> bool:
962
+ return self._autocommit
963
+
964
+ async def set_autocommit(self, value: bool) -> None:
965
+ async with self._lock:
966
+ if bool(value) != self._autocommit and self._tx_status not in (_IDLE, _BROKEN):
967
+ raise errors.ProgrammingError("can't change 'autocommit' now: connection in a transaction")
968
+ self._autocommit = bool(value)
969
+
970
+ async def commit(self) -> None:
971
+ async with self._lock:
972
+ self._check_open()
973
+ self._check_no_pipeline()
974
+ if self._tx_depth:
975
+ raise errors.ProgrammingError("explicit commit() is forbidden inside a transaction() block")
976
+ if self._tx_status != _IDLE:
977
+ await self._simple("COMMIT")
978
+
979
+ async def rollback(self) -> None:
980
+ async with self._lock:
981
+ self._check_open()
982
+ self._check_no_pipeline()
983
+ if self._tx_depth:
984
+ raise errors.ProgrammingError("explicit rollback() is forbidden inside a transaction() block")
985
+ if self._tx_status != _IDLE:
986
+ await self._simple("ROLLBACK")
987
+
988
+ def transaction(self, savepoint_name: str | None = None, force_rollback: bool = False) -> AsyncTransaction:
989
+ self._check_no_pipeline()
990
+ return AsyncTransaction(self, savepoint_name, force_rollback)
991
+
992
+ def pipeline(self, batch_size: int = 64) -> "AsyncPipeline":
993
+ """Pipeline mode (asyncio). See :mod:`pgtern.pipeline`."""
994
+ return AsyncPipeline(self, batch_size)
995
+
996
+ async def _reset_for_pool(self) -> None:
997
+ async with self._lock:
998
+ self._loader_cache.clear()
999
+ self._tx_depth = 0
1000
+ if not self._closed and self._tx_status != _IDLE:
1001
+ await self._simple("ROLLBACK")
1002
+
1003
+ async def close(self) -> None:
1004
+ if self._closed:
1005
+ return
1006
+ writer = self._writer
1007
+ self._closed = True
1008
+ self._tx_status = _BROKEN
1009
+ self._reader = self._writer = None
1010
+ if writer is not None:
1011
+ try:
1012
+ writer.write(P.TERMINATE)
1013
+ await writer.drain()
1014
+ except (OSError, ConnectionError):
1015
+ pass
1016
+ writer.close()
1017
+ try:
1018
+ await writer.wait_closed()
1019
+ except (OSError, ConnectionError):
1020
+ pass
1021
+
1022
+ async def __aenter__(self) -> "AsyncConnection":
1023
+ return self
1024
+
1025
+ async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
1026
+ try:
1027
+ if not self._closed:
1028
+ if exc_type is None:
1029
+ await self.commit()
1030
+ elif exc_type is asyncio.CancelledError:
1031
+ self._mark_broken()
1032
+ else:
1033
+ try:
1034
+ await self.rollback()
1035
+ except Exception:
1036
+ log.warning("rollback failed while leaving a connection block", exc_info=True)
1037
+ finally:
1038
+ await self.close()
1039
+
1040
+
1041
+ class AsyncPipeline:
1042
+ """asyncio pipeline: ``async with conn.pipeline() as pipe: h = await pipe.execute(...)``.
1043
+ Same semantics as :class:`pgtern.pipeline.Pipeline`."""
1044
+
1045
+ def __init__(self, conn: AsyncConnection, batch_size: int = 64) -> None:
1046
+ if batch_size < 1:
1047
+ raise ValueError("batch_size must be at least 1")
1048
+ self._conn = conn
1049
+ self._batch_size = batch_size
1050
+ self._queue: list[_Item] = []
1051
+ self._begin_queued = False
1052
+ self._open = False
1053
+
1054
+ async def __aenter__(self) -> "AsyncPipeline":
1055
+ conn = self._conn
1056
+ async with conn._lock:
1057
+ conn._check_open()
1058
+ if conn._pipeline is not None:
1059
+ raise errors.ProgrammingError("a pipeline is already active on this connection")
1060
+ conn._pipeline = self
1061
+ self._open = True
1062
+ return self
1063
+
1064
+ async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
1065
+ try:
1066
+ if exc_type is None:
1067
+ await self.sync()
1068
+ elif not self._conn.closed:
1069
+ try:
1070
+ await self.sync()
1071
+ except Exception: # noqa: BLE001 - the body's exception is the one to report
1072
+ pass
1073
+ finally:
1074
+ self._open = False
1075
+ if self._conn._pipeline is self:
1076
+ self._conn._pipeline = None
1077
+
1078
+ async def execute(self, query: Any, params: Any = None) -> AsyncCursor:
1079
+ conn = self._conn
1080
+ if self._open and len(self._queue) >= self._batch_size:
1081
+ await self.sync() # before taking the lock: sync() takes it itself
1082
+ async with conn._lock:
1083
+ conn._check_open()
1084
+ if not self._open:
1085
+ raise errors.ProgrammingError("the pipeline is not active (use it in an 'async with' block)")
1086
+ sql, redacted = conn._query_text(query)
1087
+ if not conn._autocommit and conn._tx_status == _IDLE and not self._begin_queued:
1088
+ await conn._send(P.extended_query("BEGIN", [], [], sync=False))
1089
+ self._queue.append(_Item(None, "BEGIN", "BEGIN", hidden=True))
1090
+ self._begin_queued = True
1091
+ cursor = AsyncCursor(conn)
1092
+ cursor._pipeline_pending = True
1093
+ await conn._send(_encode(sql, params))
1094
+ self._queue.append(_Item(cursor, sql, redacted))
1095
+ return cursor
1096
+
1097
+ async def sync(self) -> None:
1098
+ conn = self._conn
1099
+ async with conn._lock:
1100
+ conn._check_open()
1101
+ items, self._queue = self._queue, []
1102
+ self._begin_queued = False
1103
+ await conn._send(P.SYNC)
1104
+ failure: errors.Error | None = None
1105
+ # Items are shared with the sync pipeline (typed Cursor); here the cursor is always an AsyncCursor.
1106
+ collected: list[tuple[Any, list]] = []
1107
+ for item in items:
1108
+ if failure is not None:
1109
+ if item.cursor is not None:
1110
+ item.cursor._pipeline_pending = False
1111
+ item.cursor._pipeline_error = errors.PipelineAborted(
1112
+ "not executed: an earlier statement in the pipeline failed")
1113
+ continue
1114
+ info, entered = _enter_hooks(conn, item)
1115
+ results, error = await self._read_reply(conn)
1116
+ if info is not None and results is not None:
1117
+ info.rowcount = results[-1].rowcount if results else -1
1118
+ _exit_hooks(entered, error)
1119
+ if error is not None:
1120
+ failure = error
1121
+ if item.cursor is not None:
1122
+ item.cursor._pipeline_pending = False
1123
+ item.cursor._pipeline_error = error
1124
+ elif item.cursor is not None and results is not None:
1125
+ collected.append((item.cursor, results))
1126
+ while True:
1127
+ tag, payload = await conn._next_message()
1128
+ if tag == 0x5A:
1129
+ conn._tx_status = chr(payload[0])
1130
+ break
1131
+ if tag == 0x45:
1132
+ fields = P.parse_error_fields(payload)
1133
+ if fields.get("V", fields.get("S")) in ("FATAL", "PANIC"):
1134
+ conn._mark_broken()
1135
+ raise errors.error_from_fields(fields)
1136
+ else:
1137
+ conn._handle_async_message(tag, payload)
1138
+ await conn._prime_oids(c.type_code for _, res in collected for r in res if r.columns for c in r.columns)
1139
+ for cursor, res in collected:
1140
+ cursor._pipeline_pending = False
1141
+ cursor._set_results(res)
1142
+
1143
+ @staticmethod
1144
+ async def _read_reply(conn: AsyncConnection) -> tuple[list | None, errors.Error | None]:
1145
+ collector = P.QueryCollector()
1146
+ while True:
1147
+ tag, payload = await conn._next_message()
1148
+ if tag == 0x45:
1149
+ fields = P.parse_error_fields(payload)
1150
+ if fields.get("V", fields.get("S")) in ("FATAL", "PANIC"):
1151
+ conn._mark_broken()
1152
+ raise errors.error_from_fields(fields)
1153
+ return None, errors.error_from_fields(fields)
1154
+ if tag in (0x43, 0x49):
1155
+ collector.handle(tag, payload)
1156
+ return collector.results, None
1157
+ if tag in (0x4E, 0x53, 0x41):
1158
+ conn._handle_async_message(tag, payload)
1159
+ else:
1160
+ collector.handle(tag, payload)
1161
+
1162
+
1163
+ async def connect(conninfo: str = "", **kwargs: Any) -> AsyncConnection:
1164
+ """Open an :class:`AsyncConnection` (same arguments as :func:`pgtern.connect`)."""
1165
+ return await AsyncConnection.connect(conninfo, **kwargs)
1166
+
1167
+
1168
+ class AsyncConnectionPool:
1169
+ """asyncio connection pool: ``min_size`` kept warm, up to ``max_size`` on demand.
1170
+
1171
+ Connections are liveness-checked on checkout, replaced when dead or older than
1172
+ ``max_lifetime``, and rolled back when returned mid-transaction.
1173
+ """
1174
+
1175
+ def __init__(self, conninfo: str = "", *, kwargs: dict[str, Any] | None = None, min_size: int = 4,
1176
+ max_size: int | None = None, timeout: float = 30.0, max_lifetime: float = 3600.0,
1177
+ check: Callable | None = None, name: str | None = None) -> None:
1178
+ max_size = min_size if max_size is None else max_size
1179
+ if min_size < 0 or max_size < 1 or max_size < min_size:
1180
+ raise ValueError(f"bad pool sizes: min_size={min_size}, max_size={max_size}")
1181
+ self.conninfo, self.kwargs = conninfo, dict(kwargs or {})
1182
+ self.min_size, self.max_size = min_size, max_size
1183
+ self.timeout, self.max_lifetime = timeout, max_lifetime
1184
+ self.name = name or f"apool-{id(self):x}"
1185
+ self._check = check
1186
+ self._idle: collections.deque[AsyncConnection] = collections.deque()
1187
+ self._slots: asyncio.Semaphore | None = None
1188
+ self._closed = True
1189
+ self._default_autocommit = bool(self.kwargs.get("autocommit", False))
1190
+
1191
+ async def open(self, wait: bool = True) -> None:
1192
+ if not self._closed:
1193
+ return
1194
+ self._closed = False
1195
+ self._slots = asyncio.Semaphore(self.max_size)
1196
+ if wait:
1197
+ conns = [await self._new() for _ in range(self.min_size)]
1198
+ self._idle.extend(conns)
1199
+
1200
+ async def _new(self) -> AsyncConnection:
1201
+ return await connect(self.conninfo, **self.kwargs)
1202
+
1203
+ async def getconn(self, timeout: float | None = None) -> AsyncConnection:
1204
+ if self._closed or self._slots is None:
1205
+ raise PoolClosed("the pool is closed")
1206
+ wait = self.timeout if timeout is None else timeout
1207
+ try:
1208
+ await asyncio.wait_for(self._slots.acquire(), wait)
1209
+ except asyncio.TimeoutError:
1210
+ raise PoolTimeout(f"couldn't get a connection after {wait:.2f} sec") from None
1211
+ try:
1212
+ while self._idle:
1213
+ conn = self._idle.popleft()
1214
+ if conn._check_alive() and time.monotonic() - conn.created < self.max_lifetime:
1215
+ if await self._passes_check(conn):
1216
+ return conn
1217
+ await conn.close()
1218
+ conn = await asyncio.wait_for(self._new(), wait)
1219
+ return conn
1220
+ except BaseException:
1221
+ self._slots.release()
1222
+ raise
1223
+
1224
+ async def _passes_check(self, conn: AsyncConnection) -> bool:
1225
+ if self._check is None:
1226
+ return True
1227
+ try:
1228
+ await self._check(conn)
1229
+ return True
1230
+ except Exception:
1231
+ return False
1232
+
1233
+ async def putconn(self, conn: AsyncConnection) -> None:
1234
+ assert self._slots is not None
1235
+ try:
1236
+ if not self._closed and not conn.closed:
1237
+ try:
1238
+ await conn._reset_for_pool()
1239
+ if conn.autocommit != self._default_autocommit:
1240
+ await conn.set_autocommit(self._default_autocommit)
1241
+ except Exception:
1242
+ await conn.close()
1243
+ if self._closed or conn.closed or time.monotonic() - conn.created >= self.max_lifetime:
1244
+ await conn.close()
1245
+ else:
1246
+ self._idle.append(conn)
1247
+ finally:
1248
+ self._slots.release()
1249
+
1250
+ @asynccontextmanager
1251
+ async def connection(self, timeout: float | None = None) -> AsyncIterator[AsyncConnection]:
1252
+ """Check a connection out; commit on success, roll back on error, then return it."""
1253
+ conn = await self.getconn(timeout)
1254
+ try:
1255
+ try:
1256
+ yield conn
1257
+ except BaseException:
1258
+ if conn.closed or isinstance(_current_exc(), asyncio.CancelledError):
1259
+ conn._mark_broken()
1260
+ else:
1261
+ try:
1262
+ await conn.rollback()
1263
+ except Exception:
1264
+ await conn.close()
1265
+ raise
1266
+ else:
1267
+ if not conn.closed:
1268
+ await conn.commit()
1269
+ finally:
1270
+ await self.putconn(conn)
1271
+
1272
+ async def close(self) -> None:
1273
+ self._closed = True
1274
+ while self._idle:
1275
+ await self._idle.popleft().close()
1276
+
1277
+ async def __aenter__(self) -> "AsyncConnectionPool":
1278
+ await self.open()
1279
+ return self
1280
+
1281
+ async def __aexit__(self, *exc: Any) -> None:
1282
+ await self.close()
1283
+
1284
+
1285
+ def _current_exc() -> BaseException | None:
1286
+ import sys
1287
+ return sys.exc_info()[1]