1from typing import (
2 TYPE_CHECKING,
3 Any,
4 Callable,
5 Dict,
6 Iterator,
7 List,
8 Optional,
9 Set,
10 Tuple,
11 TypeVar,
12 Union,
13)
14
15from redis.commands.metadata import (
16 CommandMetadata,
17 CommandMetadataRecordsCache,
18 CommandPolicies,
19 PolicyRecords,
20 RequestPolicy,
21 ResponsePolicy,
22)
23from redis.exceptions import IncorrectPolicyType, RedisError, ResponseError
24from redis.utils import deprecated_function, str_if_bytes
25
26if TYPE_CHECKING:
27 from redis.asyncio.cluster import ClusterNode
28
29# The record type ``_build_command_records`` builds per command: policies or full metadata.
30# Constrained to the two, rather than left open, so a ``to_record`` that builds anything else
31# is a type error at the call site.
32_RecordT = TypeVar("_RecordT", CommandPolicies, CommandMetadata)
33
34# Re-exported for backwards compatibility: these types used to be defined here and are
35# now owned by ``redis.commands.metadata``. Import them from their new home instead.
36__all__ = [
37 "AbstractCommandsParser",
38 "AsyncCommandsParser",
39 "CommandPolicies",
40 "CommandsParser",
41 "PolicyRecords",
42 "RequestPolicy",
43 "ResponsePolicy",
44]
45
46
47class AbstractCommandsParser:
48 def _get_pubsub_keys(self, *args):
49 """
50 Get the keys from pubsub command.
51 Although PubSub commands have predetermined key locations, they are not
52 supported in the 'COMMAND's output, so the key positions are hardcoded
53 in this method
54 """
55 if len(args) < 2:
56 # The command has no keys in it
57 return None
58 args = [str_if_bytes(arg) for arg in args]
59 command = args[0].upper()
60 keys = None
61 if command == "PUBSUB":
62 # the second argument is a part of the command name, e.g.
63 # ['PUBSUB', 'NUMSUB', 'foo'].
64 pubsub_type = args[1].upper()
65 if pubsub_type in ["CHANNELS", "NUMSUB", "SHARDCHANNELS", "SHARDNUMSUB"]:
66 keys = args[2:]
67 elif command in ["SUBSCRIBE", "PSUBSCRIBE", "UNSUBSCRIBE", "PUNSUBSCRIBE"]:
68 # format example:
69 # SUBSCRIBE channel [channel ...]
70 keys = list(args[1:])
71 elif command in ["PUBLISH", "SPUBLISH"]:
72 # format example:
73 # PUBLISH channel message
74 keys = [args[1]]
75 return keys
76
77 def parse_subcommand(self, command, **options):
78 return _parse_subcommand(command)
79
80
81class CommandsParser(AbstractCommandsParser):
82 """
83 Parses Redis commands to get command keys.
84 COMMAND output is used to determine key locations.
85 Commands that do not have a predefined key location are flagged with
86 'movablekeys', and these commands' keys are determined by the command
87 'COMMAND GETKEYS'.
88 """
89
90 def __init__(self, redis_connection):
91 self.commands = {}
92 self.redis_connection = redis_connection
93 self.initialize(self.redis_connection)
94
95 def initialize(self, r):
96 commands = r.command()
97 uppercase_commands = []
98 for cmd in commands:
99 if any(x.isupper() for x in cmd):
100 uppercase_commands.append(cmd)
101 for cmd in uppercase_commands:
102 commands[cmd.lower()] = commands.pop(cmd)
103 self.commands = commands
104
105 # As soon as this PR is merged into Redis, we should reimplement
106 # our logic to use COMMAND INFO changes to determine the key positions
107 # https://github.com/redis/redis/pull/8324
108 def get_keys(self, redis_conn, *args):
109 """
110 Get the keys from the passed command.
111
112 NOTE: Due to a bug in redis<7.0, this function does not work properly
113 for EVAL or EVALSHA when the `numkeys` arg is 0.
114 - issue: https://github.com/redis/redis/issues/9493
115 - fix: https://github.com/redis/redis/pull/9733
116
117 So, don't use this function with EVAL or EVALSHA.
118 """
119 if len(args) < 2:
120 # The command has no keys in it
121 return None
122
123 cmd_name = args[0].lower()
124 if cmd_name not in self.commands:
125 # try to split the command name and to take only the main command,
126 # e.g. 'memory' for 'memory usage'
127 cmd_name_split = cmd_name.split()
128 cmd_name = cmd_name_split[0]
129 if cmd_name in self.commands:
130 # save the split command to args
131 args = cmd_name_split + list(args[1:])
132 else:
133 # We'll try to reinitialize the commands cache, if the engine
134 # version has changed, the commands may not be current
135 self.initialize(redis_conn)
136 if cmd_name not in self.commands:
137 raise RedisError(
138 f"{cmd_name.upper()} command doesn't exist in Redis commands"
139 )
140
141 command = self.commands.get(cmd_name)
142 if "movablekeys" in command["flags"]:
143 keys = self._get_moveable_keys(redis_conn, *args)
144 elif "pubsub" in command["flags"] or command["name"] == "pubsub":
145 keys = self._get_pubsub_keys(*args)
146 else:
147 if (
148 command["step_count"] == 0
149 and command["first_key_pos"] == 0
150 and command["last_key_pos"] == 0
151 ):
152 is_subcmd = False
153 if "subcommands" in command:
154 subcmd_name = f"{cmd_name}|{args[1].lower()}"
155 for subcmd in command["subcommands"]:
156 if str_if_bytes(subcmd[0]) == subcmd_name:
157 command = self.parse_subcommand(subcmd)
158
159 if command["first_key_pos"] > 0:
160 is_subcmd = True
161
162 # The command doesn't have keys in it
163 if not is_subcmd:
164 return None
165 last_key_pos = command["last_key_pos"]
166 if last_key_pos < 0:
167 last_key_pos = len(args) - abs(last_key_pos)
168 keys_pos = list(
169 range(command["first_key_pos"], last_key_pos + 1, command["step_count"])
170 )
171 keys = [args[pos] for pos in keys_pos]
172
173 return keys
174
175 def _get_moveable_keys(self, redis_conn, *args):
176 """
177 NOTE: Due to a bug in redis<7.0, this function does not work properly
178 for EVAL or EVALSHA when the `numkeys` arg is 0.
179 - issue: https://github.com/redis/redis/issues/9493
180 - fix: https://github.com/redis/redis/pull/9733
181
182 So, don't use this function with EVAL or EVALSHA.
183 """
184 # The command name should be split into separate arguments,
185 # e.g. 'MEMORY USAGE' will be split into ['MEMORY', 'USAGE']
186 pieces = args[0].split() + list(args[1:])
187 try:
188 keys = redis_conn.execute_command("COMMAND GETKEYS", *pieces)
189 except ResponseError as e:
190 message = e.__str__()
191 if (
192 "Invalid arguments" in message
193 or "The command has no key arguments" in message
194 ):
195 return None
196 else:
197 raise e
198 return keys
199
200 @deprecated_function(
201 version="8.2.0",
202 reason="Use get_commands_metadata_cache() instead.",
203 )
204 def get_command_policies(self) -> PolicyRecords:
205 """
206 Retrieve and process the command policies for all commands and subcommands.
207
208 DEPRECATED: use :meth:`get_commands_metadata_cache` instead. Nothing in this library calls this
209 method any more, and it will be removed in a future release. A metadata resolver keeps a
210 compatibility shim for an object that serves only this method, because it was the whole
211 contract in 7.1.0; that shim is not an invitation to write one. To decide routing
212 yourself, implement the public ``PolicyResolver`` ABC instead.
213
214 This method traverses through commands and subcommands, extracting policy details
215 from associated data structures and constructing a dictionary of commands with their
216 associated policies. It supports nested data structures and handles both main commands
217 and their subcommands.
218
219 This is the routing view of :meth:`get_commands_metadata_cache`: the same traversal of the
220 same reply, keyed the same way, projected down to the two policies the cluster client
221 routes by. A caller that also needs the client-side-caching metadata asks for the
222 metadata records instead.
223
224 Returns:
225 PolicyRecords: A collection of commands and subcommands associated with their
226 respective policies.
227
228 Raises:
229 IncorrectPolicyType: If an invalid policy type is encountered during policy extraction.
230 """
231 return _build_policy_records(self.commands)
232
233 def get_commands_metadata_cache(self) -> CommandMetadataRecordsCache:
234 """
235 Retrieve and process the metadata records cache for all commands and subcommands.
236
237 This method normalizes the command flags, command tips and key metadata of the
238 ``COMMAND`` output into metadata records, keyed the same way as the policy records
239 that the deprecated ``get_command_policies`` returns. The routing policies each
240 record carries are the ones that method resolves, so both views of the same reply
241 stay in step.
242
243 Returns:
244 CommandMetadataRecordsCache: A collection of commands and subcommands
245 associated with their respective metadata.
246
247 Raises:
248 IncorrectPolicyType: If an invalid policy type is encountered during policy extraction.
249 """
250 return _build_commands_metadata_cache(self.commands)
251
252
253class AsyncCommandsParser(AbstractCommandsParser):
254 """
255 Parses Redis commands to get command keys.
256
257 COMMAND output is used to determine key locations.
258 Commands that do not have a predefined key location are flagged with 'movablekeys',
259 and these commands' keys are determined by the command 'COMMAND GETKEYS'.
260
261 NOTE: Due to a bug in redis<7.0, this does not work properly
262 for EVAL or EVALSHA when the `numkeys` arg is 0.
263 - issue: https://github.com/redis/redis/issues/9493
264 - fix: https://github.com/redis/redis/pull/9733
265
266 So, don't use this with EVAL or EVALSHA.
267 """
268
269 __slots__ = ("commands", "node")
270
271 def __init__(self) -> None:
272 self.commands: Dict[str, Union[int, Dict[str, Any]]] = {}
273
274 async def initialize(self, node: Optional["ClusterNode"] = None) -> None:
275 if node:
276 self.node = node
277
278 commands = await self.node.execute_command("COMMAND")
279 self.commands = {cmd.lower(): command for cmd, command in commands.items()}
280
281 # As soon as this PR is merged into Redis, we should reimplement
282 # our logic to use COMMAND INFO changes to determine the key positions
283 # https://github.com/redis/redis/pull/8324
284 async def get_keys(self, *args: Any) -> Optional[Tuple[str, ...]]:
285 """
286 Get the keys from the passed command.
287
288 NOTE: Due to a bug in redis<7.0, this function does not work properly
289 for EVAL or EVALSHA when the `numkeys` arg is 0.
290 - issue: https://github.com/redis/redis/issues/9493
291 - fix: https://github.com/redis/redis/pull/9733
292
293 So, don't use this function with EVAL or EVALSHA.
294 """
295 if len(args) < 2:
296 # The command has no keys in it
297 return None
298
299 cmd_name = args[0].lower()
300 if cmd_name not in self.commands:
301 # try to split the command name and to take only the main command,
302 # e.g. 'memory' for 'memory usage'
303 cmd_name_split = cmd_name.split()
304 cmd_name = cmd_name_split[0]
305 if cmd_name in self.commands:
306 # save the split command to args
307 args = cmd_name_split + list(args[1:])
308 else:
309 # We'll try to reinitialize the commands cache, if the engine
310 # version has changed, the commands may not be current
311 await self.initialize()
312 if cmd_name not in self.commands:
313 raise RedisError(
314 f"{cmd_name.upper()} command doesn't exist in Redis commands"
315 )
316
317 command = self.commands.get(cmd_name)
318 if "movablekeys" in command["flags"]:
319 keys = await self._get_moveable_keys(*args)
320 elif "pubsub" in command["flags"] or command["name"] == "pubsub":
321 keys = self._get_pubsub_keys(*args)
322 else:
323 if (
324 command["step_count"] == 0
325 and command["first_key_pos"] == 0
326 and command["last_key_pos"] == 0
327 ):
328 is_subcmd = False
329 if "subcommands" in command:
330 subcmd_name = f"{cmd_name}|{args[1].lower()}"
331 for subcmd in command["subcommands"]:
332 if str_if_bytes(subcmd[0]) == subcmd_name:
333 command = self.parse_subcommand(subcmd)
334
335 if command["first_key_pos"] > 0:
336 is_subcmd = True
337
338 # The command doesn't have keys in it
339 if not is_subcmd:
340 return None
341 last_key_pos = command["last_key_pos"]
342 if last_key_pos < 0:
343 last_key_pos = len(args) - abs(last_key_pos)
344 keys_pos = list(
345 range(command["first_key_pos"], last_key_pos + 1, command["step_count"])
346 )
347 keys = [args[pos] for pos in keys_pos]
348
349 return keys
350
351 async def _get_moveable_keys(self, *args: Any) -> Optional[Tuple[str, ...]]:
352 try:
353 keys = await self.node.execute_command("COMMAND GETKEYS", *args)
354 except ResponseError as e:
355 message = e.__str__()
356 if (
357 "Invalid arguments" in message
358 or "The command has no key arguments" in message
359 ):
360 return None
361 else:
362 raise e
363 return keys
364
365 @deprecated_function(
366 version="8.2.0",
367 reason="Use get_commands_metadata_cache() instead.",
368 )
369 async def get_command_policies(self) -> PolicyRecords:
370 """
371 Retrieve and process the command policies for all commands and subcommands.
372
373 DEPRECATED: use :meth:`get_commands_metadata_cache` instead. Nothing in this library calls this
374 method any more, and it will be removed in a future release. A metadata resolver keeps a
375 compatibility shim for an object that serves only this method, because it was the whole
376 contract in 7.1.0; that shim is not an invitation to write one. To decide routing
377 yourself, implement the public ``PolicyResolver`` ABC instead.
378
379 This method traverses through commands and subcommands, extracting policy details
380 from associated data structures and constructing a dictionary of commands with their
381 associated policies. It supports nested data structures and handles both main commands
382 and their subcommands.
383
384 This is the routing view of :meth:`get_commands_metadata_cache`: the same traversal of the
385 same reply, keyed the same way, projected down to the two policies the cluster client
386 routes by. A caller that also needs the client-side-caching metadata asks for the
387 metadata records instead.
388
389 Returns:
390 PolicyRecords: A collection of commands and subcommands associated with their
391 respective policies.
392
393 Raises:
394 IncorrectPolicyType: If an invalid policy type is encountered during policy extraction.
395 """
396 return _build_policy_records(self.commands)
397
398 async def get_commands_metadata_cache(self) -> CommandMetadataRecordsCache:
399 """
400 Retrieve and process the metadata records cache for all commands and subcommands.
401
402 This method normalizes the command flags, command tips and key metadata of the
403 ``COMMAND`` output into metadata records, keyed the same way as the policy records
404 that the deprecated ``get_command_policies`` returns. The routing policies each
405 record carries are the ones that method resolves, so both views of the same reply
406 stay in step.
407
408 Returns:
409 CommandMetadataRecordsCache: A collection of commands and subcommands
410 associated with their respective metadata.
411
412 Raises:
413 IncorrectPolicyType: If an invalid policy type is encountered during policy extraction.
414 """
415 return _build_commands_metadata_cache(self.commands)
416
417
418# =============================================================================
419# Private helpers
420# =============================================================================
421def _parse_subcommand(command: Any) -> Dict[str, Any]:
422 """
423 Parse a single entry of a command's ``subcommands`` field into a details dict
424 with the same shape as a top-level ``COMMAND`` entry.
425 """
426 cmd_dict = {}
427 cmd_name = str_if_bytes(command[0])
428 cmd_dict["name"] = cmd_name
429 cmd_dict["arity"] = int(command[1])
430 cmd_dict["flags"] = [str_if_bytes(flag) for flag in command[2]]
431 cmd_dict["first_key_pos"] = command[3]
432 cmd_dict["last_key_pos"] = command[4]
433 cmd_dict["step_count"] = command[5]
434 if len(command) > 7:
435 cmd_dict["tips"] = command[7]
436 cmd_dict["key_specifications"] = command[8]
437 cmd_dict["subcommands"] = command[9]
438 return cmd_dict
439
440
441def _is_keyless_command(
442 commands: Dict[str, Any],
443 command_name: str,
444 subcommand_name: Optional[str] = None,
445) -> bool:
446 """
447 Determines whether a given command or subcommand is considered "keyless".
448
449 A keyless command does not operate on specific keys, which is determined based
450 on the first key position in the command or subcommand details. If the command
451 or subcommand's first key position is zero or negative, it is treated as keyless.
452
453 Parameters:
454 commands: Dict[str, Any]
455 The parsed ``COMMAND`` output to look the command up in.
456 command_name: str
457 The name of the command to check.
458 subcommand_name: Optional[str], default=None
459 The name of the subcommand to check, if applicable. If not provided,
460 the check is performed only on the command.
461
462 Returns:
463 bool
464 True if the specified command or subcommand is considered keyless,
465 False otherwise.
466
467 Raises:
468 ValueError
469 If the specified subcommand is not found within the command or the
470 specified command does not exist in the available commands.
471 """
472 if subcommand_name:
473 for subcommand in commands.get(command_name)["subcommands"]:
474 if str_if_bytes(subcommand[0]) == subcommand_name:
475 parsed_subcmd = _parse_subcommand(subcommand)
476 return parsed_subcmd["first_key_pos"] <= 0
477 raise ValueError(
478 f"Subcommand {subcommand_name} not found in command {command_name}"
479 )
480 else:
481 command_details = commands.get(command_name, None)
482 if command_details is not None:
483 return command_details["first_key_pos"] <= 0
484
485 raise ValueError(f"Command {command_name} not found in commands")
486
487
488# Slots of the two-element buffer ``_apply_policy_tips`` writes into. A list rather than a
489# record, so one buffer can be reused for every command of a ``COMMAND`` reply instead of a
490# policy object being allocated per command and thrown away.
491_REQUEST_POLICY = 0
492_RESPONSE_POLICY = 1
493
494
495def _walk_tips(data: Any) -> Iterator[str]:
496 """
497 Recursively yield every tip string of a ``COMMAND`` reply fragment, decoding bytes.
498
499 The one traversal both tip readers share, so the routing view and the metadata view
500 cannot disagree about which structures a tip may be nested inside. That matters because a
501 metadata record is the superset of a policy record: it carries the two routing policies
502 plus the cacheability markers, so it has to read at least everything the policy view
503 reads off the same field.
504
505 A ``COMMAND`` reply reports a command's ``tips`` as a flat array, but the fragments
506 :func:`_apply_policy_tips` is handed for a container subcommand are whole reply entries,
507 whose nested arrays and maps have to be walked. Anything that is neither a string, an
508 array nor a map carries no tip and is skipped.
509
510 Args:
511 data: The fragment to walk (can be list, dict, str, bytes, etc.)
512
513 Yields:
514 Each tip string found, in reply order, so that a caller keeping only one value per
515 slot ends up with the last one the reply carried.
516 """
517 if isinstance(data, (str, bytes)):
518 # Decode bytes to string if needed
519 yield str_if_bytes(data)
520
521 elif isinstance(data, list):
522 # For lists, recursively process each element
523 for item in data:
524 yield from _walk_tips(item)
525
526 elif isinstance(data, dict):
527 # For dictionaries, recursively process each value
528 for value in data.values():
529 yield from _walk_tips(value)
530
531
532def _apply_policy_tips(policy_pair: List[Any], data: Any) -> None:
533 """
534 Extract policies from nested data structures.
535
536 Args:
537 policy_pair: The ``[request_policy, response_policy]`` buffer to update in place.
538 The last policy tip found for a slot wins.
539 data: The data structure to search, walked by :func:`_walk_tips`.
540
541 Raises:
542 IncorrectPolicyType: If an invalid policy type is encountered.
543 """
544 for policy in _walk_tips(data):
545 # Check if this is a policy string
546 if policy.startswith("request_policy"):
547 policy_type = policy.split(":")[1]
548
549 try:
550 policy_pair[_REQUEST_POLICY] = RequestPolicy(policy_type)
551 except ValueError:
552 raise IncorrectPolicyType(
553 f"Incorrect request policy type: {policy_type}"
554 )
555
556 elif policy.startswith("response_policy"):
557 policy_type = policy.split(":")[1]
558
559 try:
560 policy_pair[_RESPONSE_POLICY] = ResponsePolicy(policy_type)
561 except ValueError:
562 raise IncorrectPolicyType(
563 f"Incorrect response policy type: {policy_type}"
564 )
565
566
567def _build_command_records(
568 commands: Dict[str, Any],
569 to_record: Callable[[Dict[str, Any], RequestPolicy, ResponsePolicy], _RecordT],
570) -> Dict[str, Dict[str, _RecordT]]:
571 """
572 Traverse a parsed ``COMMAND`` reply, resolving the policies of every command, and build
573 one record per command with ``to_record``.
574
575 ``to_record`` is called once per command and once per container subcommand, with the
576 command's details and its resolved policies. Policies start at the keyed or keyless
577 defaults, chosen by whether the command takes keys, and are then overwritten by the
578 ``request_policy`` and ``response_policy`` command tips.
579
580 This is the single traversal behind both the policy records and the metadata records, so
581 the two are keyed identically by construction and the tips of a reply are walked once no
582 matter which view a caller asks for. The policies are handed over as plain enum members,
583 so the caller that wants a ``CommandPolicies`` record builds one and the caller that
584 wants a ``CommandMetadata`` record is not charged for one it would discard.
585
586 Args:
587 commands: The parsed ``COMMAND`` output to traverse.
588 to_record: Builds the record for one command from its details and policies.
589
590 Returns:
591 The records of every command and subcommand, keyed by module name, then by command
592 name.
593
594 Raises:
595 IncorrectPolicyType: If an invalid policy type is encountered during policy extraction.
596 """
597 command_records: Dict[str, Dict[str, _RecordT]] = {}
598 # Reused by every command below: the policies are read out of it before the next command
599 # resets it, so no per-command buffer is allocated.
600 policy_pair: List[Any] = [
601 RequestPolicy.DEFAULT_KEYLESS,
602 ResponsePolicy.DEFAULT_KEYLESS,
603 ]
604
605 for command, details in commands.items():
606 # Check whether the command has keys
607 is_keyless = _is_keyless_command(commands, command)
608
609 if is_keyless:
610 policy_pair[_REQUEST_POLICY] = RequestPolicy.DEFAULT_KEYLESS
611 policy_pair[_RESPONSE_POLICY] = ResponsePolicy.DEFAULT_KEYLESS
612 else:
613 policy_pair[_REQUEST_POLICY] = RequestPolicy.DEFAULT_KEYED
614 policy_pair[_RESPONSE_POLICY] = ResponsePolicy.DEFAULT_KEYED
615
616 module_name, command_name = _split_module_and_command(command)
617 module_records = command_records.setdefault(module_name, {})
618
619 tips = details.get("tips")
620 subcommands = details.get("subcommands")
621
622 # Process tips for the main command
623 if tips:
624 _apply_policy_tips(policy_pair, tips)
625
626 module_records[command_name] = to_record(
627 details, policy_pair[_REQUEST_POLICY], policy_pair[_RESPONSE_POLICY]
628 )
629
630 # Process subcommands
631 if subcommands:
632 for subcommand_details in subcommands:
633 # Get the subcommand name (first element)
634 subcmd_name = subcommand_details[0]
635 if isinstance(subcmd_name, bytes):
636 subcmd_name = subcmd_name.decode()
637
638 # Check whether the subcommand has keys
639 is_keyless = _is_keyless_command(commands, command, subcmd_name)
640
641 if is_keyless:
642 policy_pair[_REQUEST_POLICY] = RequestPolicy.DEFAULT_KEYLESS
643 policy_pair[_RESPONSE_POLICY] = ResponsePolicy.DEFAULT_KEYLESS
644 else:
645 policy_pair[_REQUEST_POLICY] = RequestPolicy.DEFAULT_KEYED
646 policy_pair[_RESPONSE_POLICY] = ResponsePolicy.DEFAULT_KEYED
647
648 # Container subcommands are keyed by their space-joined name, e.g.
649 # ``memory usage``, which is the form ``execute_command`` receives.
650 subcmd_name = subcmd_name.replace("|", " ")
651
652 # Recursively extract policies from the rest of the subcommand details
653 for subcommand_detail in subcommand_details[1:]:
654 _apply_policy_tips(policy_pair, subcommand_detail)
655
656 module_records[subcmd_name] = to_record(
657 _parse_subcommand(subcommand_details),
658 policy_pair[_REQUEST_POLICY],
659 policy_pair[_RESPONSE_POLICY],
660 )
661
662 return command_records
663
664
665def _to_command_policies(
666 details: Dict[str, Any],
667 request_policy: RequestPolicy,
668 response_policy: ResponsePolicy,
669) -> CommandPolicies:
670 """Build the policy record for a single entry of a ``COMMAND`` reply."""
671 return CommandPolicies(
672 request_policy=request_policy, response_policy=response_policy
673 )
674
675
676def _build_policy_records(commands: Dict[str, Any]) -> PolicyRecords:
677 """
678 Retrieve and process the command policies for all commands and subcommands.
679
680 This function traverses through commands and subcommands, extracting policy details
681 from associated data structures and constructing a dictionary of commands with their
682 associated policies. It supports nested data structures and handles both main commands
683 and their subcommands.
684
685 Args:
686 commands: The parsed ``COMMAND`` output to build the policy records from.
687
688 Returns:
689 PolicyRecords: A collection of commands and subcommands associated with their
690 respective policies.
691
692 Raises:
693 IncorrectPolicyType: If an invalid policy type is encountered during policy extraction.
694 """
695 return _build_command_records(commands, _to_command_policies)
696
697
698def _split_module_and_command(command: str) -> Tuple[str, str]:
699 """
700 Split a name from a ``COMMAND`` reply into the ``(module, command)`` pair the record
701 tables are keyed by. Non-module commands live under ``"core"``.
702 """
703 split_name = command.split(".")
704
705 if len(split_name) > 1:
706 return split_name[0], split_name[1]
707
708 return "core", split_name[0]
709
710
711def _key_spec_flags(key_spec: Any) -> Set[str]:
712 """
713 The flags of a single key specification.
714
715 RESP3 reports a key spec as a map and RESP2 as a flat ``[name, value, ...]`` sequence,
716 so both shapes are read here.
717 """
718 if isinstance(key_spec, dict):
719 flags = key_spec.get(b"flags") or key_spec.get("flags") or ()
720 else:
721 flags = ()
722 for index in range(0, len(key_spec) - 1, 2):
723 if str_if_bytes(key_spec[index]) == "flags":
724 flags = key_spec[index + 1]
725 break
726
727 return {str_if_bytes(flag) for flag in flags}
728
729
730def _has_key_argument(details: Dict[str, Any]) -> bool:
731 """
732 Whether the command accepts at least one Redis key name argument.
733
734 Key specs decide it whenever the server reports them, including for ``movablekeys``
735 commands, whose keys are not discoverable from the legacy positions at all. A spec
736 flagged ``not_key`` describes an argument that is not a key name, such as a shard
737 pubsub channel, so a command whose every spec is ``not_key`` takes no keys.
738
739 Only a server that reports no key specs falls back to the legacy positions.
740 ``last_key_pos`` is never consulted: it is ``-1`` for variadic commands whose key
741 arguments run to the end of the argument list, and must not disqualify one.
742 """
743 key_specs = details.get("key_specifications")
744
745 if key_specs:
746 return any("not_key" not in _key_spec_flags(spec) for spec in key_specs)
747
748 return details.get("first_key_pos", 0) > 0 and details.get("step_count", 0) > 0
749
750
751def _to_command_metadata(
752 details: Dict[str, Any],
753 request_policy: RequestPolicy,
754 response_policy: ResponsePolicy,
755) -> CommandMetadata:
756 """
757 Build the metadata record for a single entry of a ``COMMAND`` reply.
758
759 ``details`` is either a top-level entry or a ``_parse_subcommand`` result. The routing
760 policies are passed in rather than derived again, so a record always carries the same
761 policies ``_build_policy_records`` resolves for the command.
762 """
763 flags = {str_if_bytes(flag) for flag in details.get("flags") or ()}
764 # Walked rather than read as a flat sequence, through the same traversal the routing view
765 # resolves its tips with: a metadata record is the superset of a policy record, so it must
766 # not read less of the field than the policy view does.
767 tips = set(_walk_tips(details.get("tips")))
768
769 return CommandMetadata(
770 request_policy=request_policy,
771 response_policy=response_policy,
772 is_readonly="readonly" in flags,
773 is_blocking="blocking" in flags,
774 has_key_argument=_has_key_argument(details),
775 # Matched exactly: ``nondeterministic_output_order`` is a different tip, denoting
776 # only that element order varies.
777 has_nondeterministic_output="nondeterministic_output" in tips,
778 is_script_runner="script_runner" in flags,
779 is_dont_cache="dont_cache" in tips,
780 # An empty tips list is a real answer; a missing tips key means the server is too
781 # old to report tips at all, which makes the negative markers undetectable.
782 has_complete_metadata="flags" in details and "tips" in details,
783 )
784
785
786def _build_commands_metadata_cache(
787 commands: Dict[str, Any],
788) -> CommandMetadataRecordsCache:
789 """
790 Retrieve and process the metadata records cache for all commands and subcommands.
791
792 This function traverses through commands and subcommands, normalizing the command
793 flags, command tips and key metadata of each into a metadata record. It shares its
794 traversal with ``_build_policy_records``, so records are keyed the same way and carry
795 the same policies, and a metadata resolver and a policy resolver agree on the same reply.
796
797 Args:
798 commands: The parsed ``COMMAND`` output to build the metadata records from.
799
800 Returns:
801 CommandMetadataRecordsCache: A collection of commands and subcommands associated
802 with their respective metadata.
803
804 Raises:
805 IncorrectPolicyType: If an invalid policy type is encountered during policy extraction.
806 """
807 return _build_command_records(commands, _to_command_metadata)