kedis-python 0.2.0__py3-none-any.whl → 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.
- kedis_python/commands.py +66 -41
- kedis_python/main.py +1 -1
- kedis_python/parser.py +147 -17
- kedis_python/server.py +387 -85
- kedis_python/settings.py +238 -0
- kedis_python/ui.py +1 -1
- {kedis_python-0.2.0.dist-info → kedis_python-0.3.0.dist-info}/METADATA +7 -2
- {kedis_python-0.2.0.dist-info → kedis_python-0.3.0.dist-info}/RECORD +12 -12
- {kedis_python-0.2.0.dist-info → kedis_python-0.3.0.dist-info}/WHEEL +1 -1
- kedis_python/replica.py +0 -454
- {kedis_python-0.2.0.dist-info → kedis_python-0.3.0.dist-info}/entry_points.txt +0 -0
- {kedis_python-0.2.0.dist-info → kedis_python-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {kedis_python-0.2.0.dist-info → kedis_python-0.3.0.dist-info}/top_level.txt +0 -0
kedis_python/commands.py
CHANGED
|
@@ -136,57 +136,82 @@ class CommandHandler:
|
|
|
136
136
|
if not tokens:
|
|
137
137
|
return "-ERR empty command or syntax error", []
|
|
138
138
|
|
|
139
|
-
cmd = tokens[0].upper()
|
|
140
|
-
args = tokens[
|
|
141
|
-
1:
|
|
142
|
-
] # (Though your current code passes tokens, let's keep your routing exact)
|
|
143
|
-
|
|
144
139
|
# 🚀 TRACK A: Start the high-precision telemetry stopwatch (in nanoseconds)
|
|
145
140
|
start_time = time.perf_counter_ns()
|
|
141
|
+
try:
|
|
142
|
+
with self._engine_lock:
|
|
143
|
+
return self._run_locked(tokens, client_socket)
|
|
144
|
+
finally:
|
|
145
|
+
self._record_duration(tokens, start_time)
|
|
146
|
+
|
|
147
|
+
def execute_batch(self, batch: list, client_socket=None):
|
|
148
|
+
"""Runs several commands as one unit: the engine lock is taken once, so
|
|
149
|
+
no other client's command can run between them, and the AOF records
|
|
150
|
+
them back to back. Returns (results, forwards): one result per command,
|
|
151
|
+
and the commands a master must send to replicas, in order (see
|
|
152
|
+
replication_payloads). Commands are not skipped or reordered; one that
|
|
153
|
+
fails gives an error result and the rest still run, like the one-at-a-
|
|
154
|
+
time path."""
|
|
155
|
+
results: list = []
|
|
156
|
+
forwards: list = []
|
|
157
|
+
write_commands = self.WRITE_COMMANDS
|
|
158
|
+
starts: list = []
|
|
159
|
+
with self._engine_lock:
|
|
160
|
+
for tokens in batch:
|
|
161
|
+
if not tokens:
|
|
162
|
+
results.append("-ERR empty command or syntax error")
|
|
163
|
+
starts.append(None)
|
|
164
|
+
continue
|
|
165
|
+
starts.append(time.perf_counter_ns())
|
|
166
|
+
result, records = self._run_locked(tokens, client_socket)
|
|
167
|
+
results.append(result)
|
|
168
|
+
if tokens[0].upper() in write_commands:
|
|
169
|
+
forwards.extend(self.replication_payloads(tokens, result, records))
|
|
170
|
+
for tokens, started in zip(batch, starts):
|
|
171
|
+
if started is not None:
|
|
172
|
+
self._record_duration(tokens, started)
|
|
173
|
+
return results, forwards
|
|
174
|
+
|
|
175
|
+
def _run_locked(self, tokens: list[str], client_socket=None):
|
|
176
|
+
"""Runs one command. The caller holds the engine lock."""
|
|
177
|
+
cmd = tokens[0].upper()
|
|
146
178
|
result = None
|
|
147
179
|
records = []
|
|
148
180
|
|
|
149
181
|
try:
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
result = handler_function(tokens, client_socket)
|
|
160
|
-
else:
|
|
161
|
-
handler_function = self._commands[cmd]
|
|
162
|
-
# Note: Depending on how your handlers are registered,
|
|
163
|
-
# they might expect 'tokens' or 'args'. Keeping your existing call:
|
|
164
|
-
result = handler_function(tokens)
|
|
182
|
+
try:
|
|
183
|
+
if cmd == "XGAPPLY" and client_socket is not None:
|
|
184
|
+
# Internal replication command: a network client
|
|
185
|
+
# must not be able to write raw group records.
|
|
186
|
+
result = f"-ERR unknown command '{cmd}'"
|
|
187
|
+
elif cmd in self._commands:
|
|
188
|
+
handler_function = self._commands[cmd]
|
|
189
|
+
if cmd in ["SUBSCRIBE", "PUBLISH"]:
|
|
190
|
+
result = handler_function(tokens, client_socket)
|
|
165
191
|
else:
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
result = f"-ERR wrong number of arguments for '{cmd}' command"
|
|
192
|
+
# Handlers take the whole token list, command name included.
|
|
193
|
+
result = handler_function(tokens)
|
|
194
|
+
else:
|
|
195
|
+
result = f"-ERR unknown command '{cmd}'"
|
|
196
|
+
finally:
|
|
197
|
+
records = self.store.drain_group_records()
|
|
173
198
|
return result, records
|
|
199
|
+
except TypeError:
|
|
200
|
+
return f"-ERR wrong number of arguments for '{cmd}' command", records
|
|
174
201
|
except Exception as e:
|
|
175
|
-
|
|
176
|
-
return result, records
|
|
202
|
+
return f"-ERR {str(e)}", records
|
|
177
203
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
)
|
|
189
|
-
self.store._log_slow_command(tokens, duration_us)
|
|
204
|
+
def _record_duration(self, tokens: list[str], start_time: int) -> None:
|
|
205
|
+
# 🚀 TRACK A: Stop the stopwatch, convert nanoseconds to microseconds
|
|
206
|
+
duration_us = (time.perf_counter_ns() - start_time) // 1000
|
|
207
|
+
|
|
208
|
+
# Feed the metrics to the store's slowlog ring buffer if available
|
|
209
|
+
if (
|
|
210
|
+
hasattr(self, "store")
|
|
211
|
+
and self.store
|
|
212
|
+
and hasattr(self.store, "_log_slow_command")
|
|
213
|
+
):
|
|
214
|
+
self.store._log_slow_command(tokens, duration_us)
|
|
190
215
|
|
|
191
216
|
# ---------------------------------------------------------
|
|
192
217
|
# COMMAND HANDLERS (The isolated engine components)
|
kedis_python/main.py
CHANGED
kedis_python/parser.py
CHANGED
|
@@ -36,7 +36,7 @@ class CommandParser:
|
|
|
36
36
|
_COUNT = re.compile(rb"(?:0|[1-9][0-9]*)")
|
|
37
37
|
|
|
38
38
|
@staticmethod
|
|
39
|
-
def parse(raw_data, start: int = 0):
|
|
39
|
+
def parse(raw_data, start: int = 0, normalise: bool = True):
|
|
40
40
|
"""
|
|
41
41
|
Parses incoming network data. Returns (tokens, bytes_consumed).
|
|
42
42
|
|
|
@@ -51,12 +51,16 @@ class CommandParser:
|
|
|
51
51
|
raw_data may be bytes or a bytearray. Parsing starts at offset start and
|
|
52
52
|
bytes_consumed counts from there, so a caller can work through a buffer
|
|
53
53
|
holding many pipelined commands without copying or trimming it each time.
|
|
54
|
+
|
|
55
|
+
The first token is taken to be a command name and is uppercased. Pass
|
|
56
|
+
normalise=False for a frame that is data from its first token, such as the
|
|
57
|
+
baseline snapshot a leader sends: its JSON must reach the caller as sent.
|
|
54
58
|
"""
|
|
55
|
-
tokens, consumed, _ = CommandParser.parse_buffer(raw_data, start)
|
|
59
|
+
tokens, consumed, _ = CommandParser.parse_buffer(raw_data, start, normalise)
|
|
56
60
|
return tokens, consumed
|
|
57
61
|
|
|
58
62
|
@staticmethod
|
|
59
|
-
def parse_buffer(raw_data, start: int = 0):
|
|
63
|
+
def parse_buffer(raw_data, start: int = 0, normalise: bool = True):
|
|
60
64
|
"""parse(), plus a hint for incomplete frames: returns (tokens, consumed,
|
|
61
65
|
need). When the next frame is incomplete, need is the length the bytes
|
|
62
66
|
from start + consumed must reach before parsing can make any progress
|
|
@@ -75,7 +79,8 @@ class CommandParser:
|
|
|
75
79
|
# Only the command name is normalised. Every other token is data
|
|
76
80
|
# (keys, values, field names) until a handler says otherwise, so
|
|
77
81
|
# handlers match their own options case-insensitively.
|
|
78
|
-
|
|
82
|
+
if normalise:
|
|
83
|
+
tokens[0] = tokens[0].upper()
|
|
79
84
|
return tokens, end - start, 0
|
|
80
85
|
pos = end # blank frame: skip it and keep scanning
|
|
81
86
|
return [], pos - start, 0
|
|
@@ -267,6 +272,11 @@ class KESPDecoder:
|
|
|
267
272
|
MAX_DEPTH = 32 # nesting guard, so a hostile reply can't blow the stack
|
|
268
273
|
MAX_LINE = 64 * 1024 # a status/error line longer than this is garbage
|
|
269
274
|
MAX_HEADER_LINE = 32 # I<n> / S<n> / A<n> headers are never longer
|
|
275
|
+
# Whole-reply budgets. MAX_DEPTH and the per-array/per-string caps bound one
|
|
276
|
+
# node; these bound the *sum* over a reply, so many small nodes cannot add up
|
|
277
|
+
# to an unbounded amount of memory or work. Looked up at call time.
|
|
278
|
+
MAX_REPLY_ELEMENTS = 4_194_304 # every value in the reply, containers included
|
|
279
|
+
MAX_REPLY_PAYLOAD = 1024 * 1024 * 1024 # string and text-line bytes, summed
|
|
270
280
|
|
|
271
281
|
_COUNT = re.compile(rb"(?:0|[1-9][0-9]*)")
|
|
272
282
|
_INTEGER = re.compile(rb"(?:0|-?[1-9][0-9]*)")
|
|
@@ -275,7 +285,12 @@ class KESPDecoder:
|
|
|
275
285
|
_NUMERIC_SIGILS = (b"I", b"S", b"A")
|
|
276
286
|
|
|
277
287
|
@staticmethod
|
|
278
|
-
def decode(
|
|
288
|
+
def decode(
|
|
289
|
+
buf, pos: int = 0, _depth: int = 0, lenient: bool = False, _budget=None
|
|
290
|
+
):
|
|
291
|
+
if _budget is None:
|
|
292
|
+
# [elements left, payload bytes left], shared by the whole reply.
|
|
293
|
+
_budget = [KESPDecoder.MAX_REPLY_ELEMENTS, KESPDecoder.MAX_REPLY_PAYLOAD]
|
|
279
294
|
if _depth > KESPDecoder.MAX_DEPTH:
|
|
280
295
|
raise ReplyProtocolError("reply nested too deeply")
|
|
281
296
|
if pos >= len(buf):
|
|
@@ -309,6 +324,16 @@ class KESPDecoder:
|
|
|
309
324
|
after = nl + 1
|
|
310
325
|
rest = line[1:]
|
|
311
326
|
|
|
327
|
+
# Charge this value to the whole-reply budget. A reply that is still
|
|
328
|
+
# incomplete never gets here, so a retry is not charged twice.
|
|
329
|
+
if _budget[0] <= 0:
|
|
330
|
+
raise ReplyProtocolError("reply has too many elements")
|
|
331
|
+
_budget[0] -= 1
|
|
332
|
+
if sigil not in (b"N", b"I", b"S", b"A"): # line-carried text
|
|
333
|
+
if len(line) > _budget[1]:
|
|
334
|
+
raise ReplyProtocolError("reply payload too large")
|
|
335
|
+
_budget[1] -= len(line)
|
|
336
|
+
|
|
312
337
|
if sigil == b"N":
|
|
313
338
|
if rest and not lenient:
|
|
314
339
|
raise ReplyProtocolError("invalid nil reply")
|
|
@@ -326,6 +351,10 @@ class KESPDecoder:
|
|
|
326
351
|
length = KESPDecoder._count(rest)
|
|
327
352
|
if length > CommandParser.MAX_BULK_LENGTH:
|
|
328
353
|
raise ReplyProtocolError("invalid bulk length")
|
|
354
|
+
if length > _budget[1]:
|
|
355
|
+
# Refuse on the header, before waiting for the bytes to arrive.
|
|
356
|
+
raise ReplyProtocolError("reply payload too large")
|
|
357
|
+
_budget[1] -= length
|
|
329
358
|
end = after + length
|
|
330
359
|
if len(buf) < end + 1:
|
|
331
360
|
raise IncompleteReply(end + 1)
|
|
@@ -337,9 +366,14 @@ class KESPDecoder:
|
|
|
337
366
|
count = KESPDecoder._count(rest)
|
|
338
367
|
if count > CommandParser.MAX_ARRAY_LENGTH:
|
|
339
368
|
raise ReplyProtocolError("invalid array length")
|
|
369
|
+
if count > _budget[0]:
|
|
370
|
+
# Every element costs at least one; refuse on the header.
|
|
371
|
+
raise ReplyProtocolError("reply has too many elements")
|
|
340
372
|
items = []
|
|
341
373
|
for _ in range(count):
|
|
342
|
-
item, after = KESPDecoder.decode(
|
|
374
|
+
item, after = KESPDecoder.decode(
|
|
375
|
+
buf, after, _depth + 1, lenient, _budget
|
|
376
|
+
)
|
|
343
377
|
items.append(item)
|
|
344
378
|
return items, after
|
|
345
379
|
|
|
@@ -388,10 +422,81 @@ class KESPEncoder:
|
|
|
388
422
|
"""
|
|
389
423
|
Translates raw Python objects from the router into strict KESP network bytes.
|
|
390
424
|
"""
|
|
425
|
+
# The same whole-reply budgets the decoder enforces, so a reply that
|
|
426
|
+
# leaves the server is one the client will accept.
|
|
427
|
+
budget = [KESPDecoder.MAX_REPLY_ELEMENTS, KESPDecoder.MAX_REPLY_PAYLOAD]
|
|
391
428
|
try:
|
|
392
|
-
return KESPEncoder._encode(data, 0, True)
|
|
429
|
+
return KESPEncoder._encode(data, 0, True, budget)
|
|
393
430
|
except ReplyEncodeError as exc:
|
|
394
|
-
return KESPEncoder.
|
|
431
|
+
return KESPEncoder.failure_reply(exc)
|
|
432
|
+
|
|
433
|
+
@staticmethod
|
|
434
|
+
def failure_reply(exc) -> bytes:
|
|
435
|
+
"""The one error line that stands in for a reply that could not be encoded."""
|
|
436
|
+
return KESPEncoder._error_line(f"Internal server error: {exc}")
|
|
437
|
+
|
|
438
|
+
@staticmethod
|
|
439
|
+
def encode_chunks(data, chunk: int = 512):
|
|
440
|
+
"""encode(), in pieces: b"".join(encode_chunks(x)) == encode(x) for every
|
|
441
|
+
x that encode() can encode.
|
|
442
|
+
|
|
443
|
+
A reply with more than `chunk` elements comes out as its header and then
|
|
444
|
+
one piece per `chunk` elements, so a caller can hand control back to the
|
|
445
|
+
event loop between pieces instead of holding it for the whole reply.
|
|
446
|
+
Smaller replies, and replies that are not a flat top-level array or
|
|
447
|
+
dict, come out as a single piece.
|
|
448
|
+
|
|
449
|
+
Unlike encode() this raises ReplyEncodeError when the reply cannot be
|
|
450
|
+
encoded, possibly after some pieces were produced: the caller must hold
|
|
451
|
+
every piece back until the last one is made, then send either all of
|
|
452
|
+
them or failure_reply(exc), so that a failure never leaves half a frame
|
|
453
|
+
on the wire.
|
|
454
|
+
"""
|
|
455
|
+
budget = [KESPDecoder.MAX_REPLY_ELEMENTS, KESPDecoder.MAX_REPLY_PAYLOAD]
|
|
456
|
+
|
|
457
|
+
if isinstance(data, (list, set, tuple)) and len(data) > chunk:
|
|
458
|
+
# The same checks _encode makes for a top-level array.
|
|
459
|
+
if budget[0] <= 0:
|
|
460
|
+
raise ReplyEncodeError("reply has too many elements")
|
|
461
|
+
budget[0] -= 1
|
|
462
|
+
if len(data) > CommandParser.MAX_ARRAY_LENGTH:
|
|
463
|
+
raise ReplyEncodeError("array too large")
|
|
464
|
+
if len(data) > budget[0]:
|
|
465
|
+
raise ReplyEncodeError("reply has too many elements")
|
|
466
|
+
yield f"A{len(data)}\n".encode("utf-8")
|
|
467
|
+
items = data if isinstance(data, (list, tuple)) else list(data)
|
|
468
|
+
for i in range(0, len(items), chunk):
|
|
469
|
+
yield b"".join(
|
|
470
|
+
[
|
|
471
|
+
KESPEncoder._encode(item, 1, False, budget)
|
|
472
|
+
for item in items[i : i + chunk]
|
|
473
|
+
]
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
elif isinstance(data, dict) and len(data) > chunk:
|
|
477
|
+
# The same checks _encode makes for a top-level dict (a flat array of
|
|
478
|
+
# alternating keys and values).
|
|
479
|
+
if budget[0] <= 0:
|
|
480
|
+
raise ReplyEncodeError("reply has too many elements")
|
|
481
|
+
budget[0] -= 1
|
|
482
|
+
if len(data) * 2 > CommandParser.MAX_ARRAY_LENGTH:
|
|
483
|
+
raise ReplyEncodeError("array too large")
|
|
484
|
+
if len(data) * 2 > budget[0]:
|
|
485
|
+
raise ReplyEncodeError("reply has too many elements")
|
|
486
|
+
yield f"A{len(data) * 2}\n".encode("utf-8")
|
|
487
|
+
entries = list(data.items())
|
|
488
|
+
for i in range(0, len(entries), chunk):
|
|
489
|
+
parts = []
|
|
490
|
+
for k, v in entries[i : i + chunk]:
|
|
491
|
+
if budget[0] <= 0:
|
|
492
|
+
raise ReplyEncodeError("reply has too many elements")
|
|
493
|
+
budget[0] -= 1 # the key
|
|
494
|
+
parts.append(KESPEncoder._bulk(str(k), budget))
|
|
495
|
+
parts.append(KESPEncoder._encode(v, 1, False, budget))
|
|
496
|
+
yield b"".join(parts)
|
|
497
|
+
|
|
498
|
+
else:
|
|
499
|
+
yield KESPEncoder._encode(data, 0, True, budget)
|
|
395
500
|
|
|
396
501
|
@staticmethod
|
|
397
502
|
def encode_command(tokens) -> bytes:
|
|
@@ -411,22 +516,39 @@ class KESPEncoder:
|
|
|
411
516
|
raw = to_bytes(text)
|
|
412
517
|
except UnicodeEncodeError:
|
|
413
518
|
raw = text.encode("utf-8", "replace")
|
|
519
|
+
# Errors often echo client input (an unknown command's name, say). A line
|
|
520
|
+
# longer than the decoder's MAX_LINE would be rejected by our own client,
|
|
521
|
+
# so cut it, at a character boundary, and say so.
|
|
522
|
+
limit = KESPDecoder.MAX_LINE - 1 # the 'E' counts towards the line
|
|
523
|
+
if len(raw) > limit:
|
|
524
|
+
cut = limit - 3
|
|
525
|
+
while cut > 0 and raw[cut] & 0xC0 == 0x80:
|
|
526
|
+
cut -= 1 # raw[cut] would be mid-character: step back to its start
|
|
527
|
+
raw = raw[:cut] + b"..."
|
|
414
528
|
return b"E" + raw + b"\n"
|
|
415
529
|
|
|
416
530
|
@staticmethod
|
|
417
|
-
def _bulk(text: str) -> bytes:
|
|
531
|
+
def _bulk(text: str, budget=None) -> bytes:
|
|
418
532
|
try:
|
|
419
533
|
raw = to_bytes(text)
|
|
420
534
|
except UnicodeEncodeError:
|
|
421
535
|
raise ReplyEncodeError("string is not valid UTF-8") from None
|
|
422
536
|
if len(raw) > CommandParser.MAX_BULK_LENGTH:
|
|
423
537
|
raise ReplyEncodeError("string too large")
|
|
538
|
+
if budget is not None:
|
|
539
|
+
if len(raw) > budget[1]:
|
|
540
|
+
raise ReplyEncodeError("reply payload too large")
|
|
541
|
+
budget[1] -= len(raw)
|
|
424
542
|
return b"S%d\n%s\n" % (len(raw), raw)
|
|
425
543
|
|
|
426
544
|
@staticmethod
|
|
427
|
-
def _encode(data, depth: int, top: bool) -> bytes:
|
|
545
|
+
def _encode(data, depth: int, top: bool, budget) -> bytes:
|
|
428
546
|
if depth > KESPDecoder.MAX_DEPTH:
|
|
429
547
|
raise ReplyEncodeError("reply nested too deeply")
|
|
548
|
+
# One element per value, containers included (as the decoder counts).
|
|
549
|
+
if budget[0] <= 0:
|
|
550
|
+
raise ReplyEncodeError("reply has too many elements")
|
|
551
|
+
budget[0] -= 1
|
|
430
552
|
|
|
431
553
|
# 1. Null / Missing Data
|
|
432
554
|
if data is None:
|
|
@@ -450,7 +572,7 @@ class KESPEncoder:
|
|
|
450
572
|
# 4. Strings & Status Messages
|
|
451
573
|
if isinstance(data, str):
|
|
452
574
|
if isinstance(data, BulkString) or not top:
|
|
453
|
-
return KESPEncoder._bulk(data)
|
|
575
|
+
return KESPEncoder._bulk(data, budget)
|
|
454
576
|
|
|
455
577
|
if data in KESPEncoder._STATUSES:
|
|
456
578
|
val = data if data.startswith("+") else f"+{data}"
|
|
@@ -462,16 +584,18 @@ class KESPEncoder:
|
|
|
462
584
|
return KESPEncoder._error_line(data[len(prefix) + 1 :])
|
|
463
585
|
|
|
464
586
|
# Otherwise, it's a standard Binary-Safe Bulk String
|
|
465
|
-
return KESPEncoder._bulk(data)
|
|
587
|
+
return KESPEncoder._bulk(data, budget)
|
|
466
588
|
|
|
467
589
|
# 5. Arrays (Lists/Sets)
|
|
468
590
|
if isinstance(data, (list, set, tuple)):
|
|
469
591
|
if len(data) > CommandParser.MAX_ARRAY_LENGTH:
|
|
470
592
|
raise ReplyEncodeError("array too large")
|
|
593
|
+
if len(data) > budget[0]:
|
|
594
|
+
raise ReplyEncodeError("reply has too many elements")
|
|
471
595
|
header = f"A{len(data)}\n".encode("utf-8")
|
|
472
596
|
# Recursively encode every element inside the array
|
|
473
597
|
elements = b"".join(
|
|
474
|
-
KESPEncoder._encode(item, depth + 1, False) for item in data
|
|
598
|
+
KESPEncoder._encode(item, depth + 1, False, budget) for item in data
|
|
475
599
|
)
|
|
476
600
|
return header + elements
|
|
477
601
|
|
|
@@ -480,11 +604,17 @@ class KESPEncoder:
|
|
|
480
604
|
# A dictionary of 3 items becomes a KESP array of 6 items [k1, v1, k2, v2...]
|
|
481
605
|
if len(data) * 2 > CommandParser.MAX_ARRAY_LENGTH:
|
|
482
606
|
raise ReplyEncodeError("array too large")
|
|
607
|
+
if len(data) * 2 > budget[0]:
|
|
608
|
+
raise ReplyEncodeError("reply has too many elements")
|
|
483
609
|
header = f"A{len(data) * 2}\n".encode("utf-8")
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
610
|
+
parts = []
|
|
611
|
+
for k, v in data.items():
|
|
612
|
+
if budget[0] <= 0:
|
|
613
|
+
raise ReplyEncodeError("reply has too many elements")
|
|
614
|
+
budget[0] -= 1 # the key
|
|
615
|
+
parts.append(KESPEncoder._bulk(str(k), budget))
|
|
616
|
+
parts.append(KESPEncoder._encode(v, depth + 1, False, budget))
|
|
617
|
+
elements = b"".join(parts)
|
|
488
618
|
return header + elements
|
|
489
619
|
|
|
490
620
|
# Fallback
|