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/_conn.py ADDED
@@ -0,0 +1,907 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Blocking-socket PostgreSQL connection built on the sans-I/O protocol layer."""
3
+ from __future__ import annotations
4
+
5
+ import collections
6
+ import enum
7
+ import hashlib
8
+ import itertools
9
+ import logging
10
+ import re
11
+ import select
12
+ import socket
13
+ import ssl
14
+ import threading
15
+ import time
16
+ from contextlib import ExitStack
17
+ from typing import TYPE_CHECKING, Any, Callable, Iterable, Literal
18
+
19
+ from . import _binary
20
+ from . import _protocol as P
21
+ from . import _query, _types, errors, hooks
22
+ from ._cursor import Cursor, ServerCursor
23
+ from ._dsn import ConnParams, build_params
24
+ from ._scram import MECHANISM, ScramClient
25
+ from .rows import tuple_row
26
+ from .sql import Composable, quote_ident
27
+
28
+ if TYPE_CHECKING:
29
+ from .pipeline import Pipeline
30
+
31
+ log = logging.getLogger("pgtern")
32
+
33
+ _IDLE, _INTRANS, _INERROR, _BROKEN = "I", "T", "E", "?"
34
+ PRIME_SQL = ('SELECT t.oid, n.nspname, t.typname, t.typelem, t.typcategory FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace WHERE t.oid IN ({ids}) OR t.oid IN (SELECT typelem FROM pg_type WHERE oid IN ({ids}) AND typelem <> 0)')
35
+
36
+
37
+ class TransactionStatus(enum.Enum):
38
+ IDLE = "I"
39
+ INTRANS = "T"
40
+ INERROR = "E"
41
+ UNKNOWN = "?"
42
+
43
+
44
+ _VERSION_RE = re.compile(r"^(\d+)(?:\.(\d+))?(?:\.(\d+))?")
45
+
46
+
47
+ def _parse_version(text: str) -> int:
48
+ m = _VERSION_RE.match(text)
49
+ if not m:
50
+ return 0
51
+ major, minor, patch = int(m.group(1)), int(m.group(2) or 0), int(m.group(3) or 0)
52
+ return major * 10000 + minor if major >= 10 else major * 10000 + minor * 100 + patch
53
+
54
+
55
+ class ConnectionInfo:
56
+ def __init__(self, conn: "Connection") -> None:
57
+ self._conn = conn
58
+
59
+ @property
60
+ def user(self) -> str:
61
+ return self._conn._params.user
62
+
63
+ @property
64
+ def dbname(self) -> str:
65
+ return self._conn._params.dbname
66
+
67
+ @property
68
+ def host(self) -> str:
69
+ return self._conn._host
70
+
71
+ @property
72
+ def port(self) -> int:
73
+ return self._conn._port
74
+
75
+ @property
76
+ def server_version(self) -> int:
77
+ return _parse_version(self._conn._server_params.get("server_version", ""))
78
+
79
+ @property
80
+ def backend_pid(self) -> int:
81
+ return self._conn._backend_pid
82
+
83
+ @property
84
+ def encoding(self) -> str:
85
+ return self._conn._server_params.get("client_encoding", "UTF8")
86
+
87
+ @property
88
+ def transaction_status(self) -> TransactionStatus:
89
+ return TransactionStatus(self._conn._tx_status)
90
+
91
+ def parameter_status(self, name: str) -> str | None:
92
+ return self._conn._server_params.get(name)
93
+
94
+
95
+ def _ssl_context(params: ConnParams, mode: str) -> ssl.SSLContext:
96
+ if mode in ("require", "prefer", "allow"): # encrypt without verifying the server
97
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
98
+ ctx.check_hostname = False
99
+ ctx.verify_mode = ssl.CERT_NONE
100
+ else:
101
+ root = params.sslrootcert
102
+ ctx = ssl.create_default_context(cafile=root if root and root != "system" else None)
103
+ ctx.check_hostname = mode == "verify-full"
104
+ ctx.verify_mode = ssl.CERT_REQUIRED
105
+ ctx.minimum_version = ssl.TLSVersion.TLSv1_2
106
+ if params.sslcert:
107
+ ctx.load_cert_chain(params.sslcert, params.sslkey)
108
+ return ctx
109
+
110
+
111
+ class Transaction:
112
+ """``with conn.transaction():`` BEGIN/COMMIT, or SAVEPOINT/RELEASE when already in a transaction."""
113
+
114
+ def __init__(self, conn: "Connection", savepoint_name: str | None = None,
115
+ force_rollback: bool = False) -> None:
116
+ self._conn = conn
117
+ self._name = savepoint_name
118
+ self._force_rollback = force_rollback
119
+ self._savepoint: str | None = None
120
+
121
+ def __enter__(self) -> "Transaction":
122
+ conn = self._conn
123
+ with conn._lock:
124
+ conn._check_open()
125
+ if conn._tx_status == _IDLE:
126
+ self._savepoint = None
127
+ conn._simple("BEGIN")
128
+ else:
129
+ self._savepoint = self._name or f"_pgtern_{next(conn._savepoint_ids)}"
130
+ conn._simple(f"SAVEPOINT {quote_ident(self._savepoint)}")
131
+ conn._tx_stack.append(self)
132
+ return self
133
+
134
+ def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> Literal[False]:
135
+ conn = self._conn
136
+ with conn._lock:
137
+ if conn._tx_stack and conn._tx_stack[-1] is self:
138
+ conn._tx_stack.pop()
139
+ if conn.closed:
140
+ return False
141
+ commit = exc_type is None and not self._force_rollback
142
+ try:
143
+ if self._savepoint is None:
144
+ conn._simple("COMMIT" if commit else "ROLLBACK")
145
+ else:
146
+ sp = quote_ident(self._savepoint)
147
+ conn._simple(f"RELEASE {sp}" if commit else f"ROLLBACK TO {sp}; RELEASE {sp}")
148
+ except Exception:
149
+ if exc_type is None:
150
+ raise
151
+ log.warning("error while ending a failed transaction block", exc_info=True)
152
+ return False
153
+
154
+
155
+ class Connection:
156
+ """A synchronous connection. Create it with :func:`pgtern.connect`."""
157
+
158
+ def __init__(self, params: ConnParams, *, autocommit: bool = False,
159
+ row_factory: Callable | None = None) -> None:
160
+ self._params = params
161
+ self._autocommit = autocommit
162
+ self.row_factory = row_factory or tuple_row
163
+ self._lock = threading.RLock()
164
+ self._sock: socket.socket | None = None
165
+ self._closed = True
166
+ self._parser = P.MessageParser()
167
+ self._pending: collections.deque[tuple[int, bytes]] = collections.deque()
168
+ self._server_params: dict[str, str] = {}
169
+ self._backend_pid = 0
170
+ self._tx_status = _BROKEN
171
+ self._tx_stack: list[Transaction] = []
172
+ self._savepoint_ids = itertools.count(1)
173
+ self._host = ""
174
+ self._port = 0
175
+ self._dates_ok = True
176
+ self._notice_handlers: list[Callable[[errors.Diagnostic], None]] = []
177
+ self._notify_handlers: list[Callable[[P.Notify], None]] = []
178
+ self._loader_cache: dict[int, Any] = {}
179
+ self._pipeline: Any = None
180
+ self._notifies: collections.deque[P.Notify] = collections.deque()
181
+ self.info = ConnectionInfo(self)
182
+
183
+ # ---------------------------------------------------------------- opening
184
+ @classmethod
185
+ def connect(cls, conninfo: str = "", *, autocommit: bool = False,
186
+ row_factory: Callable | None = None, **kwargs: Any) -> "Connection":
187
+ conn = cls(build_params(conninfo, **kwargs), autocommit=autocommit, row_factory=row_factory)
188
+ conn._open()
189
+ return conn
190
+
191
+ def _open(self) -> None:
192
+ modes = ["disable", "require"] if self._params.sslmode == "allow" else [self._params.sslmode]
193
+ last: errors.OperationalError | None = None
194
+ for host, port in self._params.hosts:
195
+ for mode in modes:
196
+ try:
197
+ self._connect_host(host, port, mode)
198
+ return
199
+ except errors.OperationalError as exc:
200
+ last = exc
201
+ self._drop_socket()
202
+ assert last is not None
203
+ raise last
204
+
205
+ def _drop_socket(self) -> None:
206
+ sock, self._sock = self._sock, None
207
+ self._closed = True
208
+ self._parser = P.MessageParser()
209
+ self._pending.clear()
210
+ if sock is not None:
211
+ try:
212
+ sock.close()
213
+ except OSError:
214
+ pass
215
+
216
+ def _connect_host(self, host: str, port: int, mode: str) -> None:
217
+ p = self._params
218
+ unix = host.startswith("/")
219
+ try:
220
+ if unix:
221
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
222
+ sock.settimeout(p.connect_timeout)
223
+ sock.connect(f"{host}/.s.PGSQL.{port}")
224
+ else:
225
+ sock = socket.create_connection((host, port), timeout=p.connect_timeout)
226
+ sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
227
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
228
+ except OSError as exc:
229
+ raise errors.OperationalError(
230
+ f'connection to server at "{host}", port {port} failed: {exc}') from exc
231
+ self._sock = sock
232
+ self._host, self._port = host, port
233
+ self._closed = False
234
+ self._parser = P.MessageParser()
235
+ self._pending.clear()
236
+ self._server_params = {}
237
+ if not unix and mode != "disable":
238
+ try:
239
+ self._sock = self._negotiate_tls(sock, host, mode)
240
+ except (ssl.SSLError, ConnectionError) as exc:
241
+ self._drop_socket()
242
+ if mode == "prefer":
243
+ return self._connect_host(host, port, "disable")
244
+ raise errors.OperationalError(
245
+ f'SSL connection to server at "{host}", port {port} failed: {exc}') from exc
246
+ try:
247
+ self._startup()
248
+ except socket.timeout as exc: # pragma: no cover - mapped in _recv
249
+ raise errors.OperationalError(f"timeout during connection startup: {exc}") from exc
250
+ if self._sock is not None:
251
+ self._sock.settimeout(None)
252
+ self._dates_ok = self._server_params.get("DateStyle", "ISO").upper().startswith("ISO")
253
+
254
+ def _negotiate_tls(self, sock: socket.socket, host: str, mode: str) -> socket.socket:
255
+ sock.sendall(P.SSL_REQUEST)
256
+ answer = sock.recv(1)
257
+ if answer == b"S":
258
+ return _ssl_context(self._params, mode).wrap_socket(sock, server_hostname=host)
259
+ if answer == b"N":
260
+ if mode in ("require", "verify-ca", "verify-full"):
261
+ raise errors.OperationalError("server does not support SSL, but SSL was required")
262
+ return sock
263
+ raise errors.OperationalError(f"received an invalid response to the SSL request: {answer!r}")
264
+
265
+ def _password(self) -> str:
266
+ if self._params.password is None:
267
+ raise errors.OperationalError("the server requested a password, but none was provided")
268
+ return self._params.password
269
+
270
+ def _startup(self) -> None:
271
+ p = self._params
272
+ startup = {"user": p.user, "database": p.dbname, "client_encoding": "UTF8", "DateStyle": "ISO"}
273
+ if p.application_name:
274
+ startup["application_name"] = p.application_name
275
+ if p.options:
276
+ startup["options"] = p.options
277
+ self._send(P.startup_message(startup))
278
+ scram: ScramClient | None = None
279
+ while True:
280
+ tag, payload = self._next_message()
281
+ if tag == 0x52: # R Authentication*
282
+ code = int.from_bytes(payload[:4], "big")
283
+ if code == 0:
284
+ continue
285
+ if code == 3:
286
+ self._send(P.password_message(self._password().encode()))
287
+ elif code == 5:
288
+ inner = hashlib.md5((self._password() + p.user).encode(), usedforsecurity=False).hexdigest()
289
+ outer = hashlib.md5(inner.encode() + payload[4:8], usedforsecurity=False).hexdigest()
290
+ self._send(P.password_message(b"md5" + outer.encode()))
291
+ elif code == 10:
292
+ mechanisms = [m for m in payload[4:].split(b"\0") if m]
293
+ if MECHANISM.encode() not in mechanisms:
294
+ raise errors.NotSupportedError(
295
+ "the server offers no supported SASL mechanism: "
296
+ + ", ".join(m.decode() for m in mechanisms))
297
+ scram = ScramClient(self._password())
298
+ self._send(P.sasl_initial_response(MECHANISM, scram.client_first()))
299
+ elif code == 11 and scram is not None:
300
+ self._send(P.sasl_response(scram.client_final(payload[4:])))
301
+ elif code == 12 and scram is not None:
302
+ scram.verify_server_final(payload[4:])
303
+ else:
304
+ raise errors.NotSupportedError(f"authentication method {code} is not supported")
305
+ elif tag == 0x45: # E
306
+ raise errors.error_from_fields(P.parse_error_fields(payload))
307
+ elif tag == 0x53: # S ParameterStatus
308
+ name, value = P.parse_parameter_status(payload)
309
+ self._server_params[name] = value
310
+ elif tag == 0x4B: # K BackendKeyData
311
+ self._backend_pid = int.from_bytes(payload[:4], "big")
312
+ elif tag == 0x4E: # N
313
+ self._notice(payload)
314
+ elif tag == 0x5A: # Z ReadyForQuery
315
+ self._tx_status = chr(payload[0])
316
+ return
317
+
318
+ # ---------------------------------------------------------------- I/O
319
+ def _mark_broken(self) -> None:
320
+ self._closed = True
321
+ self._tx_status = _BROKEN
322
+ sock, self._sock = self._sock, None
323
+ if sock is not None:
324
+ try:
325
+ sock.close()
326
+ except OSError:
327
+ pass
328
+
329
+ def _send(self, data: bytes) -> None:
330
+ sock = self._sock
331
+ if sock is None:
332
+ raise errors.OperationalError("the connection is closed")
333
+ try:
334
+ sock.sendall(data)
335
+ except (OSError, ssl.SSLError) as exc:
336
+ self._mark_broken()
337
+ raise errors.OperationalError(f"connection failed while sending: {exc}") from exc
338
+
339
+ def _next_message(self) -> tuple[int, bytes]:
340
+ while not self._pending:
341
+ sock = self._sock
342
+ if sock is None:
343
+ raise errors.OperationalError("the connection is closed")
344
+ try:
345
+ data = sock.recv(65536)
346
+ except (OSError, ssl.SSLError) as exc:
347
+ self._mark_broken()
348
+ raise errors.OperationalError(f"connection failed while receiving: {exc}") from exc
349
+ if not data:
350
+ self._mark_broken()
351
+ raise errors.OperationalError("server closed the connection unexpectedly")
352
+ self._pending.extend(self._parser.feed(data))
353
+ return self._pending.popleft()
354
+
355
+ def _notice(self, payload: bytes) -> None:
356
+ diag = errors.Diagnostic(P.parse_error_fields(payload))
357
+ if self._notice_handlers:
358
+ for handler in self._notice_handlers:
359
+ handler(diag)
360
+ else:
361
+ log.debug("server notice: %s", diag.message_primary)
362
+
363
+ def add_notice_handler(self, handler: Callable[[errors.Diagnostic], None]) -> None:
364
+ self._notice_handlers.append(handler)
365
+
366
+ def add_notify_handler(self, handler: Callable[[P.Notify], None]) -> None:
367
+ """Call ``handler(notify)`` for every notification received while running other statements."""
368
+ self._notify_handlers.append(handler)
369
+
370
+ def _on_notification(self, payload: bytes) -> None:
371
+ note = P.parse_notification(payload)
372
+ if self._notify_handlers:
373
+ for handler in self._notify_handlers:
374
+ handler(note)
375
+ else:
376
+ self._notifies.append(note)
377
+
378
+ def _handle_async_message(self, tag: int, payload: bytes) -> None:
379
+ """Messages that may arrive at any time: notices, parameter changes, notifications."""
380
+ if tag == 0x4E:
381
+ self._notice(payload)
382
+ elif tag == 0x53:
383
+ name, value = P.parse_parameter_status(payload)
384
+ self._server_params[name] = value
385
+ elif tag == 0x41:
386
+ self._on_notification(payload)
387
+
388
+ def notifies(self, *, timeout: float | None = None, stop_after: int | None = None):
389
+ """Yield :class:`Notify` objects as they arrive (``LISTEN channel`` first).
390
+
391
+ Stops after ``timeout`` seconds without a new notification (``None`` waits forever) or
392
+ after ``stop_after`` notifications. Holds the connection while waiting.
393
+ """
394
+ count = 0
395
+ while stop_after is None or count < stop_after:
396
+ with self._lock:
397
+ self._check_open()
398
+ if not self._notifies:
399
+ self._wait_for_data(timeout)
400
+ note = self._notifies.popleft() if self._notifies else None
401
+ if note is None:
402
+ return
403
+ count += 1
404
+ yield note
405
+
406
+ def _wait_for_data(self, timeout: float | None) -> None:
407
+ """Block until something notifies us, the timeout passes, or the server goes away."""
408
+ deadline = None if timeout is None else time.monotonic() + timeout
409
+ while not self._notifies:
410
+ if not self._pending:
411
+ sock = self._sock
412
+ if sock is None:
413
+ raise errors.OperationalError("the connection is closed")
414
+ ready = bool(getattr(sock, "pending", lambda: 0)())
415
+ if not ready:
416
+ wait = None if deadline is None else max(0.0, deadline - time.monotonic())
417
+ try:
418
+ ready = bool(select.select([sock], [], [], wait)[0])
419
+ except (OSError, ValueError) as exc:
420
+ self._mark_broken()
421
+ raise errors.OperationalError(f"connection failed while waiting: {exc}") from exc
422
+ if not ready:
423
+ return
424
+ try:
425
+ data = sock.recv(65536)
426
+ except (OSError, ssl.SSLError) as exc:
427
+ self._mark_broken()
428
+ raise errors.OperationalError(f"connection failed while receiving: {exc}") from exc
429
+ if not data:
430
+ self._mark_broken()
431
+ raise errors.OperationalError("server closed the connection unexpectedly")
432
+ self._pending.extend(self._parser.feed(data))
433
+ while self._pending:
434
+ tag, payload = self._pending.popleft()
435
+ if tag == 0x45:
436
+ self._mark_broken()
437
+ raise errors.error_from_fields(P.parse_error_fields(payload))
438
+ self._handle_async_message(tag, payload)
439
+ if deadline is not None and time.monotonic() >= deadline and not self._notifies:
440
+ return
441
+
442
+ def _read_cycle(self) -> list[P.Result]:
443
+ """Read messages up to and including ReadyForQuery; raise the server error, if any."""
444
+ collector = P.QueryCollector()
445
+ error: dict[str, str] | None = None
446
+ unsupported = False
447
+ while True:
448
+ tag, payload = self._next_message()
449
+ if tag == 0x5A: # Z
450
+ self._tx_status = chr(payload[0])
451
+ break
452
+ if tag == 0x45: # E
453
+ fields = P.parse_error_fields(payload)
454
+ if error is None:
455
+ error = fields
456
+ if fields.get("V", fields.get("S")) in ("FATAL", "PANIC"):
457
+ self._mark_broken()
458
+ raise errors.error_from_fields(fields)
459
+ elif tag == 0x4E:
460
+ self._notice(payload)
461
+ elif tag == 0x53:
462
+ name, value = P.parse_parameter_status(payload)
463
+ self._server_params[name] = value
464
+ elif tag == 0x41: # A NotificationResponse
465
+ self._on_notification(payload)
466
+ elif tag == 0x47: # G CopyInResponse: abort it cleanly
467
+ unsupported = True
468
+ self._send(P.copy_fail("COPY needs cursor.copy()"))
469
+ elif tag in (0x48, 0x57, 0x64, 0x63): # H W d c: drain CopyOut data
470
+ unsupported = True
471
+ else:
472
+ collector.handle(tag, payload)
473
+ if unsupported:
474
+ raise errors.NotSupportedError("COPY must be run with cursor.copy(), not execute()")
475
+ if error is not None:
476
+ raise errors.error_from_fields(error)
477
+ return collector.results
478
+
479
+ def _check_open(self) -> None:
480
+ if self._closed:
481
+ raise errors.OperationalError("the connection is closed")
482
+
483
+ def _check_alive(self) -> bool:
484
+ """Cheap liveness check for an idle connection: no round trip, just a non-blocking read."""
485
+ with self._lock:
486
+ sock = self._sock
487
+ if self._closed or sock is None:
488
+ return False
489
+ try:
490
+ sock.settimeout(0.0)
491
+ try:
492
+ data = sock.recv(65536)
493
+ except (BlockingIOError, ssl.SSLWantReadError, ssl.SSLWantWriteError):
494
+ return True
495
+ except (OSError, ssl.SSLError):
496
+ self._mark_broken()
497
+ return False
498
+ if not data:
499
+ self._mark_broken()
500
+ return False
501
+ self._pending.extend(self._parser.feed(data))
502
+ while self._pending:
503
+ tag, payload = self._pending[0]
504
+ if tag == 0x4E:
505
+ self._notice(payload)
506
+ elif tag == 0x53:
507
+ name, value = P.parse_parameter_status(payload)
508
+ self._server_params[name] = value
509
+ elif tag == 0x41:
510
+ self._on_notification(payload)
511
+ elif tag == 0x45:
512
+ self._mark_broken()
513
+ return False
514
+ else:
515
+ return True
516
+ self._pending.popleft()
517
+ return True
518
+ finally:
519
+ if self._sock is not None:
520
+ self._sock.settimeout(None)
521
+
522
+ # ---------------------------------------------------------------- execution
523
+ def _simple(self, sql: str) -> list[P.Result]:
524
+ self._send(P.query_message(sql))
525
+ return self._read_cycle()
526
+
527
+ def _run(self, sql: str, params: Any) -> list[P.Result]:
528
+ self._send_query(sql, params)
529
+ return self._read_cycle()
530
+
531
+ def _run_many(self, sql: str, params_seq: Iterable[Any]) -> int:
532
+ """``executemany`` as one pipeline.
533
+
534
+ Every statement is sent without waiting for its reply. All of them run in one
535
+ transaction: a single Sync ends the batch, so with ``autocommit=True`` a failure
536
+ rolls the whole batch back. Replies are read through a sliding window, so the
537
+ server never blocks on a full socket while we are still sending. Returns the
538
+ total rowcount. Query hooks see one call per statement, as with ``execute``."""
539
+ from .pipeline import Pipeline, _encode # internal helpers; imported here to avoid a cycle
540
+
541
+ window = 64
542
+ pg_sql, redacted = self._query_text(sql)
543
+ with self._lock:
544
+ self._check_open()
545
+ self._check_no_pipeline()
546
+ # Outside a transaction the batch gets one: with autocommit it is BEGIN..COMMIT (so a failure,
547
+ # even one before the Sync, rolls it all back); without it, the usual implicit transaction.
548
+ own_tx = self._autocommit and self._tx_status == _IDLE
549
+ if own_tx or (not self._autocommit and self._tx_status == _IDLE):
550
+ self._send(P.query_message("BEGIN")) # BEGIN is its own round trip, as in _send_query
551
+ self._read_cycle()
552
+ total = 0
553
+ in_flight: collections.deque = collections.deque() # (hook stack, info) per unread reply
554
+ failure: BaseException | None = None
555
+ sent_sync = False
556
+
557
+ def enter_hooks() -> tuple[ExitStack, Any]:
558
+ stack = ExitStack()
559
+ info = None
560
+ if hooks._HOOKS:
561
+ info = hooks.QueryInfo(redacted, self._host, self._port, self._params.dbname, self._params.user)
562
+ for hook in tuple(hooks._HOOKS):
563
+ try:
564
+ stack.enter_context(hook(info))
565
+ except Exception:
566
+ log.exception("a pgtern query hook failed on entry; ignoring it")
567
+ return stack, info
568
+
569
+ def read_one() -> errors.Error | None:
570
+ nonlocal total
571
+ stack, info = in_flight.popleft()
572
+ results, error = Pipeline._read_reply(self)
573
+ if results is not None:
574
+ rc = results[-1].rowcount if results else -1
575
+ if info is not None:
576
+ info.rowcount = rc
577
+ if rc > 0:
578
+ total += rc
579
+ if error is None:
580
+ stack.__exit__(None, None, None)
581
+ else:
582
+ stack.__exit__(type(error), error, None)
583
+ return error
584
+
585
+ try:
586
+ for params in params_seq:
587
+ body = _encode(pg_sql, params) # no Sync: the batch ends with one
588
+ in_flight.append(enter_hooks())
589
+ self._send(body)
590
+ if len(in_flight) > window:
591
+ self._send(P.FLUSH) # make the server send the replies it has produced so far
592
+ while len(in_flight) > window // 2:
593
+ failure = read_one()
594
+ if failure is not None:
595
+ break # statements sent after a failure are skipped by the server
596
+ if failure is not None:
597
+ break
598
+ self._send(P.SYNC)
599
+ sent_sync = True
600
+ while failure is None and in_flight:
601
+ failure = read_one()
602
+ except BaseException as exc:
603
+ failure = exc
604
+ if self._sock is not None:
605
+ # a bad parameter or a dropped socket mid-batch: end the batch so the connection stays usable
606
+ try:
607
+ if not sent_sync:
608
+ self._send(P.SYNC)
609
+ while True:
610
+ tag, payload = self._next_message()
611
+ if tag == 0x5A:
612
+ self._tx_status = chr(payload[0])
613
+ break
614
+ if own_tx and self._tx_status != _IDLE:
615
+ self._simple("ROLLBACK")
616
+ except Exception:
617
+ self._mark_broken()
618
+ raise
619
+ finally:
620
+ # statements that were sent but never got a reply (skipped after a failure) end their hooks here
621
+ while in_flight:
622
+ stack, _ = in_flight.popleft()
623
+ if failure is None:
624
+ stack.__exit__(None, None, None)
625
+ else:
626
+ stack.__exit__(type(failure), failure, None)
627
+ # read up to ReadyForQuery
628
+ while True:
629
+ tag, payload = self._next_message()
630
+ if tag == 0x5A:
631
+ self._tx_status = chr(payload[0])
632
+ break
633
+ if tag == 0x45:
634
+ fields = P.parse_error_fields(payload)
635
+ if fields.get("V", fields.get("S")) in ("FATAL", "PANIC"):
636
+ self._mark_broken()
637
+ raise errors.error_from_fields(fields)
638
+ else:
639
+ self._handle_async_message(tag, payload)
640
+ if own_tx:
641
+ self._simple("ROLLBACK" if failure is not None else "COMMIT")
642
+ if failure is not None:
643
+ raise failure
644
+ return total
645
+
646
+ def _send_query(self, sql: str, params: Any, *, inline: bool = False) -> None:
647
+ begin = not self._autocommit and self._tx_status == _IDLE
648
+ if inline and params is not None: # client-side binding: PostgreSQL has no parameters in COPY
649
+ sql, params = _query.translate(sql, params, _types.render_literal)[0], None
650
+ body = P.build_query(sql, params, _query.translate, _types.dump)
651
+ if begin: # implicit BEGIN rides in the same packet as the query
652
+ self._send(P.query_message("BEGIN") + body)
653
+ try:
654
+ self._read_cycle()
655
+ except BaseException:
656
+ self._mark_broken()
657
+ raise
658
+ else:
659
+ self._send(body)
660
+
661
+ def _start_copy(self, query: Any, params: Any, stack: ExitStack) -> str:
662
+ """Send a COPY statement and wait for its Copy*Response; returns ``"in"`` or ``"out"``."""
663
+ sql, redacted = self._query_text(query)
664
+ self._check_open()
665
+ if hooks._HOOKS:
666
+ info = hooks.QueryInfo(redacted, self._host, self._port, self._params.dbname, self._params.user)
667
+ for hook in tuple(hooks._HOOKS):
668
+ try:
669
+ stack.enter_context(hook(info))
670
+ except Exception:
671
+ log.exception("a pgtern query hook failed on entry; ignoring it")
672
+ self._send_query(sql, params, inline=True)
673
+ error: dict[str, str] | None = None
674
+ while True:
675
+ tag, payload = self._next_message()
676
+ if error is None and tag in (0x47, 0x48):
677
+ return "in" if tag == 0x47 else "out"
678
+ if tag == 0x45:
679
+ fields = P.parse_error_fields(payload)
680
+ if fields.get("V", fields.get("S")) in ("FATAL", "PANIC"):
681
+ self._mark_broken()
682
+ raise errors.error_from_fields(fields)
683
+ error = error or fields
684
+ elif tag == 0x5A:
685
+ self._tx_status = chr(payload[0])
686
+ if error is not None:
687
+ raise errors.error_from_fields(error)
688
+ raise errors.ProgrammingError("the statement is not a COPY ... FROM STDIN / TO STDOUT")
689
+ else:
690
+ self._handle_async_message(tag, payload)
691
+
692
+ def _query_text(self, query: Any) -> tuple[str, str]:
693
+ if isinstance(query, Composable):
694
+ return query.as_string(), query.as_redacted()
695
+ if isinstance(query, str):
696
+ return query, query
697
+ if isinstance(query, (bytes, bytearray)):
698
+ text = bytes(query).decode()
699
+ return text, text
700
+ raise TypeError(f"the query must be str, bytes or sql.Composable, not {type(query).__name__}")
701
+
702
+ def _prime_oids(self, oids: Iterable[int]) -> None:
703
+ """Resolve user loaders for these types (one catalog query per connection and new type)."""
704
+ if not _types.has_loaders():
705
+ return
706
+ wanted = set(oids) - self._loader_cache.keys()
707
+ if not wanted:
708
+ return
709
+ ids = ", ".join(str(int(o)) for o in sorted(wanted))
710
+ rows = self._simple(PRIME_SQL.format(ids=ids))[0].rows
711
+ _types.resolve_loaders(rows, self._loader_cache, sorted(wanted))
712
+
713
+ def _prime_loaders(self, results: list[P.Result]) -> None:
714
+ self._prime_oids(c.type_code for r in results if r.columns for c in r.columns)
715
+
716
+ def _describe(self, sql: str) -> tuple[list[int], list[P.Column] | None]:
717
+ """Ask the server for the parameter and column types of ``sql`` without running it."""
718
+ self._send(P.describe_statement(sql))
719
+ param_oids: list[int] = []
720
+ columns: list[P.Column] | None = None
721
+ error: dict[str, str] | None = None
722
+ while True:
723
+ tag, payload = self._next_message()
724
+ if tag == 0x5A:
725
+ self._tx_status = chr(payload[0])
726
+ break
727
+ if tag == 0x74: # t ParameterDescription
728
+ param_oids = P.parse_param_description(payload)
729
+ elif tag == 0x54: # T RowDescription
730
+ columns = P.parse_row_description(payload)
731
+ elif tag == 0x45:
732
+ fields = P.parse_error_fields(payload)
733
+ if fields.get("V", fields.get("S")) in ("FATAL", "PANIC"):
734
+ self._mark_broken()
735
+ raise errors.error_from_fields(fields)
736
+ error = error or fields
737
+ elif tag in (0x31, 0x6E): # 1 ParseComplete, n NoData
738
+ pass
739
+ else:
740
+ self._handle_async_message(tag, payload)
741
+ if error is not None:
742
+ raise errors.error_from_fields(error)
743
+ return param_oids, columns
744
+
745
+ def _binary_result_formats(self, columns: list[P.Column] | None) -> list[int] | None:
746
+ if columns is None:
747
+ return None
748
+ self._prime_oids(c.type_code for c in columns)
749
+ return [1 if _binary.scalar_decoder(c.type_code) is not None and self._loader_cache.get(c.type_code) is None
750
+ else 0 for c in columns]
751
+
752
+ def _run_binary(self, sql: str, params: Any) -> list[P.Result]:
753
+ """Like _run, but parameters and columns use binary format where the type supports it.
754
+
755
+ Costs one extra round trip (describe) per statement, so it is opt-in with ``binary=True``.
756
+ """
757
+ begin = not self._autocommit and self._tx_status == _IDLE
758
+ if begin:
759
+ self._simple("BEGIN")
760
+ values: list[Any]
761
+ if params is None:
762
+ pg_sql, values = sql, []
763
+ else:
764
+ pg_sql, values = _query.translate(sql, params)
765
+ param_oids, columns = self._describe(pg_sql)
766
+ if len(param_oids) != len(values):
767
+ raise errors.ProgrammingError(
768
+ f"the query has {len(param_oids)} parameters but {len(values)} were passed")
769
+ pformats, payloads = _types.binary_param_plan(param_oids, values)
770
+ rformats = self._binary_result_formats(columns)
771
+ self._send(P.extended_query(pg_sql, param_oids, payloads, pformats, rformats))
772
+ results = self._read_cycle()
773
+ for r in results:
774
+ if r.columns is not None:
775
+ r.formats = rformats
776
+ return results
777
+
778
+ def _check_no_pipeline(self) -> None:
779
+ if self._pipeline is not None:
780
+ raise errors.ProgrammingError(
781
+ "a pipeline is active on this connection: queue statements with pipe.execute() "
782
+ "or leave the pipeline block first")
783
+
784
+ def _execute(self, query: Any, params: Any, binary: bool = False) -> list[P.Result]:
785
+ sql, redacted = self._query_text(query)
786
+ run = self._run_binary if binary else self._run
787
+ with self._lock:
788
+ self._check_open()
789
+ self._check_no_pipeline()
790
+ if not hooks._HOOKS:
791
+ results = run(sql, params)
792
+ else:
793
+ info = hooks.QueryInfo(redacted, self._host, self._port, self._params.dbname, self._params.user)
794
+ with ExitStack() as stack:
795
+ for hook in tuple(hooks._HOOKS):
796
+ try:
797
+ stack.enter_context(hook(info))
798
+ except Exception:
799
+ log.exception("a pgtern query hook failed on entry; ignoring it")
800
+ results = run(sql, params)
801
+ info.rowcount = results[-1].rowcount if results else -1
802
+ self._prime_loaders(results)
803
+ return results
804
+
805
+ # ---------------------------------------------------------------- public API
806
+ def cursor(self, name: str | None = None, *, row_factory: Callable | None = None,
807
+ scrollable: bool | None = None, withhold: bool = False) -> Cursor:
808
+ """A client-side cursor, or a server-side one (``DECLARE``) when ``name`` is given."""
809
+ if name is not None:
810
+ return ServerCursor(self, name, row_factory=row_factory, scrollable=scrollable, withhold=withhold)
811
+ return Cursor(self, row_factory=row_factory)
812
+
813
+ def execute(self, query: Any, params: Any = None, *, prepare: bool | None = None,
814
+ binary: bool | None = None) -> Cursor:
815
+ return self.cursor().execute(query, params, binary=bool(binary))
816
+
817
+ @property
818
+ def closed(self) -> bool:
819
+ return self._closed
820
+
821
+ @property
822
+ def autocommit(self) -> bool:
823
+ return self._autocommit
824
+
825
+ @autocommit.setter
826
+ def autocommit(self, value: bool) -> None:
827
+ with self._lock:
828
+ if bool(value) != self._autocommit and self._tx_status not in (_IDLE, _BROKEN):
829
+ raise errors.ProgrammingError("can't change 'autocommit' now: connection in a transaction")
830
+ self._autocommit = bool(value)
831
+
832
+ def commit(self) -> None:
833
+ with self._lock:
834
+ self._check_open()
835
+ self._check_no_pipeline()
836
+ if self._tx_stack:
837
+ raise errors.ProgrammingError(
838
+ "explicit commit() is forbidden inside a transaction() block; "
839
+ "the block commits on a successful exit")
840
+ if self._tx_status != _IDLE:
841
+ self._simple("COMMIT")
842
+
843
+ def rollback(self) -> None:
844
+ with self._lock:
845
+ self._check_open()
846
+ self._check_no_pipeline()
847
+ if self._tx_stack:
848
+ raise errors.ProgrammingError("explicit rollback() is forbidden inside a transaction() block")
849
+ if self._tx_status != _IDLE:
850
+ self._simple("ROLLBACK")
851
+
852
+ def transaction(self, savepoint_name: str | None = None, force_rollback: bool = False) -> Transaction:
853
+ self._check_no_pipeline()
854
+ return Transaction(self, savepoint_name, force_rollback)
855
+
856
+ def pipeline(self, batch_size: int = 64) -> "Pipeline":
857
+ """Pipeline mode: queue many statements and read their replies together. See :mod:`pgtern.pipeline`."""
858
+ from .pipeline import Pipeline
859
+ return Pipeline(self, batch_size)
860
+
861
+ def _reset_for_pool(self) -> None:
862
+ with self._lock:
863
+ self._loader_cache.clear()
864
+ self._tx_stack.clear()
865
+ if not self._closed and self._tx_status != _IDLE:
866
+ self._simple("ROLLBACK")
867
+
868
+ def close(self) -> None:
869
+ with self._lock:
870
+ if self._closed:
871
+ return
872
+ sock = self._sock
873
+ self._closed = True
874
+ self._tx_status = _BROKEN
875
+ self._sock = None
876
+ if sock is not None:
877
+ try:
878
+ sock.sendall(P.TERMINATE)
879
+ except (OSError, ssl.SSLError):
880
+ pass
881
+ try:
882
+ sock.close()
883
+ except OSError:
884
+ pass
885
+
886
+ def __enter__(self) -> "Connection":
887
+ return self
888
+
889
+ def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
890
+ try:
891
+ if not self._closed:
892
+ if exc_type is None:
893
+ self.commit()
894
+ else:
895
+ try:
896
+ self.rollback()
897
+ except Exception:
898
+ log.warning("rollback failed while leaving a connection block", exc_info=True)
899
+ finally:
900
+ self.close()
901
+
902
+ def __del__(self) -> None:
903
+ try:
904
+ if not self._closed and self._sock is not None:
905
+ self._sock.close()
906
+ except Exception:
907
+ pass