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/_protocol.py ADDED
@@ -0,0 +1,258 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """PostgreSQL frontend/backend protocol 3.0, sans-I/O.
3
+
4
+ Functions here build or parse bytes and never touch a socket, so the same code
5
+ can later be driven by asyncio, Trio or a blocking socket.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import struct
10
+ from typing import NamedTuple, Sequence
11
+
12
+ from . import errors
13
+
14
+ _I32 = struct.Struct("!I")
15
+ _S32 = struct.Struct("!i")
16
+ _U16 = struct.Struct("!H")
17
+ _S16 = struct.Struct("!h")
18
+ _MAX_MESSAGE = 1 << 30
19
+
20
+ SSL_REQUEST = struct.pack("!II", 8, 80877103)
21
+
22
+
23
+ def _msg(tag: bytes, payload: bytes) -> bytes:
24
+ return tag + _I32.pack(len(payload) + 4) + payload
25
+
26
+
27
+ def startup_message(params: dict[str, str]) -> bytes:
28
+ body = _I32.pack(196608) + b"".join(
29
+ k.encode() + b"\0" + v.encode() + b"\0" for k, v in params.items()
30
+ ) + b"\0"
31
+ return _I32.pack(len(body) + 4) + body
32
+
33
+
34
+ def query_message(sql: str) -> bytes:
35
+ return _msg(b"Q", _checked(sql).encode() + b"\0")
36
+
37
+
38
+ def _checked(sql: str) -> str:
39
+ if "\x00" in sql:
40
+ raise errors.ProgrammingError("a query cannot contain NUL (0x00) characters")
41
+ return sql
42
+
43
+
44
+ def extended_query(sql: str, oids: Sequence[int], values: Sequence[bytes | None],
45
+ pformats: Sequence[int] | None = None, rformats: Sequence[int] | None = None,
46
+ sync: bool = True) -> bytes:
47
+ """Parse + Bind + Describe + Execute + Sync on the unnamed statement and portal.
48
+
49
+ Parameters travel in text format unless ``pformats`` says otherwise (one code per
50
+ parameter, 0 text or 1 binary); results likewise with ``rformats`` (one per column).
51
+ """
52
+ if len(values) > 65535:
53
+ raise errors.ProgrammingError("PostgreSQL allows at most 65535 query parameters")
54
+ parse = b"\0" + _checked(sql).encode() + b"\0" + _U16.pack(len(oids)) + struct.pack(f"!{len(oids)}I", *oids)
55
+ pf = b"" if pformats is None else _U16.pack(len(pformats)) + b"".join(_U16.pack(f) for f in pformats)
56
+ parts = [b"\0\0", pf or _U16.pack(0), _U16.pack(len(values))]
57
+ for v in values:
58
+ parts.append(_S32.pack(-1) if v is None else _S32.pack(len(v)) + v)
59
+ rf = b"" if rformats is None else _U16.pack(len(rformats)) + b"".join(_U16.pack(f) for f in rformats)
60
+ parts.append(rf or _U16.pack(0))
61
+ return (
62
+ _msg(b"P", parse)
63
+ + _msg(b"B", b"".join(parts))
64
+ + _msg(b"D", b"P\0")
65
+ + _msg(b"E", b"\0" + _S32.pack(0))
66
+ + (_msg(b"S", b"") if sync else b"")
67
+ )
68
+
69
+
70
+ SYNC = _msg(b"S", b"")
71
+ # The server buffers its replies until a Sync or a Flush: pipelines use Flush to read mid-batch.
72
+ FLUSH = _msg(b"H", b"")
73
+
74
+
75
+ def build_query(sql: str, params, translate, dump) -> bytes:
76
+ """Frontend bytes for a query: simple protocol without parameters, extended with them."""
77
+ if params is None:
78
+ return query_message(sql)
79
+ pg_sql, values = translate(sql, params)
80
+ dumped = [dump(v) for v in values]
81
+ return extended_query(pg_sql, [oid for oid, _ in dumped], [data for _, data in dumped])
82
+
83
+
84
+ def describe_statement(sql: str) -> bytes:
85
+ """Parse the unnamed statement (types inferred by the server) and describe it. Runs nothing."""
86
+ parse = b"\0" + _checked(sql).encode() + b"\0" + _U16.pack(0)
87
+ return _msg(b"P", parse) + _msg(b"D", b"S\0") + _msg(b"S", b"")
88
+
89
+
90
+ def parse_param_description(payload: bytes) -> list[int]:
91
+ (count,) = _U16.unpack_from(payload, 0)
92
+ return list(struct.unpack_from(f"!{count}I", payload, 2))
93
+
94
+
95
+ def password_message(password: bytes) -> bytes:
96
+ return _msg(b"p", password + b"\0")
97
+
98
+
99
+ def sasl_initial_response(mechanism: str, data: bytes) -> bytes:
100
+ return _msg(b"p", mechanism.encode() + b"\0" + _S32.pack(len(data)) + data)
101
+
102
+
103
+ def sasl_response(data: bytes) -> bytes:
104
+ return _msg(b"p", data)
105
+
106
+
107
+ def copy_fail(reason: str) -> bytes:
108
+ return _msg(b"f", reason.encode() + b"\0")
109
+
110
+
111
+ def copy_data(data: bytes) -> bytes:
112
+ return _msg(b"d", data)
113
+
114
+
115
+ COPY_DONE = _msg(b"c", b"")
116
+ TERMINATE = _msg(b"X", b"")
117
+
118
+
119
+ class Notify(NamedTuple):
120
+ """An asynchronous notification from LISTEN/NOTIFY."""
121
+
122
+ channel: str
123
+ payload: str
124
+ pid: int
125
+
126
+
127
+ def parse_notification(payload: bytes) -> Notify:
128
+ pid = _I32.unpack_from(payload, 0)[0]
129
+ channel, body, _ = payload[4:].split(b"\0", 2)
130
+ return Notify(channel.decode("utf-8", "replace"), body.decode("utf-8", "replace"), pid)
131
+
132
+
133
+ class MessageParser:
134
+ """Reassembles backend messages from arbitrary chunks of bytes."""
135
+
136
+ def __init__(self) -> None:
137
+ self._buf = bytearray()
138
+
139
+ def feed(self, data: bytes) -> list[tuple[int, bytes]]:
140
+ buf = self._buf
141
+ buf += data
142
+ out: list[tuple[int, bytes]] = []
143
+ pos, n = 0, len(buf)
144
+ while n - pos >= 5:
145
+ (length,) = _I32.unpack_from(buf, pos + 1)
146
+ if length < 4 or length > _MAX_MESSAGE:
147
+ raise errors.InterfaceError(f"invalid message length {length} from the server")
148
+ end = pos + 1 + length
149
+ if end > n:
150
+ break
151
+ out.append((buf[pos], bytes(buf[pos + 5:end])))
152
+ pos = end
153
+ if pos:
154
+ del buf[:pos]
155
+ return out
156
+
157
+
158
+ def parse_error_fields(payload: bytes) -> dict[str, str]:
159
+ """ErrorResponse / NoticeResponse: ``code byte`` + ``cstring`` pairs, ended by a zero byte."""
160
+ fields: dict[str, str] = {}
161
+ i, n = 0, len(payload)
162
+ while i < n and payload[i] != 0:
163
+ code = chr(payload[i])
164
+ end = payload.index(b"\0", i + 1)
165
+ fields[code] = payload[i + 1:end].decode("utf-8", "replace")
166
+ i = end + 1
167
+ return fields
168
+
169
+
170
+ def parse_parameter_status(payload: bytes) -> tuple[str, str]:
171
+ name, value, _ = payload.split(b"\0", 2)
172
+ return name.decode(), value.decode("utf-8", "replace")
173
+
174
+
175
+ class Column(NamedTuple):
176
+ """A DB-API 7-item description entry (only the first four are filled in)."""
177
+
178
+ name: str
179
+ type_code: int
180
+ display_size: int | None = None
181
+ internal_size: int | None = None
182
+ precision: int | None = None
183
+ scale: int | None = None
184
+ null_ok: bool | None = None
185
+
186
+
187
+ def parse_row_description(payload: bytes) -> list[Column]:
188
+ (count,) = _U16.unpack_from(payload, 0)
189
+ pos = 2
190
+ cols: list[Column] = []
191
+ for _ in range(count):
192
+ end = payload.index(b"\0", pos)
193
+ name = payload[pos:end].decode("utf-8", "replace")
194
+ _table, _attr, type_oid, size, _mod, _fmt = struct.unpack_from("!IhIhih", payload, end + 1)
195
+ cols.append(Column(name, type_oid, None, size if size >= 0 else None))
196
+ pos = end + 1 + 18
197
+ return cols
198
+
199
+
200
+ def parse_data_row(payload: bytes) -> list[bytes | None]:
201
+ (count,) = _U16.unpack_from(payload, 0)
202
+ pos = 2
203
+ row: list[bytes | None] = []
204
+ for _ in range(count):
205
+ (length,) = _S32.unpack_from(payload, pos)
206
+ pos += 4
207
+ if length < 0:
208
+ row.append(None)
209
+ else:
210
+ row.append(payload[pos:pos + length])
211
+ pos += length
212
+ return row
213
+
214
+
215
+ def _rowcount(tag: str) -> int:
216
+ words = tag.split()
217
+ if words and words[0] in {"INSERT", "SELECT", "UPDATE", "DELETE", "MERGE", "FETCH", "MOVE", "COPY"}:
218
+ try:
219
+ return int(words[-1])
220
+ except ValueError:
221
+ return -1
222
+ return -1
223
+
224
+
225
+ class Result:
226
+ __slots__ = ("columns", "rows", "tag", "rowcount", "formats")
227
+
228
+ def __init__(self, columns: list[Column] | None = None) -> None:
229
+ self.columns = columns
230
+ self.rows: list[list[bytes | None]] = []
231
+ self.tag = ""
232
+ self.rowcount = -1
233
+ self.formats: list[int] | None = None # per column: 1 when the rows are binary
234
+
235
+
236
+ class QueryCollector:
237
+ """Builds the list of results of one query cycle from backend messages."""
238
+
239
+ def __init__(self) -> None:
240
+ self.results: list[Result] = []
241
+ self._current: Result | None = None
242
+
243
+ def handle(self, tag: int, payload: bytes) -> None:
244
+ if tag == 0x54: # T RowDescription
245
+ self._current = Result(parse_row_description(payload))
246
+ elif tag == 0x44: # D DataRow
247
+ if self._current is None:
248
+ self._current = Result()
249
+ self._current.rows.append(parse_data_row(payload))
250
+ elif tag == 0x43: # C CommandComplete
251
+ res = self._current or Result()
252
+ res.tag = payload.rstrip(b"\0").decode("utf-8", "replace")
253
+ res.rowcount = _rowcount(res.tag)
254
+ self.results.append(res)
255
+ self._current = None
256
+ elif tag == 0x49: # I EmptyQueryResponse
257
+ self.results.append(Result())
258
+ # 1 ParseComplete, 2 BindComplete, 3 CloseComplete, n NoData, t ParameterDescription: nothing to keep
pgtern/_query.py ADDED
@@ -0,0 +1,69 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Turn ``%s`` / ``%(name)s`` placeholders into PostgreSQL ``$n`` parameters."""
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Mapping, Sequence
7
+ from typing import Any
8
+
9
+ from . import errors
10
+
11
+ _PLACEHOLDER = re.compile(r"%(?:\(([^)]*)\))?(.?)", re.S)
12
+
13
+
14
+ def translate(sql: str, params: Any, render: Any = None) -> tuple[str, list[Any]]:
15
+ """Return ``(sql_with_$n, values)``.
16
+
17
+ ``%s``, ``%b`` and ``%t`` are placeholders and ``%%`` is a literal percent sign.
18
+ The query is scanned as plain text (string literals are not
19
+ parsed), so a literal ``%`` inside a quoted string must be written ``%%``.
20
+ With ``render`` (a function value -> SQL text) values are written into the SQL
21
+ (client-side binding) instead of becoming ``$n``; used where PostgreSQL has no
22
+ parameters, such as ``COPY``.
23
+ """
24
+ if isinstance(params, Mapping):
25
+ named = True
26
+ elif isinstance(params, Sequence) and not isinstance(params, (str, bytes, bytearray)):
27
+ named = False
28
+ else:
29
+ raise errors.ProgrammingError(
30
+ f"query parameters should be a sequence or a mapping, got {type(params).__name__}")
31
+
32
+ out: list[str] = []
33
+ values: list[Any] = []
34
+ index_by_name: dict[str, int] = {}
35
+ positional = 0
36
+ last = 0
37
+ for m in _PLACEHOLDER.finditer(sql):
38
+ name, fmt = m.group(1), m.group(2)
39
+ out.append(sql[last:m.start()])
40
+ last = m.end()
41
+ if fmt == "%" and name is None:
42
+ out.append("%")
43
+ continue
44
+ if fmt == "":
45
+ raise errors.ProgrammingError("incomplete placeholder: '%' at the end of the query")
46
+ if fmt not in ("s", "b", "t"):
47
+ raise errors.ProgrammingError(
48
+ f"only '%s', '%b', '%t' are allowed as placeholders, got '{m.group(0)}'")
49
+ if name is not None:
50
+ if not named:
51
+ raise errors.ProgrammingError("named placeholders require a mapping of parameters")
52
+ if name not in index_by_name:
53
+ if name not in params:
54
+ raise errors.ProgrammingError(f"query parameter missing: {name}")
55
+ values.append(params[name])
56
+ index_by_name[name] = len(values)
57
+ out.append(f"${index_by_name[name]}" if render is None else render(params[name]))
58
+ else:
59
+ if named:
60
+ raise errors.ProgrammingError("positional placeholders require a sequence of parameters")
61
+ positional += 1
62
+ out.append(f"${positional}" if render is None else render(params[positional - 1]))
63
+ out.append(sql[last:])
64
+ if not named:
65
+ if positional != len(params):
66
+ raise errors.ProgrammingError(
67
+ f"the query has {positional} placeholders but {len(params)} parameters were passed")
68
+ values = list(params)
69
+ return "".join(out), values
pgtern/_scram.py ADDED
@@ -0,0 +1,101 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """SCRAM-SHA-256 client (RFC 5802 / RFC 7677), sans-I/O: bytes in, bytes out."""
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import hmac
8
+ import os
9
+ import stringprep
10
+ import unicodedata
11
+
12
+ from . import errors
13
+
14
+ MECHANISM = "SCRAM-SHA-256"
15
+ _MAX_ITERATIONS = 1 << 24 # refuse absurd server-chosen costs
16
+
17
+
18
+ def saslprep(password: str) -> str:
19
+ """RFC 4013 SASLprep. Falls back to the raw password if it can't be prepared."""
20
+ if password.isascii():
21
+ return password
22
+ try:
23
+ mapped = "".join(
24
+ " " if stringprep.in_table_c12(ch) else ch
25
+ for ch in password
26
+ if not stringprep.in_table_b1(ch)
27
+ )
28
+ out = unicodedata.normalize("NFKC", mapped)
29
+ if not out:
30
+ return password
31
+ prohibited = (
32
+ stringprep.in_table_c12, stringprep.in_table_c21_c22, stringprep.in_table_c3,
33
+ stringprep.in_table_c4, stringprep.in_table_c5, stringprep.in_table_c6,
34
+ stringprep.in_table_c7, stringprep.in_table_c8, stringprep.in_table_c9,
35
+ stringprep.in_table_a1,
36
+ )
37
+ if any(check(ch) for ch in out for check in prohibited):
38
+ return password
39
+ if any(stringprep.in_table_d1(ch) for ch in out):
40
+ if any(stringprep.in_table_d2(ch) for ch in out):
41
+ return password
42
+ if not (stringprep.in_table_d1(out[0]) and stringprep.in_table_d1(out[-1])):
43
+ return password
44
+ return out
45
+ except Exception: # pragma: no cover - defensive
46
+ return password
47
+
48
+
49
+ def _attrs(message: str) -> dict[str, str]:
50
+ out: dict[str, str] = {}
51
+ for part in message.split(","):
52
+ key, sep, value = part.partition("=")
53
+ if sep:
54
+ out[key] = value
55
+ return out
56
+
57
+
58
+ class ScramClient:
59
+ def __init__(self, password: str, *, username: str = "", nonce: str | None = None) -> None:
60
+ self._password = password
61
+ self._nonce = nonce or base64.b64encode(os.urandom(18)).decode()
62
+ self._first_bare = f"n={username},r={self._nonce}"
63
+ self._server_signature = b""
64
+
65
+ def client_first(self) -> bytes:
66
+ return ("n,," + self._first_bare).encode()
67
+
68
+ def client_final(self, server_first: bytes) -> bytes:
69
+ text = server_first.decode()
70
+ attrs = _attrs(text)
71
+ if "e" in attrs:
72
+ raise errors.OperationalError(f"SCRAM authentication failed: {attrs['e']}")
73
+ try:
74
+ server_nonce, salt, iterations = attrs["r"], base64.b64decode(attrs["s"]), int(attrs["i"])
75
+ except (KeyError, ValueError) as exc:
76
+ raise errors.OperationalError("malformed SCRAM server-first-message") from exc
77
+ if not server_nonce.startswith(self._nonce):
78
+ raise errors.OperationalError("SCRAM server nonce does not extend the client nonce")
79
+ if not 1 <= iterations <= _MAX_ITERATIONS:
80
+ raise errors.OperationalError(f"unacceptable SCRAM iteration count {iterations}")
81
+ salted = hashlib.pbkdf2_hmac("sha256", saslprep(self._password).encode(), salt, iterations)
82
+ client_key = hmac.digest(salted, b"Client Key", "sha256")
83
+ stored_key = hashlib.sha256(client_key).digest()
84
+ without_proof = f"c=biws,r={server_nonce}"
85
+ auth_message = f"{self._first_bare},{text},{without_proof}".encode()
86
+ signature = hmac.digest(stored_key, auth_message, "sha256")
87
+ proof = bytes(a ^ b for a, b in zip(client_key, signature))
88
+ server_key = hmac.digest(salted, b"Server Key", "sha256")
89
+ self._server_signature = hmac.digest(server_key, auth_message, "sha256")
90
+ return f"{without_proof},p={base64.b64encode(proof).decode()}".encode()
91
+
92
+ def verify_server_final(self, server_final: bytes) -> None:
93
+ attrs = _attrs(server_final.decode())
94
+ if "e" in attrs:
95
+ raise errors.OperationalError(f"SCRAM authentication failed: {attrs['e']}")
96
+ try:
97
+ received = base64.b64decode(attrs["v"])
98
+ except (KeyError, ValueError) as exc:
99
+ raise errors.OperationalError("malformed SCRAM server-final-message") from exc
100
+ if not hmac.compare_digest(received, self._server_signature):
101
+ raise errors.OperationalError("SCRAM server signature does not match: the server could not be verified")