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/pool.py ADDED
@@ -0,0 +1,309 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """A synchronous, thread-safe connection pool. Built in: no separate package to install."""
3
+ from __future__ import annotations
4
+
5
+ import collections
6
+ import logging
7
+ import threading
8
+ import time
9
+ from contextlib import contextmanager
10
+ from typing import Any, Callable, Iterator
11
+
12
+ from . import errors
13
+ from ._conn import Connection
14
+
15
+ log = logging.getLogger("pgtern.pool")
16
+
17
+ __all__ = ["ConnectionPool", "PoolClosed", "PoolTimeout"]
18
+
19
+
20
+ class PoolClosed(errors.OperationalError):
21
+ """The pool was closed."""
22
+
23
+
24
+ class PoolTimeout(errors.OperationalError):
25
+ """No connection became available in time."""
26
+
27
+
28
+ class _Idle:
29
+ __slots__ = ("conn", "returned")
30
+
31
+ def __init__(self, conn: Connection, returned: float) -> None:
32
+ self.conn = conn
33
+ self.returned = returned
34
+
35
+
36
+ class ConnectionPool:
37
+ """Pool of :class:`Connection`.
38
+
39
+ ``min_size`` connections are kept open by a background thread (which also retires
40
+ connections older than ``max_lifetime`` and surplus ones idle longer than ``max_idle``);
41
+ up to ``max_size`` are opened on demand. A connection is checked for liveness
42
+ (no round trip) every time it is handed out, a failed connection is replaced, and
43
+ a connection returned mid-transaction is rolled back.
44
+ """
45
+
46
+ def __init__(self, conninfo: str = "", *, kwargs: dict[str, Any] | None = None, min_size: int = 4,
47
+ max_size: int | None = None, open: bool = True, timeout: float = 30.0,
48
+ max_lifetime: float = 3600.0, max_idle: float = 600.0,
49
+ check: Callable[[Connection], None] | None = None, name: str | None = None,
50
+ connection_class: type[Connection] = Connection) -> None:
51
+ max_size = min_size if max_size is None else max_size
52
+ if min_size < 0 or max_size < 1 or max_size < min_size:
53
+ raise ValueError(f"bad pool sizes: min_size={min_size}, max_size={max_size}")
54
+ self.conninfo = conninfo
55
+ self.kwargs = dict(kwargs or {})
56
+ self.min_size, self.max_size = min_size, max_size
57
+ self.timeout, self.max_lifetime, self.max_idle = timeout, max_lifetime, max_idle
58
+ self.name = name or f"pool-{id(self):x}"
59
+ self._check = check
60
+ self._connection_class = connection_class
61
+ self._default_autocommit = bool(self.kwargs.get("autocommit", False))
62
+ self._cond = threading.Condition()
63
+ self._idle: collections.deque[_Idle] = collections.deque()
64
+ self._n = 0 # open + being opened
65
+ self._connecting = 0
66
+ self._waiting = 0
67
+ self._closed = True
68
+ self._thread: threading.Thread | None = None
69
+ self._wake = threading.Event()
70
+ if open:
71
+ self.open()
72
+
73
+ # ---------------------------------------------------------------- lifecycle
74
+ def open(self, wait: bool = False, timeout: float = 30.0) -> None:
75
+ with self._cond:
76
+ if not self._closed:
77
+ return
78
+ self._closed = False
79
+ self._wake.clear()
80
+ self._thread = threading.Thread(target=self._maintain, name=f"pgtern-{self.name}", daemon=True)
81
+ self._thread.start()
82
+ if wait:
83
+ self.wait(timeout)
84
+
85
+ def wait(self, timeout: float = 30.0) -> None:
86
+ """Block until ``min_size`` connections are open."""
87
+ deadline = time.monotonic() + timeout
88
+ with self._cond:
89
+ while self._n - self._connecting < self.min_size:
90
+ if self._closed:
91
+ raise PoolClosed("the pool is closed")
92
+ remaining = deadline - time.monotonic()
93
+ if remaining <= 0:
94
+ raise PoolTimeout(f"pool initialization incomplete after {timeout:.2f} sec")
95
+ self._cond.wait(min(remaining, 0.2))
96
+
97
+ def close(self, timeout: float = 5.0) -> None:
98
+ with self._cond:
99
+ if self._closed:
100
+ return
101
+ self._closed = True
102
+ idle = list(self._idle)
103
+ self._idle.clear()
104
+ self._n -= len(idle)
105
+ self._cond.notify_all()
106
+ self._wake.set()
107
+ for item in idle:
108
+ item.conn.close()
109
+ thread = self._thread
110
+ if thread is not None and thread is not threading.current_thread():
111
+ thread.join(timeout)
112
+
113
+ @property
114
+ def closed(self) -> bool:
115
+ return self._closed
116
+
117
+ def __enter__(self) -> "ConnectionPool":
118
+ self.open()
119
+ return self
120
+
121
+ def __exit__(self, *exc: Any) -> None:
122
+ self.close()
123
+
124
+ def get_stats(self) -> dict[str, int]:
125
+ with self._cond:
126
+ return {
127
+ "pool_min": self.min_size, "pool_max": self.max_size,
128
+ "pool_size": self._n - self._connecting, "pool_available": len(self._idle),
129
+ "requests_waiting": self._waiting,
130
+ }
131
+
132
+ # ---------------------------------------------------------------- internals
133
+ def _connect(self) -> Connection:
134
+ conn = self._connection_class.connect(self.conninfo, **self.kwargs)
135
+ conn._pool_created = time.monotonic() # type: ignore[attr-defined]
136
+ return conn
137
+
138
+ def _reap(self) -> list[Connection]:
139
+ """Pick idle connections to retire. Caller holds the lock and closes the result."""
140
+ now = time.monotonic()
141
+ keep: collections.deque[_Idle] = collections.deque()
142
+ retire: list[Connection] = []
143
+ for item in self._idle:
144
+ conn = item.conn
145
+ too_old = now - conn._pool_created > self.max_lifetime # type: ignore[attr-defined]
146
+ surplus_idle = now - item.returned > self.max_idle and self._n - len(retire) > self.min_size
147
+ if conn.closed or too_old or surplus_idle:
148
+ retire.append(conn)
149
+ else:
150
+ keep.append(item)
151
+ self._idle = keep
152
+ self._n -= len(retire)
153
+ return retire
154
+
155
+ def _maintain(self) -> None:
156
+ backoff = 0.5
157
+ while True:
158
+ create = False
159
+ with self._cond:
160
+ if self._closed:
161
+ return
162
+ retire = self._reap()
163
+ if self._n < self.min_size:
164
+ self._n += 1
165
+ self._connecting += 1
166
+ create = True
167
+ for conn in retire:
168
+ conn.close()
169
+ if create:
170
+ try:
171
+ conn = self._connect()
172
+ except Exception as exc:
173
+ with self._cond:
174
+ self._n -= 1
175
+ self._connecting -= 1
176
+ log.warning("pool %s: couldn't open a connection: %s", self.name, exc)
177
+ self._wake.wait(backoff)
178
+ self._wake.clear()
179
+ backoff = min(backoff * 2, 30.0)
180
+ continue
181
+ backoff = 0.5
182
+ with self._cond:
183
+ self._connecting -= 1
184
+ stored = not self._closed
185
+ if stored:
186
+ self._idle.append(_Idle(conn, time.monotonic()))
187
+ else:
188
+ self._n -= 1
189
+ self._cond.notify_all()
190
+ if not stored:
191
+ conn.close()
192
+ continue
193
+ self._wake.wait(5.0)
194
+ self._wake.clear()
195
+
196
+ def _usable(self, conn: Connection) -> bool:
197
+ if conn.closed or time.monotonic() - conn._pool_created > self.max_lifetime: # type: ignore[attr-defined]
198
+ return False
199
+ if not conn._check_alive():
200
+ return False
201
+ if self._check is not None:
202
+ try:
203
+ self._check(conn)
204
+ except Exception:
205
+ return False
206
+ return True
207
+
208
+ def _discard(self, conn: Connection) -> None:
209
+ with self._cond:
210
+ self._n -= 1
211
+ self._cond.notify_all()
212
+ self._wake.set()
213
+ conn.close()
214
+
215
+ # ---------------------------------------------------------------- public API
216
+ def getconn(self, timeout: float | None = None) -> Connection:
217
+ timeout = self.timeout if timeout is None else timeout
218
+ deadline = time.monotonic() + timeout
219
+ backoff = 0.1
220
+ while True:
221
+ candidate: _Idle | None = None
222
+ create = False
223
+ with self._cond:
224
+ if self._closed:
225
+ raise PoolClosed("the pool is closed")
226
+ if self._idle:
227
+ candidate = self._idle.pop()
228
+ elif self._n < self.max_size:
229
+ self._n += 1
230
+ self._connecting += 1
231
+ create = True
232
+ else:
233
+ remaining = deadline - time.monotonic()
234
+ if remaining <= 0:
235
+ raise PoolTimeout(f"couldn't get a connection after {timeout:.2f} sec")
236
+ self._waiting += 1
237
+ try:
238
+ self._cond.wait(remaining)
239
+ finally:
240
+ self._waiting -= 1
241
+ continue
242
+ if candidate is not None:
243
+ if self._usable(candidate.conn):
244
+ return candidate.conn
245
+ self._discard(candidate.conn)
246
+ continue
247
+ assert create
248
+ try:
249
+ conn = self._connect()
250
+ except Exception as exc:
251
+ with self._cond:
252
+ self._n -= 1
253
+ self._connecting -= 1
254
+ self._cond.notify_all()
255
+ remaining = deadline - time.monotonic()
256
+ if remaining <= 0:
257
+ raise PoolTimeout(f"couldn't get a connection after {timeout:.2f} sec: {exc}") from exc
258
+ time.sleep(min(backoff, remaining))
259
+ backoff = min(backoff * 2, 2.0)
260
+ continue
261
+ with self._cond:
262
+ self._connecting -= 1
263
+ closed = self._closed
264
+ if closed:
265
+ self._n -= 1
266
+ if closed:
267
+ conn.close()
268
+ raise PoolClosed("the pool is closed")
269
+ return conn
270
+
271
+ def putconn(self, conn: Connection) -> None:
272
+ ok = not conn.closed
273
+ if ok:
274
+ try:
275
+ conn._reset_for_pool()
276
+ if conn.autocommit != self._default_autocommit:
277
+ conn.autocommit = self._default_autocommit
278
+ except Exception:
279
+ ok = False
280
+ with self._cond:
281
+ fresh = time.monotonic() - conn._pool_created <= self.max_lifetime # type: ignore[attr-defined]
282
+ if ok and fresh and not self._closed:
283
+ self._idle.append(_Idle(conn, time.monotonic()))
284
+ self._cond.notify_all()
285
+ return
286
+ self._n -= 1
287
+ self._cond.notify_all()
288
+ self._wake.set()
289
+ conn.close()
290
+
291
+ @contextmanager
292
+ def connection(self, timeout: float | None = None) -> Iterator[Connection]:
293
+ """Borrow a connection: commit on success, roll back on error, return it to the pool."""
294
+ conn = self.getconn(timeout)
295
+ try:
296
+ try:
297
+ yield conn
298
+ except BaseException:
299
+ try:
300
+ if not conn.closed:
301
+ conn._reset_for_pool()
302
+ except Exception:
303
+ log.warning("rollback failed while returning a connection", exc_info=True)
304
+ raise
305
+ else:
306
+ if not conn.closed:
307
+ conn.commit()
308
+ finally:
309
+ self.putconn(conn)
pgtern/py.typed ADDED
File without changes
pgtern/rows.py ADDED
@@ -0,0 +1,31 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Row factories: callables taking a cursor and returning a ``make_row(values)`` function."""
3
+ from __future__ import annotations
4
+
5
+ from collections import namedtuple
6
+ from typing import Any, Callable
7
+
8
+ __all__ = ["tuple_row", "dict_row", "namedtuple_row"]
9
+
10
+
11
+ def _identity(values: tuple) -> tuple:
12
+ return values
13
+
14
+
15
+ def tuple_row(cursor: Any) -> Callable[[tuple], tuple]:
16
+ """Rows as plain tuples (the default)."""
17
+ return _identity
18
+
19
+
20
+ def dict_row(cursor: Any) -> Callable[[tuple], dict[str, Any]]:
21
+ """Rows as ``dict`` keyed by column name (a repeated name keeps the last value)."""
22
+ names = [c.name for c in (cursor.description or ())]
23
+ return lambda values: dict(zip(names, values))
24
+
25
+
26
+ def namedtuple_row(cursor: Any) -> Callable[[tuple], Any]:
27
+ """Rows as ``namedtuple`` instances (unusable names are renamed ``_0``, ``_1``, ...)."""
28
+ names = [c.name for c in (cursor.description or ())]
29
+ factory: Any = namedtuple # typed as Any: the field list is only known at run time
30
+ row_type = factory("Row", names, rename=True)
31
+ return row_type._make
pgtern/sql.py ADDED
@@ -0,0 +1,200 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Safe composition of SQL text: ``SQL``, ``Identifier``, ``Literal``, ``Placeholder``.
3
+
4
+ Use this for statements that cannot take bind parameters (``CREATE ROLE ... PASSWORD``,
5
+ ``GRANT ... TO <role>``). Everything else should use ``%s`` parameters.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import binascii
10
+ import string
11
+ import uuid
12
+ from datetime import date, datetime, time, timedelta
13
+ from decimal import Decimal
14
+ from typing import Any, Iterable, Iterator
15
+
16
+ from . import errors
17
+ from .types import Json, Jsonb
18
+
19
+ __all__ = ["Composable", "Composed", "SQL", "Identifier", "Literal", "Placeholder",
20
+ "quote_ident", "quote_literal"]
21
+
22
+
23
+ def quote_ident(name: str) -> str:
24
+ if "\x00" in name:
25
+ raise errors.DataError("PostgreSQL identifiers cannot contain NUL (0x00) bytes")
26
+ return '"' + name.replace('"', '""') + '"'
27
+
28
+
29
+ def quote_literal(text: str) -> str:
30
+ """Quote a string as a PostgreSQL literal, independent of ``standard_conforming_strings``.
31
+
32
+ Backslashes force the ``E'...'`` form with the backslash doubled, so the result
33
+ means the same thing whichever way the server setting is configured.
34
+ """
35
+ if "\x00" in text:
36
+ raise errors.DataError("PostgreSQL text fields cannot contain NUL (0x00) bytes")
37
+ body = text.replace("'", "''")
38
+ if "\\" in body:
39
+ return " E'" + body.replace("\\", "\\\\") + "'"
40
+ return "'" + body + "'"
41
+
42
+
43
+ class Composable:
44
+ __slots__ = ("_obj",)
45
+
46
+ def __init__(self, obj: Any) -> None:
47
+ self._obj = obj
48
+
49
+ def as_string(self, context: Any = None) -> str:
50
+ raise NotImplementedError
51
+
52
+ def as_redacted(self) -> str:
53
+ """The statement with every Literal hidden: safe to log or put on a trace span."""
54
+ return self.as_string()
55
+
56
+ def __repr__(self) -> str:
57
+ return f"{type(self).__name__}({self._obj!r})"
58
+
59
+ def __add__(self, other: Any) -> "Composed":
60
+ if isinstance(other, Composed):
61
+ return Composed([self, *other._obj])
62
+ if isinstance(other, Composable):
63
+ return Composed([self, other])
64
+ return NotImplemented
65
+
66
+ def __mul__(self, n: int) -> "Composed":
67
+ return Composed([self] * n)
68
+
69
+ def __eq__(self, other: object) -> bool:
70
+ return type(self) is type(other) and self._obj == other._obj # type: ignore[attr-defined]
71
+
72
+ def __hash__(self) -> int:
73
+ return hash((type(self), repr(self._obj)))
74
+
75
+
76
+ class Composed(Composable):
77
+ def __init__(self, seq: Iterable[Composable]) -> None:
78
+ items = list(seq)
79
+ for item in items:
80
+ if not isinstance(item, Composable):
81
+ raise TypeError(f"Composed elements must be Composable, got {item!r}")
82
+ super().__init__(items)
83
+
84
+ def __iter__(self) -> Iterator[Composable]:
85
+ return iter(self._obj)
86
+
87
+ def as_string(self, context: Any = None) -> str:
88
+ return "".join(item.as_string(context) for item in self._obj)
89
+
90
+ def as_redacted(self) -> str:
91
+ return "".join(item.as_redacted() for item in self._obj)
92
+
93
+ def join(self, joiner: "str | SQL") -> "Composed":
94
+ sep = SQL(joiner) if isinstance(joiner, str) else joiner
95
+ out: list[Composable] = []
96
+ for index, item in enumerate(self._obj):
97
+ if index:
98
+ out.append(sep)
99
+ out.append(item)
100
+ return Composed(out)
101
+
102
+
103
+ class SQL(Composable):
104
+ def __init__(self, text: str) -> None:
105
+ if not isinstance(text, str):
106
+ raise TypeError(f"SQL values must be strings, got {text!r}")
107
+ super().__init__(text)
108
+
109
+ def as_string(self, context: Any = None) -> str:
110
+ return self._obj
111
+
112
+ def format(self, *args: Composable, **kwargs: Composable) -> Composed:
113
+ parts: list[Composable] = []
114
+ auto = 0
115
+ for literal, field, spec, conversion in string.Formatter().parse(self._obj):
116
+ if spec or conversion:
117
+ raise ValueError("no format specification supported by SQL")
118
+ if literal:
119
+ parts.append(SQL(literal))
120
+ if field is None:
121
+ continue
122
+ if field == "":
123
+ value = args[auto]
124
+ auto += 1
125
+ elif field.isdigit():
126
+ value = args[int(field)]
127
+ else:
128
+ value = kwargs[field]
129
+ if not isinstance(value, Composable):
130
+ raise TypeError(f"SQL.format() arguments must be Composable, got {value!r}")
131
+ parts.append(value)
132
+ return Composed(parts)
133
+
134
+ def join(self, seq: Iterable[Composable]) -> Composed:
135
+ return Composed(list(seq)).join(self)
136
+
137
+
138
+ class Identifier(Composable):
139
+ def __init__(self, *strings: str) -> None:
140
+ if not strings:
141
+ raise TypeError("Identifier requires at least one string")
142
+ for s in strings:
143
+ if not isinstance(s, str):
144
+ raise TypeError(f"SQL identifier parts must be strings, got {s!r}")
145
+ super().__init__(strings)
146
+
147
+ def as_string(self, context: Any = None) -> str:
148
+ return ".".join(quote_ident(s) for s in self._obj)
149
+
150
+
151
+ class Literal(Composable):
152
+ def as_string(self, context: Any = None) -> str:
153
+ return _literal(self._obj)
154
+
155
+ def as_redacted(self) -> str:
156
+ return "?"
157
+
158
+
159
+ class Placeholder(Composable):
160
+ def __init__(self, name: str = "") -> None:
161
+ super().__init__(name)
162
+
163
+ def as_string(self, context: Any = None) -> str:
164
+ return f"%({self._obj})s" if self._obj else "%s"
165
+
166
+
167
+ def _literal(value: Any) -> str:
168
+ if value is None:
169
+ return "NULL"
170
+ if value is True or value is False:
171
+ return "true" if value else "false"
172
+ if isinstance(value, int):
173
+ return f" {value}" if value < 0 else str(value)
174
+ if isinstance(value, float):
175
+ if value != value or value in (float("inf"), float("-inf")):
176
+ return quote_literal(str(value).replace("inf", "Infinity").replace("nan", "NaN")) + "::float8"
177
+ return f" {value!r}" if value < 0 else repr(value)
178
+ if isinstance(value, Decimal):
179
+ if not value.is_finite():
180
+ return quote_literal(str(value)) + "::numeric"
181
+ return f" {value}" if value < 0 else str(value)
182
+ if isinstance(value, str):
183
+ return quote_literal(value)
184
+ if isinstance(value, (bytes, bytearray, memoryview)):
185
+ return "'\\x" + binascii.hexlify(bytes(value)).decode() + "'::bytea"
186
+ if isinstance(value, datetime):
187
+ return quote_literal(value.isoformat(" ")) + ("::timestamptz" if value.tzinfo else "::timestamp")
188
+ if isinstance(value, date):
189
+ return quote_literal(value.isoformat()) + "::date"
190
+ if isinstance(value, time):
191
+ return quote_literal(value.isoformat()) + ("::timetz" if value.tzinfo else "::time")
192
+ if isinstance(value, timedelta):
193
+ return quote_literal(f"{value.days} days {value.seconds} seconds {value.microseconds} microseconds") + "::interval"
194
+ if isinstance(value, uuid.UUID):
195
+ return quote_literal(str(value)) + "::uuid"
196
+ if isinstance(value, Json):
197
+ return quote_literal(value.encode()) + ("::jsonb" if isinstance(value, Jsonb) else "::json")
198
+ if isinstance(value, list):
199
+ return "ARRAY[" + ",".join(_literal(v) for v in value) + "]" if value else "'{}'"
200
+ raise errors.ProgrammingError(f"cannot make a SQL literal from {type(value).__name__!r}")
@@ -0,0 +1,30 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Wrapper types that tell pgtern how to send a Python value to PostgreSQL."""
3
+ from __future__ import annotations
4
+
5
+ import json as _json
6
+ from typing import Any, Callable
7
+
8
+ __all__ = ["Json", "Jsonb"]
9
+
10
+
11
+ class Json:
12
+ """Send a Python object as a PostgreSQL ``json`` value."""
13
+
14
+ __slots__ = ("obj", "dumps")
15
+
16
+ def __init__(self, obj: Any, dumps: Callable[[Any], str] | None = None) -> None:
17
+ self.obj = obj
18
+ self.dumps = dumps or _json.dumps
19
+
20
+ def encode(self) -> str:
21
+ return self.dumps(self.obj)
22
+
23
+ def __repr__(self) -> str:
24
+ return f"{type(self).__name__}({self.obj!r})"
25
+
26
+
27
+ class Jsonb(Json):
28
+ """Send a Python object as a PostgreSQL ``jsonb`` value."""
29
+
30
+ __slots__ = ()
pgtern/types/json.py ADDED
@@ -0,0 +1,5 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """``pgtern.types.json``: JSON wrappers for query parameters."""
3
+ from . import Json, Jsonb
4
+
5
+ __all__ = ["Json", "Jsonb"]