nusadb 0.1.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.
- nusadb/__init__.py +65 -0
- nusadb/_protocol.py +271 -0
- nusadb/_scram.py +76 -0
- nusadb/connection.py +887 -0
- nusadb/exceptions.py +76 -0
- nusadb/pool.py +82 -0
- nusadb/sqlalchemy.py +213 -0
- nusadb-0.1.0.dist-info/METADATA +216 -0
- nusadb-0.1.0.dist-info/RECORD +12 -0
- nusadb-0.1.0.dist-info/WHEEL +5 -0
- nusadb-0.1.0.dist-info/entry_points.txt +2 -0
- nusadb-0.1.0.dist-info/top_level.txt +1 -0
nusadb/__init__.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""nusadb — the Python driver for NusaDB (PEP-249 / DB-API 2.0).
|
|
2
|
+
|
|
3
|
+
Speaks the Nusa Wire Protocol (``PROTOCOL_VERSION 1.1``, see
|
|
4
|
+
``docs/wire-protocol.md``) directly over a socket — no C extension, stdlib only.
|
|
5
|
+
|
|
6
|
+
Example::
|
|
7
|
+
|
|
8
|
+
import nusadb
|
|
9
|
+
|
|
10
|
+
conn = nusadb.connect(host="127.0.0.1", port=5678, user="nusa-root", database="nusadb")
|
|
11
|
+
cur = conn.cursor()
|
|
12
|
+
cur.execute("CREATE TABLE t (id INT NOT NULL, name TEXT)")
|
|
13
|
+
cur.execute("INSERT INTO t VALUES ($1, $2)", [1, "alice"])
|
|
14
|
+
cur.execute("SELECT id, name FROM t WHERE id = $1", [1])
|
|
15
|
+
print(cur.fetchall())
|
|
16
|
+
conn.close()
|
|
17
|
+
|
|
18
|
+
Parameter markers are positional ``$1``, ``$2`` (the server's native form);
|
|
19
|
+
``paramstyle`` is reported as ``"numeric"``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from .connection import Connection, Cursor, Notification, connect
|
|
25
|
+
from .exceptions import (
|
|
26
|
+
DatabaseError,
|
|
27
|
+
DataError,
|
|
28
|
+
Error,
|
|
29
|
+
IntegrityError,
|
|
30
|
+
InterfaceError,
|
|
31
|
+
InternalError,
|
|
32
|
+
NotSupportedError,
|
|
33
|
+
OperationalError,
|
|
34
|
+
ProgrammingError,
|
|
35
|
+
Warning,
|
|
36
|
+
)
|
|
37
|
+
from .pool import Pool
|
|
38
|
+
|
|
39
|
+
# PEP-249 module globals.
|
|
40
|
+
apilevel = "2.0"
|
|
41
|
+
threadsafety = 1 # threads may share the module but not connections
|
|
42
|
+
paramstyle = "numeric" # positional markers, written ``$1`` (server-native)
|
|
43
|
+
|
|
44
|
+
__version__ = "0.1.0"
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"connect",
|
|
48
|
+
"Connection",
|
|
49
|
+
"Cursor",
|
|
50
|
+
"Notification",
|
|
51
|
+
"Pool",
|
|
52
|
+
"apilevel",
|
|
53
|
+
"threadsafety",
|
|
54
|
+
"paramstyle",
|
|
55
|
+
"Warning",
|
|
56
|
+
"Error",
|
|
57
|
+
"InterfaceError",
|
|
58
|
+
"DatabaseError",
|
|
59
|
+
"DataError",
|
|
60
|
+
"OperationalError",
|
|
61
|
+
"IntegrityError",
|
|
62
|
+
"InternalError",
|
|
63
|
+
"ProgrammingError",
|
|
64
|
+
"NotSupportedError",
|
|
65
|
+
]
|
nusadb/_protocol.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Low-level Nusa Wire Protocol codec.
|
|
2
|
+
|
|
3
|
+
Implements the framing and message layouts of ``docs/wire-protocol.md``
|
|
4
|
+
(``PROTOCOL_VERSION 1.1``): a frame is ``[type:u8][len:u32][payload]`` with
|
|
5
|
+
``len`` the *total* size including the 5-byte header, big-endian throughout.
|
|
6
|
+
Strings are length-prefixed UTF-8 (``[len:u32][bytes]``, **not** null-terminated).
|
|
7
|
+
|
|
8
|
+
This module is intentionally dependency-free (stdlib only).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import struct
|
|
14
|
+
from typing import List, Optional, Tuple
|
|
15
|
+
|
|
16
|
+
# Protocol magic: ASCII "NUSA".
|
|
17
|
+
PROTOCOL_MAGIC = 0x4E555341
|
|
18
|
+
# Request minor 2 to receive the typed RowDescription (per-column type tags) AND the element type of
|
|
19
|
+
# an ARRAY column (protocol 1.2). An older server ignores the minor and answers with a form the driver
|
|
20
|
+
# still handles (1.1 = typed scalars only; 1.0 = untyped names).
|
|
21
|
+
PROTOCOL_VERSION = (1, 2)
|
|
22
|
+
HEADER_LEN = 5
|
|
23
|
+
MAX_FRAME_LEN = 256 * 1024 * 1024
|
|
24
|
+
|
|
25
|
+
# Column type tags carried by RowDescriptionTyped (protocol 1.1, wire-protocol.md §9.2). Maps the
|
|
26
|
+
# 1-byte tag to a canonical type name; an unknown/0x00 tag is UNKNOWN (treated as text).
|
|
27
|
+
TYPE_TAGS = {
|
|
28
|
+
0x00: "UNKNOWN", 0x01: "BOOL", 0x02: "INT", 0x03: "FLOAT", 0x04: "NUMERIC",
|
|
29
|
+
0x05: "TEXT", 0x06: "BYTES", 0x07: "DATE", 0x08: "TIME", 0x09: "TIMETZ",
|
|
30
|
+
0x0A: "TIMESTAMP", 0x0B: "TIMESTAMPTZ", 0x0C: "INTERVAL", 0x0D: "UUID",
|
|
31
|
+
0x0E: "JSON", 0x0F: "ARRAY", 0x10: "VECTOR",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# Protocol 1.2: an ARRAY column's tag is ``ARRAY_ELEMENT_FLAG | <element tag>`` (e.g. INT[] = 0x82),
|
|
35
|
+
# so the element type travels with the column and the row decoder casts the elements at their type.
|
|
36
|
+
ARRAY_ELEMENT_FLAG = 0x80
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def type_name(tag: int) -> str:
|
|
40
|
+
"""Canonical type name for a RowDescriptionTyped type tag (UNKNOWN if unrecognised). An
|
|
41
|
+
element-typed array tag (protocol 1.2, ``0x80 | element``) is named ``ARRAY:<element>`` —
|
|
42
|
+
e.g. ``ARRAY:INT`` — so the row decoder can cast the elements at their real type."""
|
|
43
|
+
if tag & ARRAY_ELEMENT_FLAG:
|
|
44
|
+
return "ARRAY:" + TYPE_TAGS.get(tag & ~ARRAY_ELEMENT_FLAG, "UNKNOWN")
|
|
45
|
+
return TYPE_TAGS.get(tag, "UNKNOWN")
|
|
46
|
+
|
|
47
|
+
# Frontend (client -> server) type bytes.
|
|
48
|
+
T_STARTUP = ord("S")
|
|
49
|
+
T_QUERY = ord("Q")
|
|
50
|
+
T_PARSE = ord("P")
|
|
51
|
+
T_BIND = ord("B")
|
|
52
|
+
T_DESCRIBE = ord("D")
|
|
53
|
+
T_EXECUTE = ord("E")
|
|
54
|
+
T_SYNC = ord("Y")
|
|
55
|
+
T_CLOSE = ord("C")
|
|
56
|
+
T_SASL_INITIAL = ord("p")
|
|
57
|
+
T_SASL_RESPONSE = ord("r")
|
|
58
|
+
T_COPY_DATA = ord("d")
|
|
59
|
+
T_COPY_DONE = ord("c")
|
|
60
|
+
T_COPY_FAIL = ord("f")
|
|
61
|
+
T_CANCEL_REQUEST = ord("K")
|
|
62
|
+
T_TERMINATE = ord("X")
|
|
63
|
+
|
|
64
|
+
# Backend (server -> client) type bytes.
|
|
65
|
+
B_AUTH = ord("R")
|
|
66
|
+
B_BACKEND_KEY = ord("K")
|
|
67
|
+
B_READY = ord("Z")
|
|
68
|
+
B_COMMAND_COMPLETE = ord("C")
|
|
69
|
+
B_ERROR = ord("E")
|
|
70
|
+
B_ROW_DESCRIPTION = ord("T")
|
|
71
|
+
B_ROW_DESCRIPTION_TYPED = ord("y") # protocol 1.1 (typed columns)
|
|
72
|
+
B_DATA_ROW = ord("D")
|
|
73
|
+
B_PARSE_COMPLETE = ord("1")
|
|
74
|
+
B_BIND_COMPLETE = ord("2")
|
|
75
|
+
B_CLOSE_COMPLETE = ord("3")
|
|
76
|
+
B_PARAMETER_DESCRIPTION = ord("t")
|
|
77
|
+
B_NO_DATA = ord("n")
|
|
78
|
+
B_PORTAL_SUSPENDED = ord("z")
|
|
79
|
+
B_COPY_IN = ord("G")
|
|
80
|
+
B_COPY_OUT = ord("H")
|
|
81
|
+
B_COPY_DATA = ord("d")
|
|
82
|
+
B_COPY_DONE = ord("c")
|
|
83
|
+
B_NOTIFICATION_RESPONSE = ord("A") # async LISTEN/NOTIFY: [pid:u32][channel:str][payload:str]
|
|
84
|
+
|
|
85
|
+
# Authentication sub-codes (the leading u32 of an `R` message).
|
|
86
|
+
AUTH_OK = 0
|
|
87
|
+
AUTH_SASL = 10
|
|
88
|
+
AUTH_SASL_CONTINUE = 11
|
|
89
|
+
AUTH_SASL_FINAL = 12
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Writer:
|
|
93
|
+
"""Accumulates a message payload with the protocol's primitive encodings."""
|
|
94
|
+
|
|
95
|
+
__slots__ = ("_buf",)
|
|
96
|
+
|
|
97
|
+
def __init__(self) -> None:
|
|
98
|
+
self._buf = bytearray()
|
|
99
|
+
|
|
100
|
+
def u8(self, value: int) -> "Writer":
|
|
101
|
+
self._buf += struct.pack(">B", value)
|
|
102
|
+
return self
|
|
103
|
+
|
|
104
|
+
def u16(self, value: int) -> "Writer":
|
|
105
|
+
self._buf += struct.pack(">H", value)
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
def u32(self, value: int) -> "Writer":
|
|
109
|
+
self._buf += struct.pack(">I", value)
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
def raw(self, data: bytes) -> "Writer":
|
|
113
|
+
self._buf += data
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
def string(self, text: str) -> "Writer":
|
|
117
|
+
encoded = text.encode("utf-8")
|
|
118
|
+
self.u32(len(encoded))
|
|
119
|
+
self._buf += encoded
|
|
120
|
+
return self
|
|
121
|
+
|
|
122
|
+
def fields(self, values: List[Optional[bytes]]) -> "Writer":
|
|
123
|
+
"""Encode a `Fields` list (DataRow / Bind params)."""
|
|
124
|
+
self.u16(len(values))
|
|
125
|
+
for value in values:
|
|
126
|
+
if value is None:
|
|
127
|
+
self.u8(0)
|
|
128
|
+
else:
|
|
129
|
+
self.u8(1)
|
|
130
|
+
self.u32(len(value))
|
|
131
|
+
self._buf += value
|
|
132
|
+
return self
|
|
133
|
+
|
|
134
|
+
def frame(self, message_type: int) -> bytes:
|
|
135
|
+
"""Wrap the payload in a frame header: ``[type][len][payload]``."""
|
|
136
|
+
total = len(self._buf) + HEADER_LEN
|
|
137
|
+
return struct.pack(">BI", message_type, total) + bytes(self._buf)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class Reader:
|
|
141
|
+
"""Reads the protocol's primitive encodings from a payload buffer."""
|
|
142
|
+
|
|
143
|
+
__slots__ = ("_buf", "_pos")
|
|
144
|
+
|
|
145
|
+
def __init__(self, payload: bytes) -> None:
|
|
146
|
+
self._buf = payload
|
|
147
|
+
self._pos = 0
|
|
148
|
+
|
|
149
|
+
def remaining(self) -> int:
|
|
150
|
+
return len(self._buf) - self._pos
|
|
151
|
+
|
|
152
|
+
def _take(self, n: int) -> bytes:
|
|
153
|
+
if self._pos + n > len(self._buf):
|
|
154
|
+
raise ValueError("truncated payload")
|
|
155
|
+
chunk = self._buf[self._pos : self._pos + n]
|
|
156
|
+
self._pos += n
|
|
157
|
+
return chunk
|
|
158
|
+
|
|
159
|
+
def u8(self) -> int:
|
|
160
|
+
return struct.unpack(">B", self._take(1))[0]
|
|
161
|
+
|
|
162
|
+
def u16(self) -> int:
|
|
163
|
+
return struct.unpack(">H", self._take(2))[0]
|
|
164
|
+
|
|
165
|
+
def u32(self) -> int:
|
|
166
|
+
return struct.unpack(">I", self._take(4))[0]
|
|
167
|
+
|
|
168
|
+
def string(self) -> str:
|
|
169
|
+
n = self.u32()
|
|
170
|
+
return self._take(n).decode("utf-8")
|
|
171
|
+
|
|
172
|
+
def rest(self) -> bytes:
|
|
173
|
+
chunk = self._buf[self._pos :]
|
|
174
|
+
self._pos = len(self._buf)
|
|
175
|
+
return chunk
|
|
176
|
+
|
|
177
|
+
def fields(self) -> List[Optional[bytes]]:
|
|
178
|
+
count = self.u16()
|
|
179
|
+
out: List[Optional[bytes]] = []
|
|
180
|
+
for _ in range(count):
|
|
181
|
+
present = self.u8()
|
|
182
|
+
if present == 0:
|
|
183
|
+
out.append(None)
|
|
184
|
+
else:
|
|
185
|
+
n = self.u32()
|
|
186
|
+
out.append(self._take(n))
|
|
187
|
+
return out
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# --- frontend message builders (return wire-ready frame bytes) ---
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def startup(user: str, database: str) -> bytes:
|
|
194
|
+
return (
|
|
195
|
+
Writer()
|
|
196
|
+
.u32(PROTOCOL_MAGIC)
|
|
197
|
+
.u16(PROTOCOL_VERSION[0])
|
|
198
|
+
.u16(PROTOCOL_VERSION[1])
|
|
199
|
+
.string(user)
|
|
200
|
+
.string(database)
|
|
201
|
+
.frame(T_STARTUP)
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def query(sql: str) -> bytes:
|
|
206
|
+
return Writer().string(sql).frame(T_QUERY)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def parse(name: str, sql: str) -> bytes:
|
|
210
|
+
return Writer().string(name).string(sql).u16(0).frame(T_PARSE)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def bind(portal: str, statement: str, params: List[Optional[bytes]]) -> bytes:
|
|
214
|
+
return (
|
|
215
|
+
Writer()
|
|
216
|
+
.string(portal)
|
|
217
|
+
.string(statement)
|
|
218
|
+
.fields(params)
|
|
219
|
+
.u16(0) # result format count: empty => all text
|
|
220
|
+
.frame(T_BIND)
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def describe_portal(name: str) -> bytes:
|
|
225
|
+
return Writer().u8(ord("P")).string(name).frame(T_DESCRIBE)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def execute(portal: str, max_rows: int = 0) -> bytes:
|
|
229
|
+
return Writer().string(portal).u32(max_rows).frame(T_EXECUTE)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def sync() -> bytes:
|
|
233
|
+
return Writer().frame(T_SYNC)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def terminate() -> bytes:
|
|
237
|
+
return Writer().frame(T_TERMINATE)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def sasl_initial(mechanism: str, data: bytes) -> bytes:
|
|
241
|
+
return Writer().string(mechanism).u32(len(data)).raw(data).frame(T_SASL_INITIAL)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def sasl_response(data: bytes) -> bytes:
|
|
245
|
+
return Writer().raw(data).frame(T_SASL_RESPONSE)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def cancel_request(pid: int, secret: int) -> bytes:
|
|
249
|
+
return Writer().u32(pid).u32(secret).frame(T_CANCEL_REQUEST)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def copy_data(data: bytes) -> bytes:
|
|
253
|
+
return Writer().raw(data).frame(T_COPY_DATA)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def copy_done() -> bytes:
|
|
257
|
+
return Writer().frame(T_COPY_DONE)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def copy_fail(message: str) -> bytes:
|
|
261
|
+
return Writer().string(message).frame(T_COPY_FAIL)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def decode_frame_header(header: bytes) -> Tuple[int, int]:
|
|
265
|
+
"""Return ``(message_type, payload_len)`` from a 5-byte frame header."""
|
|
266
|
+
message_type, total = struct.unpack(">BI", header)
|
|
267
|
+
if total < HEADER_LEN:
|
|
268
|
+
raise ValueError(f"malformed frame length {total}")
|
|
269
|
+
if total > MAX_FRAME_LEN:
|
|
270
|
+
raise ValueError(f"frame too large: {total}")
|
|
271
|
+
return message_type, total - HEADER_LEN
|
nusadb/_scram.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Client-side SCRAM-SHA-256 (RFC 5802 / RFC 7677) for the Nusa Wire Protocol.
|
|
2
|
+
|
|
3
|
+
Stdlib only (``hashlib``/``hmac``/``os``/``base64``). Implements the four-step
|
|
4
|
+
exchange of ``docs/wire-protocol.md`` §7.2 and verifies the server's signature
|
|
5
|
+
(mutual authentication).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import hashlib
|
|
12
|
+
import hmac
|
|
13
|
+
import os
|
|
14
|
+
from typing import Tuple
|
|
15
|
+
|
|
16
|
+
_GS2_HEADER = "n,,"
|
|
17
|
+
# base64("n,,") — the channel-binding value `c=` in client-final.
|
|
18
|
+
_CHANNEL_BINDING = base64.b64encode(_GS2_HEADER.encode("ascii")).decode("ascii")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _hmac(key: bytes, msg: bytes) -> bytes:
|
|
22
|
+
return hmac.new(key, msg, hashlib.sha256).digest()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _xor(a: bytes, b: bytes) -> bytes:
|
|
26
|
+
return bytes(x ^ y for x, y in zip(a, b))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def client_first(user: str) -> Tuple[str, str]:
|
|
30
|
+
"""Return ``(client_first_bare, full_client_first)`` with a fresh nonce."""
|
|
31
|
+
nonce = base64.b64encode(os.urandom(18)).decode("ascii")
|
|
32
|
+
bare = f"n={user},r={nonce}"
|
|
33
|
+
return bare, _GS2_HEADER + bare
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _parse_server_first(message: str) -> Tuple[str, bytes, int]:
|
|
37
|
+
"""Parse ``r=<nonce>,s=<base64 salt>,i=<iterations>``."""
|
|
38
|
+
parts = dict(field.split("=", 1) for field in message.split(","))
|
|
39
|
+
combined_nonce = parts["r"]
|
|
40
|
+
salt = base64.b64decode(parts["s"])
|
|
41
|
+
iterations = int(parts["i"])
|
|
42
|
+
return combined_nonce, salt, iterations
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def client_final(
|
|
46
|
+
password: str,
|
|
47
|
+
client_first_bare: str,
|
|
48
|
+
server_first: str,
|
|
49
|
+
) -> Tuple[bytes, str]:
|
|
50
|
+
"""Build the client-final message and the cached state to verify the server.
|
|
51
|
+
|
|
52
|
+
Returns ``(client_final_bytes, server_signature_b64_expected)``.
|
|
53
|
+
"""
|
|
54
|
+
combined_nonce, salt, iterations = _parse_server_first(server_first)
|
|
55
|
+
|
|
56
|
+
salted = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
|
|
57
|
+
client_key = _hmac(salted, b"Client Key")
|
|
58
|
+
stored_key = hashlib.sha256(client_key).digest()
|
|
59
|
+
|
|
60
|
+
without_proof = f"c={_CHANNEL_BINDING},r={combined_nonce}"
|
|
61
|
+
auth_message = f"{client_first_bare},{server_first},{without_proof}".encode("utf-8")
|
|
62
|
+
client_signature = _hmac(stored_key, auth_message)
|
|
63
|
+
proof = _xor(client_key, client_signature)
|
|
64
|
+
final = f"{without_proof},p={base64.b64encode(proof).decode('ascii')}"
|
|
65
|
+
|
|
66
|
+
server_key = _hmac(salted, b"Server Key")
|
|
67
|
+
server_signature = _hmac(server_key, auth_message)
|
|
68
|
+
expected = base64.b64encode(server_signature).decode("ascii")
|
|
69
|
+
return final.encode("utf-8"), expected
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def verify_server_final(server_final: str, expected_signature_b64: str) -> bool:
|
|
73
|
+
"""Check the server-final ``v=<signature>`` against the expected value."""
|
|
74
|
+
parts = dict(field.split("=", 1) for field in server_final.split(","))
|
|
75
|
+
got = parts.get("v", "")
|
|
76
|
+
return hmac.compare_digest(got, expected_signature_b64)
|