nitrodb 2.4.3__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.
nedb/resp2.py ADDED
@@ -0,0 +1,250 @@
1
+ """
2
+ nedb.resp2 — RESP2 wire protocol server for nedbd.
3
+
4
+ Clients that speak Redis (redis-cli, redis-benchmark, every Redis client
5
+ library in every language) connect to this server and NEDB handles the
6
+ commands natively. No Redis installation required — nedbd IS the server.
7
+
8
+ Protocol: RESP2 (Redis Serialization Protocol v2)
9
+ +OK\r\n — Simple string
10
+ -ERR msg\r\n — Error
11
+ :42\r\n — Integer
12
+ $6\r\nhello\r\n — Bulk string ($-1 = null)
13
+ *3\r\n... — Array of N elements
14
+
15
+ Commands mapped to NEDB (database = selected via SELECT or default "db0"):
16
+ PING [msg]
17
+ SELECT <db_name> — switch active database (creates if needed)
18
+ SET key value [EX secs]
19
+ GET key
20
+ DEL key [key …]
21
+ EXISTS key [key …]
22
+ INCR key / INCRBY key n / DECR key / DECRBY key n
23
+ MSET k v [k v …] / MGET k [k …]
24
+ SETNX key value
25
+ HSET hash field value [field value …]
26
+ HGET hash field
27
+ HMGET hash field [field …]
28
+ HGETALL hash
29
+ HDEL hash field [field …]
30
+ HEXISTS hash field / HKEYS hash / HVALS hash / HLEN hash
31
+ SADD key member [member …] / SMEMBERS key / SISMEMBER key m / SREM key m / SCARD key
32
+ LPUSH key val [val …] / RPUSH key val [val …]
33
+ LRANGE key start stop / LLEN key / LPOP key / RPOP key
34
+ KEYS pattern / TYPE key / DBSIZE / FLUSHDB
35
+ COMMAND (stub — returns OK so redis-cli connects cleanly)
36
+ QUIT
37
+
38
+ NQL pass-through:
39
+ EVAL "FROM users WHERE status = \\"active\\"" 0 → runs NQL directly
40
+
41
+ Unsupported (on roadmap): EXPIRE TTL SUBSCRIBE PUBLISH MULTI EXEC
42
+ """
43
+ from __future__ import annotations
44
+
45
+ import os
46
+ import socketserver
47
+ import threading
48
+ from typing import Any, Dict, List, Optional
49
+
50
+ from .redis_compat import RedisCompat, RedisUnsupportedError
51
+
52
+ DEFAULT_DB = "db0"
53
+
54
+
55
+ # ── RESP2 encoding ────────────────────────────────────────────────────────────
56
+
57
+ def _bulk(s: Optional[str]) -> bytes:
58
+ if s is None:
59
+ return b"$-1\r\n"
60
+ enc = str(s).encode()
61
+ return b"$" + str(len(enc)).encode() + b"\r\n" + enc + b"\r\n"
62
+
63
+
64
+ def _int(n: int) -> bytes:
65
+ return b":" + str(int(n)).encode() + b"\r\n"
66
+
67
+
68
+ def _ok() -> bytes:
69
+ return b"+OK\r\n"
70
+
71
+
72
+ def _err(msg: str) -> bytes:
73
+ return b"-ERR " + msg.replace("\r\n", " ").encode() + b"\r\n"
74
+
75
+
76
+ def _arr(items: List[Any]) -> bytes:
77
+ out = b"*" + str(len(items)).encode() + b"\r\n"
78
+ for item in items:
79
+ # If the item is already RESP2-encoded bytes (e.g. from _bulk()), use it
80
+ # directly — calling _encode() on bytes would stringify them ("b'...'").
81
+ out += item if isinstance(item, bytes) else _encode(item)
82
+ return out
83
+
84
+
85
+ def _encode(value: Any) -> bytes:
86
+ if value is None:
87
+ return b"$-1\r\n"
88
+ if isinstance(value, bool):
89
+ return _int(1 if value else 0)
90
+ if isinstance(value, int):
91
+ return _int(value)
92
+ if isinstance(value, (set, frozenset)):
93
+ return _arr([_bulk(str(x)) for x in sorted(value)])
94
+ if isinstance(value, list):
95
+ return _arr([_encode(x) for x in value])
96
+ if isinstance(value, dict):
97
+ parts = []
98
+ for k, v in value.items():
99
+ parts.append(_bulk(str(k)))
100
+ parts.append(_bulk(str(v) if v is not None else None))
101
+ return _arr(parts)
102
+ return _bulk(str(value))
103
+
104
+
105
+ # ── RESP2 parser ──────────────────────────────────────────────────────────────
106
+
107
+ class RespReader:
108
+ def __init__(self, rfile):
109
+ self._f = rfile
110
+
111
+ def read_command(self) -> Optional[List[str]]:
112
+ line = self._f.readline()
113
+ if not line:
114
+ return None
115
+ line = line.rstrip(b"\r\n")
116
+ if not line:
117
+ return None
118
+ if line.startswith(b"*"):
119
+ n = int(line[1:])
120
+ args = []
121
+ for _ in range(n):
122
+ bulk_hdr = self._f.readline().rstrip(b"\r\n")
123
+ if not bulk_hdr.startswith(b"$"):
124
+ raise ValueError(f"Expected bulk string, got {bulk_hdr!r}")
125
+ length = int(bulk_hdr[1:])
126
+ data = self._f.read(length + 2) # +2 for \r\n
127
+ args.append(data[:-2].decode(errors="replace"))
128
+ return args
129
+ # Inline command (e.g. redis-cli in inline mode, PING from telnet)
130
+ return line.decode(errors="replace").split()
131
+
132
+
133
+ # ── Connection handler ────────────────────────────────────────────────────────
134
+
135
+ class _Handler(socketserver.StreamRequestHandler):
136
+ def setup(self):
137
+ super().setup()
138
+ self._db_name: str = DEFAULT_DB
139
+ self._rc: Optional[RedisCompat] = None
140
+
141
+ def _get_rc(self) -> RedisCompat:
142
+ if self._rc is None or getattr(self._rc, "_db_name", None) != self._db_name:
143
+ db = self.server.manager.open(self._db_name)
144
+ self._rc = RedisCompat(db)
145
+ self._rc._db_name = self._db_name # type: ignore[attr-defined]
146
+ return self._rc
147
+
148
+ def handle(self):
149
+ reader = RespReader(self.rfile)
150
+ while True:
151
+ try:
152
+ args = reader.read_command()
153
+ except Exception:
154
+ break
155
+ if args is None:
156
+ break
157
+ if not args:
158
+ continue
159
+ cmd = args[0].upper()
160
+ rest = args[1:]
161
+ try:
162
+ reply = self._dispatch(cmd, rest)
163
+ except RedisUnsupportedError as e:
164
+ reply = _err(str(e))
165
+ except Exception as e:
166
+ reply = _err(str(e))
167
+ try:
168
+ self.wfile.write(reply)
169
+ self.wfile.flush()
170
+ except Exception:
171
+ break
172
+
173
+ def _dispatch(self, cmd: str, args: List[str]) -> bytes:
174
+ rc = self._get_rc
175
+
176
+ # ── server / connection ──────────────────────────────────────────────
177
+ if cmd == "PING":
178
+ return b"+PONG\r\n" if not args else _bulk(args[0])
179
+ if cmd == "QUIT":
180
+ return _ok()
181
+ if cmd == "COMMAND":
182
+ return _ok() # stub — enough for redis-cli to connect
183
+ if cmd == "SELECT":
184
+ if not args:
185
+ return _err("SELECT requires a database name")
186
+ self._db_name = args[0]
187
+ self._rc = None
188
+ return _ok()
189
+ if cmd == "DBSIZE":
190
+ return _int(rc().execute("DBSIZE"))
191
+
192
+ # ── NQL pass-through via EVAL ────────────────────────────────────────
193
+ if cmd == "EVAL":
194
+ if not args:
195
+ return _err("EVAL requires a NQL string")
196
+ nql = args[0]
197
+ import json as _json
198
+ db = self.server.manager.open(self._db_name)
199
+ rows = db.query(nql)
200
+ # Each row is returned as a compact JSON string — clients can parse it
201
+ return _arr([_bulk(_json.dumps(r, separators=(",", ":"))) for r in rows])
202
+
203
+ # ── strings ──────────────────────────────────────────────────────────
204
+ if cmd in ("SET", "GET", "GETDEL", "SETNX", "MSET", "MGET",
205
+ "DEL", "UNLINK", "EXISTS", "INCR", "INCRBY",
206
+ "DECR", "DECRBY", "APPEND", "STRLEN", "TYPE",
207
+ "RENAME", "KEYS", "FLUSHDB"):
208
+ result = rc().execute(cmd, *args)
209
+ return _encode(result)
210
+
211
+ # ── hashes ───────────────────────────────────────────────────────────
212
+ if cmd in ("HSET", "HMSET", "HSETNX", "HGET", "HMGET",
213
+ "HGETALL", "HDEL", "HEXISTS", "HKEYS", "HVALS",
214
+ "HLEN", "HINCRBY"):
215
+ result = rc().execute(cmd, *args)
216
+ return _encode(result)
217
+
218
+ # ── sets ─────────────────────────────────────────────────────────────
219
+ if cmd in ("SADD", "SMEMBERS", "SISMEMBER", "SREM",
220
+ "SCARD", "SUNION", "SINTER", "SDIFF"):
221
+ result = rc().execute(cmd, *args)
222
+ return _encode(result)
223
+
224
+ # ── lists ────────────────────────────────────────────────────────────
225
+ if cmd in ("LPUSH", "RPUSH", "LRANGE", "LLEN",
226
+ "LINDEX", "LSET", "LPOP", "RPOP"):
227
+ result = rc().execute(cmd, *args)
228
+ return _encode(result)
229
+
230
+ # ── unsupported with clear message ───────────────────────────────────
231
+ if cmd in ("EXPIRE", "EXPIREAT", "TTL", "PTTL", "PEXPIRE", "PERSIST"):
232
+ return _err(f"{cmd} is on the NEDB roadmap — use db.expire() via the Python API.")
233
+ if cmd in ("SUBSCRIBE", "PUBLISH", "UNSUBSCRIBE", "PSUBSCRIBE"):
234
+ return _err(f"{cmd} (pub-sub) is on the NEDB roadmap.")
235
+ if cmd in ("MULTI", "EXEC", "DISCARD", "WATCH"):
236
+ return _err(f"{cmd} (transactions) is on the NEDB roadmap.")
237
+
238
+ return _err(f"unknown command '{cmd}'")
239
+
240
+
241
+ class _ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
242
+ allow_reuse_address = True
243
+ daemon_threads = True
244
+
245
+
246
+ def make_resp2_server(manager, host: str, port: int):
247
+ """Create and return a threaded TCP server that speaks RESP2."""
248
+ srv = _ThreadedTCPServer((host, port), _Handler)
249
+ srv.manager = manager
250
+ return srv