Voice et bot modif

This commit is contained in:
pi 2026-06-16 17:09:34 +00:00
parent 189d56026b
commit 7333a22bcd
10774 changed files with 634644 additions and 933308 deletions

View file

@ -0,0 +1,27 @@
from .base import (
AsyncPushNotificationsParser,
BaseParser,
PushNotificationsParser,
_AsyncRESPBase,
)
from .commands import AsyncCommandsParser, CommandsParser
from .encoders import Encoder
from .hiredis import _AsyncHiredisParser, _HiredisParser
from .resp2 import _AsyncRESP2Parser, _RESP2Parser
from .resp3 import _AsyncRESP3Parser, _RESP3Parser
__all__ = [
"AsyncCommandsParser",
"_AsyncHiredisParser",
"_AsyncRESPBase",
"_AsyncRESP2Parser",
"_AsyncRESP3Parser",
"AsyncPushNotificationsParser",
"CommandsParser",
"Encoder",
"BaseParser",
"_HiredisParser",
"_RESP2Parser",
"_RESP3Parser",
"PushNotificationsParser",
]

View file

@ -0,0 +1,584 @@
import logging
from abc import ABC, abstractmethod
from asyncio import IncompleteReadError, StreamReader
from typing import Awaitable, Callable, List, Optional, Protocol, Union
from redis.maint_notifications import (
MaintenanceNotification,
NodeFailedOverNotification,
NodeFailingOverNotification,
NodeMigratedNotification,
NodeMigratingNotification,
NodeMovingNotification,
OSSNodeMigratedNotification,
OSSNodeMigratingNotification,
)
from redis.utils import deprecated_function, safe_str
from ..exceptions import (
AskError,
AuthenticationError,
AuthenticationWrongNumberOfArgsError,
BusyLoadingError,
ClusterCrossSlotError,
ClusterDownError,
ConnectionError,
ExecAbortError,
ExternalAuthProviderError,
MasterDownError,
ModuleError,
MovedError,
NoPermissionError,
NoScriptError,
OutOfMemoryError,
ReadOnlyError,
ResponseError,
TryAgainError,
)
from ..typing import EncodableT
from .encoders import Encoder
from .socket import SERVER_CLOSED_CONNECTION_ERROR, SocketBuffer
MODULE_LOAD_ERROR = "Error loading the extension. Please check the server logs."
NO_SUCH_MODULE_ERROR = "Error unloading module: no such module with that name"
MODULE_UNLOAD_NOT_POSSIBLE_ERROR = "Error unloading module: operation not possible."
MODULE_EXPORTS_DATA_TYPES_ERROR = (
"Error unloading module: the module "
"exports one or more module-side data "
"types, can't unload"
)
# user send an AUTH cmd to a server without authorization configured
NO_AUTH_SET_ERROR = {
# Redis >= 6.0
"AUTH <password> called without any password "
"configured for the default user. Are you sure "
"your configuration is correct?": AuthenticationError,
# Redis < 6.0
"Client sent AUTH, but no password is set": AuthenticationError,
}
EXTERNAL_AUTH_PROVIDER_ERROR = {
"problem with LDAP service": ExternalAuthProviderError,
}
logger = logging.getLogger(__name__)
class BaseParser(ABC):
EXCEPTION_CLASSES = {
"ERR": {
"max number of clients reached": ConnectionError,
"invalid password": AuthenticationError,
# some Redis server versions report invalid command syntax
# in lowercase
"wrong number of arguments "
"for 'auth' command": AuthenticationWrongNumberOfArgsError,
# some Redis server versions report invalid command syntax
# in uppercase
"wrong number of arguments "
"for 'AUTH' command": AuthenticationWrongNumberOfArgsError,
MODULE_LOAD_ERROR: ModuleError,
MODULE_EXPORTS_DATA_TYPES_ERROR: ModuleError,
NO_SUCH_MODULE_ERROR: ModuleError,
MODULE_UNLOAD_NOT_POSSIBLE_ERROR: ModuleError,
**NO_AUTH_SET_ERROR,
**EXTERNAL_AUTH_PROVIDER_ERROR,
},
"OOM": OutOfMemoryError,
"WRONGPASS": AuthenticationError,
"EXECABORT": ExecAbortError,
"LOADING": BusyLoadingError,
"NOSCRIPT": NoScriptError,
"READONLY": ReadOnlyError,
"NOAUTH": AuthenticationError,
"NOPERM": NoPermissionError,
"ASK": AskError,
"TRYAGAIN": TryAgainError,
"MOVED": MovedError,
"CLUSTERDOWN": ClusterDownError,
"CROSSSLOT": ClusterCrossSlotError,
"MASTERDOWN": MasterDownError,
}
@classmethod
def parse_error(cls, response):
"Parse an error response"
error_code = response.split(" ")[0]
if error_code in cls.EXCEPTION_CLASSES:
response = response[len(error_code) + 1 :]
exception_class = cls.EXCEPTION_CLASSES[error_code]
if isinstance(exception_class, dict):
exception_class = exception_class.get(response, ResponseError)
return exception_class(response, status_code=error_code)
return ResponseError(response)
@abstractmethod
def on_disconnect(self):
pass
@abstractmethod
def on_connect(self, connection):
pass
class _RESPBase(BaseParser):
"""Base class for sync-based resp parsing"""
def __init__(self, socket_read_size):
self.socket_read_size = socket_read_size
self.encoder = None
self._sock = None
self._buffer = None
def __del__(self):
try:
self.on_disconnect()
except Exception:
pass
def on_connect(self, connection):
"Called when the socket connects"
self._sock = connection._sock
self._buffer = SocketBuffer(
self._sock, self.socket_read_size, connection.socket_timeout
)
self.encoder = connection.encoder
def on_disconnect(self):
"Called when the socket disconnects"
self._sock = None
if self._buffer is not None:
self._buffer.close()
self._buffer = None
self.encoder = None
def can_read(self, timeout: float = 0) -> bool:
# TODO: Rename this API; it detects pending data or dirty/closed
# connection state, not only whether application data can be read.
if self._buffer is None:
return False
return self._buffer.can_read(timeout)
class AsyncBaseParser(BaseParser):
"""Base parsing class for the python-backed async parser"""
__slots__ = "_stream", "_read_size"
def __init__(self, socket_read_size: int):
self._stream: Optional[StreamReader] = None
self._read_size = socket_read_size
@deprecated_function(
version="8.0.0", reason="Use can_read() instead", name="can_read_destructive"
)
@abstractmethod
async def can_read_destructive(self) -> bool:
pass
@abstractmethod
async def can_read(self) -> bool:
# TODO: Rename this API; it detects pending data or dirty/closed
# connection state, not only whether application data can be read.
pass
async def read_response(
self, disable_decoding: bool = False
) -> Union[EncodableT, ResponseError, None, List[EncodableT]]:
raise NotImplementedError()
class MaintenanceNotificationsParser:
"""Protocol defining maintenance push notification parsing functionality"""
@staticmethod
def parse_oss_maintenance_start_msg(response):
# Expected message format is:
# SMIGRATING <seq_number> <slot, range1-range2,...>
id = response[1]
slots = safe_str(response[2])
return OSSNodeMigratingNotification(id, slots)
@staticmethod
def parse_oss_maintenance_completed_msg(response):
# Expected message format is:
# SMIGRATED <seq_number> [[<src_host:port> <dest_host:port> <slot_range>], ...]
id = response[1]
nodes_to_slots_mapping_data = response[2]
# Build the nodes_to_slots_mapping dict structure:
# {
# "src_host:port": [
# {"dest_host:port": "slot_range"},
# ...
# ],
# ...
# }
nodes_to_slots_mapping = {}
for src_node, dest_node, slots in nodes_to_slots_mapping_data:
src_node_str = safe_str(src_node)
dest_node_str = safe_str(dest_node)
slots_str = safe_str(slots)
if src_node_str not in nodes_to_slots_mapping:
nodes_to_slots_mapping[src_node_str] = []
nodes_to_slots_mapping[src_node_str].append({dest_node_str: slots_str})
return OSSNodeMigratedNotification(id, nodes_to_slots_mapping)
@staticmethod
def parse_maintenance_start_msg(response, notification_type):
# Expected message format is: <notification_type> <seq_number> <time>
# Examples:
# MIGRATING 1 10
# FAILING_OVER 2 20
id = response[1]
ttl = response[2]
return notification_type(id, ttl)
@staticmethod
def parse_maintenance_completed_msg(response, notification_type):
# Expected message format is: <notification_type> <seq_number>
# Examples:
# MIGRATED 1
# FAILED_OVER 2
id = response[1]
return notification_type(id)
@staticmethod
def parse_moving_msg(response):
# Expected message format is: MOVING <seq_number> <time> <endpoint>
id = response[1]
ttl = response[2]
if response[3] is None:
host, port = None, None
else:
value = safe_str(response[3])
host, port = value.split(":")
port = int(port) if port is not None else None
return NodeMovingNotification(id, host, port, ttl)
_INVALIDATION_MESSAGE = "invalidate"
_MOVING_MESSAGE = "MOVING"
_MIGRATING_MESSAGE = "MIGRATING"
_MIGRATED_MESSAGE = "MIGRATED"
_FAILING_OVER_MESSAGE = "FAILING_OVER"
_FAILED_OVER_MESSAGE = "FAILED_OVER"
_SMIGRATING_MESSAGE = "SMIGRATING"
_SMIGRATED_MESSAGE = "SMIGRATED"
_MAINTENANCE_MESSAGES = (
_MIGRATING_MESSAGE,
_MIGRATED_MESSAGE,
_FAILING_OVER_MESSAGE,
_FAILED_OVER_MESSAGE,
_SMIGRATING_MESSAGE,
)
MSG_TYPE_TO_MAINT_NOTIFICATION_PARSER_MAPPING: dict[
str, tuple[type[MaintenanceNotification], Callable]
] = {
_MIGRATING_MESSAGE: (
NodeMigratingNotification,
MaintenanceNotificationsParser.parse_maintenance_start_msg,
),
_MIGRATED_MESSAGE: (
NodeMigratedNotification,
MaintenanceNotificationsParser.parse_maintenance_completed_msg,
),
_FAILING_OVER_MESSAGE: (
NodeFailingOverNotification,
MaintenanceNotificationsParser.parse_maintenance_start_msg,
),
_FAILED_OVER_MESSAGE: (
NodeFailedOverNotification,
MaintenanceNotificationsParser.parse_maintenance_completed_msg,
),
_MOVING_MESSAGE: (
NodeMovingNotification,
MaintenanceNotificationsParser.parse_moving_msg,
),
_SMIGRATING_MESSAGE: (
OSSNodeMigratingNotification,
MaintenanceNotificationsParser.parse_oss_maintenance_start_msg,
),
_SMIGRATED_MESSAGE: (
OSSNodeMigratedNotification,
MaintenanceNotificationsParser.parse_oss_maintenance_completed_msg,
),
}
class PushNotificationsParser(Protocol):
"""Protocol defining RESP3-specific parsing functionality"""
pubsub_push_handler_func: Callable
invalidation_push_handler_func: Optional[Callable] = None
node_moving_push_handler_func: Optional[Callable] = None
maintenance_push_handler_func: Optional[Callable] = None
oss_cluster_maint_push_handler_func: Optional[Callable] = None
def handle_pubsub_push_response(self, response):
"""Handle pubsub push responses"""
raise NotImplementedError()
def handle_push_response(self, response, **kwargs):
msg_type = response[0]
if isinstance(msg_type, bytes):
msg_type = msg_type.decode()
if msg_type not in (
_INVALIDATION_MESSAGE,
*_MAINTENANCE_MESSAGES,
_MOVING_MESSAGE,
_SMIGRATED_MESSAGE,
):
return self.pubsub_push_handler_func(response)
try:
if (
msg_type == _INVALIDATION_MESSAGE
and self.invalidation_push_handler_func
):
return self.invalidation_push_handler_func(response)
if msg_type == _MOVING_MESSAGE and self.node_moving_push_handler_func:
parser_function = MSG_TYPE_TO_MAINT_NOTIFICATION_PARSER_MAPPING[
msg_type
][1]
notification = parser_function(response)
return self.node_moving_push_handler_func(notification)
if msg_type in _MAINTENANCE_MESSAGES and self.maintenance_push_handler_func:
parser_function = MSG_TYPE_TO_MAINT_NOTIFICATION_PARSER_MAPPING[
msg_type
][1]
if msg_type == _SMIGRATING_MESSAGE:
notification = parser_function(response)
else:
notification_type = MSG_TYPE_TO_MAINT_NOTIFICATION_PARSER_MAPPING[
msg_type
][0]
notification = parser_function(response, notification_type)
if notification is not None:
return self.maintenance_push_handler_func(notification)
if msg_type == _SMIGRATED_MESSAGE and (
self.oss_cluster_maint_push_handler_func
or self.maintenance_push_handler_func
):
parser_function = MSG_TYPE_TO_MAINT_NOTIFICATION_PARSER_MAPPING[
msg_type
][1]
notification = parser_function(response)
if notification is not None:
if self.maintenance_push_handler_func:
self.maintenance_push_handler_func(notification)
if self.oss_cluster_maint_push_handler_func:
self.oss_cluster_maint_push_handler_func(notification)
except Exception as e:
logger.error(
"Error handling {} message ({}): {}".format(msg_type, response, e)
)
return None
def set_pubsub_push_handler(self, pubsub_push_handler_func):
self.pubsub_push_handler_func = pubsub_push_handler_func
def set_invalidation_push_handler(self, invalidation_push_handler_func):
self.invalidation_push_handler_func = invalidation_push_handler_func
def set_node_moving_push_handler(self, node_moving_push_handler_func):
self.node_moving_push_handler_func = node_moving_push_handler_func
def set_maintenance_push_handler(self, maintenance_push_handler_func):
self.maintenance_push_handler_func = maintenance_push_handler_func
def set_oss_cluster_maint_push_handler(self, oss_cluster_maint_push_handler_func):
self.oss_cluster_maint_push_handler_func = oss_cluster_maint_push_handler_func
class AsyncPushNotificationsParser(Protocol):
"""Protocol defining async RESP3-specific parsing functionality"""
pubsub_push_handler_func: Callable
invalidation_push_handler_func: Optional[Callable] = None
node_moving_push_handler_func: Optional[Callable[..., Awaitable[None]]] = None
maintenance_push_handler_func: Optional[Callable[..., Awaitable[None]]] = None
oss_cluster_maint_push_handler_func: Optional[Callable[..., Awaitable[None]]] = None
async def handle_pubsub_push_response(self, response):
"""Handle pubsub push responses asynchronously"""
raise NotImplementedError()
async def handle_push_response(self, response, **kwargs):
"""Handle push responses asynchronously"""
msg_type = response[0]
if isinstance(msg_type, bytes):
msg_type = msg_type.decode()
if msg_type not in (
_INVALIDATION_MESSAGE,
*_MAINTENANCE_MESSAGES,
_MOVING_MESSAGE,
_SMIGRATED_MESSAGE,
):
return await self.pubsub_push_handler_func(response)
try:
if (
msg_type == _INVALIDATION_MESSAGE
and self.invalidation_push_handler_func
):
return await self.invalidation_push_handler_func(response)
if isinstance(msg_type, bytes):
msg_type = msg_type.decode()
if msg_type == _MOVING_MESSAGE and self.node_moving_push_handler_func:
parser_function = MSG_TYPE_TO_MAINT_NOTIFICATION_PARSER_MAPPING[
msg_type
][1]
notification = parser_function(response)
return await self.node_moving_push_handler_func(notification)
if msg_type in _MAINTENANCE_MESSAGES and self.maintenance_push_handler_func:
parser_function = MSG_TYPE_TO_MAINT_NOTIFICATION_PARSER_MAPPING[
msg_type
][1]
if msg_type == _SMIGRATING_MESSAGE:
notification = parser_function(response)
else:
notification_type = MSG_TYPE_TO_MAINT_NOTIFICATION_PARSER_MAPPING[
msg_type
][0]
notification = parser_function(response, notification_type)
if notification is not None:
return await self.maintenance_push_handler_func(notification)
if (
msg_type == _SMIGRATED_MESSAGE
and self.oss_cluster_maint_push_handler_func
):
parser_function = MSG_TYPE_TO_MAINT_NOTIFICATION_PARSER_MAPPING[
msg_type
][1]
notification = parser_function(response)
if notification is not None:
return await self.oss_cluster_maint_push_handler_func(notification)
except Exception as e:
logger.error(
"Error handling {} message ({}): {}".format(msg_type, response, e)
)
return None
def set_pubsub_push_handler(self, pubsub_push_handler_func):
"""Set the pubsub push handler function"""
self.pubsub_push_handler_func = pubsub_push_handler_func
def set_invalidation_push_handler(self, invalidation_push_handler_func):
"""Set the invalidation push handler function"""
self.invalidation_push_handler_func = invalidation_push_handler_func
def set_node_moving_push_handler(self, node_moving_push_handler_func):
self.node_moving_push_handler_func = node_moving_push_handler_func
def set_maintenance_push_handler(self, maintenance_push_handler_func):
self.maintenance_push_handler_func = maintenance_push_handler_func
def set_oss_cluster_maint_push_handler(self, oss_cluster_maint_push_handler_func):
self.oss_cluster_maint_push_handler_func = oss_cluster_maint_push_handler_func
class _AsyncRESPBase(AsyncBaseParser):
"""Base class for async resp parsing"""
__slots__ = AsyncBaseParser.__slots__ + ("encoder", "_buffer", "_pos", "_chunks")
def __init__(self, socket_read_size: int):
super().__init__(socket_read_size)
self.encoder: Optional[Encoder] = None
self._buffer = b""
self._chunks = []
self._pos = 0
def _clear(self):
self._buffer = b""
self._chunks.clear()
def on_connect(self, connection):
"""Called when the stream connects"""
self._stream = connection._reader
if self._stream is None:
raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR)
self.encoder = connection.encoder
self._clear()
self._connected = True
def on_disconnect(self):
"""Called when the stream disconnects"""
self._connected = False
@deprecated_function(
version="8.0.0",
reason="Use can_read() instead",
name="can_read_destructive",
)
async def can_read_destructive(self) -> bool:
return await self.can_read()
async def can_read(self) -> bool:
# TODO: Rename this API; it detects pending data or dirty/closed
# connection state, not only whether application data can be read.
if not self._connected:
raise OSError("Buffer is closed.")
if self._buffer:
return True
# asyncio.StreamReader has no public non-destructive API for checking
# buffered bytes. Preserve dirty-connection detection for the Python
# parser and fail loudly if the private buffer API changes.
return bool(self._stream._buffer) or self._stream.at_eof()
async def _read(self, length: int) -> bytes:
"""
Read `length` bytes of data. These are assumed to be followed
by a '\r\n' terminator which is subsequently discarded.
"""
want = length + 2
end = self._pos + want
if len(self._buffer) >= end:
result = self._buffer[self._pos : end - 2]
else:
tail = self._buffer[self._pos :]
try:
data = await self._stream.readexactly(want - len(tail))
except IncompleteReadError as error:
raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR) from error
result = (tail + data)[:-2]
self._chunks.append(data)
self._pos += want
return result
async def _readline(self) -> bytes:
"""
read an unknown number of bytes up to the next '\r\n'
line separator, which is discarded.
"""
found = self._buffer.find(b"\r\n", self._pos)
if found >= 0:
result = self._buffer[self._pos : found]
else:
tail = self._buffer[self._pos :]
data = await self._stream.readline()
if not data.endswith(b"\r\n"):
raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR)
result = (tail + data)[:-2]
self._chunks.append(data)
self._pos += len(result) + 2
return result

View file

@ -0,0 +1,692 @@
from enum import Enum
from typing import TYPE_CHECKING, Any, Awaitable, Dict, Optional, Tuple, Union
from redis.exceptions import IncorrectPolicyType, RedisError, ResponseError
from redis.utils import str_if_bytes
if TYPE_CHECKING:
from redis.asyncio.cluster import ClusterNode
class RequestPolicy(Enum):
ALL_NODES = "all_nodes"
ALL_SHARDS = "all_shards"
ALL_REPLICAS = "all_replicas"
MULTI_SHARD = "multi_shard"
SPECIAL = "special"
DEFAULT_KEYLESS = "default_keyless"
DEFAULT_KEYED = "default_keyed"
DEFAULT_NODE = "default_node"
class ResponsePolicy(Enum):
ONE_SUCCEEDED = "one_succeeded"
ALL_SUCCEEDED = "all_succeeded"
AGG_LOGICAL_AND = "agg_logical_and"
AGG_LOGICAL_OR = "agg_logical_or"
AGG_MIN = "agg_min"
AGG_MAX = "agg_max"
AGG_SUM = "agg_sum"
SPECIAL = "special"
DEFAULT_KEYLESS = "default_keyless"
DEFAULT_KEYED = "default_keyed"
class CommandPolicies:
def __init__(
self,
request_policy: RequestPolicy = RequestPolicy.DEFAULT_KEYLESS,
response_policy: ResponsePolicy = ResponsePolicy.DEFAULT_KEYLESS,
):
self.request_policy = request_policy
self.response_policy = response_policy
PolicyRecords = dict[str, dict[str, CommandPolicies]]
class AbstractCommandsParser:
def _get_pubsub_keys(self, *args):
"""
Get the keys from pubsub command.
Although PubSub commands have predetermined key locations, they are not
supported in the 'COMMAND's output, so the key positions are hardcoded
in this method
"""
if len(args) < 2:
# The command has no keys in it
return None
args = [str_if_bytes(arg) for arg in args]
command = args[0].upper()
keys = None
if command == "PUBSUB":
# the second argument is a part of the command name, e.g.
# ['PUBSUB', 'NUMSUB', 'foo'].
pubsub_type = args[1].upper()
if pubsub_type in ["CHANNELS", "NUMSUB", "SHARDCHANNELS", "SHARDNUMSUB"]:
keys = args[2:]
elif command in ["SUBSCRIBE", "PSUBSCRIBE", "UNSUBSCRIBE", "PUNSUBSCRIBE"]:
# format example:
# SUBSCRIBE channel [channel ...]
keys = list(args[1:])
elif command in ["PUBLISH", "SPUBLISH"]:
# format example:
# PUBLISH channel message
keys = [args[1]]
return keys
def parse_subcommand(self, command, **options):
cmd_dict = {}
cmd_name = str_if_bytes(command[0])
cmd_dict["name"] = cmd_name
cmd_dict["arity"] = int(command[1])
cmd_dict["flags"] = [str_if_bytes(flag) for flag in command[2]]
cmd_dict["first_key_pos"] = command[3]
cmd_dict["last_key_pos"] = command[4]
cmd_dict["step_count"] = command[5]
if len(command) > 7:
cmd_dict["tips"] = command[7]
cmd_dict["key_specifications"] = command[8]
cmd_dict["subcommands"] = command[9]
return cmd_dict
class CommandsParser(AbstractCommandsParser):
"""
Parses Redis commands to get command keys.
COMMAND output is used to determine key locations.
Commands that do not have a predefined key location are flagged with
'movablekeys', and these commands' keys are determined by the command
'COMMAND GETKEYS'.
"""
def __init__(self, redis_connection):
self.commands = {}
self.redis_connection = redis_connection
self.initialize(self.redis_connection)
def initialize(self, r):
commands = r.command()
uppercase_commands = []
for cmd in commands:
if any(x.isupper() for x in cmd):
uppercase_commands.append(cmd)
for cmd in uppercase_commands:
commands[cmd.lower()] = commands.pop(cmd)
self.commands = commands
# As soon as this PR is merged into Redis, we should reimplement
# our logic to use COMMAND INFO changes to determine the key positions
# https://github.com/redis/redis/pull/8324
def get_keys(self, redis_conn, *args):
"""
Get the keys from the passed command.
NOTE: Due to a bug in redis<7.0, this function does not work properly
for EVAL or EVALSHA when the `numkeys` arg is 0.
- issue: https://github.com/redis/redis/issues/9493
- fix: https://github.com/redis/redis/pull/9733
So, don't use this function with EVAL or EVALSHA.
"""
if len(args) < 2:
# The command has no keys in it
return None
cmd_name = args[0].lower()
if cmd_name not in self.commands:
# try to split the command name and to take only the main command,
# e.g. 'memory' for 'memory usage'
cmd_name_split = cmd_name.split()
cmd_name = cmd_name_split[0]
if cmd_name in self.commands:
# save the split command to args
args = cmd_name_split + list(args[1:])
else:
# We'll try to reinitialize the commands cache, if the engine
# version has changed, the commands may not be current
self.initialize(redis_conn)
if cmd_name not in self.commands:
raise RedisError(
f"{cmd_name.upper()} command doesn't exist in Redis commands"
)
command = self.commands.get(cmd_name)
if "movablekeys" in command["flags"]:
keys = self._get_moveable_keys(redis_conn, *args)
elif "pubsub" in command["flags"] or command["name"] == "pubsub":
keys = self._get_pubsub_keys(*args)
else:
if (
command["step_count"] == 0
and command["first_key_pos"] == 0
and command["last_key_pos"] == 0
):
is_subcmd = False
if "subcommands" in command:
subcmd_name = f"{cmd_name}|{args[1].lower()}"
for subcmd in command["subcommands"]:
if str_if_bytes(subcmd[0]) == subcmd_name:
command = self.parse_subcommand(subcmd)
if command["first_key_pos"] > 0:
is_subcmd = True
# The command doesn't have keys in it
if not is_subcmd:
return None
last_key_pos = command["last_key_pos"]
if last_key_pos < 0:
last_key_pos = len(args) - abs(last_key_pos)
keys_pos = list(
range(command["first_key_pos"], last_key_pos + 1, command["step_count"])
)
keys = [args[pos] for pos in keys_pos]
return keys
def _get_moveable_keys(self, redis_conn, *args):
"""
NOTE: Due to a bug in redis<7.0, this function does not work properly
for EVAL or EVALSHA when the `numkeys` arg is 0.
- issue: https://github.com/redis/redis/issues/9493
- fix: https://github.com/redis/redis/pull/9733
So, don't use this function with EVAL or EVALSHA.
"""
# The command name should be split into separate arguments,
# e.g. 'MEMORY USAGE' will be split into ['MEMORY', 'USAGE']
pieces = args[0].split() + list(args[1:])
try:
keys = redis_conn.execute_command("COMMAND GETKEYS", *pieces)
except ResponseError as e:
message = e.__str__()
if (
"Invalid arguments" in message
or "The command has no key arguments" in message
):
return None
else:
raise e
return keys
def _is_keyless_command(
self, command_name: str, subcommand_name: Optional[str] = None
) -> bool:
"""
Determines whether a given command or subcommand is considered "keyless".
A keyless command does not operate on specific keys, which is determined based
on the first key position in the command or subcommand details. If the command
or subcommand's first key position is zero or negative, it is treated as keyless.
Parameters:
command_name: str
The name of the command to check.
subcommand_name: Optional[str], default=None
The name of the subcommand to check, if applicable. If not provided,
the check is performed only on the command.
Returns:
bool
True if the specified command or subcommand is considered keyless,
False otherwise.
Raises:
ValueError
If the specified subcommand is not found within the command or the
specified command does not exist in the available commands.
"""
if subcommand_name:
for subcommand in self.commands.get(command_name)["subcommands"]:
if str_if_bytes(subcommand[0]) == subcommand_name:
parsed_subcmd = self.parse_subcommand(subcommand)
return parsed_subcmd["first_key_pos"] <= 0
raise ValueError(
f"Subcommand {subcommand_name} not found in command {command_name}"
)
else:
command_details = self.commands.get(command_name, None)
if command_details is not None:
return command_details["first_key_pos"] <= 0
raise ValueError(f"Command {command_name} not found in commands")
def get_command_policies(self) -> PolicyRecords:
"""
Retrieve and process the command policies for all commands and subcommands.
This method traverses through commands and subcommands, extracting policy details
from associated data structures and constructing a dictionary of commands with their
associated policies. It supports nested data structures and handles both main commands
and their subcommands.
Returns:
PolicyRecords: A collection of commands and subcommands associated with their
respective policies.
Raises:
IncorrectPolicyType: If an invalid policy type is encountered during policy extraction.
"""
command_with_policies = {}
def extract_policies(data, module_name, command_name):
"""
Recursively extract policies from nested data structures.
Args:
data: The data structure to search (can be list, dict, str, bytes, etc.)
command_name: The command name to associate with found policies
"""
if isinstance(data, (str, bytes)):
# Decode bytes to string if needed
policy = str_if_bytes(data.decode())
# Check if this is a policy string
if policy.startswith("request_policy") or policy.startswith(
"response_policy"
):
if policy.startswith("request_policy"):
policy_type = policy.split(":")[1]
try:
command_with_policies[module_name][
command_name
].request_policy = RequestPolicy(policy_type)
except ValueError:
raise IncorrectPolicyType(
f"Incorrect request policy type: {policy_type}"
)
if policy.startswith("response_policy"):
policy_type = policy.split(":")[1]
try:
command_with_policies[module_name][
command_name
].response_policy = ResponsePolicy(policy_type)
except ValueError:
raise IncorrectPolicyType(
f"Incorrect response policy type: {policy_type}"
)
elif isinstance(data, list):
# For lists, recursively process each element
for item in data:
extract_policies(item, module_name, command_name)
elif isinstance(data, dict):
# For dictionaries, recursively process each value
for value in data.values():
extract_policies(value, module_name, command_name)
for command, details in self.commands.items():
# Check whether the command has keys
is_keyless = self._is_keyless_command(command)
if is_keyless:
default_request_policy = RequestPolicy.DEFAULT_KEYLESS
default_response_policy = ResponsePolicy.DEFAULT_KEYLESS
else:
default_request_policy = RequestPolicy.DEFAULT_KEYED
default_response_policy = ResponsePolicy.DEFAULT_KEYED
# Check if it's a core or module command
split_name = command.split(".")
if len(split_name) > 1:
module_name = split_name[0]
command_name = split_name[1]
else:
module_name = "core"
command_name = split_name[0]
# Create a CommandPolicies object with default policies on the new command.
if command_with_policies.get(module_name, None) is None:
command_with_policies[module_name] = {
command_name: CommandPolicies(
request_policy=default_request_policy,
response_policy=default_response_policy,
)
}
else:
command_with_policies[module_name][command_name] = CommandPolicies(
request_policy=default_request_policy,
response_policy=default_response_policy,
)
tips = details.get("tips")
subcommands = details.get("subcommands")
# Process tips for the main command
if tips:
extract_policies(tips, module_name, command_name)
# Process subcommands
if subcommands:
for subcommand_details in subcommands:
# Get the subcommand name (first element)
subcmd_name = subcommand_details[0]
if isinstance(subcmd_name, bytes):
subcmd_name = subcmd_name.decode()
# Check whether the subcommand has keys
is_keyless = self._is_keyless_command(command, subcmd_name)
if is_keyless:
default_request_policy = RequestPolicy.DEFAULT_KEYLESS
default_response_policy = ResponsePolicy.DEFAULT_KEYLESS
else:
default_request_policy = RequestPolicy.DEFAULT_KEYED
default_response_policy = ResponsePolicy.DEFAULT_KEYED
subcmd_name = subcmd_name.replace("|", " ")
# Create a CommandPolicies object with default policies on the new command.
command_with_policies[module_name][subcmd_name] = CommandPolicies(
request_policy=default_request_policy,
response_policy=default_response_policy,
)
# Recursively extract policies from the rest of the subcommand details
for subcommand_detail in subcommand_details[1:]:
extract_policies(subcommand_detail, module_name, subcmd_name)
return command_with_policies
class AsyncCommandsParser(AbstractCommandsParser):
"""
Parses Redis commands to get command keys.
COMMAND output is used to determine key locations.
Commands that do not have a predefined key location are flagged with 'movablekeys',
and these commands' keys are determined by the command 'COMMAND GETKEYS'.
NOTE: Due to a bug in redis<7.0, this does not work properly
for EVAL or EVALSHA when the `numkeys` arg is 0.
- issue: https://github.com/redis/redis/issues/9493
- fix: https://github.com/redis/redis/pull/9733
So, don't use this with EVAL or EVALSHA.
"""
__slots__ = ("commands", "node")
def __init__(self) -> None:
self.commands: Dict[str, Union[int, Dict[str, Any]]] = {}
async def initialize(self, node: Optional["ClusterNode"] = None) -> None:
if node:
self.node = node
commands = await self.node.execute_command("COMMAND")
self.commands = {cmd.lower(): command for cmd, command in commands.items()}
# As soon as this PR is merged into Redis, we should reimplement
# our logic to use COMMAND INFO changes to determine the key positions
# https://github.com/redis/redis/pull/8324
async def get_keys(self, *args: Any) -> Optional[Tuple[str, ...]]:
"""
Get the keys from the passed command.
NOTE: Due to a bug in redis<7.0, this function does not work properly
for EVAL or EVALSHA when the `numkeys` arg is 0.
- issue: https://github.com/redis/redis/issues/9493
- fix: https://github.com/redis/redis/pull/9733
So, don't use this function with EVAL or EVALSHA.
"""
if len(args) < 2:
# The command has no keys in it
return None
cmd_name = args[0].lower()
if cmd_name not in self.commands:
# try to split the command name and to take only the main command,
# e.g. 'memory' for 'memory usage'
cmd_name_split = cmd_name.split()
cmd_name = cmd_name_split[0]
if cmd_name in self.commands:
# save the split command to args
args = cmd_name_split + list(args[1:])
else:
# We'll try to reinitialize the commands cache, if the engine
# version has changed, the commands may not be current
await self.initialize()
if cmd_name not in self.commands:
raise RedisError(
f"{cmd_name.upper()} command doesn't exist in Redis commands"
)
command = self.commands.get(cmd_name)
if "movablekeys" in command["flags"]:
keys = await self._get_moveable_keys(*args)
elif "pubsub" in command["flags"] or command["name"] == "pubsub":
keys = self._get_pubsub_keys(*args)
else:
if (
command["step_count"] == 0
and command["first_key_pos"] == 0
and command["last_key_pos"] == 0
):
is_subcmd = False
if "subcommands" in command:
subcmd_name = f"{cmd_name}|{args[1].lower()}"
for subcmd in command["subcommands"]:
if str_if_bytes(subcmd[0]) == subcmd_name:
command = self.parse_subcommand(subcmd)
if command["first_key_pos"] > 0:
is_subcmd = True
# The command doesn't have keys in it
if not is_subcmd:
return None
last_key_pos = command["last_key_pos"]
if last_key_pos < 0:
last_key_pos = len(args) - abs(last_key_pos)
keys_pos = list(
range(command["first_key_pos"], last_key_pos + 1, command["step_count"])
)
keys = [args[pos] for pos in keys_pos]
return keys
async def _get_moveable_keys(self, *args: Any) -> Optional[Tuple[str, ...]]:
try:
keys = await self.node.execute_command("COMMAND GETKEYS", *args)
except ResponseError as e:
message = e.__str__()
if (
"Invalid arguments" in message
or "The command has no key arguments" in message
):
return None
else:
raise e
return keys
async def _is_keyless_command(
self, command_name: str, subcommand_name: Optional[str] = None
) -> bool:
"""
Determines whether a given command or subcommand is considered "keyless".
A keyless command does not operate on specific keys, which is determined based
on the first key position in the command or subcommand details. If the command
or subcommand's first key position is zero or negative, it is treated as keyless.
Parameters:
command_name: str
The name of the command to check.
subcommand_name: Optional[str], default=None
The name of the subcommand to check, if applicable. If not provided,
the check is performed only on the command.
Returns:
bool
True if the specified command or subcommand is considered keyless,
False otherwise.
Raises:
ValueError
If the specified subcommand is not found within the command or the
specified command does not exist in the available commands.
"""
if subcommand_name:
for subcommand in self.commands.get(command_name)["subcommands"]:
if str_if_bytes(subcommand[0]) == subcommand_name:
parsed_subcmd = self.parse_subcommand(subcommand)
return parsed_subcmd["first_key_pos"] <= 0
raise ValueError(
f"Subcommand {subcommand_name} not found in command {command_name}"
)
else:
command_details = self.commands.get(command_name, None)
if command_details is not None:
return command_details["first_key_pos"] <= 0
raise ValueError(f"Command {command_name} not found in commands")
async def get_command_policies(self) -> Awaitable[PolicyRecords]:
"""
Retrieve and process the command policies for all commands and subcommands.
This method traverses through commands and subcommands, extracting policy details
from associated data structures and constructing a dictionary of commands with their
associated policies. It supports nested data structures and handles both main commands
and their subcommands.
Returns:
PolicyRecords: A collection of commands and subcommands associated with their
respective policies.
Raises:
IncorrectPolicyType: If an invalid policy type is encountered during policy extraction.
"""
command_with_policies = {}
def extract_policies(data, module_name, command_name):
"""
Recursively extract policies from nested data structures.
Args:
data: The data structure to search (can be list, dict, str, bytes, etc.)
command_name: The command name to associate with found policies
"""
if isinstance(data, (str, bytes)):
# Decode bytes to string if needed
policy = str_if_bytes(data.decode())
# Check if this is a policy string
if policy.startswith("request_policy") or policy.startswith(
"response_policy"
):
if policy.startswith("request_policy"):
policy_type = policy.split(":")[1]
try:
command_with_policies[module_name][
command_name
].request_policy = RequestPolicy(policy_type)
except ValueError:
raise IncorrectPolicyType(
f"Incorrect request policy type: {policy_type}"
)
if policy.startswith("response_policy"):
policy_type = policy.split(":")[1]
try:
command_with_policies[module_name][
command_name
].response_policy = ResponsePolicy(policy_type)
except ValueError:
raise IncorrectPolicyType(
f"Incorrect response policy type: {policy_type}"
)
elif isinstance(data, list):
# For lists, recursively process each element
for item in data:
extract_policies(item, module_name, command_name)
elif isinstance(data, dict):
# For dictionaries, recursively process each value
for value in data.values():
extract_policies(value, module_name, command_name)
for command, details in self.commands.items():
# Check whether the command has keys
is_keyless = await self._is_keyless_command(command)
if is_keyless:
default_request_policy = RequestPolicy.DEFAULT_KEYLESS
default_response_policy = ResponsePolicy.DEFAULT_KEYLESS
else:
default_request_policy = RequestPolicy.DEFAULT_KEYED
default_response_policy = ResponsePolicy.DEFAULT_KEYED
# Check if it's a core or module command
split_name = command.split(".")
if len(split_name) > 1:
module_name = split_name[0]
command_name = split_name[1]
else:
module_name = "core"
command_name = split_name[0]
# Create a CommandPolicies object with default policies on the new command.
if command_with_policies.get(module_name, None) is None:
command_with_policies[module_name] = {
command_name: CommandPolicies(
request_policy=default_request_policy,
response_policy=default_response_policy,
)
}
else:
command_with_policies[module_name][command_name] = CommandPolicies(
request_policy=default_request_policy,
response_policy=default_response_policy,
)
tips = details.get("tips")
subcommands = details.get("subcommands")
# Process tips for the main command
if tips:
extract_policies(tips, module_name, command_name)
# Process subcommands
if subcommands:
for subcommand_details in subcommands:
# Get the subcommand name (first element)
subcmd_name = subcommand_details[0]
if isinstance(subcmd_name, bytes):
subcmd_name = subcmd_name.decode()
# Check whether the subcommand has keys
is_keyless = await self._is_keyless_command(command, subcmd_name)
if is_keyless:
default_request_policy = RequestPolicy.DEFAULT_KEYLESS
default_response_policy = ResponsePolicy.DEFAULT_KEYLESS
else:
default_request_policy = RequestPolicy.DEFAULT_KEYED
default_response_policy = ResponsePolicy.DEFAULT_KEYED
subcmd_name = subcmd_name.replace("|", " ")
# Create a CommandPolicies object with default policies on the new command.
command_with_policies[module_name][subcmd_name] = CommandPolicies(
request_policy=default_request_policy,
response_policy=default_response_policy,
)
# Recursively extract policies from the rest of the subcommand details
for subcommand_detail in subcommand_details[1:]:
extract_policies(subcommand_detail, module_name, subcmd_name)
return command_with_policies

View file

@ -0,0 +1,44 @@
from ..exceptions import DataError
class Encoder:
"Encode strings to bytes-like and decode bytes-like to strings"
__slots__ = "encoding", "encoding_errors", "decode_responses"
def __init__(self, encoding, encoding_errors, decode_responses):
self.encoding = encoding
self.encoding_errors = encoding_errors
self.decode_responses = decode_responses
def encode(self, value):
"Return a bytestring or bytes-like representation of the value"
if isinstance(value, (bytes, bytearray, memoryview)):
return value
elif isinstance(value, bool):
# special case bool since it is a subclass of int
raise DataError(
"Invalid input of type: 'bool'. Convert to a "
"bytes, string, int or float first."
)
elif isinstance(value, (int, float)):
value = repr(value).encode()
elif not isinstance(value, str):
# a value we don't know how to deal with. throw an error
typename = type(value).__name__
raise DataError(
f"Invalid input of type: '{typename}'. "
f"Convert to a bytes, string, int or float first."
)
if isinstance(value, str):
value = value.encode(self.encoding, self.encoding_errors)
return value
def decode(self, value, force=False):
"Return a unicode string from the bytes-like representation"
if self.decode_responses or force:
if isinstance(value, memoryview):
value = value.tobytes()
if isinstance(value, bytes):
value = value.decode(self.encoding, self.encoding_errors)
return value

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,298 @@
import select
import socket
from logging import getLogger
from typing import Callable, List, Optional, TypedDict, Union
from ..exceptions import ConnectionError, InvalidResponse, RedisError, TimeoutError
from ..typing import EncodableT
from ..utils import HIREDIS_AVAILABLE, SENTINEL, deprecated_function
from .base import (
AsyncBaseParser,
AsyncPushNotificationsParser,
BaseParser,
PushNotificationsParser,
)
from .socket import (
NONBLOCKING_EXCEPTION_ERROR_NUMBERS,
NONBLOCKING_EXCEPTIONS,
SERVER_CLOSED_CONNECTION_ERROR,
)
# Used to signal that hiredis-py does not have enough data to parse.
# Using `False` or `None` is not reliable, given that the parser can
# return `False` or `None` for legitimate reasons from RESP payloads.
NOT_ENOUGH_DATA = object()
def _socket_can_read(sock, timeout: float) -> bool:
# SSL sockets can have decrypted bytes buffered above the OS socket layer.
if hasattr(sock, "pending") and sock.pending():
return True
return bool(select.select([sock], [], [], timeout)[0])
class _HiredisReaderArgs(TypedDict, total=False):
protocolError: Callable[[str], Exception]
replyError: Callable[[str], Exception]
encoding: Optional[str]
errors: Optional[str]
class _HiredisParser(BaseParser, PushNotificationsParser):
"Parser class for connections using Hiredis"
def __init__(self, socket_read_size):
if not HIREDIS_AVAILABLE:
raise RedisError("Hiredis is not installed")
self.socket_read_size = socket_read_size
self._buffer = bytearray(socket_read_size)
self.pubsub_push_handler_func = self.handle_pubsub_push_response
self.node_moving_push_handler_func = None
self.maintenance_push_handler_func = None
self.oss_cluster_maint_push_handler_func = None
self.invalidation_push_handler_func = None
self._hiredis_PushNotificationType = None
def __del__(self):
try:
self.on_disconnect()
except Exception:
pass
def handle_pubsub_push_response(self, response):
logger = getLogger("push_response")
logger.debug("Push response: " + str(response))
return response
def on_connect(self, connection, **kwargs):
import hiredis
self._sock = connection._sock
self._socket_timeout = connection.socket_timeout
kwargs = {
"protocolError": InvalidResponse,
"replyError": self.parse_error,
"errors": connection.encoder.encoding_errors,
"notEnoughData": NOT_ENOUGH_DATA,
}
if connection.encoder.decode_responses:
kwargs["encoding"] = connection.encoder.encoding
self._reader = hiredis.Reader(**kwargs)
try:
self._hiredis_PushNotificationType = hiredis.PushNotification
except AttributeError:
# hiredis < 3.2
self._hiredis_PushNotificationType = None
def on_disconnect(self):
self._sock = None
self._reader = None
def can_read(self, timeout: float = 0) -> bool:
# TODO: Rename this API; it detects pending data or dirty/closed
# connection state, not only whether application data can be read.
if not self._reader:
raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR)
if self._reader.has_data():
return True
return _socket_can_read(self._sock, timeout)
def read_from_socket(self, timeout=SENTINEL, raise_on_timeout=True):
sock = self._sock
custom_timeout = timeout is not SENTINEL
try:
if custom_timeout:
sock.settimeout(timeout)
bufflen = self._sock.recv_into(self._buffer)
if bufflen == 0:
raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR)
self._reader.feed(self._buffer, 0, bufflen)
# data was read from the socket and added to the buffer.
# return True to indicate that data was read.
return True
except socket.timeout:
if raise_on_timeout:
raise TimeoutError("Timeout reading from socket")
return False
except NONBLOCKING_EXCEPTIONS as ex:
# if we're in nonblocking mode and the recv raises a
# blocking error, simply return False indicating that
# there's no data to be read. otherwise raise the
# original exception.
allowed = NONBLOCKING_EXCEPTION_ERROR_NUMBERS.get(ex.__class__, -1)
if ex.errno == allowed:
if not raise_on_timeout:
return False
if timeout == 0:
raise TimeoutError("Timeout reading from socket")
raise ConnectionError(f"Error while reading from socket: {ex.args}")
finally:
if custom_timeout:
sock.settimeout(self._socket_timeout)
def read_response(
self,
disable_decoding=False,
push_request=False,
timeout: Union[float, object] = SENTINEL,
):
if not self._reader:
raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR)
if disable_decoding:
response = self._reader.gets(False)
else:
response = self._reader.gets()
while response is NOT_ENOUGH_DATA:
self.read_from_socket(timeout=timeout)
if disable_decoding:
response = self._reader.gets(False)
else:
response = self._reader.gets()
# if the response is a ConnectionError or the response is a list and
# the first item is a ConnectionError, raise it as something bad
# happened
if isinstance(response, ConnectionError):
raise response
elif self._hiredis_PushNotificationType is not None and isinstance(
response, self._hiredis_PushNotificationType
):
response = self.handle_push_response(response)
if push_request:
return response
return self.read_response(
disable_decoding=disable_decoding,
push_request=push_request,
timeout=timeout,
)
elif (
isinstance(response, list)
and response
and isinstance(response[0], ConnectionError)
):
raise response[0]
return response
class _AsyncHiredisParser(AsyncBaseParser, AsyncPushNotificationsParser):
"""Async implementation of parser class for connections using Hiredis"""
__slots__ = ("_reader",)
def __init__(self, socket_read_size: int):
if not HIREDIS_AVAILABLE:
raise RedisError("Hiredis is not available.")
super().__init__(socket_read_size=socket_read_size)
self._reader = None
self.pubsub_push_handler_func = self.handle_pubsub_push_response
self.invalidation_push_handler_func = None
self._hiredis_PushNotificationType = None
async def handle_pubsub_push_response(self, response):
logger = getLogger("push_response")
logger.debug("Push response: " + str(response))
return response
def on_connect(self, connection):
import hiredis
self._stream = connection._reader
kwargs: _HiredisReaderArgs = {
"protocolError": InvalidResponse,
"replyError": self.parse_error,
"notEnoughData": NOT_ENOUGH_DATA,
}
if connection.encoder.decode_responses:
kwargs["encoding"] = connection.encoder.encoding
kwargs["errors"] = connection.encoder.encoding_errors
self._reader = hiredis.Reader(**kwargs)
self._connected = True
try:
self._hiredis_PushNotificationType = getattr(
hiredis, "PushNotification", None
)
except AttributeError:
# hiredis < 3.2
self._hiredis_PushNotificationType = None
def on_disconnect(self):
self._connected = False
@deprecated_function(
version="8.0.0", reason="Use can_read() instead", name="can_read_destructive"
)
async def can_read_destructive(self) -> bool:
return await self.can_read()
async def can_read(self) -> bool:
# TODO: Rename this API; it detects pending data or dirty/closed
# connection state, not only whether application data can be read.
if not self._connected:
raise OSError("Buffer is closed.")
# EOF means the connection is closed and not safe to reuse.
if self._reader.has_data() or self._stream.at_eof():
return True
# asyncio.StreamReader has no public non-destructive API for checking
# buffered bytes. Preserve dirty-connection detection for hiredis; tests
# with a real StreamReader guard this private buffer API in CI.
return bool(self._stream._buffer)
async def read_from_socket(self):
buffer = await self._stream.read(self._read_size)
if not buffer or not isinstance(buffer, bytes):
raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR) from None
self._reader.feed(buffer)
# data was read from the socket and added to the buffer.
# return True to indicate that data was read.
return True
async def read_response(
self, disable_decoding: bool = False, push_request: bool = False
) -> Union[EncodableT, List[EncodableT]]:
# If `on_disconnect()` has been called, prohibit any more reads
# even if they could happen because data might be present.
# We still allow reads in progress to finish
if not self._connected:
raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR) from None
if disable_decoding:
response = self._reader.gets(False)
else:
response = self._reader.gets()
while response is NOT_ENOUGH_DATA:
await self.read_from_socket()
if disable_decoding:
response = self._reader.gets(False)
else:
response = self._reader.gets()
# if the response is a ConnectionError or the response is a list and
# the first item is a ConnectionError, raise it as something bad
# happened
if isinstance(response, ConnectionError):
raise response
elif self._hiredis_PushNotificationType is not None and isinstance(
response, self._hiredis_PushNotificationType
):
response = await self.handle_push_response(response)
if not push_request:
return await self.read_response(
disable_decoding=disable_decoding, push_request=push_request
)
else:
return response
elif (
isinstance(response, list)
and response
and isinstance(response[0], ConnectionError)
):
raise response[0]
return response

View file

@ -0,0 +1,139 @@
from typing import Any, Union
from ..exceptions import ConnectionError, InvalidResponse, ResponseError
from ..typing import EncodableT
from ..utils import SENTINEL
from .base import _AsyncRESPBase, _RESPBase
from .socket import SERVER_CLOSED_CONNECTION_ERROR
class _RESP2Parser(_RESPBase):
"""RESP2 protocol implementation"""
def read_response(
self, disable_decoding=False, timeout: Union[float, object] = SENTINEL
):
pos = self._buffer.get_pos() if self._buffer else None
try:
result = self._read_response(
disable_decoding=disable_decoding, timeout=timeout
)
except BaseException:
if self._buffer:
self._buffer.rewind(pos)
raise
else:
self._buffer.purge()
return result
def _read_response(
self, disable_decoding=False, timeout: Union[float, object] = SENTINEL
):
raw = self._buffer.readline(timeout=timeout)
if not raw:
raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR)
byte, response = raw[:1], raw[1:]
# server returned an error
if byte == b"-":
response = response.decode("utf-8", errors="replace")
error = self.parse_error(response)
# if the error is a ConnectionError, raise immediately so the user
# is notified
if isinstance(error, ConnectionError):
raise error
# otherwise, we're dealing with a ResponseError that might belong
# inside a pipeline response. the connection's read_response()
# and/or the pipeline's execute() will raise this error if
# necessary, so just return the exception instance here.
return error
# single value
elif byte == b"+":
pass
# int value
elif byte == b":":
return int(response)
# bulk response
elif byte == b"$" and response == b"-1":
return None
elif byte == b"$":
response = self._buffer.read(int(response), timeout=timeout)
# multi-bulk response
elif byte == b"*" and response == b"-1":
return None
elif byte == b"*":
response = [
self._read_response(disable_decoding=disable_decoding, timeout=timeout)
for i in range(int(response))
]
else:
raise InvalidResponse(f"Protocol Error: {raw!r}")
if disable_decoding is False:
response = self.encoder.decode(response)
return response
class _AsyncRESP2Parser(_AsyncRESPBase):
"""Async class for the RESP2 protocol"""
async def read_response(self, disable_decoding: bool = False):
if not self._connected:
raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR)
if self._chunks:
# augment parsing buffer with previously read data
self._buffer += b"".join(self._chunks)
self._chunks.clear()
self._pos = 0
response = await self._read_response(disable_decoding=disable_decoding)
# Successfully parsing a response allows us to clear our parsing buffer
self._clear()
return response
async def _read_response(
self, disable_decoding: bool = False
) -> Union[EncodableT, ResponseError, None]:
raw = await self._readline()
response: Any
byte, response = raw[:1], raw[1:]
# server returned an error
if byte == b"-":
response = response.decode("utf-8", errors="replace")
error = self.parse_error(response)
# if the error is a ConnectionError, raise immediately so the user
# is notified
if isinstance(error, ConnectionError):
self._clear() # Successful parse
raise error
# otherwise, we're dealing with a ResponseError that might belong
# inside a pipeline response. the connection's read_response()
# and/or the pipeline's execute() will raise this error if
# necessary, so just return the exception instance here.
return error
# single value
elif byte == b"+":
pass
# int value
elif byte == b":":
return int(response)
# bulk response
elif byte == b"$" and response == b"-1":
return None
elif byte == b"$":
response = await self._read(int(response))
# multi-bulk response
elif byte == b"*" and response == b"-1":
return None
elif byte == b"*":
response = [
(await self._read_response(disable_decoding))
for _ in range(int(response)) # noqa
]
else:
raise InvalidResponse(f"Protocol Error: {raw!r}")
if disable_decoding is False:
response = self.encoder.decode(response)
return response

View file

@ -0,0 +1,289 @@
from logging import getLogger
from typing import Any, Union
from ..exceptions import ConnectionError, InvalidResponse, ResponseError
from ..typing import EncodableT
from ..utils import SENTINEL
from .base import (
AsyncPushNotificationsParser,
PushNotificationsParser,
_AsyncRESPBase,
_RESPBase,
)
from .socket import SERVER_CLOSED_CONNECTION_ERROR
class _RESP3Parser(_RESPBase, PushNotificationsParser):
"""RESP3 protocol implementation"""
def __init__(self, socket_read_size):
super().__init__(socket_read_size)
self.pubsub_push_handler_func = self.handle_pubsub_push_response
self.node_moving_push_handler_func = None
self.maintenance_push_handler_func = None
self.oss_cluster_maint_push_handler_func = None
self.invalidation_push_handler_func = None
def handle_pubsub_push_response(self, response):
logger = getLogger("push_response")
logger.debug("Push response: " + str(response))
return response
def read_response(
self,
disable_decoding=False,
push_request=False,
timeout: Union[float, object] = SENTINEL,
):
pos = self._buffer.get_pos() if self._buffer is not None else None
try:
result = self._read_response(
disable_decoding=disable_decoding,
push_request=push_request,
timeout=timeout,
)
except BaseException:
if self._buffer is not None:
self._buffer.rewind(pos)
raise
else:
if self._buffer is not None:
try:
self._buffer.purge()
except AttributeError:
# Buffer may have been set to None by another thread after
# the check above; result is still valid so we don't raise
pass
return result
def _read_response(
self,
disable_decoding=False,
push_request=False,
timeout: Union[float, object] = SENTINEL,
):
raw = self._buffer.readline(timeout=timeout)
if not raw:
raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR)
byte, response = raw[:1], raw[1:]
# server returned an error
if byte in (b"-", b"!"):
if byte == b"!":
response = self._buffer.read(int(response), timeout=timeout)
response = response.decode("utf-8", errors="replace")
error = self.parse_error(response)
# if the error is a ConnectionError, raise immediately so the user
# is notified
if isinstance(error, ConnectionError):
raise error
# otherwise, we're dealing with a ResponseError that might belong
# inside a pipeline response. the connection's read_response()
# and/or the pipeline's execute() will raise this error if
# necessary, so just return the exception instance here.
return error
# single value
elif byte == b"+":
pass
# null value
elif byte == b"_":
return None
# int and big int values
elif byte in (b":", b"("):
return int(response)
# double value
elif byte == b",":
return float(response)
# bool value
elif byte == b"#":
return response == b"t"
# bulk response
elif byte == b"$":
response = self._buffer.read(int(response), timeout=timeout)
# verbatim string response
elif byte == b"=":
response = self._buffer.read(int(response), timeout=timeout)[4:]
# array response
elif byte == b"*":
response = [
self._read_response(disable_decoding=disable_decoding, timeout=timeout)
for _ in range(int(response))
]
# set response
elif byte == b"~":
# redis can return unhashable types (like dict) in a set,
# so we return sets as list, all the time, for predictability
response = [
self._read_response(disable_decoding=disable_decoding, timeout=timeout)
for _ in range(int(response))
]
# map response
elif byte == b"%":
# We cannot use a dict-comprehension to parse stream.
# Evaluation order of key:val expression in dict comprehension only
# became defined to be left-right in version 3.8
resp_dict = {}
for _ in range(int(response)):
key = self._read_response(
disable_decoding=disable_decoding, timeout=timeout
)
resp_dict[key] = self._read_response(
disable_decoding=disable_decoding,
push_request=push_request,
timeout=timeout,
)
response = resp_dict
# push response
elif byte == b">":
response = [
self._read_response(
disable_decoding=disable_decoding,
push_request=push_request,
timeout=timeout,
)
for _ in range(int(response))
]
response = self.handle_push_response(response)
# if this is a push request return the push response
if push_request:
return response
return self._read_response(
disable_decoding=disable_decoding,
push_request=push_request,
)
else:
raise InvalidResponse(f"Protocol Error: {raw!r}")
if isinstance(response, bytes) and disable_decoding is False:
response = self.encoder.decode(response)
return response
class _AsyncRESP3Parser(_AsyncRESPBase, AsyncPushNotificationsParser):
def __init__(self, socket_read_size):
super().__init__(socket_read_size)
self.pubsub_push_handler_func = self.handle_pubsub_push_response
self.invalidation_push_handler_func = None
async def handle_pubsub_push_response(self, response):
logger = getLogger("push_response")
logger.debug("Push response: " + str(response))
return response
async def read_response(
self, disable_decoding: bool = False, push_request: bool = False
):
if self._chunks:
# augment parsing buffer with previously read data
self._buffer += b"".join(self._chunks)
self._chunks.clear()
self._pos = 0
response = await self._read_response(
disable_decoding=disable_decoding, push_request=push_request
)
# Successfully parsing a response allows us to clear our parsing buffer
self._clear()
return response
async def _read_response(
self, disable_decoding: bool = False, push_request: bool = False
) -> Union[EncodableT, ResponseError, None]:
if not self._stream or not self.encoder:
raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR)
raw = await self._readline()
response: Any
byte, response = raw[:1], raw[1:]
# if byte not in (b"-", b"+", b":", b"$", b"*"):
# raise InvalidResponse(f"Protocol Error: {raw!r}")
# server returned an error
if byte in (b"-", b"!"):
if byte == b"!":
response = await self._read(int(response))
response = response.decode("utf-8", errors="replace")
error = self.parse_error(response)
# if the error is a ConnectionError, raise immediately so the user
# is notified
if isinstance(error, ConnectionError):
self._clear() # Successful parse
raise error
# otherwise, we're dealing with a ResponseError that might belong
# inside a pipeline response. the connection's read_response()
# and/or the pipeline's execute() will raise this error if
# necessary, so just return the exception instance here.
return error
# single value
elif byte == b"+":
pass
# null value
elif byte == b"_":
return None
# int and big int values
elif byte in (b":", b"("):
return int(response)
# double value
elif byte == b",":
return float(response)
# bool value
elif byte == b"#":
return response == b"t"
# bulk response
elif byte == b"$":
response = await self._read(int(response))
# verbatim string response
elif byte == b"=":
response = (await self._read(int(response)))[4:]
# array response
elif byte == b"*":
response = [
(await self._read_response(disable_decoding=disable_decoding))
for _ in range(int(response))
]
# set response
elif byte == b"~":
# redis can return unhashable types (like dict) in a set,
# so we always convert to a list, to have predictable return types
response = [
(await self._read_response(disable_decoding=disable_decoding))
for _ in range(int(response))
]
# map response
elif byte == b"%":
# We cannot use a dict-comprehension to parse stream.
# Evaluation order of key:val expression in dict comprehension only
# became defined to be left-right in version 3.8
resp_dict = {}
for _ in range(int(response)):
key = await self._read_response(disable_decoding=disable_decoding)
resp_dict[key] = await self._read_response(
disable_decoding=disable_decoding, push_request=push_request
)
response = resp_dict
# push response
elif byte == b">":
response = [
(
await self._read_response(
disable_decoding=disable_decoding, push_request=push_request
)
)
for _ in range(int(response))
]
response = await self.handle_push_response(response)
if not push_request:
return await self._read_response(
disable_decoding=disable_decoding, push_request=push_request
)
else:
return response
else:
raise InvalidResponse(f"Protocol Error: {raw!r}")
if isinstance(response, bytes) and disable_decoding is False:
response = self.encoder.decode(response)
return response

View file

@ -0,0 +1,501 @@
"""Response-callback dictionaries and the protocol/legacy selector.
This module is the single source of truth for the mapping between Redis
command names and the Python-side callbacks that post-process raw parser
output into user-facing values.
Six dictionaries are defined:
* ``_RedisCallbacks`` entries that produce the same Python value
regardless of wire protocol or legacy-response selection.
* ``_RedisCallbacksRESP2`` RESP2 wire, legacy Python shapes.
* ``_RedisCallbacksRESP3`` RESP3 wire, Python shapes from the previous
RESP3 callbacks.
* ``_RedisCallbacksRESP2Unified`` RESP2 wire, unified Python shapes
(``legacy_responses=False``).
* ``_RedisCallbacksRESP3Unified`` RESP3 wire, unified Python shapes
(``legacy_responses=False``).
* ``_RedisCallbacksRESP3toRESP2Legacy`` RESP3 wire converted back to the
legacy RESP2 Python shapes for users who keep ``legacy_responses=True``
on a RESP3 connection.
``get_response_callbacks`` merges ``_RedisCallbacks`` with the appropriate
protocol-specific overlay. Callers wrap the returned dict in
``CaseInsensitiveDict``.
"""
from typing import Any, Callable, Optional
from redis.utils import str_if_bytes
from .helpers import (
bool_ok,
bzpop_score_resp3_to_resp2_legacy,
bzpop_score_unified,
float_or_none,
hrandfield_resp3_to_resp2_legacy,
hrandfield_unified,
pairs_to_dict,
parse_acl_getuser,
parse_acl_getuser_resp3_to_resp2_legacy,
parse_acl_getuser_unified,
parse_acl_log,
parse_acl_log_resp3_to_resp2_legacy,
parse_acl_log_resp3_unified,
parse_arinfo,
parse_client_info,
parse_client_kill,
parse_client_list,
parse_client_trackinginfo_resp3_to_resp2_legacy,
parse_client_trackinginfo_unified,
parse_cluster_info,
parse_cluster_links_resp3_to_resp2_legacy,
parse_cluster_links_unified,
parse_cluster_nodes,
parse_command,
parse_command_resp3,
parse_command_unified,
parse_config_get,
parse_config_get_resp3_to_resp2_legacy,
parse_debug_object,
parse_function_list_resp3_to_resp2_legacy,
parse_function_list_unified,
parse_geopos_resp3_to_resp2_legacy,
parse_geopos_unified,
parse_geosearch_generic,
parse_geosearch_generic_unified,
parse_hscan,
parse_info,
parse_lcs_idx_resp3_to_resp2_legacy,
parse_lcs_idx_unified,
parse_list_of_dicts,
parse_list_of_dicts_resp3,
parse_memory_stats,
parse_memory_stats_resp3,
parse_memory_stats_unified,
parse_pubsub_numsub,
parse_scan,
parse_sentinel_get_master,
parse_sentinel_master,
parse_sentinel_master_resp3_to_resp2_legacy,
parse_sentinel_master_unified,
parse_sentinel_master_unified_resp3,
parse_sentinel_masters,
parse_sentinel_masters_resp3,
parse_sentinel_masters_resp3_to_resp2_legacy,
parse_sentinel_masters_unified,
parse_sentinel_masters_unified_resp3,
parse_sentinel_slaves_and_sentinels,
parse_sentinel_slaves_and_sentinels_resp3,
parse_sentinel_slaves_and_sentinels_resp3_to_resp2_legacy,
parse_sentinel_slaves_and_sentinels_unified,
parse_sentinel_slaves_and_sentinels_unified_resp3,
parse_sentinel_state_resp3,
parse_set_result,
parse_slowlog_get,
parse_stralgo,
parse_stralgo_resp3_unified,
parse_stralgo_unified,
parse_stream_list,
parse_xautoclaim,
parse_xclaim,
parse_xinfo_stream,
parse_xpending,
parse_xread,
parse_xread_resp3,
parse_xread_resp3_to_resp2_legacy,
parse_xread_unified,
parse_zadd,
parse_zmscore,
parse_zscan,
parse_zscan_unified,
sort_return_tuples,
string_keys_to_dict,
timestamp_to_datetime,
zmpop_resp3_to_resp2_legacy,
zmpop_unified,
zpop_score_pairs,
zpop_score_pairs_resp3_to_resp2_legacy,
zpop_score_pairs_resp3_unified,
zpop_score_pairs_unified,
zset_score_for_rank,
zset_score_for_rank_resp3,
zset_score_for_rank_resp3_to_resp2_legacy,
zset_score_for_rank_unified,
zset_score_pairs,
zset_score_pairs_resp3,
zset_score_pairs_resp3_to_resp2_legacy,
zset_score_pairs_resp3_to_resp2_legacy_flat,
zset_score_pairs_unified,
)
_RedisCallbacks = {
**string_keys_to_dict(
"AUTH COPY EXPIRE EXPIREAT HEXISTS HMSET MOVE MSETNX PERSIST PSETEX "
"PEXPIRE PEXPIREAT RENAMENX SETEX SETNX SMOVE",
bool,
),
**string_keys_to_dict("HINCRBYFLOAT INCRBYFLOAT", float),
**string_keys_to_dict(
"ASKING FLUSHALL FLUSHDB LSET LTRIM MSET PFMERGE READONLY READWRITE "
"RENAME SAVE SELECT SHUTDOWN SLAVEOF SWAPDB WATCH UNWATCH",
bool_ok,
),
**string_keys_to_dict("XREAD XREADGROUP", parse_xread),
**string_keys_to_dict(
"GEORADIUS GEORADIUSBYMEMBER GEOSEARCH",
parse_geosearch_generic,
),
**string_keys_to_dict("XRANGE XREVRANGE", parse_stream_list),
"ACL GETUSER": parse_acl_getuser,
"ACL LOAD": bool_ok,
"ACL LOG": parse_acl_log,
"ACL SETUSER": bool_ok,
"ACL SAVE": bool_ok,
"CLIENT INFO": parse_client_info,
"CLIENT KILL": parse_client_kill,
"CLIENT LIST": parse_client_list,
"CLIENT PAUSE": bool_ok,
"CLIENT SETINFO": bool_ok,
"CLIENT SETNAME": bool_ok,
"CLIENT UNBLOCK": bool,
"CLUSTER ADDSLOTS": bool_ok,
"CLUSTER ADDSLOTSRANGE": bool_ok,
"CLUSTER DELSLOTS": bool_ok,
"CLUSTER DELSLOTSRANGE": bool_ok,
"CLUSTER FAILOVER": bool_ok,
"CLUSTER FORGET": bool_ok,
"CLUSTER INFO": parse_cluster_info,
"CLUSTER MEET": bool_ok,
"CLUSTER NODES": parse_cluster_nodes,
"CLUSTER REPLICAS": parse_cluster_nodes,
"CLUSTER REPLICATE": bool_ok,
"CLUSTER RESET": bool_ok,
"CLUSTER SAVECONFIG": bool_ok,
"CLUSTER SET-CONFIG-EPOCH": bool_ok,
"CLUSTER SETSLOT": bool_ok,
"CLUSTER SLAVES": parse_cluster_nodes,
"COMMAND": parse_command,
"CONFIG RESETSTAT": bool_ok,
"CONFIG SET": bool_ok,
"FUNCTION DELETE": bool_ok,
"FUNCTION FLUSH": bool_ok,
"FUNCTION RESTORE": bool_ok,
"GEODIST": float_or_none,
"HSCAN": parse_hscan,
"INFO": parse_info,
"LASTSAVE": timestamp_to_datetime,
"MEMORY PURGE": bool_ok,
"MODULE LOAD": bool,
"MODULE UNLOAD": bool,
"PING": lambda r: str_if_bytes(r) == "PONG",
"PUBSUB NUMSUB": parse_pubsub_numsub,
"PUBSUB SHARDNUMSUB": parse_pubsub_numsub,
"QUIT": bool_ok,
"SET": parse_set_result,
"SCAN": parse_scan,
"SCRIPT EXISTS": lambda r: list(map(bool, r)),
"SCRIPT FLUSH": bool_ok,
"SCRIPT KILL": bool_ok,
"SCRIPT LOAD": str_if_bytes,
"SENTINEL CKQUORUM": bool_ok,
"SENTINEL FAILOVER": bool_ok,
"SENTINEL FLUSHCONFIG": bool_ok,
"SENTINEL GET-MASTER-ADDR-BY-NAME": parse_sentinel_get_master,
"SENTINEL MONITOR": bool_ok,
"SENTINEL RESET": bool_ok,
"SENTINEL REMOVE": bool_ok,
"SENTINEL SET": bool_ok,
"SLOWLOG GET": parse_slowlog_get,
"SLOWLOG RESET": bool_ok,
"SORT": sort_return_tuples,
"SSCAN": parse_scan,
"TIME": lambda x: (int(x[0]), int(x[1])),
"XAUTOCLAIM": parse_xautoclaim,
"XCLAIM": parse_xclaim,
"XGROUP CREATE": bool_ok,
"XGROUP DESTROY": bool,
"XGROUP SETID": bool_ok,
"ARINFO": parse_arinfo,
"XINFO STREAM": parse_xinfo_stream,
"XPENDING": parse_xpending,
"ZSCAN": parse_zscan,
}
_RedisCallbacksRESP2 = {
**string_keys_to_dict(
"SDIFF SINTER SMEMBERS SUNION", lambda r: r and set(r) or set()
),
**string_keys_to_dict(
"ZINTER ZRANGE ZRANGEBYSCORE ZREVRANGE ZREVRANGEBYSCORE ZUNION",
zset_score_pairs,
),
**string_keys_to_dict("ZPOPMAX ZPOPMIN", zpop_score_pairs),
**string_keys_to_dict(
"ZREVRANK ZRANK",
zset_score_for_rank,
),
**string_keys_to_dict("ZINCRBY ZSCORE", float_or_none),
**string_keys_to_dict("BGREWRITEAOF BGSAVE", lambda r: True),
**string_keys_to_dict("BLPOP BRPOP", lambda r: r and tuple(r) or None),
**string_keys_to_dict(
"BZPOPMAX BZPOPMIN", lambda r: r and (r[0], r[1], float(r[2])) or None
),
"ACL CAT": lambda r: list(map(str_if_bytes, r)),
"ACL GENPASS": str_if_bytes,
"ACL HELP": lambda r: list(map(str_if_bytes, r)),
"ACL LIST": lambda r: list(map(str_if_bytes, r)),
"ACL USERS": lambda r: list(map(str_if_bytes, r)),
"ACL WHOAMI": str_if_bytes,
"CLIENT GETNAME": str_if_bytes,
"CLIENT TRACKINGINFO": lambda r: list(map(str_if_bytes, r)),
"CLUSTER GETKEYSINSLOT": lambda r: list(map(str_if_bytes, r)),
"COMMAND GETKEYS": lambda r: list(map(str_if_bytes, r)),
"CONFIG GET": parse_config_get,
"DEBUG OBJECT": parse_debug_object,
"GEOHASH": lambda r: list(map(str_if_bytes, r)),
"GEOPOS": lambda r: list(
map(lambda ll: (float(ll[0]), float(ll[1])) if ll is not None else None, r)
),
"HGETALL": lambda r: r and pairs_to_dict(r) or {},
"HOTKEYS GET": lambda r: [pairs_to_dict(m) for m in r],
"MEMORY STATS": parse_memory_stats,
"MODULE LIST": lambda r: [pairs_to_dict(m) for m in r],
"RESET": str_if_bytes,
"SENTINEL MASTER": parse_sentinel_master,
"SENTINEL MASTERS": parse_sentinel_masters,
"SENTINEL SENTINELS": parse_sentinel_slaves_and_sentinels,
"SENTINEL SLAVES": parse_sentinel_slaves_and_sentinels,
"STRALGO": parse_stralgo,
"XINFO CONSUMERS": parse_list_of_dicts,
"XINFO GROUPS": parse_list_of_dicts,
"ZADD": parse_zadd,
"ZMSCORE": parse_zmscore,
}
_RedisCallbacksRESP3 = {
**string_keys_to_dict(
"SDIFF SINTER SMEMBERS SUNION", lambda r: r and set(r) or set()
),
**string_keys_to_dict(
"ZRANGE ZINTER ZPOPMAX ZPOPMIN HGETALL XREADGROUP",
lambda r, **kwargs: r,
),
**string_keys_to_dict(
"ZRANGE ZRANGEBYSCORE ZREVRANGE ZREVRANGEBYSCORE ZUNION",
zset_score_pairs_resp3,
),
**string_keys_to_dict(
"ZREVRANK ZRANK",
zset_score_for_rank_resp3,
),
**string_keys_to_dict("XREAD XREADGROUP", parse_xread_resp3),
"ACL LOG": lambda r: (
[
{str_if_bytes(key): str_if_bytes(value) for key, value in x.items()}
for x in r
]
if isinstance(r, list)
else bool_ok(r)
),
"COMMAND": parse_command_resp3,
"CONFIG GET": parse_config_get_resp3_to_resp2_legacy,
"MEMORY STATS": parse_memory_stats_resp3,
"SENTINEL MASTER": parse_sentinel_state_resp3,
"SENTINEL MASTERS": parse_sentinel_masters_resp3,
"SENTINEL SENTINELS": parse_sentinel_slaves_and_sentinels_resp3,
"SENTINEL SLAVES": parse_sentinel_slaves_and_sentinels_resp3,
"STRALGO": lambda r, **options: (
{str_if_bytes(key): str_if_bytes(value) for key, value in r.items()}
if isinstance(r, dict)
else str_if_bytes(r)
),
"XINFO CONSUMERS": parse_list_of_dicts_resp3,
"XINFO GROUPS": parse_list_of_dicts_resp3,
}
# RESP2 wire, unified response shapes (``legacy_responses=False``).
_RedisCallbacksRESP2Unified: dict[str, Callable[..., Any]] = {
**_RedisCallbacksRESP2,
**string_keys_to_dict(
"ZDIFF ZINTER ZRANGE ZRANGEBYSCORE ZREVRANGE ZREVRANGEBYSCORE ZUNION",
zset_score_pairs_unified,
),
**string_keys_to_dict(
"ZPOPMAX ZPOPMIN",
zpop_score_pairs_unified,
),
**string_keys_to_dict(
"ZREVRANK ZRANK",
zset_score_for_rank_unified,
),
**string_keys_to_dict(
"BZPOPMAX BZPOPMIN",
bzpop_score_unified,
),
**string_keys_to_dict("XREAD XREADGROUP", parse_xread_unified),
**string_keys_to_dict(
"GEORADIUS GEORADIUSBYMEMBER GEOSEARCH",
parse_geosearch_generic_unified,
),
**string_keys_to_dict("BLPOP BRPOP", lambda r: r or None),
**string_keys_to_dict("ZMPOP BZMPOP", zmpop_unified),
"ZSCAN": parse_zscan_unified,
"ZRANDMEMBER": zset_score_pairs_unified,
"HRANDFIELD": hrandfield_unified,
"ACL GETUSER": parse_acl_getuser_unified,
"CLIENT TRACKINGINFO": parse_client_trackinginfo_unified,
"CLUSTER GETKEYSINSLOT": lambda r, **kwargs: r,
"CLUSTER LINKS": parse_cluster_links_unified,
"COMMAND": parse_command_unified,
"COMMAND GETKEYS": lambda r, **kwargs: r,
"FUNCTION LIST": parse_function_list_unified,
"GEOPOS": parse_geopos_unified,
"LCS": parse_lcs_idx_unified,
"MEMORY STATS": parse_memory_stats_unified,
"SENTINEL MASTER": parse_sentinel_master_unified,
"SENTINEL MASTERS": parse_sentinel_masters_unified,
"SENTINEL SENTINELS": parse_sentinel_slaves_and_sentinels_unified,
"SENTINEL SLAVES": parse_sentinel_slaves_and_sentinels_unified,
"STRALGO": parse_stralgo_unified,
}
# RESP3 wire, unified response shapes (``legacy_responses=False``).
_RedisCallbacksRESP3Unified: dict[str, Callable[..., Any]] = {
**_RedisCallbacksRESP3,
**string_keys_to_dict(
"ZDIFF ZINTER ZRANGE ZRANGEBYSCORE ZREVRANGE ZREVRANGEBYSCORE ZUNION",
zset_score_pairs_resp3,
),
**string_keys_to_dict(
"ZPOPMAX ZPOPMIN",
zpop_score_pairs_resp3_unified,
),
**string_keys_to_dict(
"BZPOPMAX BZPOPMIN",
bzpop_score_unified,
),
**string_keys_to_dict("XREAD XREADGROUP", parse_xread_unified),
**string_keys_to_dict(
"GEORADIUS GEORADIUSBYMEMBER GEOSEARCH",
parse_geosearch_generic_unified,
),
"ZSCAN": parse_zscan_unified,
"ZRANDMEMBER": zset_score_pairs_resp3,
"ACL CAT": lambda r: list(map(str_if_bytes, r)),
"ACL GENPASS": str_if_bytes,
"ACL HELP": lambda r: list(map(str_if_bytes, r)),
"ACL LIST": lambda r: list(map(str_if_bytes, r)),
"ACL LOG": parse_acl_log_resp3_unified,
"ACL USERS": lambda r: list(map(str_if_bytes, r)),
"ACL WHOAMI": str_if_bytes,
"CLIENT GETNAME": str_if_bytes,
"CLIENT TRACKINGINFO": parse_client_trackinginfo_unified,
"CLUSTER LINKS": parse_cluster_links_unified,
"COMMAND": parse_command_unified,
"FUNCTION LIST": parse_function_list_unified,
"GEOHASH": lambda r: list(map(str_if_bytes, r)),
"LCS": parse_lcs_idx_unified,
"RESET": str_if_bytes,
"SENTINEL MASTER": parse_sentinel_master_unified_resp3,
"SENTINEL MASTERS": parse_sentinel_masters_unified_resp3,
"SENTINEL SENTINELS": parse_sentinel_slaves_and_sentinels_unified_resp3,
"SENTINEL SLAVES": parse_sentinel_slaves_and_sentinels_unified_resp3,
"STRALGO": parse_stralgo_resp3_unified,
}
# RESP3 wire converted back to the legacy RESP2 Python shapes. Only the
# entries needed to undo RESP3-side differences are listed; everything
# else falls through to ``_RedisCallbacks``. Scores are re-encoded to bytes
# before being passed to ``score_cast_func`` so the callable observes the
# same input type it would on a RESP2 connection.
_RedisCallbacksRESP3toRESP2Legacy: dict[str, Callable[..., Any]] = {
**string_keys_to_dict(
"SDIFF SINTER SMEMBERS SUNION", lambda r: r and set(r) or set()
),
**string_keys_to_dict(
"ZINTER ZRANGE ZRANGEBYSCORE ZREVRANGE ZREVRANGEBYSCORE ZUNION",
zset_score_pairs_resp3_to_resp2_legacy,
),
"ZDIFF": zset_score_pairs_resp3_to_resp2_legacy_flat,
"ZRANDMEMBER": zset_score_pairs_resp3_to_resp2_legacy_flat,
**string_keys_to_dict(
"ZPOPMAX ZPOPMIN",
zpop_score_pairs_resp3_to_resp2_legacy,
),
**string_keys_to_dict(
"BZPOPMAX BZPOPMIN",
bzpop_score_resp3_to_resp2_legacy,
),
**string_keys_to_dict(
"ZREVRANK ZRANK",
zset_score_for_rank_resp3_to_resp2_legacy,
),
**string_keys_to_dict("BGREWRITEAOF BGSAVE", lambda r: True),
**string_keys_to_dict("XREAD XREADGROUP", parse_xread_resp3_to_resp2_legacy),
**string_keys_to_dict("BLPOP BRPOP", lambda r: r and tuple(r) or None),
**string_keys_to_dict("ZMPOP BZMPOP", zmpop_resp3_to_resp2_legacy),
"HRANDFIELD": hrandfield_resp3_to_resp2_legacy,
"ACL CAT": lambda r: list(map(str_if_bytes, r)),
"ACL GENPASS": str_if_bytes,
"ACL GETUSER": parse_acl_getuser_resp3_to_resp2_legacy,
"ACL HELP": lambda r: list(map(str_if_bytes, r)),
"ACL LIST": lambda r: list(map(str_if_bytes, r)),
"ACL LOG": parse_acl_log_resp3_to_resp2_legacy,
"ACL USERS": lambda r: list(map(str_if_bytes, r)),
"ACL WHOAMI": str_if_bytes,
"CLIENT GETNAME": str_if_bytes,
"CLIENT TRACKINGINFO": parse_client_trackinginfo_resp3_to_resp2_legacy,
"CLUSTER GETKEYSINSLOT": lambda r: list(map(str_if_bytes, r)),
"CLUSTER LINKS": parse_cluster_links_resp3_to_resp2_legacy,
"COMMAND GETKEYS": lambda r: list(map(str_if_bytes, r)),
"CONFIG GET": parse_config_get_resp3_to_resp2_legacy,
"DEBUG OBJECT": parse_debug_object,
"FUNCTION LIST": parse_function_list_resp3_to_resp2_legacy,
"GEOHASH": lambda r: list(map(str_if_bytes, r)),
"GEOPOS": parse_geopos_resp3_to_resp2_legacy,
"LCS": parse_lcs_idx_resp3_to_resp2_legacy,
"MEMORY STATS": parse_memory_stats_resp3,
"RESET": str_if_bytes,
"SENTINEL MASTER": parse_sentinel_master_resp3_to_resp2_legacy,
"SENTINEL MASTERS": parse_sentinel_masters_resp3_to_resp2_legacy,
"SENTINEL SENTINELS": parse_sentinel_slaves_and_sentinels_resp3_to_resp2_legacy,
"SENTINEL SLAVES": parse_sentinel_slaves_and_sentinels_resp3_to_resp2_legacy,
"XINFO CONSUMERS": parse_list_of_dicts_resp3,
"XINFO GROUPS": parse_list_of_dicts_resp3,
}
def get_response_callbacks(
user_protocol: Optional[int],
legacy_responses: bool,
) -> dict[str, Callable[..., Any]]:
"""Return the merged callback dict for the given (protocol, legacy)
combination.
``user_protocol`` is the value the user supplied to the client
constructor (``None`` means "not specified"). ``legacy_responses``
defaults to ``True`` and selects today's RESP2-style Python shapes
even when the wire protocol is RESP3.
Callers wrap the returned dict in ``CaseInsensitiveDict``.
"""
callbacks: dict[str, Callable[..., Any]] = dict(_RedisCallbacks)
if legacy_responses:
if user_protocol is None:
callbacks.update(_RedisCallbacksRESP3toRESP2Legacy)
elif user_protocol in (3, "3"):
callbacks.update(_RedisCallbacksRESP3)
else:
callbacks.update(_RedisCallbacksRESP2)
else:
if user_protocol is None or user_protocol in (3, "3"):
callbacks.update(_RedisCallbacksRESP3Unified)
else:
callbacks.update(_RedisCallbacksRESP2Unified)
return callbacks

View file

@ -0,0 +1,164 @@
import errno
import io
import socket
from io import SEEK_END
from typing import Optional, Union
from ..exceptions import ConnectionError, TimeoutError
from ..utils import SENTINEL, SSL_AVAILABLE
NONBLOCKING_EXCEPTION_ERROR_NUMBERS = {BlockingIOError: errno.EWOULDBLOCK}
if SSL_AVAILABLE:
import ssl
if hasattr(ssl, "SSLWantReadError"):
NONBLOCKING_EXCEPTION_ERROR_NUMBERS[ssl.SSLWantReadError] = 2
NONBLOCKING_EXCEPTION_ERROR_NUMBERS[ssl.SSLWantWriteError] = 2
else:
NONBLOCKING_EXCEPTION_ERROR_NUMBERS[ssl.SSLError] = 2
NONBLOCKING_EXCEPTIONS = tuple(NONBLOCKING_EXCEPTION_ERROR_NUMBERS.keys())
SERVER_CLOSED_CONNECTION_ERROR = "Connection closed by server."
SYM_CRLF = b"\r\n"
class SocketBuffer:
def __init__(
self, socket: socket.socket, socket_read_size: int, socket_timeout: float
):
self._sock = socket
self.socket_read_size = socket_read_size
self.socket_timeout = socket_timeout
self._buffer = io.BytesIO()
def unread_bytes(self) -> int:
"""
Remaining unread length of buffer
"""
pos = self._buffer.tell()
end = self._buffer.seek(0, SEEK_END)
self._buffer.seek(pos)
return end - pos
def _read_from_socket(
self,
length: Optional[int] = None,
timeout: Union[float, object] = SENTINEL,
raise_on_timeout: Optional[bool] = True,
) -> bool:
sock = self._sock
socket_read_size = self.socket_read_size
marker = 0
custom_timeout = timeout is not SENTINEL
buf = self._buffer
current_pos = buf.tell()
buf.seek(0, SEEK_END)
if custom_timeout:
sock.settimeout(timeout)
try:
while True:
data = sock.recv(socket_read_size)
# an empty string indicates the server shutdown the socket
if isinstance(data, bytes) and len(data) == 0:
raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR)
buf.write(data)
data_length = len(data)
marker += data_length
if length is not None and length > marker:
continue
return True
except socket.timeout:
if raise_on_timeout:
raise TimeoutError("Timeout reading from socket")
return False
except NONBLOCKING_EXCEPTIONS as ex:
# if we're in nonblocking mode and the recv raises a
# blocking error, simply return False indicating that
# there's no data to be read. otherwise raise the
# original exception.
allowed = NONBLOCKING_EXCEPTION_ERROR_NUMBERS.get(ex.__class__, -1)
if ex.errno == allowed:
if not raise_on_timeout:
return False
if timeout == 0:
raise TimeoutError("Timeout reading from socket")
raise ConnectionError(f"Error while reading from socket: {ex.args}")
finally:
buf.seek(current_pos)
if custom_timeout:
sock.settimeout(self.socket_timeout)
def can_read(self, timeout: float = 0) -> bool:
return bool(self.unread_bytes()) or self._read_from_socket(
timeout=timeout, raise_on_timeout=False
)
def read(self, length: int, timeout: Union[float, object] = SENTINEL) -> bytes:
length = length + 2 # make sure to read the \r\n terminator
# BufferIO will return less than requested if buffer is short
data = self._buffer.read(length)
missing = length - len(data)
if missing:
# fill up the buffer and read the remainder
self._read_from_socket(length=missing, timeout=timeout)
data += self._buffer.read(missing)
return data[:-2]
def readline(self, timeout: Union[float, object] = SENTINEL) -> bytes:
buf = self._buffer
data = buf.readline()
while not data.endswith(SYM_CRLF):
# there's more data in the socket that we need
self._read_from_socket(timeout=timeout)
data += buf.readline()
return data[:-2]
def get_pos(self) -> int:
"""
Get current read position
"""
return self._buffer.tell()
def rewind(self, pos: int) -> None:
"""
Rewind the buffer to a specific position, to re-start reading
"""
self._buffer.seek(pos)
def purge(self) -> None:
"""
After a successful read, purge the read part of buffer
"""
unread = self.unread_bytes()
# Only if we have read all of the buffer do we truncate, to
# reduce the amount of memory thrashing. This heuristic
# can be changed or removed later.
if unread > 0:
return
if unread > 0:
# move unread data to the front
view = self._buffer.getbuffer()
view[:unread] = view[-unread:]
self._buffer.truncate(unread)
self._buffer.seek(0)
def close(self) -> None:
try:
self._buffer.close()
except Exception:
# issue #633 suggests the purge/close somehow raised a
# BadFileDescriptor error. Perhaps the client ran out of
# memory or something else? It's probably OK to ignore
# any error being raised from purge/close since we're
# removing the reference to the instance below.
pass
self._buffer = None
self._sock = None