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/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """pgtern: a zero-dependency, pure-Python PostgreSQL client with a familiar DB-API style interface.
3
+
4
+ import pgtern
5
+ from pgtern.rows import dict_row
6
+
7
+ with pgtern.connect("postgresql://user:pw@host/db", row_factory=dict_row) as conn:
8
+ for row in conn.execute("SELECT id, name FROM users WHERE id = %s", (42,)):
9
+ ...
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+ from . import adapters, aio, errors, hooks, rows, sql, types
16
+ from ._conn import Connection, ConnectionInfo, Transaction, TransactionStatus
17
+ from ._cursor import Cursor, ServerCursor
18
+ from ._protocol import Notify
19
+ from .errors import (
20
+ DatabaseError, DataError, Error, IntegrityError, InterfaceError, InternalError,
21
+ NotSupportedError, OperationalError, ProgrammingError, Warning,
22
+ )
23
+ from .hooks import QueryInfo, add_query_hook
24
+ from .pool import ConnectionPool, PoolClosed, PoolTimeout
25
+
26
+ __version__ = "0.3.0"
27
+
28
+ # DB-API 2.0 module globals
29
+ apilevel = "2.0"
30
+ threadsafety = 2 # connections can be shared between threads
31
+ paramstyle = "pyformat"
32
+
33
+
34
+ def connect(conninfo: str = "", **kwargs: Any) -> Connection:
35
+ """Open a connection. ``conninfo`` is a ``postgresql://`` URL or ``key=value`` string;
36
+ keyword arguments (``autocommit``, ``row_factory``, ``connect_timeout``, ``sslmode``, ...) override it."""
37
+ return Connection.connect(conninfo, **kwargs)
38
+
39
+
40
+ __all__ = [
41
+ "connect", "Connection", "ConnectionInfo", "ConnectionPool", "Cursor", "ServerCursor", "Notify", "Transaction",
42
+ "TransactionStatus", "PoolClosed", "PoolTimeout", "QueryInfo", "add_query_hook",
43
+ "Warning", "Error", "InterfaceError", "DatabaseError", "DataError", "OperationalError",
44
+ "IntegrityError", "InternalError", "ProgrammingError", "NotSupportedError",
45
+ "adapters", "aio", "errors", "hooks", "rows", "sql", "types", "apilevel", "threadsafety", "paramstyle", "__version__",
46
+ ]
pgtern/_binary.py ADDED
@@ -0,0 +1,226 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Binary wire format for the common types (opt-in with ``binary=True``).
3
+
4
+ Decoders must agree with the text decoders in ``_types`` for every value both support:
5
+ tests check this against the server's own text output. Types not listed here keep using text.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import datetime as _dt
10
+ import json
11
+ import struct
12
+ import uuid
13
+ from decimal import Decimal
14
+ from typing import Any, Callable
15
+
16
+ from . import errors
17
+ from ._types import (
18
+ BOOL, BPCHAR, BYTEA, CHAR, DATE, ELEMENT_OF, FLOAT4, FLOAT8, INT2, INT4, INT8, INTERVAL, JSON,
19
+ JSONB, NAME, NUMERIC, OID, TEXT, TIME, TIMESTAMP, TIMESTAMPTZ, UUID, VARCHAR, ARRAY_OF,
20
+ )
21
+
22
+ _EPOCH = _dt.datetime(2000, 1, 1)
23
+ _EPOCH_DATE = _dt.date(2000, 1, 1)
24
+ _EPOCH_UTC = _dt.datetime(2000, 1, 1, tzinfo=_dt.timezone.utc)
25
+ _I64_MAX = 2**63 - 1
26
+ _I64_MIN = -(2**63)
27
+
28
+ _s_i16 = struct.Struct("!h")
29
+ _s_u16 = struct.Struct("!H")
30
+ _s_i32 = struct.Struct("!i")
31
+ _s_u32 = struct.Struct("!I")
32
+ _s_i64 = struct.Struct("!q")
33
+ _s_f32 = struct.Struct("!f")
34
+ _s_f64 = struct.Struct("!d")
35
+
36
+
37
+ # ---------------------------------------------------------------- decoders (bytes -> Python)
38
+ def _dec_numeric(b: bytes) -> Decimal:
39
+ ndigits, weight, sign, dscale = struct.unpack_from("!hhHh", b, 0)
40
+ if sign == 0xC000:
41
+ return Decimal("NaN")
42
+ if sign in (0xD000, 0xF000):
43
+ return Decimal("Infinity") if sign == 0xD000 else Decimal("-Infinity")
44
+ digits = [_s_i16.unpack_from(b, 8 + 2 * i)[0] for i in range(ndigits)]
45
+ n = 0
46
+ lowest = weight - (ndigits - 1) if ndigits else 0
47
+ for i, d in enumerate(digits):
48
+ n += d * (10000 ** (weight - i - lowest)) # base-10000 groups; digit i is worth 10000**(weight-i)
49
+ # value = n * 10**(4*lowest); exact decimal with exactly dscale fraction digits (no context limits)
50
+ shift = 4 * lowest + dscale
51
+ coefficient = n * 10 ** shift if shift >= 0 else n // 10 ** (-shift)
52
+ text = str(coefficient)
53
+ return Decimal((1 if sign == 0x4000 else 0, tuple(int(c) for c in text), -dscale))
54
+
55
+
56
+ def _dec_interval(b: bytes) -> _dt.timedelta:
57
+ usec, days, months = struct.unpack("!qii", b)
58
+ years = int(months / 12) # truncate toward zero, as PostgreSQL prints years and months
59
+ rest = months - years * 12
60
+ return _dt.timedelta(days=years * 365 + rest * 30 + days, microseconds=usec)
61
+
62
+
63
+ def _dec_timestamp_bin(b: bytes) -> _dt.datetime:
64
+ (usec,) = _s_i64.unpack(b)
65
+ _check_finite(usec)
66
+ return _EPOCH + _dt.timedelta(microseconds=usec)
67
+
68
+
69
+ def _dec_timestamptz_bin(b: bytes) -> _dt.datetime:
70
+ (usec,) = _s_i64.unpack(b)
71
+ _check_finite(usec)
72
+ return (_EPOCH_UTC + _dt.timedelta(microseconds=usec)).astimezone(_dt.timezone.utc)
73
+
74
+
75
+ def _check_finite(usec: int) -> None:
76
+ if usec in (_I64_MAX, _I64_MIN):
77
+ raise errors.DataError("can't decode infinite timestamp in binary format")
78
+
79
+
80
+ def _dec_date_bin(b: bytes) -> _dt.date:
81
+ (days,) = _s_i32.unpack(b)
82
+ if days in (2147483647, -2147483648):
83
+ raise errors.DataError("can't decode infinite date in binary format")
84
+ return _EPOCH_DATE + _dt.timedelta(days=days)
85
+
86
+
87
+ def _dec_time_bin(b: bytes) -> _dt.time:
88
+ (usec,) = _s_i64.unpack(b)
89
+ return (_dt.datetime(2000, 1, 1) + _dt.timedelta(microseconds=usec)).time()
90
+
91
+
92
+ def _dec_text_bin(b: bytes) -> str:
93
+ return b.decode("utf-8")
94
+
95
+
96
+ def _dec_jsonb_bin(b: bytes) -> Any:
97
+ if not b or b[0] != 1:
98
+ raise errors.NotSupportedError("unsupported jsonb binary version")
99
+ return json.loads(b[1:].decode("utf-8"))
100
+
101
+
102
+ def _dec_float4(b: bytes) -> float:
103
+ return _s_f32.unpack(b)[0]
104
+
105
+
106
+ def _dec_float8(b: bytes) -> float:
107
+ return _s_f64.unpack(b)[0]
108
+
109
+
110
+ _SCALAR_BIN: dict[int, Callable[[bytes], Any]] = {
111
+ BOOL: lambda b: b != b"\x00",
112
+ BYTEA: bytes,
113
+ INT2: lambda b: _s_i16.unpack(b)[0],
114
+ INT4: lambda b: _s_i32.unpack(b)[0],
115
+ INT8: lambda b: _s_i64.unpack(b)[0],
116
+ OID: lambda b: _s_u32.unpack(b)[0],
117
+ FLOAT4: _dec_float4,
118
+ FLOAT8: _dec_float8,
119
+ NUMERIC: _dec_numeric,
120
+ UUID: lambda b: uuid.UUID(bytes=b),
121
+ JSON: lambda b: json.loads(b.decode("utf-8")),
122
+ JSONB: _dec_jsonb_bin,
123
+ DATE: _dec_date_bin,
124
+ TIME: _dec_time_bin,
125
+ TIMESTAMP: _dec_timestamp_bin,
126
+ TIMESTAMPTZ: _dec_timestamptz_bin,
127
+ INTERVAL: _dec_interval,
128
+ TEXT: _dec_text_bin,
129
+ VARCHAR: _dec_text_bin,
130
+ BPCHAR: _dec_text_bin,
131
+ NAME: _dec_text_bin,
132
+ CHAR: _dec_text_bin,
133
+ }
134
+
135
+
136
+ def decode_array(b: bytes, element: Callable[[bytes], Any]) -> list:
137
+ ndim, _flags, _elem = struct.unpack_from("!iii", b, 0)
138
+ pos = 12
139
+ dims = []
140
+ for _ in range(ndim):
141
+ length, _lb = struct.unpack_from("!ii", b, pos)
142
+ dims.append(length)
143
+ pos += 8
144
+ if ndim == 0:
145
+ return []
146
+ items: list = []
147
+ total = 1
148
+ for d in dims:
149
+ total *= d
150
+ for _ in range(total):
151
+ (length,) = _s_i32.unpack_from(b, pos)
152
+ pos += 4
153
+ if length < 0:
154
+ items.append(None)
155
+ else:
156
+ items.append(element(b[pos:pos + length]))
157
+ pos += length
158
+ # fold the flat list into nested lists, outermost dimension first
159
+ for d in reversed(dims[1:]):
160
+ items = [items[i:i + d] for i in range(0, len(items), d)]
161
+ return items
162
+
163
+
164
+ def scalar_decoder(oid: int) -> Callable[[bytes], Any] | None:
165
+ """Binary decoder for a type (arrays included), or ``None`` if the type has no binary form here."""
166
+ if oid in _SCALAR_BIN:
167
+ return _SCALAR_BIN[oid]
168
+ elem = ELEMENT_OF.get(oid)
169
+ if elem is not None and elem in _SCALAR_BIN:
170
+ inner = _SCALAR_BIN[elem]
171
+ return lambda b: decode_array(b, inner)
172
+ return None
173
+
174
+
175
+ # ---------------------------------------------------------------- encoders (Python -> bytes)
176
+ def encode_scalar(value: Any, oid: int) -> bytes | None:
177
+ """Binary form of ``value`` for a parameter the server typed as ``oid``; ``None`` means
178
+ "send it as text instead" (always safe: the text form is what the server parses anyway)."""
179
+ if oid == BOOL and isinstance(value, bool):
180
+ return b"\x01" if value else b"\x00"
181
+ if oid in (INT2, INT4, INT8, OID) and isinstance(value, int) and not isinstance(value, bool):
182
+ fmt, lo, hi = {INT2: ("!h", -2**15, 2**15 - 1), INT4: ("!i", -2**31, 2**31 - 1),
183
+ INT8: ("!q", -2**63, 2**63 - 1), OID: ("!I", 0, 2**32 - 1)}[oid]
184
+ return struct.pack(fmt, value) if lo <= value <= hi else None
185
+ if oid in (FLOAT4, FLOAT8) and isinstance(value, (int, float)) and not isinstance(value, bool):
186
+ return _s_f32.pack(value) if oid == FLOAT4 else _s_f64.pack(float(value))
187
+ if oid in (TEXT, VARCHAR, BPCHAR, NAME) and isinstance(value, str) and "\x00" not in value:
188
+ return value.encode("utf-8")
189
+ if oid == BYTEA and isinstance(value, (bytes, bytearray, memoryview)):
190
+ return bytes(value)
191
+ if oid == UUID and isinstance(value, uuid.UUID):
192
+ return value.bytes
193
+ if oid == DATE and isinstance(value, _dt.date) and not isinstance(value, _dt.datetime):
194
+ return _s_i32.pack((value - _EPOCH_DATE).days)
195
+ if oid == TIMESTAMP and isinstance(value, _dt.datetime) and value.tzinfo is None:
196
+ return _s_i64.pack(_usec(value - _EPOCH))
197
+ if oid == TIMESTAMPTZ and isinstance(value, _dt.datetime) and value.tzinfo is not None:
198
+ return _s_i64.pack(_usec(value.astimezone(_dt.timezone.utc) - _EPOCH_UTC))
199
+ if oid == TIME and isinstance(value, _dt.time) and value.tzinfo is None:
200
+ t = _dt.timedelta(hours=value.hour, minutes=value.minute, seconds=value.second, microseconds=value.microsecond)
201
+ return _s_i64.pack(_usec(t))
202
+ if oid == INTERVAL and isinstance(value, _dt.timedelta):
203
+ return struct.pack("!qii", _usec(value), 0, 0)
204
+ if oid in ARRAY_OF.values() and isinstance(value, list): # a one-dimensional array of scalars
205
+ elem = ELEMENT_OF.get(oid)
206
+ return _encode_array(value, elem) if elem is not None else None
207
+ return None
208
+
209
+
210
+ def _usec(delta: _dt.timedelta) -> int:
211
+ return (delta.days * 86400 + delta.seconds) * 1_000_000 + delta.microseconds
212
+
213
+
214
+ def _encode_array(items: list, elem: int) -> bytes | None:
215
+ if any(isinstance(v, list) for v in items):
216
+ return None # multi-dimensional: text
217
+ parts = [struct.pack("!iii", 1, 0, elem), struct.pack("!ii", len(items), 1)]
218
+ for v in items:
219
+ if v is None:
220
+ parts.append(_s_i32.pack(-1))
221
+ continue
222
+ data = encode_scalar(v, elem)
223
+ if data is None:
224
+ return None
225
+ parts.append(_s_i32.pack(len(data)) + data)
226
+ return b"".join(parts)
pgtern/_composite.py ADDED
@@ -0,0 +1,103 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Text-format parsing and formatting for PostgreSQL records (composite types) and ranges."""
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Callable, Sequence
7
+
8
+ from . import errors
9
+
10
+
11
+ def parse_record(text: str) -> list[str | None]:
12
+ """``(a,"b c",,"d""e")`` -> ``["a", "b c", None, 'd"e']``. An unquoted empty field is NULL."""
13
+ if len(text) < 2 or text[0] != "(" or text[-1] != ")":
14
+ raise errors.DataError(f"malformed record literal: {text!r}")
15
+ s = text[1:-1]
16
+ n = len(s)
17
+ if n == 0:
18
+ return [None]
19
+ out: list[str | None] = []
20
+ i = 0
21
+ while True:
22
+ buf: list[str] = []
23
+ quoted = False
24
+ if i < n and s[i] == '"':
25
+ quoted = True
26
+ i += 1
27
+ while i < n:
28
+ c = s[i]
29
+ if c == "\\":
30
+ if i + 1 >= n:
31
+ raise errors.DataError(f"malformed record literal: {text!r}")
32
+ buf.append(s[i + 1])
33
+ i += 2
34
+ elif c == '"':
35
+ if i + 1 < n and s[i + 1] == '"':
36
+ buf.append('"')
37
+ i += 2
38
+ else:
39
+ i += 1
40
+ break
41
+ else:
42
+ buf.append(c)
43
+ i += 1
44
+ else:
45
+ while i < n and s[i] != ",":
46
+ if s[i] == "\\" and i + 1 < n:
47
+ buf.append(s[i + 1])
48
+ i += 2
49
+ else:
50
+ buf.append(s[i])
51
+ i += 1
52
+ out.append("".join(buf) if (quoted or buf) else None)
53
+ if i >= n:
54
+ break
55
+ if s[i] != ",":
56
+ raise errors.DataError(f"malformed record literal: {text!r}")
57
+ i += 1
58
+ if i == n: # trailing comma: one more, empty, field
59
+ out.append(None)
60
+ break
61
+ return out
62
+
63
+
64
+ def _quote_field(value: str) -> str:
65
+ if value == "" or any(c in value for c in ',()"\\') or value.isspace() or value != value.strip():
66
+ return '"' + value.replace("\\", "\\\\").replace('"', '""') + '"'
67
+ return value
68
+
69
+
70
+ def format_record(values: Sequence[str | None]) -> str:
71
+ """Inverse of :func:`parse_record`: values are already text (``None`` is NULL)."""
72
+ if len(values) == 0:
73
+ return "()"
74
+ return "(" + ",".join("" if v is None else _quote_field(v) for v in values) + ")"
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class Range:
79
+ """A PostgreSQL range. ``lower``/``upper`` are ``None`` when unbounded (or when ``empty``)."""
80
+
81
+ lower: Any = None
82
+ upper: Any = None
83
+ lower_inc: bool = True
84
+ upper_inc: bool = False
85
+ empty: bool = False
86
+
87
+
88
+ def parse_range(text: str, element: Callable[[str], Any] | None = None) -> Range:
89
+ if text == "empty":
90
+ return Range(empty=True, lower_inc=False, upper_inc=False)
91
+ if len(text) < 2 or text[0] not in "[(" or text[-1] not in "])":
92
+ raise errors.DataError(f"malformed range literal: {text!r}")
93
+ lo, hi = parse_record("(" + text[1:-1] + ")")[:2] if text[1:-1] else (None, None)
94
+ conv = (lambda v: v if v is None or element is None else element(v))
95
+ return Range(conv(lo), conv(hi), text[0] == "[", text[-1] == "]")
96
+
97
+
98
+ def format_range(r: Range, to_text: Callable[[Any], str] = str) -> str:
99
+ if r.empty:
100
+ return "empty"
101
+ lo = "" if r.lower is None else _quote_field(to_text(r.lower))
102
+ hi = "" if r.upper is None else _quote_field(to_text(r.upper))
103
+ return ("[" if r.lower_inc else "(") + lo + "," + hi + ("]" if r.upper_inc else ")")