1import datetime
2
3from redis.commands.core import GCRAResponse
4from redis.utils import str_if_bytes
5
6
7def timestamp_to_datetime(response):
8 "Converts a unix timestamp to a Python datetime object"
9 if not response:
10 return None
11 try:
12 response = int(response)
13 except ValueError:
14 return None
15 return datetime.datetime.fromtimestamp(response)
16
17
18def parse_debug_object(response):
19 "Parse the results of Redis's DEBUG OBJECT command into a Python dict"
20 # The 'type' of the object is the first item in the response, but isn't
21 # prefixed with a name
22 response = str_if_bytes(response)
23 response = "type:" + response
24 response = dict(kv.split(":") for kv in response.split())
25
26 # parse some expected int values from the string response
27 # note: this cmd isn't spec'd so these may not appear in all redis versions
28 int_fields = ("refcount", "serializedlength", "lru", "lru_seconds_idle")
29 for field in int_fields:
30 if field in response:
31 response[field] = int(response[field])
32
33 return response
34
35
36def parse_info(response):
37 """Parse the result of Redis's INFO command into a Python dict"""
38 info = {}
39 response = str_if_bytes(response)
40
41 def get_value(value):
42 if "," not in value and "=" not in value:
43 try:
44 if "." in value:
45 return float(value)
46 else:
47 return int(value)
48 except ValueError:
49 return value
50 elif "=" not in value:
51 return [get_value(v) for v in value.split(",") if v]
52 else:
53 sub_dict = {}
54 for item in value.split(","):
55 if not item:
56 continue
57 if "=" in item:
58 k, v = item.rsplit("=", 1)
59 sub_dict[k] = get_value(v)
60 else:
61 sub_dict[item] = True
62 return sub_dict
63
64 for line in response.splitlines():
65 if line and not line.startswith("#"):
66 if line.find(":") != -1:
67 # Split, the info fields keys and values.
68 # Note that the value may contain ':'. but the 'host:'
69 # pseudo-command is the only case where the key contains ':'
70 key, value = line.split(":", 1)
71 if key == "cmdstat_host":
72 key, value = line.rsplit(":", 1)
73
74 if key == "module":
75 # Hardcode a list for key 'modules' since there could be
76 # multiple lines that started with 'module'
77 info.setdefault("modules", []).append(get_value(value))
78 else:
79 info[key] = get_value(value)
80 else:
81 # if the line isn't splittable, append it to the "__raw__" key
82 info.setdefault("__raw__", []).append(line)
83
84 return info
85
86
87def parse_memory_stats(response, **kwargs):
88 """Parse the results of MEMORY STATS"""
89 stats = pairs_to_dict(response, decode_keys=True)
90 for key, value in stats.items():
91 if key.startswith("db.") and isinstance(value, list):
92 stats[key] = pairs_to_dict(value, decode_keys=True)
93 return stats
94
95
96def parse_memory_stats_resp3(response, **kwargs):
97 """Parse MEMORY STATS for RESP3 — decode keys to str, preserve native values."""
98 stats = {str_if_bytes(key): value for key, value in response.items()}
99 for key, value in stats.items():
100 if key.startswith("db.") and isinstance(value, dict):
101 stats[key] = {str_if_bytes(k): v for k, v in value.items()}
102 return stats
103
104
105SENTINEL_STATE_TYPES = {
106 "can-failover-its-master": int,
107 "config-epoch": int,
108 "down-after-milliseconds": int,
109 "failover-timeout": int,
110 "info-refresh": int,
111 "last-hello-message": int,
112 "last-ok-ping-reply": int,
113 "last-ping-reply": int,
114 "last-ping-sent": int,
115 "master-link-down-time": int,
116 "master-port": int,
117 "num-other-sentinels": int,
118 "num-slaves": int,
119 "o-down-time": int,
120 "pending-commands": int,
121 "parallel-syncs": int,
122 "port": int,
123 "quorum": int,
124 "role-reported-time": int,
125 "s-down-time": int,
126 "slave-priority": int,
127 "slave-repl-offset": int,
128 "voted-leader-epoch": int,
129}
130
131
132def parse_sentinel_state(item):
133 result = pairs_to_dict_typed(item, SENTINEL_STATE_TYPES)
134 flags = set(result["flags"].split(","))
135 result["flags"] = flags
136 for name, flag in (
137 ("is_master", "master"),
138 ("is_slave", "slave"),
139 ("is_sdown", "s_down"),
140 ("is_odown", "o_down"),
141 ("is_sentinel", "sentinel"),
142 ("is_disconnected", "disconnected"),
143 ("is_master_down", "master_down"),
144 ):
145 result[name] = flag in flags
146 return result
147
148
149def parse_sentinel_master(response, **options):
150 return parse_sentinel_state(map(str_if_bytes, response))
151
152
153def parse_sentinel_state_resp3(response, **options):
154 result = {}
155 for key in response:
156 try:
157 value = SENTINEL_STATE_TYPES[str_if_bytes(key)](str_if_bytes(response[key]))
158 result[str_if_bytes(key)] = value
159 except Exception:
160 result[str_if_bytes(key)] = str_if_bytes(response[key])
161 flags = set(result["flags"].split(","))
162 result["flags"] = flags
163 for name, flag in (
164 ("is_master", "master"),
165 ("is_slave", "slave"),
166 ("is_sdown", "s_down"),
167 ("is_odown", "o_down"),
168 ("is_sentinel", "sentinel"),
169 ("is_disconnected", "disconnected"),
170 ("is_master_down", "master_down"),
171 ):
172 result[name] = flag in flags
173 return result
174
175
176def parse_sentinel_masters(response, **options):
177 result = {}
178 for item in response:
179 state = parse_sentinel_state(map(str_if_bytes, item))
180 result[state["name"]] = state
181 return result
182
183
184def parse_sentinel_masters_resp3(response, **options):
185 result = {}
186 for master in response:
187 state = parse_sentinel_state_resp3(master)
188 result[state["name"]] = state
189 return result
190
191
192def parse_sentinel_slaves_and_sentinels(response, **options):
193 return [parse_sentinel_state(map(str_if_bytes, item)) for item in response]
194
195
196def parse_sentinel_slaves_and_sentinels_resp3(response, **options):
197 return [parse_sentinel_state_resp3(item, **options) for item in response]
198
199
200def parse_sentinel_get_master(response, **options):
201 return response and (response[0], int(response[1])) or None
202
203
204def pairs_to_dict(response, decode_keys=False, decode_string_values=False):
205 """Create a dict given a list of key/value pairs"""
206 if response is None:
207 return {}
208 if decode_keys or decode_string_values:
209 # the iter form is faster, but I don't know how to make that work
210 # with a str_if_bytes() map
211 keys = response[::2]
212 if decode_keys:
213 keys = map(str_if_bytes, keys)
214 values = response[1::2]
215 if decode_string_values:
216 values = map(str_if_bytes, values)
217 return dict(zip(keys, values))
218 else:
219 it = iter(response)
220 return dict(zip(it, it))
221
222
223def pairs_to_dict_typed(response, type_info):
224 it = iter(response)
225 result = {}
226 for key, value in zip(it, it):
227 if key in type_info:
228 try:
229 value = type_info[key](value)
230 except Exception:
231 # if for some reason the value can't be coerced, just use
232 # the string value
233 pass
234 result[key] = value
235 return result
236
237
238def _wrap_score_cast_func(score_cast_func):
239 """Wrap score_cast_func to handle scientific notation in RESP2 byte strings.
240
241 Redis returns scores as byte strings in RESP2, and large numbers may use
242 scientific notation (e.g., b'1.7732526297292595e+18'). Python's int() cannot
243 parse scientific notation directly. Rather than unconditionally routing
244 through float() (which would change the input type for every custom
245 callable), we try the original function first and only fall back to
246 converting through float() on ValueError.
247 """
248 if score_cast_func is float:
249 return score_cast_func
250
251 def _safe_cast(x):
252 try:
253 return score_cast_func(x)
254 except (ValueError, TypeError):
255 return score_cast_func(float(x))
256
257 return _safe_cast
258
259
260def zset_score_pairs(response, **options):
261 """
262 If ``withscores`` is specified in the options, return the response as
263 a list of [value, score] pairs
264 """
265 if not response or not options.get("withscores"):
266 return response
267 score_cast_func = _wrap_score_cast_func(options.get("score_cast_func", float))
268 it = iter(response)
269 # Normalise RESP2 byte-string scores to float before applying the cast
270 # so that score_cast_func receives the same type as in RESP3.
271 return [[val, score_cast_func(float(score))] for val, score in zip(it, it)]
272
273
274def zset_score_for_rank(response, **options):
275 """
276 If ``withscores`` is specified in the options, return the response as
277 a [value, score] pair
278 """
279 if not response or not options.get("withscore"):
280 return response
281 score_cast_func = _wrap_score_cast_func(options.get("score_cast_func", float))
282 # Normalise RESP2 byte-string scores to float before applying the cast
283 # so that score_cast_func receives the same type as in RESP3.
284 return [response[0], score_cast_func(float(response[1]))]
285
286
287def zset_score_pairs_resp3(response, **options):
288 """
289 If ``withscores`` is specified in the options, return the response as
290 a list of [value, score] pairs
291 """
292 if not response or not options.get("withscores"):
293 return response
294 score_cast_func = options.get("score_cast_func", float)
295 return [[name, score_cast_func(val)] for name, val in response]
296
297
298def hrandfield_pairs(response, **options):
299 """
300 If ``withvalues`` is specified in the options, return the response as
301 a list of [field, value] pairs (pairing flat interleaved list).
302 """
303 if not response or not options.get("withvalues"):
304 return response
305 it = iter(response)
306 return [[field, val] for field, val in zip(it, it)]
307
308
309def parse_zmpop(response, **options):
310 """
311 Parse ZMPOP/BZMPOP response, casting scores to float.
312 Response format: [key, [[member, score], ...]] or None.
313 """
314 if response is None:
315 return None
316 key, members = response
317 return [
318 key,
319 [
320 [member, score if isinstance(score, float) else float(score)]
321 for member, score in members
322 ],
323 ]
324
325
326def parse_lcs(response, **options):
327 """
328 Parse LCS response. Without modifiers returns the raw string.
329 With LEN returns an integer. With IDX returns a dict with string keys.
330 RESP2 with IDX returns a flat list [key, val, key, val, ...]
331 which we convert to a dict. RESP3 returns a native dict.
332 Both have keys normalized to strings.
333 """
334 if isinstance(response, list):
335 it = iter(response)
336 return {str_if_bytes(k): v for k, v in zip(it, it)}
337 if isinstance(response, dict):
338 return {str_if_bytes(k): v for k, v in response.items()}
339 return response
340
341
342def zpop_score_pairs(response, **options):
343 """
344 Handle ZPOPMAX/ZPOPMIN RESP2 responses.
345 RESP2 always returns a flat array: [member1, score1, member2, score2, ...]
346 Scores are byte strings that need to be cast to float.
347 Always pairs and casts scores — no ``withscores`` gate required because
348 ZPOPMAX/ZPOPMIN always include scores in their response.
349 """
350 if not response:
351 return response
352 score_cast_func = _wrap_score_cast_func(options.get("score_cast_func", float))
353 it = iter(response)
354 return [[val, score_cast_func(float(score))] for val, score in zip(it, it)]
355
356
357def zpop_score_pairs_resp3(response, **options):
358 """
359 Handle ZPOPMAX/ZPOPMIN RESP3 responses which differ based on count:
360 - Without count: flat [member, score]
361 - With count: nested [[member, score], ...]
362 Normalizes both to list of [member, score] pairs with score_cast_func applied.
363 """
364 if not response:
365 return response
366 score_cast_func = options.get("score_cast_func", float)
367 # Detect flat vs nested: if first element is a list, it's nested (with count)
368 if isinstance(response[0], list):
369 return [[name, score_cast_func(val)] for name, val in response]
370 else:
371 return [[response[0], score_cast_func(response[1])]]
372
373
374def zset_score_for_rank_resp3(response, **options):
375 """
376 If ``withscores`` is specified in the options, return the response as
377 a [value, score] pair
378 """
379 if not response or not options.get("withscore"):
380 return response
381 score_cast_func = options.get("score_cast_func", float)
382 return [response[0], score_cast_func(response[1])]
383
384
385def sort_return_tuples(response, **options):
386 """
387 If ``groups`` is specified, return the response as a list of
388 n-element tuples with n being the value found in options['groups']
389 """
390 if not response or not options.get("groups"):
391 return response
392 n = options["groups"]
393 return list(zip(*[response[i::n] for i in range(n)]))
394
395
396def parse_stream_list(response, **options):
397 if response is None:
398 return None
399 data = []
400 for r in response:
401 if r is not None:
402 if "claim_min_idle_time" in options:
403 data.append((r[0], pairs_to_dict(r[1]), *r[2:]))
404 else:
405 data.append((r[0], pairs_to_dict(r[1])))
406 else:
407 data.append((None, None))
408 return data
409
410
411def pairs_to_dict_with_str_keys(response):
412 return pairs_to_dict(response, decode_keys=True)
413
414
415def parse_list_of_dicts(response):
416 return list(map(pairs_to_dict_with_str_keys, response))
417
418
419def parse_xclaim(response, **options):
420 if options.get("parse_justid", False):
421 return response
422 return parse_stream_list(response)
423
424
425def parse_xautoclaim(response, **options):
426 if options.get("parse_justid", False):
427 return response[1]
428 response[1] = parse_stream_list(response[1])
429 return response
430
431
432def parse_xinfo_stream(response, **options):
433 if isinstance(response, list):
434 data = pairs_to_dict(response, decode_keys=True)
435 else:
436 data = {str_if_bytes(k): v for k, v in response.items()}
437 if not options.get("full", False):
438 first = data.get("first-entry")
439 if first is not None and first[0] is not None:
440 data["first-entry"] = (first[0], pairs_to_dict(first[1]))
441 last = data["last-entry"]
442 if last is not None and last[0] is not None:
443 data["last-entry"] = (last[0], pairs_to_dict(last[1]))
444 else:
445 data["entries"] = {_id: pairs_to_dict(entry) for _id, entry in data["entries"]}
446 if len(data["groups"]) > 0 and isinstance(data["groups"][0], list):
447 data["groups"] = [
448 pairs_to_dict(group, decode_keys=True) for group in data["groups"]
449 ]
450 for g in data["groups"]:
451 if g["consumers"] and g["consumers"][0] is not None:
452 g["consumers"] = [
453 pairs_to_dict(c, decode_keys=True) for c in g["consumers"]
454 ]
455 else:
456 data["groups"] = [
457 {str_if_bytes(k): v for k, v in group.items()}
458 for group in data["groups"]
459 ]
460 return data
461
462
463def parse_xread(response, **options):
464 if response is None:
465 return {}
466 return {r[0]: parse_stream_list(r[1], **options) for r in response}
467
468
469def parse_xread_resp3(response, **options):
470 if response is None:
471 return {}
472 return {key: parse_stream_list(value, **options) for key, value in response.items()}
473
474
475def parse_xpending(response, **options):
476 if options.get("parse_detail", False):
477 return parse_xpending_range(response)
478 consumers = [{"name": n, "pending": int(p)} for n, p in response[3] or []]
479 return {
480 "pending": response[0],
481 "min": response[1],
482 "max": response[2],
483 "consumers": consumers,
484 }
485
486
487def parse_xpending_range(response):
488 k = ("message_id", "consumer", "time_since_delivered", "times_delivered")
489 return [dict(zip(k, r)) for r in response]
490
491
492def float_or_none(response):
493 if response is None:
494 return None
495 return float(response)
496
497
498def bool_ok(response, **options):
499 return str_if_bytes(response) == "OK"
500
501
502def parse_zadd(response, **options):
503 if response is None:
504 return None
505 if options.get("as_score"):
506 return float(response)
507 return int(response)
508
509
510def parse_client_list(response, **options):
511 clients = []
512 for c in str_if_bytes(response).splitlines():
513 client_dict = {}
514 tokens = c.split(" ")
515 last_key = None
516 for token in tokens:
517 if "=" in token:
518 # Values might contain '='
519 key, value = token.split("=", 1)
520 client_dict[key] = value
521 last_key = key
522 else:
523 # Values may include spaces. For instance, when running Redis via a Unix socket — such as
524 # "/tmp/redis sock/redis.sock" — the addr or laddr field will include a space.
525 client_dict[last_key] += " " + token
526
527 if client_dict:
528 clients.append(client_dict)
529 return clients
530
531
532def parse_config_get(response, **options):
533 response = [str_if_bytes(i) if i is not None else None for i in response]
534 return response and pairs_to_dict(response) or {}
535
536
537def parse_scan(response, **options):
538 cursor, r = response
539 return int(cursor), r
540
541
542def parse_hscan(response, **options):
543 cursor, r = response
544 no_values = options.get("no_values", False)
545 if no_values:
546 payload = r or []
547 else:
548 payload = r and pairs_to_dict(r) or {}
549 return int(cursor), payload
550
551
552def parse_zscan(response, **options):
553 score_cast_func = _wrap_score_cast_func(options.get("score_cast_func", float))
554 cursor, r = response
555 it = iter(r)
556 # Normalise scores to float before applying the cast so that
557 # score_cast_func receives the same type regardless of protocol.
558 return int(cursor), [
559 [val, score_cast_func(float(score))] for val, score in zip(it, it)
560 ]
561
562
563def parse_zmscore(response, **options):
564 # zmscore: list of scores (double precision floating point number) or nil
565 return [float(score) if score is not None else None for score in response]
566
567
568def parse_slowlog_get(response, **options):
569 space = " " if options.get("decode_responses", False) else b" "
570
571 def parse_item(item):
572 result = {"id": item[0], "start_time": int(item[1]), "duration": int(item[2])}
573 # Redis Enterprise injects another entry at index [3], which has
574 # the complexity info (i.e. the value N in case the command has
575 # an O(N) complexity) instead of the command.
576 if isinstance(item[3], list):
577 result["command"] = space.join(item[3])
578
579 # These fields are optional, depends on environment.
580 if len(item) >= 6:
581 result["client_address"] = item[4]
582 result["client_name"] = item[5]
583 else:
584 result["complexity"] = item[3]
585 result["command"] = space.join(item[4])
586
587 # These fields are optional, depends on environment.
588 if len(item) >= 7:
589 result["client_address"] = item[5]
590 result["client_name"] = item[6]
591
592 return result
593
594 return [parse_item(item) for item in response]
595
596
597def parse_client_trackinginfo(response, **kwargs):
598 """
599 Parse CLIENT TRACKINGINFO response into a dict with str keys.
600 RESP2: flat list [key, val, key, val, ...] → dict
601 RESP3: native dict with bytes keys → dict with str keys
602 """
603 if isinstance(response, list):
604 data = pairs_to_dict(response, decode_keys=True)
605 else:
606 data = {str_if_bytes(k): v for k, v in response.items()}
607 if "flags" in data:
608 data["flags"] = [str_if_bytes(f) for f in data["flags"]]
609 if "prefixes" in data:
610 data["prefixes"] = [str_if_bytes(p) for p in data["prefixes"]]
611 return data
612
613
614def parse_stralgo(response, **options):
615 """
616 Parse the response from `STRALGO` command.
617 Without modifiers the returned value is string.
618 When LEN is given the command returns the length of the result
619 (i.e integer).
620 When IDX is given the command returns a dictionary with the LCS
621 length and all the ranges in both the strings, start and end
622 offset for each string, where there are matches.
623 When WITHMATCHLEN is given, each array representing a match will
624 also have the length of the match at the beginning of the array.
625 """
626 if options.get("len", False):
627 return int(response)
628 if options.get("idx", False):
629 if options.get("withmatchlen", False):
630 matches = [
631 [(int(match[-1]))] + [list(m) for m in match[:-1]]
632 for match in response[1]
633 ]
634 else:
635 matches = [[list(m) for m in match] for match in response[1]]
636 return {
637 str_if_bytes(response[0]): matches,
638 str_if_bytes(response[2]): int(response[3]),
639 }
640 return str_if_bytes(response)
641
642
643def parse_stralgo_resp3(response, **options):
644 """Parse RESP3 ``STRALGO`` response to match RESP2 ``parse_stralgo`` output.
645
646 RESP3 returns a dict ``{b"matches": [...], b"len": N}`` for ``idx=True``,
647 an int for ``len=True``, or a plain string otherwise. The match
648 restructuring mirrors :func:`parse_stralgo` exactly so both protocols
649 produce identical results.
650 """
651 if options.get("len", False):
652 return int(response)
653 if options.get("idx", False):
654 if isinstance(response, dict):
655 raw_matches = response.get("matches", response.get(b"matches", []))
656 raw_len = response.get("len", response.get(b"len", 0))
657 else:
658 return str_if_bytes(response)
659 if options.get("withmatchlen", False):
660 matches = [
661 [int(match[-1])] + [list(m) for m in match[:-1]]
662 for match in raw_matches
663 ]
664 else:
665 matches = [[list(m) for m in match] for match in raw_matches]
666 return {
667 "matches": matches,
668 "len": int(raw_len),
669 }
670 return str_if_bytes(response)
671
672
673def parse_cluster_links(response, **options):
674 """Parse CLUSTER LINKS into a list of dicts with str keys.
675
676 RESP2 returns a list of flat lists ``[key, val, key, val, ...]``.
677 RESP3 returns a list of dicts with bytes keys.
678 Both are normalised to ``[{"direction": ..., "node": ..., ...}, ...]``.
679 """
680 result = []
681 for item in response:
682 if isinstance(item, dict):
683 result.append({str_if_bytes(k): v for k, v in item.items()})
684 else:
685 result.append(pairs_to_dict(item, decode_keys=True))
686 return result
687
688
689def parse_cluster_info(response, **options):
690 response = str_if_bytes(response)
691 return dict(line.split(":") for line in response.splitlines() if line)
692
693
694def _parse_node_line(line):
695 line_items = line.split(" ")
696 node_id, addr, flags, master_id, ping, pong, epoch, connected = line.split(" ")[:8]
697 ip = addr.split("@")[0]
698 hostname = addr.split("@")[1].split(",")[1] if "@" in addr and "," in addr else ""
699 node_dict = {
700 "node_id": node_id,
701 "hostname": hostname,
702 "flags": flags,
703 "master_id": master_id,
704 "last_ping_sent": ping,
705 "last_pong_rcvd": pong,
706 "epoch": epoch,
707 "slots": [],
708 "migrations": [],
709 "connected": True if connected == "connected" else False,
710 }
711 if len(line_items) >= 9:
712 slots, migrations = _parse_slots(line_items[8:])
713 node_dict["slots"], node_dict["migrations"] = slots, migrations
714 return ip, node_dict
715
716
717def _parse_slots(slot_ranges):
718 slots, migrations = [], []
719 for s_range in slot_ranges:
720 if "->-" in s_range:
721 slot_id, dst_node_id = s_range[1:-1].split("->-", 1)
722 migrations.append(
723 {"slot": slot_id, "node_id": dst_node_id, "state": "migrating"}
724 )
725 elif "-<-" in s_range:
726 slot_id, src_node_id = s_range[1:-1].split("-<-", 1)
727 migrations.append(
728 {"slot": slot_id, "node_id": src_node_id, "state": "importing"}
729 )
730 else:
731 s_range = [sl for sl in s_range.split("-")]
732 slots.append(s_range)
733
734 return slots, migrations
735
736
737def parse_cluster_nodes(response, **options):
738 """
739 @see: https://redis.io/commands/cluster-nodes # string / bytes
740 @see: https://redis.io/commands/cluster-replicas # list of string / bytes
741 """
742 if isinstance(response, (str, bytes)):
743 response = response.splitlines()
744 return dict(_parse_node_line(str_if_bytes(node)) for node in response)
745
746
747def parse_geosearch_generic(response, **options):
748 """
749 Parse the response of 'GEOSEARCH', GEORADIUS' and 'GEORADIUSBYMEMBER'
750 commands according to 'withdist', 'withhash' and 'withcoord' labels.
751 """
752 try:
753 if options["store"] or options["store_dist"]:
754 # `store` and `store_dist` cant be combined
755 # with other command arguments.
756 # relevant to 'GEORADIUS' and 'GEORADIUSBYMEMBER'
757 return response
758 except KeyError: # it means the command was sent via execute_command
759 return response
760
761 if not isinstance(response, list):
762 response_list = [response]
763 else:
764 response_list = response
765
766 if not options["withdist"] and not options["withcoord"] and not options["withhash"]:
767 # just a bunch of places
768 return response_list
769
770 cast = {
771 "withdist": float,
772 "withcoord": lambda ll: [float(ll[0]), float(ll[1])],
773 "withhash": int,
774 }
775
776 # zip all output results with each casting function to get
777 # the properly native Python value.
778 f = [lambda x: x]
779 f += [cast[o] for o in ["withdist", "withhash", "withcoord"] if options[o]]
780 return [list(map(lambda fv: fv[0](fv[1]), zip(f, r))) for r in response_list]
781
782
783def parse_command(response, **options):
784 commands = {}
785 for command in response:
786 cmd_dict = {}
787 cmd_name = str_if_bytes(command[0])
788 cmd_dict["name"] = cmd_name
789 cmd_dict["arity"] = int(command[1])
790 cmd_dict["flags"] = {str_if_bytes(flag) for flag in command[2]}
791 cmd_dict["first_key_pos"] = command[3]
792 cmd_dict["last_key_pos"] = command[4]
793 cmd_dict["step_count"] = command[5]
794 if len(command) > 6:
795 cmd_dict["acl_categories"] = {str_if_bytes(c) for c in command[6]}
796 if len(command) > 7:
797 cmd_dict["tips"] = command[7]
798 cmd_dict["key_specifications"] = command[8]
799 cmd_dict["subcommands"] = command[9]
800 commands[cmd_name] = cmd_dict
801 return commands
802
803
804def parse_command_resp3(response, **options):
805 commands = {}
806 for command in response:
807 cmd_dict = {}
808 cmd_name = str_if_bytes(command[0])
809 cmd_dict["name"] = cmd_name
810 cmd_dict["arity"] = command[1]
811 cmd_dict["flags"] = {str_if_bytes(flag) for flag in command[2]}
812 cmd_dict["first_key_pos"] = command[3]
813 cmd_dict["last_key_pos"] = command[4]
814 cmd_dict["step_count"] = command[5]
815 cmd_dict["acl_categories"] = {str_if_bytes(c) for c in command[6]}
816 if len(command) > 7:
817 cmd_dict["tips"] = command[7]
818 cmd_dict["key_specifications"] = command[8]
819 cmd_dict["subcommands"] = command[9]
820
821 commands[cmd_name] = cmd_dict
822 return commands
823
824
825def parse_pubsub_numsub(response, **options):
826 return list(zip(response[0::2], response[1::2]))
827
828
829def parse_client_kill(response, **options):
830 if isinstance(response, int):
831 return response
832 return str_if_bytes(response) == "OK"
833
834
835def parse_acl_getuser(response, **options):
836 if response is None:
837 return None
838 if isinstance(response, list):
839 data = pairs_to_dict(response, decode_keys=True)
840 else:
841 data = {str_if_bytes(key): value for key, value in response.items()}
842
843 # convert everything but user-defined data in 'keys' to native strings
844 data["flags"] = list(map(str_if_bytes, data["flags"]))
845 data["passwords"] = list(map(str_if_bytes, data["passwords"]))
846 data["commands"] = str_if_bytes(data["commands"])
847 if isinstance(data["keys"], str) or isinstance(data["keys"], bytes):
848 data["keys"] = list(str_if_bytes(data["keys"]).split(" "))
849 if data["keys"] == [""]:
850 data["keys"] = []
851 if "channels" in data:
852 if isinstance(data["channels"], str) or isinstance(data["channels"], bytes):
853 data["channels"] = list(str_if_bytes(data["channels"]).split(" "))
854 if data["channels"] == [""]:
855 data["channels"] = []
856 if "selectors" in data:
857 if data["selectors"] != [] and isinstance(data["selectors"][0], list):
858 # RESP2: flat list [key, val, key, val] → convert to dict
859 data["selectors"] = [
860 pairs_to_dict(selector, decode_keys=True, decode_string_values=True)
861 for selector in data["selectors"]
862 ]
863 elif data["selectors"] != []:
864 data["selectors"] = [
865 {str_if_bytes(k): str_if_bytes(v) for k, v in selector.items()}
866 for selector in data["selectors"]
867 ]
868
869 # split 'commands' into separate 'categories' and 'commands' lists
870 commands, categories = [], []
871 for command in data["commands"].split(" "):
872 categories.append(command) if "@" in command else commands.append(command)
873
874 data["commands"] = commands
875 data["categories"] = categories
876 data["enabled"] = "on" in data["flags"]
877 return data
878
879
880def parse_acl_log(response, **options):
881 if response is None:
882 return None
883 if isinstance(response, list):
884 data = []
885 for log in response:
886 log_data = pairs_to_dict(log, True, True)
887 client_info = log_data.get("client-info", "")
888 log_data["client-info"] = parse_client_info(client_info)
889
890 # float() is lossy comparing to the "double" in C
891 log_data["age-seconds"] = float(log_data["age-seconds"])
892 data.append(log_data)
893 else:
894 data = bool_ok(response)
895 return data
896
897
898def parse_acl_log_resp3(response, **options):
899 """Parse ACL LOG for RESP3 — normalize to match RESP2 semantic richness.
900 Converts age-seconds to float and client-info to parsed dict."""
901 if not isinstance(response, list):
902 return bool_ok(response)
903 data = []
904 for entry in response:
905 log_data = {str_if_bytes(k): v for k, v in entry.items()}
906 # Ensure age-seconds is float
907 if "age-seconds" in log_data:
908 log_data["age-seconds"] = float(log_data["age-seconds"])
909 # Parse client-info string into dict
910 if "client-info" in log_data:
911 log_data["client-info"] = parse_client_info(log_data["client-info"])
912 # Decode remaining string values
913 for key in log_data:
914 if key not in ("age-seconds", "client-info"):
915 log_data[key] = str_if_bytes(log_data[key])
916 data.append(log_data)
917 return data
918
919
920def parse_client_info(value):
921 """
922 Parsing client-info in ACL Log in following format.
923 "key1=value1 key2=value2 key3=value3"
924 """
925 client_info = {}
926 for info in str_if_bytes(value).strip().split():
927 key, value = info.split("=")
928 client_info[key] = value
929
930 # Those fields are defined as int in networking.c
931 for int_key in {
932 "id",
933 "age",
934 "idle",
935 "db",
936 "sub",
937 "psub",
938 "multi",
939 "qbuf",
940 "qbuf-free",
941 "obl",
942 "argv-mem",
943 "oll",
944 "omem",
945 "tot-mem",
946 }:
947 if int_key in client_info:
948 client_info[int_key] = int(client_info[int_key])
949 return client_info
950
951
952def parse_set_result(response, **options):
953 """
954 Handle SET result since GET argument is available since Redis 6.2.
955 Parsing SET result into:
956 - BOOL
957 - String when GET argument is used
958 """
959 if options.get("get"):
960 # Redis will return a getCommand result.
961 # See `setGenericCommand` in t_string.c
962 return response
963 return response and str_if_bytes(response) == "OK"
964
965
966def parse_gcra(response, **options):
967 """
968 Parse the GCRA rate limiting command response into a GCRAResponse dataclass.
969
970 Response format: [limited, max_req_num, num_avail_req, retry_after, full_burst_after]
971 """
972 return GCRAResponse(
973 limited=bool(response[0]),
974 max_req_num=int(response[1]),
975 num_avail_req=int(response[2]),
976 retry_after=int(response[3]),
977 full_burst_after=int(response[4]),
978 )
979
980
981def parse_function_list(response):
982 """Parse FUNCTION LIST response from RESP2 flat lists into nested dicts.
983
984 RESP2 returns: [[key, val, key, val, ...], ...]
985 where nested 'functions' values are also flat lists.
986 Converts to match RESP3's native dict format.
987 """
988 result = []
989 for lib_flat in response:
990 lib_dict = pairs_to_dict(lib_flat)
991 # Convert each function's flat list to a dict.
992 # The key is b"functions" normally, but "functions" when
993 # decode_responses=True.
994 func_key = "functions" if "functions" in lib_dict else b"functions"
995 if func_key in lib_dict:
996 lib_dict[func_key] = [pairs_to_dict(func) for func in lib_dict[func_key]]
997 result.append(lib_dict)
998 return result
999
1000
1001def string_keys_to_dict(key_string, callback):
1002 return dict.fromkeys(key_string.split(), callback)
1003
1004
1005_RedisCallbacks = {
1006 **string_keys_to_dict(
1007 "AUTH COPY EXPIRE EXPIREAT HEXISTS HMSET MOVE MSETNX PERSIST PSETEX "
1008 "PEXPIRE PEXPIREAT RENAMENX SETEX SETNX SMOVE",
1009 bool,
1010 ),
1011 **string_keys_to_dict("HINCRBYFLOAT INCRBYFLOAT", float),
1012 **string_keys_to_dict(
1013 "ASKING FLUSHALL FLUSHDB LSET LTRIM MSET PFMERGE READONLY READWRITE "
1014 "RENAME SAVE SELECT SHUTDOWN SLAVEOF SWAPDB WATCH UNWATCH",
1015 bool_ok,
1016 ),
1017 **string_keys_to_dict("XREAD XREADGROUP", parse_xread),
1018 **string_keys_to_dict(
1019 "GEORADIUS GEORADIUSBYMEMBER GEOSEARCH",
1020 parse_geosearch_generic,
1021 ),
1022 **string_keys_to_dict("XRANGE XREVRANGE", parse_stream_list),
1023 "ACL GETUSER": parse_acl_getuser,
1024 "ACL LOAD": bool_ok,
1025 "ACL LOG": parse_acl_log,
1026 "ACL SETUSER": bool_ok,
1027 "ACL SAVE": bool_ok,
1028 "CLIENT INFO": parse_client_info,
1029 "CLIENT KILL": parse_client_kill,
1030 "CLIENT LIST": parse_client_list,
1031 "CLIENT PAUSE": bool_ok,
1032 "CLIENT SETINFO": bool_ok,
1033 "CLIENT TRACKINGINFO": parse_client_trackinginfo,
1034 "CLIENT SETNAME": bool_ok,
1035 "CLIENT UNBLOCK": bool,
1036 "CLUSTER ADDSLOTS": bool_ok,
1037 "CLUSTER ADDSLOTSRANGE": bool_ok,
1038 "CLUSTER DELSLOTS": bool_ok,
1039 "CLUSTER DELSLOTSRANGE": bool_ok,
1040 "CLUSTER FAILOVER": bool_ok,
1041 "CLUSTER FORGET": bool_ok,
1042 "CLUSTER INFO": parse_cluster_info,
1043 "CLUSTER LINKS": parse_cluster_links,
1044 "CLUSTER MEET": bool_ok,
1045 "CLUSTER NODES": parse_cluster_nodes,
1046 "CLUSTER REPLICAS": parse_cluster_nodes,
1047 "CLUSTER REPLICATE": bool_ok,
1048 "CLUSTER RESET": bool_ok,
1049 "CLUSTER SAVECONFIG": bool_ok,
1050 "CLUSTER SET-CONFIG-EPOCH": bool_ok,
1051 "CLUSTER SETSLOT": bool_ok,
1052 "CLUSTER SLAVES": parse_cluster_nodes,
1053 "COMMAND": parse_command,
1054 "CONFIG RESETSTAT": bool_ok,
1055 "CONFIG SET": bool_ok,
1056 "FUNCTION DELETE": bool_ok,
1057 "FUNCTION FLUSH": bool_ok,
1058 "FUNCTION RESTORE": bool_ok,
1059 "GCRA": parse_gcra,
1060 "GEODIST": float_or_none,
1061 "HSCAN": parse_hscan,
1062 "INFO": parse_info,
1063 "LASTSAVE": timestamp_to_datetime,
1064 "MEMORY PURGE": bool_ok,
1065 "MODULE LOAD": bool,
1066 "MODULE UNLOAD": bool,
1067 "PING": lambda r: str_if_bytes(r) == "PONG",
1068 "PUBSUB NUMSUB": parse_pubsub_numsub,
1069 "PUBSUB SHARDNUMSUB": parse_pubsub_numsub,
1070 "QUIT": bool_ok,
1071 "SET": parse_set_result,
1072 "SCAN": parse_scan,
1073 "SCRIPT EXISTS": lambda r: list(map(bool, r)),
1074 "SCRIPT FLUSH": bool_ok,
1075 "SCRIPT KILL": bool_ok,
1076 "SCRIPT LOAD": str_if_bytes,
1077 "SENTINEL CKQUORUM": bool_ok,
1078 "SENTINEL FAILOVER": bool_ok,
1079 "SENTINEL FLUSHCONFIG": bool_ok,
1080 "SENTINEL GET-MASTER-ADDR-BY-NAME": parse_sentinel_get_master,
1081 "SENTINEL MONITOR": bool_ok,
1082 "SENTINEL RESET": bool_ok,
1083 "SENTINEL REMOVE": bool_ok,
1084 "SENTINEL SET": bool_ok,
1085 "SLOWLOG GET": parse_slowlog_get,
1086 "SLOWLOG RESET": bool_ok,
1087 "SORT": sort_return_tuples,
1088 "SSCAN": parse_scan,
1089 "TIME": lambda x: (int(x[0]), int(x[1])),
1090 "XAUTOCLAIM": parse_xautoclaim,
1091 "XCLAIM": parse_xclaim,
1092 "XGROUP CREATE": bool_ok,
1093 "XGROUP DESTROY": bool,
1094 "XGROUP SETID": bool_ok,
1095 "XINFO STREAM": parse_xinfo_stream,
1096 "XPENDING": parse_xpending,
1097 "ZSCAN": parse_zscan,
1098 "LCS": parse_lcs,
1099}
1100
1101
1102_RedisCallbacksRESP2 = {
1103 **string_keys_to_dict(
1104 "SDIFF SINTER SMEMBERS SUNION", lambda r: r and set(r) or set()
1105 ),
1106 **string_keys_to_dict(
1107 "ZDIFF ZINTER ZRANGE ZRANGEBYSCORE ZREVRANGE ZREVRANGEBYSCORE ZUNION",
1108 zset_score_pairs,
1109 ),
1110 **string_keys_to_dict(
1111 "ZPOPMAX ZPOPMIN",
1112 zpop_score_pairs,
1113 ),
1114 **string_keys_to_dict(
1115 "ZREVRANK ZRANK",
1116 zset_score_for_rank,
1117 ),
1118 **string_keys_to_dict("ZINCRBY ZSCORE", float_or_none),
1119 **string_keys_to_dict("BGREWRITEAOF BGSAVE", lambda r: True),
1120 **string_keys_to_dict("BLPOP BRPOP", lambda r: r and list(r) or None),
1121 **string_keys_to_dict(
1122 "BZPOPMAX BZPOPMIN",
1123 lambda r: r and [r[0], r[1], float(r[2])] or None,
1124 ),
1125 "ACL CAT": lambda r: list(map(str_if_bytes, r)),
1126 "ACL GENPASS": str_if_bytes,
1127 "ACL HELP": lambda r: list(map(str_if_bytes, r)),
1128 "ACL LIST": lambda r: list(map(str_if_bytes, r)),
1129 "ACL USERS": lambda r: list(map(str_if_bytes, r)),
1130 "ACL WHOAMI": str_if_bytes,
1131 "CLIENT GETNAME": str_if_bytes,
1132 "CONFIG GET": parse_config_get,
1133 "DEBUG OBJECT": parse_debug_object,
1134 "GEOHASH": lambda r: list(map(str_if_bytes, r)),
1135 "GEOPOS": lambda r: list(
1136 map(lambda ll: [float(ll[0]), float(ll[1])] if ll is not None else None, r)
1137 ),
1138 "HGETALL": lambda r: r and pairs_to_dict(r) or {},
1139 "HOTKEYS GET": lambda r: [pairs_to_dict(m) for m in r],
1140 "MEMORY STATS": parse_memory_stats,
1141 "MODULE LIST": lambda r: [pairs_to_dict(m) for m in r],
1142 "RESET": str_if_bytes,
1143 "SENTINEL MASTER": parse_sentinel_master,
1144 "SENTINEL MASTERS": parse_sentinel_masters,
1145 "SENTINEL SENTINELS": parse_sentinel_slaves_and_sentinels,
1146 "SENTINEL SLAVES": parse_sentinel_slaves_and_sentinels,
1147 "STRALGO": parse_stralgo,
1148 "FUNCTION LIST": parse_function_list,
1149 "XINFO CONSUMERS": parse_list_of_dicts,
1150 "XINFO GROUPS": parse_list_of_dicts,
1151 "HRANDFIELD": hrandfield_pairs,
1152 "ZADD": parse_zadd,
1153 "ZMPOP": parse_zmpop,
1154 "BZMPOP": parse_zmpop,
1155 "ZMSCORE": parse_zmscore,
1156 "ZRANDMEMBER": zset_score_pairs,
1157}
1158
1159
1160_RedisCallbacksRESP3 = {
1161 **string_keys_to_dict(
1162 "SDIFF SINTER SMEMBERS SUNION", lambda r: r and set(r) or set()
1163 ),
1164 **string_keys_to_dict(
1165 "HGETALL",
1166 lambda r, **kwargs: r,
1167 ),
1168 **string_keys_to_dict(
1169 "ZDIFF ZINTER ZRANGE ZRANGEBYSCORE ZREVRANGE ZREVRANGEBYSCORE ZUNION",
1170 zset_score_pairs_resp3,
1171 ),
1172 **string_keys_to_dict(
1173 "ZPOPMAX ZPOPMIN",
1174 zpop_score_pairs_resp3,
1175 ),
1176 **string_keys_to_dict(
1177 "ZREVRANK ZRANK",
1178 zset_score_for_rank_resp3,
1179 ),
1180 **string_keys_to_dict(
1181 "BZPOPMAX BZPOPMIN",
1182 lambda r: r
1183 and [r[0], r[1], r[2] if isinstance(r[2], float) else float(r[2])]
1184 or None,
1185 ),
1186 **string_keys_to_dict("XREAD XREADGROUP", parse_xread_resp3),
1187 **string_keys_to_dict("ZMPOP BZMPOP", parse_zmpop),
1188 "ZRANDMEMBER": zset_score_pairs_resp3,
1189 "ACL CAT": lambda r: list(map(str_if_bytes, r)),
1190 "ACL GENPASS": str_if_bytes,
1191 "ACL HELP": lambda r: list(map(str_if_bytes, r)),
1192 "ACL LIST": lambda r: list(map(str_if_bytes, r)),
1193 "ACL USERS": lambda r: list(map(str_if_bytes, r)),
1194 "ACL WHOAMI": str_if_bytes,
1195 "CLIENT GETNAME": str_if_bytes,
1196 "GEOHASH": lambda r: list(map(str_if_bytes, r)),
1197 "RESET": str_if_bytes,
1198 "ACL LOG": parse_acl_log_resp3,
1199 "COMMAND": parse_command_resp3,
1200 "CONFIG GET": lambda r: {
1201 str_if_bytes(key) if key is not None else None: (
1202 str_if_bytes(value) if value is not None else None
1203 )
1204 for key, value in r.items()
1205 },
1206 **string_keys_to_dict("BGREWRITEAOF BGSAVE", lambda r: True),
1207 "DEBUG OBJECT": parse_debug_object,
1208 "MEMORY STATS": parse_memory_stats_resp3,
1209 "SENTINEL MASTER": parse_sentinel_state_resp3,
1210 "SENTINEL MASTERS": parse_sentinel_masters_resp3,
1211 "SENTINEL SENTINELS": parse_sentinel_slaves_and_sentinels_resp3,
1212 "SENTINEL SLAVES": parse_sentinel_slaves_and_sentinels_resp3,
1213 "STRALGO": lambda r, **options: parse_stralgo_resp3(r, **options),
1214 "XINFO CONSUMERS": lambda r: [
1215 {str_if_bytes(key): value for key, value in x.items()} for x in r
1216 ],
1217 "XINFO GROUPS": lambda r: [
1218 {str_if_bytes(key): value for key, value in d.items()} for d in r
1219 ],
1220}