[
  {
    "path": ".agent/sync_async_type_hints_overload_guide.md",
    "content": "# Agent Overload Implementation Guide\n\n## Purpose\nThis document provides instructions for implementing `@overload` signatures for redis-py command methods.\n\n## How to Use This Guide\n\n### Step 1: Select a Batch\nWork on one batch at a time. Each batch contains methods grouped by command class.\n\n### Step 2: For Each Method in the Batch\n1. **Verify the return type** by checking Redis docs at https://redis.io/commands\n2. **Add two `@overload` signatures** before the method when the sync and async return types differ:\n   - One for sync (`self: SyncClientProtocol`) returning the sync type\n   - One for async (`self: AsyncClientProtocol`) returning `Awaitable[sync_type]`\n   - If both clients return the same concrete type, keep a single typed method instead of adding redundant overloads\n3. **Mirror the full input signature exactly** in both overloads:\n   - Same parameter names, order, defaults, positional/keyword shape, `*args`, and `**kwargs` as the implementation\n   - Use the same modern annotation style as return types: prefer `X | Y` and `T | None` over `Union[...]` / `Optional[...]`\n4. **Keep the original implementation** unchanged except for signature annotation normalization when needed\n   - For implementation return unions, prefer the readable order `SyncType | Awaitable[SyncType]` (for example `(dict | None) | Awaitable[dict | None]`)\n   - Runtime decorators such as deprecation or experimental markers must stay on the real implementation, not on `@overload` stubs. Put those decorators immediately above the implementation `def` after the overload block.\n5. **Run type checker** to verify no errors\n\n### Step 3: Mark Batch Complete\nAfter implementing all methods in a batch, update status in inventory.\n\n---\n\n## Overload Pattern Template\n\n```python\nfrom typing import Awaitable, overload\n\nfrom redis.typing import AsyncClientProtocol, SyncClientProtocol\n\nclass SomeCommands:\n    @overload\n    def method(self: SyncClientProtocol, arg: str | None = None) -> SyncReturnType: ...\n    @overload\n    def method(\n        self: AsyncClientProtocol, arg: str | None = None\n    ) -> Awaitable[SyncReturnType]: ...\n    def method(\n        self, arg: str | None = None\n    ) -> SyncReturnType | Awaitable[SyncReturnType]:\n        # original implementation unchanged\n        ...\n```\n\n### Protocol Definitions (in `redis/typing.py`)\n\n```python\nclass SyncClientProtocol(Protocol):\n    \"\"\"Marker for sync clients.\"\"\"\n    _is_async_client: Literal[False]\n\nclass AsyncClientProtocol(Protocol):\n    \"\"\"Marker for async clients.\"\"\"\n    _is_async_client: Literal[True]\n```\n\n**Note:** We use Protocol-based discrimination (not `Redis[bytes]` / `AsyncRedis[str]`) to avoid multiplying overloads by `decode_responses` setting.\n\n---\n\n## Status Legend\n\n| Symbol | Meaning |\n|--------|---------|\n| ✅ | Can use standard overload pattern |\n| ⚠️ | Has separate async implementation - SKIP |\n| 🔄 | Returns iterator - SKIP |\n| ❌ | Dunder method - SKIP |\n| 📋 | Already has explicit types - may still need overloads |\n\n---\n\n## CRITICAL: Response Callback System\n\nUnderstanding the callback system is essential for determining accurate return types.\n\n### Three-Tier Callback Architecture\n\nThe `redis-py` library uses three callback dictionaries in `redis/_parsers/helpers.py`:\n\n| Dictionary | Lines | Purpose |\n|------------|-------|---------|\n| `_RedisCallbacks` | 754-844 | **Base callbacks** - shared by both RESP2 and RESP3 |\n| `_RedisCallbacksRESP2` | 847-896 | **RESP2-specific** overrides/additions |\n| `_RedisCallbacksRESP3` | 899-947 | **RESP3-specific** overrides/additions |\n\n### How Callbacks Are Applied\n\n1. When a command is executed, the library checks for a callback in this order:\n   - If using RESP2: Check `_RedisCallbacksRESP2` first, then fall back to `_RedisCallbacks`\n   - If using RESP3: Check `_RedisCallbacksRESP3` first, then fall back to `_RedisCallbacks`\n\n2. If no callback exists, the raw response is returned (depends on `decode_responses` setting)\n\n### Key Callback Functions\n\n| Callback | Return Type | Notes |\n|----------|-------------|-------|\n| `bool_ok` | `bool` | Converts \"OK\" to `True` |\n| `bool` | `bool` | Converts int to bool |\n| `str_if_bytes` | `str` | Converts bytes to str regardless of decode_responses |\n| `float_or_none` | `float \\| None` | Parses float, returns None if null |\n| `parse_scan` | `tuple[int, list]` | Cursor + keys |\n| `parse_info` | `dict[str, Any]` | Parses INFO output |\n| `zset_score_pairs` | `list[tuple[..., float]]` | For sorted set with scores (RESP2) |\n| `zset_score_pairs_resp3` | `list[tuple[..., float]]` | For sorted set with scores (RESP3) |\n\n### Protocol-Specific Return Types\n\n**IMPORTANT**: Some commands have different return types depending on protocol version:\n\n| Command | RESP2 Return | RESP3 Return | Reason |\n|---------|--------------|--------------|--------|\n| `acl_cat` | `list[str]` | `list[bytes \\| str]` | RESP2 has `str_if_bytes`, RESP3 has no callback |\n| `acl_genpass` | `str` | `bytes \\| str` | RESP2 has `str_if_bytes`, RESP3 has no callback |\n| `client_getname` | `str \\| None` | `bytes \\| str \\| None` | RESP2 has `str_if_bytes`, RESP3 has no callback |\n| `geohash` | `list[str]` | `list[bytes \\| str]` | RESP2 has `str_if_bytes`, RESP3 has no callback |\n| `hgetall` | `dict` (pairs_to_dict) | `dict` (identity) | Different parsing logic |\n| `zincrby`, `zscore` | `float` (float_or_none) | `float` (raw) | RESP2 parses, RESP3 returns directly |\n\n### How to Determine Return Type\n\n1. **Check `_RedisCallbacks`** (base) - Is there a callback for the command?\n2. **Check `_RedisCallbacksRESP2`** - Is there a RESP2-specific override?\n3. **Check `_RedisCallbacksRESP3`** - Is there a RESP3-specific override?\n4. **If no callback** - Return type depends on `decode_responses`:\n   - Bulk string: `bytes | str`\n   - Integer: `int`\n   - Array: `list[...]`\n   - Null: `None`\n\n### Recommended Typing Strategy\n\nFor commands with protocol-specific differences, use the **most permissive union type**:\n- If RESP2 returns `str` and RESP3 returns `bytes | str`, use `bytes | str`\n- This ensures type safety regardless of protocol version\n\n---\n\n## Batches\n\n### BATCH 1: ACLCommands (core.py)\n| # | Method | Sync Type | Async Type | Status | Notes |\n|---|--------|-----------|------------|--------|-------|\n| 1 | `acl_cat` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ | RESP2: str_if_bytes / RESP3: raw |\n| 2 | `acl_dryrun` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ | No callback |\n| 3 | `acl_deluser` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 4 | `acl_genpass` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ | RESP2: str_if_bytes / RESP3: raw |\n| 5 | `acl_getuser` | `dict \\| None` | `Awaitable[dict \\| None]` | ✅ | Base: parse_acl_getuser |\n| 6 | `acl_help` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ | RESP2: str_if_bytes / RESP3: raw |\n| 7 | `acl_list` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ | RESP2: str_if_bytes / RESP3: raw |\n| 8 | `acl_log` | `list[dict]` | `Awaitable[list[dict]]` | ✅ | Base: parse_acl_log / RESP3: lambda |\n| 9 | `acl_log_reset` | `bool` | `Awaitable[bool]` | ✅ | Via acl_log with RESET |\n| 10 | `acl_load` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 11 | `acl_save` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 12 | `acl_setuser` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 13 | `acl_users` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ | RESP2: str_if_bytes / RESP3: raw |\n| 14 | `acl_whoami` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ | RESP2: str_if_bytes / RESP3: raw |\n\n### BATCH 2: ManagementCommands Part 1 (core.py) - Methods 15-50\n| # | Method | Sync Type | Async Type | Status | Notes |\n|---|--------|-----------|------------|--------|-------|\n| 15 | `auth` | `bool` | `Awaitable[bool]` | ✅ | Base: bool |\n| 16 | `bgrewriteaof` | `bool \\| bytes \\| str` | `Awaitable[bool \\| bytes \\| str]` | ✅ | RESP2: True / RESP3: raw |\n| 17 | `bgsave` | `bool \\| bytes \\| str` | `Awaitable[bool \\| bytes \\| str]` | ✅ | RESP2: True / RESP3: raw |\n| 18 | `role` | `list` | `Awaitable[list]` | ✅ | No callback - mixed types |\n| 19 | `client_kill` | `bool \\| int` | `Awaitable[bool \\| int]` | ✅ | Base: parse_client_kill |\n| 20 | `client_kill_filter` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 21 | `client_info` | `dict[str, str]` | `Awaitable[dict[str, str]]` | ✅ | Base: parse_client_info |\n| 22 | `client_list` | `list[dict[str, str]]` | `Awaitable[list[dict[str, str]]]` | ✅ | Base: parse_client_list |\n| 23 | `client_getname` | `bytes \\| str \\| None` | `Awaitable[bytes \\| str \\| None]` | ✅ | RESP2: str_if_bytes / RESP3: raw |\n| 24 | `client_getredir` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 25 | `client_reply` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ | No callback |\n| 26 | `client_id` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 27 | `client_tracking_on` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ | No callback - returns raw OK |\n| 28 | `client_tracking_off` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ | No callback - returns raw OK |\n| 29 | `client_tracking` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ | No callback - returns raw OK |\n| 30 | `client_trackinginfo` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ | RESP2: str_if_bytes / RESP3: raw |\n| 31 | `client_setname` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 32 | `client_setinfo` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 33 | `client_unblock` | `bool` | `Awaitable[bool]` | ✅ | Base: bool (converts int) |\n| 34 | `client_pause` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 35 | `client_unpause` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ | No callback - returns raw OK |\n| 36 | `client_no_evict` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ | No callback - returns raw OK |\n| 37 | `client_no_touch` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ | No callback - returns raw OK |\n| 38 | `command` | `list` | `Awaitable[list]` | ✅ | Base: parse_command / RESP3: parse_command_resp3 |\n| 39 | `command_info` | `None` | `None` | ⚠️ SKIP | N/A |\n| 40 | `command_count` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 41 | `command_list` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ | No callback - raw |\n| 42 | `command_getkeysandflags` | `list[list[bytes \\| str \\| list[bytes \\| str]]]` | `Awaitable[list[list[bytes \\| str \\| list[bytes \\| str]]]]` | ✅ | No callback - mixed [key, flags] shape |\n| 43 | `command_docs` | `dict` | `Awaitable[dict]` | ✅ | No callback - raw dict |\n| 44 | `config_get` | `dict[str, str]` | `Awaitable[dict[str, str]]` | ✅ | RESP2: parse_config_get / RESP3: lambda |\n| 45 | `config_set` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 46 | `config_resetstat` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 47 | `config_rewrite` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ | No callback - returns raw OK |\n| 48 | `dbsize` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 49 | `debug_object` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ | RESP2: parse_debug_object / RESP3: raw |\n| 50 | `debug_segfault` | `None` | `None` | ⚠️ SKIP | N/A |\n\n### BATCH 3: ManagementCommands Part 2 (core.py) - Methods 51-93\n| # | Method | Sync Type | Async Type | Status | Notes |\n|---|--------|-----------|------------|--------|-------|\n| 51 | `echo` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ | No callback - raw |\n| 52 | `flushall` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 53 | `flushdb` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 54 | `sync` | `bytes` | `Awaitable[bytes]` | ✅ | No callback - raw bytes |\n| 55 | `psync` | `bytes` | `Awaitable[bytes]` | ✅ | No callback - raw bytes |\n| 56 | `swapdb` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 57 | `select` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 58 | `info` | `dict[str, Any]` | `Awaitable[dict[str, Any]]` | ✅ | Base: parse_info |\n| 59 | `lastsave` | `datetime` | `Awaitable[datetime]` | ✅ | Base: timestamp_to_datetime |\n| 60 | `latency_doctor` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ | No callback - raw |\n| 61 | `latency_graph` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ | No callback - raw |\n| 62 | `lolwut` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ | No callback - raw |\n| 63 | `reset` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ | RESP2: str_if_bytes / RESP3: raw |\n| 64 | `migrate` | `bool \\| bytes \\| str` | `Awaitable[bool \\| bytes \\| str]` | ✅ | No callback - NOKEY or OK |\n| 65 | `object` | `Any` | `Awaitable[Any]` | ✅ | Varies by subcommand |\n| 66 | `memory_doctor` | `None` | `None` | ⚠️ SKIP | N/A |\n| 67 | `memory_help` | `None` | `None` | ⚠️ SKIP | N/A |\n| 68 | `memory_stats` | `dict[str, Any]` | `Awaitable[dict[str, Any]]` | ✅ | RESP2: parse_memory_stats / RESP3: lambda |\n| 69 | `memory_malloc_stats` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ | No callback - raw |\n| 70 | `memory_usage` | `int \\| None` | `Awaitable[int \\| None]` | ✅ | Integer or nil reply |\n| 71 | `memory_purge` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 72 | `latency_histogram` | `dict` | `Awaitable[dict]` | ✅ | No callback - raw dict |\n| 73 | `latency_history` | `list[tuple[int, int]]` | `Awaitable[list[tuple[int, int]]]` | ✅ | No callback - array of arrays |\n| 74 | `latency_latest` | `list[tuple[bytes \\| str, int, int, int]]` | `Awaitable[list[tuple[bytes \\| str, int, int, int]]]` | ✅ | First element is key name |\n| 75 | `latency_reset` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 76 | `ping` | `bool` | `Awaitable[bool]` | 📋 | Base: lambda - returns True if PONG |\n| 77 | `quit` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 78 | `replicaof` | `bool` | `Awaitable[bool]` | ✅ | No callback - OK |\n| 79 | `save` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 80 | `shutdown` | `None` | `None` | ⚠️ SKIP | N/A |\n| 81 | `slaveof` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 82 | `slowlog_get` | `list[dict]` | `Awaitable[list[dict]]` | ✅ | Base: parse_slowlog_get |\n| 83 | `slowlog_len` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 84 | `slowlog_reset` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 85 | `time` | `tuple[int, int]` | `Awaitable[tuple[int, int]]` | ✅ | Base: lambda - tuple(secs, usecs) |\n| 86 | `wait` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 87 | `waitaof` | `list[int]` | `Awaitable[list[int]]` | ✅ | Array of integers |\n| 88 | `hello` | `dict` | `Awaitable[dict]` | ✅ | No callback - raw dict |\n| 89 | `failover` | `bool` | `Awaitable[bool]` | ✅ | No callback - OK |\n| 90 | `hotkeys_start` | `bytes \\| str` | `Awaitable[bytes \\| str]` | 📋 | No callback - raw |\n| 91 | `hotkeys_stop` | `bytes \\| str` | `Awaitable[bytes \\| str]` | 📋 | No callback - raw |\n| 92 | `hotkeys_reset` | `bytes \\| str` | `Awaitable[bytes \\| str]` | 📋 | No callback - raw |\n| 93 | `hotkeys_get` | `list[dict]` | `Awaitable[list[dict]]` | 📋 | RESP2: lambda pairs_to_dict |\n\n### BATCH 4: BasicKeyCommands Part 1 (core.py) - Methods 94-130\n| # | Method | Sync Type | Async Type | Status | Notes |\n|---|--------|-----------|------------|--------|-------|\n| 94 | `append` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 95 | `bitcount` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 96 | `bitfield` | `BitFieldOperation` | `BitFieldOperation` | 📋 | Returns operation builder |\n| 97 | `bitfield_ro` | `list[int]` | `Awaitable[list[int]]` | ✅ | Array of integers |\n| 98 | `bitop` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 99 | `bitpos` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 100 | `copy` | `bool` | `Awaitable[bool]` | ✅ | Base: bool |\n| 101 | `decrby` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 102 | `delete` | `int` | `Awaitable[int]` | ✅ | Integer reply - count deleted |\n| 103 | `__delitem__` | `None` | N/A | ❌ SKIP | Dunder |\n| 104 | `delex` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 105 | `dump` | `bytes \\| None` | `Awaitable[bytes \\| None]` | ✅ | Always bytes (serialized) |\n| 106 | `exists` | `int` | `Awaitable[int]` | ✅ | Integer reply - count |\n| 107 | `expire` | `bool` | `Awaitable[bool]` | ✅ | Base: bool |\n| 108 | `expireat` | `bool` | `Awaitable[bool]` | ✅ | Base: bool |\n| 109 | `expiretime` | `int` | `Awaitable[int]` | 📋 | Integer - timestamp |\n| 110 | `digest_local` | `bytes \\| str` | `Awaitable[bytes \\| str]` | 📋 | No callback - raw |\n| 111 | `digest` | `bytes \\| str \\| None` | `Awaitable[bytes \\| str \\| None]` | ✅ | No callback - raw |\n| 112 | `get` | `bytes \\| str \\| None` | `Awaitable[bytes \\| str \\| None]` | ✅ DONE | No callback - raw |\n| 113 | `getdel` | `bytes \\| str \\| None` | `Awaitable[bytes \\| str \\| None]` | ✅ | No callback - raw |\n| 114 | `getex` | `bytes \\| str \\| None` | `Awaitable[bytes \\| str \\| None]` | ✅ | No callback - raw |\n| 115 | `__getitem__` | `bytes \\| str` | N/A | ❌ SKIP | Dunder |\n| 116 | `getbit` | `int` | `Awaitable[int]` | ✅ | Integer reply - 0 or 1 |\n| 117 | `getrange` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ | No callback - raw |\n| 118 | `getset` | `bytes \\| str \\| None` | `Awaitable[bytes \\| str \\| None]` | ✅ | No callback - raw |\n| 119 | `incrby` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 120 | `incrbyfloat` | `float` | `Awaitable[float]` | ✅ | Base: float |\n| 121 | `keys` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ | No callback - raw array |\n| 122 | `lmove` | `bytes \\| str \\| None` | `Awaitable[bytes \\| str \\| None]` | ✅ | No callback - raw |\n| 123 | `blmove` | `bytes \\| str \\| None` | `Awaitable[bytes \\| str \\| None]` | ✅ | No callback - raw |\n| 124 | `mget` | `list[bytes \\| str \\| None]` | `Awaitable[list[bytes \\| str \\| None]]` | ✅ | No callback - raw array |\n| 125 | `mset` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 126 | `msetex` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 127 | `msetnx` | `bool` | `Awaitable[bool]` | ✅ | Base: bool |\n| 128 | `move` | `bool` | `Awaitable[bool]` | ✅ | Base: bool |\n| 129 | `persist` | `bool` | `Awaitable[bool]` | ✅ | Base: bool |\n| 130 | `pexpire` | `bool` | `Awaitable[bool]` | ✅ | Base: bool |\n\n### BATCH 5: BasicKeyCommands Part 2 (core.py) - Methods 131-156\n| # | Method | Sync Type | Async Type | Status | Notes |\n|---|--------|-----------|------------|--------|-------|\n| 131 | `pexpireat` | `bool` | `Awaitable[bool]` | ✅ | Base: bool |\n| 132 | `pexpiretime` | `int` | `Awaitable[int]` | ✅ | Integer - timestamp in ms |\n| 133 | `psetex` | `bool` | `Awaitable[bool]` | ✅ | Base: bool |\n| 134 | `pttl` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 135 | `hrandfield` | `bytes \\| str \\| list \\| None` | `Awaitable[bytes \\| str \\| list \\| None]` | ✅ | Varies by COUNT param |\n| 136 | `randomkey` | `bytes \\| str \\| None` | `Awaitable[bytes \\| str \\| None]` | ✅ | No callback - raw |\n| 137 | `rename` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 138 | `renamenx` | `bool` | `Awaitable[bool]` | ✅ | Base: bool |\n| 139 | `restore` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ | No callback - raw OK |\n| 140 | `set` | `bool \\| None` | `Awaitable[bool \\| None]` | ✅ DONE | Base: parse_set_result |\n| 141 | `__setitem__` | `None` | N/A | ❌ SKIP | Dunder |\n| 142 | `setbit` | `int` | `Awaitable[int]` | ✅ | Integer reply - prev bit |\n| 143 | `setex` | `bool` | `Awaitable[bool]` | ✅ | Base: bool |\n| 144 | `setnx` | `bool` | `Awaitable[bool]` | ✅ | Base: bool |\n| 145 | `setrange` | `int` | `Awaitable[int]` | ✅ | Integer reply - new length |\n| 146 | `stralgo` | `dict \\| str \\| int` | `Awaitable[dict \\| str \\| int]` | ✅ | RESP2: parse_stralgo / RESP3: lambda |\n| 147 | `strlen` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 148 | `substr` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ | No callback - raw |\n| 149 | `touch` | `int` | `Awaitable[int]` | ✅ | Integer reply - count |\n| 150 | `ttl` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 151 | `type` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ | No callback - raw |\n| 152 | `watch` | `bool` | `Awaitable[bool]` | ⚠️ SKIP | Transaction |\n| 153 | `unwatch` | `bool` | `Awaitable[bool]` | ⚠️ SKIP | Transaction |\n| 154 | `unlink` | `int` | `Awaitable[int]` | ✅ | Integer reply - count |\n| 155 | `lcs` | `bytes \\| str \\| int \\| list \\| dict` | `Awaitable[bytes \\| str \\| int \\| list \\| dict]` | ✅ | Raw reply varies by options and protocol |\n| 156 | `__contains__` | `bool` | N/A | ❌ SKIP | Dunder |\n\n### BATCH 6: ListCommands (core.py) - Methods 157-178\n| # | Method | Sync Type | Async Type | Status | Notes |\n|---|--------|-----------|------------|--------|-------|\n| 157 | `blpop` | `tuple[bytes \\| str, bytes \\| str] \\| list[bytes \\| str] \\| None` | `Awaitable[tuple[bytes \\| str, bytes \\| str] \\| list[bytes \\| str] \\| None]` | ✅ | RESP2: tuple / RESP3: raw list |\n| 158 | `brpop` | `tuple[bytes \\| str, bytes \\| str] \\| list[bytes \\| str] \\| None` | `Awaitable[tuple[bytes \\| str, bytes \\| str] \\| list[bytes \\| str] \\| None]` | ✅ | RESP2: tuple / RESP3: raw list |\n| 159 | `brpoplpush` | `bytes \\| str \\| None` | `Awaitable[bytes \\| str \\| None]` | ✅ | No callback - raw |\n| 160 | `blmpop` | `list[bytes \\| str \\| list[bytes \\| str]] \\| None` | `Awaitable[list[bytes \\| str \\| list[bytes \\| str]] \\| None]` | ✅ | No callback - nested [key, values] shape |\n| 161 | `lmpop` | `list[bytes \\| str \\| list[bytes \\| str]] \\| None` | `Awaitable[list[bytes \\| str \\| list[bytes \\| str]] \\| None]` | ✅ | No callback - nested [key, values] shape |\n| 162 | `lindex` | `bytes \\| str \\| None` | `Awaitable[bytes \\| str \\| None]` | ✅ | No callback - raw |\n| 163 | `linsert` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 164 | `llen` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 165 | `lpop` | `bytes \\| str \\| list[bytes \\| str] \\| None` | `Awaitable[bytes \\| str \\| list[bytes \\| str] \\| None]` | ✅ | Varies by COUNT |\n| 166 | `lpush` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 167 | `lpushx` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 168 | `lrange` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ | No callback - raw array |\n| 169 | `lrem` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 170 | `lset` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 171 | `ltrim` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 172 | `rpop` | `bytes \\| str \\| list[bytes \\| str] \\| None` | `Awaitable[bytes \\| str \\| list[bytes \\| str] \\| None]` | ✅ | Varies by COUNT |\n| 173 | `rpoplpush` | `bytes \\| str \\| None` | `Awaitable[bytes \\| str \\| None]` | ✅ | No callback - raw |\n| 174 | `rpush` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 175 | `rpushx` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 176 | `lpos` | `int \\| list[int] \\| None` | `Awaitable[int \\| list[int] \\| None]` | ✅ | Varies by COUNT/RANK |\n| 177 | `sort` | `list[bytes \\| str] \\| list[tuple[bytes \\| str, ...]] \\| int` | `Awaitable[list[bytes \\| str] \\| list[tuple[bytes \\| str, ...]] \\| int]` | ✅ | Base: sort_return_tuples incl. grouped tuples |\n| 178 | `sort_ro` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ | No callback - raw array |\n\n### BATCH 7: ScanCommands + SetCommands (core.py) - Methods 179-202\n| # | Method | Sync Type | Async Type | Status | Notes |\n|---|--------|-----------|------------|--------|-------|\n| 179 | `scan` | `tuple[int, list[bytes \\| str]]` | `Awaitable[tuple[int, list[bytes \\| str]]]` | ✅ | Base: parse_scan |\n| 180 | `scan_iter` | `Iterator` | `AsyncIterator` | 🔄 SKIP | Iterator |\n| 181 | `sscan` | `tuple[int, list[bytes \\| str]]` | `Awaitable[tuple[int, list[bytes \\| str]]]` | ✅ | Base: parse_scan |\n| 182 | `sscan_iter` | `Iterator` | `AsyncIterator` | 🔄 SKIP | Iterator |\n| 183 | `hscan` | `tuple[int, dict[bytes \\| str, bytes \\| str] \\| list[bytes \\| str]]` | `Awaitable[tuple[int, dict[bytes \\| str, bytes \\| str] \\| list[bytes \\| str]]]` | ✅ | Base: parse_hscan, `NOVALUES` returns key list |\n| 184 | `hscan_iter` | `Iterator` | `AsyncIterator` | 🔄 SKIP | Iterator |\n| 185 | `zscan` | `tuple[int, list[tuple[bytes \\| str, float]]]` | `Awaitable[tuple[int, list[tuple[bytes \\| str, float]]]]` | ✅ | Base: parse_zscan |\n| 186 | `zscan_iter` | `Iterator` | `AsyncIterator` | 🔄 SKIP | Iterator |\n| 187 | `sadd` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 188 | `scard` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 189 | `sdiff` | `set[bytes \\| str]` | `Awaitable[set[bytes \\| str]]` | ✅ | RESP2+RESP3: lambda set |\n| 190 | `sdiffstore` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 191 | `sinter` | `set[bytes \\| str]` | `Awaitable[set[bytes \\| str]]` | ✅ | RESP2+RESP3: lambda set |\n| 192 | `sintercard` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 193 | `sinterstore` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 194 | `Literal[0] \\| Literal[1]` | `Awaitable[Literal[0] \\| Literal[1]]` | ✅ | Integer 0/1 - no callback |\n| 195 | `smembers` | `set[bytes \\| str]` | `Awaitable[set[bytes \\| str]]` | ✅ | RESP2+RESP3: lambda set |\n| 196 | `smismember` | `list[int]` | `Awaitable[list[int]]` | ✅ | Array of 0/1 |\n| 197 | `smove` | `bool` | `Awaitable[bool]` | ✅ | Base: bool |\n| 198 | `spop` | `bytes \\| str \\| set[bytes \\| str] \\| None` | `Awaitable[bytes \\| str \\| set[bytes \\| str] \\| None]` | ✅ | Varies by COUNT, count form returns a set |\n| 199 | `srandmember` | `bytes \\| str \\| list[bytes \\| str] \\| None` | `Awaitable[bytes \\| str \\| list[bytes \\| str] \\| None]` | ✅ | Varies by NUMBER |\n| 200 | `srem` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 201 | `sunion` | `set[bytes \\| str]` | `Awaitable[set[bytes \\| str]]` | ✅ | RESP2+RESP3: lambda set |\n| 202 | `sunionstore` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n\n### BATCH 8: StreamCommands (core.py) - Methods 203-226\n| # | Method | Sync Type | Async Type | Status | Notes |\n|---|--------|-----------|------------|--------|-------|\n| 203 | `xack` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 204 | `xackdel` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 205 | `xadd` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ | No callback - stream ID |\n| 206 | `xcfgset` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ | No callback - raw OK |\n| 207 | `xautoclaim` | `list[Any]` | `Awaitable[list[Any]]` | ✅ | Base: parse_xautoclaim / JUSTID special case |\n| 208 | `xclaim` | `list[tuple[bytes \\| str \\| None, dict \\| None]] \\| list[bytes \\| str]` | `Awaitable[list[tuple[bytes \\| str \\| None, dict \\| None]] \\| list[bytes \\| str]]` | ✅ | Base: parse_xclaim / JUSTID returns ID list |\n| 209 | `xdel` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 210 | `xdelex` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 211 | `xgroup_create` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 212 | `xgroup_delconsumer` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 213 | `xgroup_destroy` | `bool` | `Awaitable[bool]` | ✅ | Base: bool |\n| 214 | `xgroup_createconsumer` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 215 | `xgroup_setid` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 216 | `xinfo_consumers` | `list[dict]` | `Awaitable[list[dict]]` | ✅ | RESP2: parse_list_of_dicts / RESP3: lambda |\n| 217 | `xinfo_groups` | `list[dict]` | `Awaitable[list[dict]]` | ✅ | RESP2: parse_list_of_dicts / RESP3: lambda |\n| 218 | `xinfo_stream` | `dict[str, Any]` | `Awaitable[dict[str, Any]]` | ✅ | Base: parse_xinfo_stream |\n| 219 | `xlen` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 220 | `xpending` | `dict[str, Any]` | `Awaitable[dict[str, Any]]` | ✅ | Base: parse_xpending |\n| 221 | `xpending_range` | `list[dict[str, bytes \\| str \\| int]]` | `Awaitable[list[dict[str, bytes \\| str \\| int]]]` | ✅ | parse_xpending_range detail rows |\n| 222 | `xrange` | `list[tuple[bytes \\| str \\| None, dict \\| None]] \\| None` | `Awaitable[list[tuple[bytes \\| str \\| None, dict \\| None]] \\| None]` | ✅ | Base: parse_stream_list |\n| 223 | `xread` | `list \\| dict` | `Awaitable[list \\| dict]` | ✅ | RESP2: parse_xread / RESP3: parse_xread_resp3 |\n| 224 | `xreadgroup` | `list \\| dict` | `Awaitable[list \\| dict]` | ✅ | RESP2: parse_xread / RESP3: parse_xread_resp3 |\n| 225 | `xrevrange` | `list[tuple[bytes \\| str \\| None, dict \\| None]] \\| None` | `Awaitable[list[tuple[bytes \\| str \\| None, dict \\| None]] \\| None]` | ✅ | Base: parse_stream_list |\n| 226 | `xtrim` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n\n### BATCH 9: SortedSetCommands Part 1 (core.py) - Methods 227-260\n| # | Method | Sync Type | Async Type | Status | Notes |\n|---|--------|-----------|------------|--------|-------|\n| 227 | `zadd` | `int \\| float` | `Awaitable[int \\| float]` | ✅ | RESP2: parse_zadd |\n| 228 | `zcard` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 229 | `zcount` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 230 | `zdiff` | `ZSetRangeResponse` | `Awaitable[ZSetRangeResponse]` | ✅ | `WITHSCORES`: RESP2 tuple pairs / RESP3 raw nested lists |\n| 231 | `zdiffstore` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 232 | `zincrby` | `float \\| None` | `Awaitable[float \\| None]` | ✅ | RESP2: `float_or_none` / RESP3: raw float |\n| 233 | `zinter` | `ZSetRangeResponse` | `Awaitable[ZSetRangeResponse]` | ✅ | `score_cast_func` means scored branch uses `Any` |\n| 234 | `zinterstore` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 235 | `zintercard` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 236 | `zlexcount` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 237 | `zpopmax` | `ZSetRangeResponse` | `Awaitable[ZSetRangeResponse]` | ✅ | RESP2 tuple pairs / RESP3 nested lists |\n| 238 | `zpopmin` | `ZSetRangeResponse` | `Awaitable[ZSetRangeResponse]` | ✅ | RESP2 tuple pairs / RESP3 nested lists |\n| 239 | `zrandmember` | `ZRandMemberResponse` | `Awaitable[ZRandMemberResponse]` | ✅ | COUNT / WITHSCORES shape varies by protocol |\n| 240 | `bzpopmax` | `BlockingZSetPopResponse` | `Awaitable[BlockingZSetPopResponse]` | ✅ | RESP2 tuple / RESP3 raw list / `None` |\n| 241 | `bzpopmin` | `BlockingZSetPopResponse` | `Awaitable[BlockingZSetPopResponse]` | ✅ | RESP2 tuple / RESP3 raw list / `None` |\n| 242 | `zmpop` | `ZMPopResponse` | `Awaitable[ZMPopResponse]` | ✅ | Raw `[key, [[member, score], ...]]` or `None` |\n| 243 | `bzmpop` | `ZMPopResponse` | `Awaitable[ZMPopResponse]` | ✅ | Raw `[key, [[member, score], ...]]` or `None` |\n| 244 | `zrange` | `ZSetRangeResponse` | `Awaitable[ZSetRangeResponse]` | ✅ | `score_cast_func` means scored branch uses `Any` |\n| 245 | `zrevrange` | `ZSetRangeResponse` | `Awaitable[ZSetRangeResponse]` | ✅ | `score_cast_func` means scored branch uses `Any` |\n| 246 | `zrangestore` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 247 | `zrangebylex` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ | No callback - raw array |\n| 248 | `zrevrangebylex` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ | No callback - raw array |\n| 249 | `zrangebyscore` | `ZSetRangeResponse` | `Awaitable[ZSetRangeResponse]` | ✅ | `score_cast_func` means scored branch uses `Any` |\n| 250 | `zrevrangebyscore` | `ZSetRangeResponse` | `Awaitable[ZSetRangeResponse]` | ✅ | `score_cast_func` means scored branch uses `Any` |\n| 251 | `zrank` | `ZRankResponse` | `Awaitable[ZRankResponse]` | ✅ | `WITHSCORE` returns `[rank, score]`, not tuple |\n| 252 | `zrem` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 253 | `zremrangebylex` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 254 | `zremrangebyrank` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 255 | `zremrangebyscore` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 256 | `zrevrank` | `ZRankResponse` | `Awaitable[ZRankResponse]` | ✅ | `WITHSCORE` returns `[rank, score]`, not tuple |\n| 257 | `zscore` | `float \\| None` | `Awaitable[float \\| None]` | ✅ | RESP2: `float_or_none` / RESP3: raw float |\n| 258 | `zunion` | `ZSetRangeResponse` | `Awaitable[ZSetRangeResponse]` | ✅ | `score_cast_func` means scored branch uses `Any` |\n| 259 | `zunionstore` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 260 | `zmscore` | `list[float \\| None]` | `Awaitable[list[float \\| None]]` | ✅ | RESP2: `parse_zmscore` / RESP3: raw float list |\n\n### BATCH 10: HyperlogCommands + HashCommands (core.py) - Methods 261-289\n| # | Method | Sync Type | Async Type | Status | Notes |\n|---|--------|-----------|------------|--------|-------|\n| 261 | `pfadd` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 262 | `pfcount` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 263 | `pfmerge` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 264 | `hdel` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 265 | `hexists` | `bool` | `Awaitable[bool]` | ✅ | Base: bool |\n| 266 | `hget` | `bytes \\| str \\| None` | `Awaitable[bytes \\| str \\| None]` | ✅ | No callback - raw |\n| 267 | `hgetall` | `dict[bytes \\| str, bytes \\| str]` | `Awaitable[dict[bytes \\| str, bytes \\| str]]` | ✅ | RESP2: pairs_to_dict / RESP3: identity |\n| 268 | `hgetdel` | `list[bytes \\| str \\| None]` | `Awaitable[list[bytes \\| str \\| None]]` | ✅ | No callback - one result per requested field |\n| 269 | `hgetex` | `list[bytes \\| str \\| None]` | `Awaitable[list[bytes \\| str \\| None]]` | ✅ | No callback - one result per requested field |\n| 270 | `hincrby` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 271 | `hincrbyfloat` | `float` | `Awaitable[float]` | ✅ | Base: float |\n| 272 | `hkeys` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ | No callback - raw array |\n| 273 | `hlen` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 274 | `hset` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 275 | `hsetex` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 276 | `hsetnx` | `int` | `Awaitable[int]` | ✅ | No callback - integer 0/1 reply |\n| 277 | `hmset` | `bool` | `Awaitable[bool]` | ✅ | Base: bool |\n| 278 | `hmget` | `list[bytes \\| str \\| None]` | `Awaitable[list[bytes \\| str \\| None]]` | ✅ | No callback - raw array |\n| 279 | `hvals` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ | No callback - raw array |\n| 280 | `hstrlen` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 281 | `hexpire` | `list[int]` | `Awaitable[list[int]]` | ✅ | Array of integers |\n| 282 | `hpexpire` | `list[int]` | `Awaitable[list[int]]` | ✅ | Array of integers |\n| 283 | `hexpireat` | `list[int]` | `Awaitable[list[int]]` | ✅ | Array of integers |\n| 284 | `hpexpireat` | `list[int]` | `Awaitable[list[int]]` | ✅ | Array of integers |\n| 285 | `hpersist` | `list[int]` | `Awaitable[list[int]]` | ✅ | Array of integers |\n| 286 | `hexpiretime` | `list[int]` | `Awaitable[list[int]]` | ✅ | Array of integers |\n| 287 | `hpexpiretime` | `list[int]` | `Awaitable[list[int]]` | ✅ | Array of integers |\n| 288 | `httl` | `list[int]` | `Awaitable[list[int]]` | ✅ | Array of integers |\n| 289 | `hpttl` | `list[int]` | `Awaitable[list[int]]` | ✅ | Array of integers |\n\n### BATCH 11: PubSubCommands + ScriptCommands (core.py) - Methods 290-306\n| # | Method | Sync Type | Async Type | Status | Notes |\n|---|--------|-----------|------------|--------|-------|\n| 290 | `publish` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 291 | `spublish` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 292 | `pubsub_channels` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ | No callback - raw |\n| 293 | `pubsub_shardchannels` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ | No callback - raw |\n| 294 | `pubsub_numpat` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 295 | `pubsub_numsub` | `list[tuple[bytes \\| str, int]]` | `Awaitable[list[tuple[bytes \\| str, int]]]` | ✅ | Base: parse_pubsub_numsub |\n| 296 | `pubsub_shardnumsub` | `list[tuple[bytes \\| str, int]]` | `Awaitable[list[tuple[bytes \\| str, int]]]` | ✅ | Base: parse_pubsub_numsub |\n| 297 | `eval` | `Any` | `Awaitable[Any]` | ✅ | Script-dependent |\n| 298 | `eval_ro` | `Any` | `Awaitable[Any]` | ✅ | Script-dependent |\n| 299 | `evalsha` | `Any` | `Awaitable[Any]` | ✅ | Script-dependent |\n| 300 | `evalsha_ro` | `Any` | `Awaitable[Any]` | ✅ | Script-dependent |\n| 301 | `script_exists` | `list[bool]` | `Awaitable[list[bool]]` | ✅ | Base: lambda map bool |\n| 302 | `script_debug` | `None` | `None` | ⚠️ SKIP | N/A |\n| 303 | `script_flush` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 304 | `script_kill` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 305 | `script_load` | `str` | `Awaitable[str]` | ✅ | Base: str_if_bytes |\n| 306 | `register_script` | `Script` | `AsyncScript` | ⚠️ SKIP | Different classes |\n\n### BATCH 12: GeoCommands + ModuleCommands + ClusterCommands + FunctionCommands (core.py) - Methods 307-335\n| # | Method | Sync Type | Async Type | Status | Notes |\n|---|--------|-----------|------------|--------|-------|\n| 307 | `geoadd` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 308 | `geodist` | `float \\| None` | `Awaitable[float \\| None]` | ✅ | Base: float_or_none |\n| 309 | `geohash` | `list[bytes \\| str \\| None]` | `Awaitable[list[bytes \\| str \\| None]]` | ✅ | RESP2: str_if_bytes / RESP3: raw |\n| 310 | `geopos` | `list[tuple[float, float] \\| None]` | `Awaitable[list[tuple[float, float] \\| None]]` | ✅ | RESP2: lambda float tuple / RESP3: raw |\n| 311 | `georadius` | `list[Any] \\| int` | `Awaitable[list[Any] \\| int]` | ✅ | `store` / `store_dist` return int, otherwise parsed list |\n| 312 | `georadiusbymember` | `list[Any] \\| int` | `Awaitable[list[Any] \\| int]` | ✅ | `store` / `store_dist` return int, otherwise parsed list |\n| 313 | `geosearch` | `list[Any]` | `Awaitable[list[Any]]` | ✅ | Base: parse_geosearch_generic |\n| 314 | `geosearchstore` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 315 | `module_load` | `bool` | `Awaitable[bool]` | ✅ | Base: bool |\n| 316 | `module_loadex` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ | No callback - raw OK reply |\n| 317 | `module_unload` | `bool` | `Awaitable[bool]` | ✅ | Base: bool |\n| 318 | `module_list` | `list[dict[Any, Any]]` | `Awaitable[list[dict[Any, Any]]]` | ✅ | RESP2: lambda pairs_to_dict / RESP3: raw dict list |\n| 319 | `command_info` | `None` | `None` | ⚠️ SKIP | N/A |\n| 320 | `command_count` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 321 | `command_getkeys` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ | RESP2: str_if_bytes / RESP3: raw |\n| 322 | `command` | `dict[str, dict[str, Any]]` | `Awaitable[dict[str, dict[str, Any]]]` | ✅ | Base: parse_command / RESP3: parse_command_resp3 |\n| 323 | `cluster` | `Any` | `Awaitable[Any]` | ✅ | Subcommand-dependent |\n| 324 | `readwrite` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 325 | `readonly` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 326 | `function_load` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ | No callback - raw |\n| 327 | `function_delete` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 328 | `function_flush` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 329 | `function_list` | `list[Any]` | `Awaitable[list[Any]]` | ✅ | No callback - raw protocol-dependent list |\n| 330 | `fcall` | `Any` | `Awaitable[Any]` | ✅ | Function-dependent |\n| 331 | `fcall_ro` | `Any` | `Awaitable[Any]` | ✅ | Function-dependent |\n| 332 | `function_dump` | `bytes` | `Awaitable[bytes]` | ✅ | No callback - raw bytes |\n| 333 | `function_restore` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 334 | `function_kill` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ | No callback - raw OK reply |\n| 335 | `function_stats` | `Any` | `Awaitable[Any]` | ✅ | No callback - raw protocol-dependent structure |\n\n### BATCH 13: ClusterCommands (cluster.py) - Methods 336-378\n| # | Method | Sync Type | Async Type | Status | Notes |\n|---|--------|-----------|------------|--------|-------|\n| 336 | `mget_nonatomic` | `list[bytes \\| str \\| None]` | `Awaitable[list[bytes \\| str \\| None]]` | ⚠️ SKIP | Complex multi-node |\n| 337 | `mset_nonatomic` | `list[bool]` | `Awaitable[list[bool]]` | ⚠️ SKIP | Complex multi-node |\n| 338 | `exists` | `int` | `Awaitable[int]` | 📋 | Integer reply |\n| 339 | `delete` | `int` | `Awaitable[int]` | 📋 | Integer reply |\n| 340 | `touch` | `int` | `Awaitable[int]` | 📋 | Integer reply |\n| 341 | `unlink` | `int` | `Awaitable[int]` | 📋 | Integer reply |\n| 342 | `slaveof` | `NoReturn` | `NoReturn` | 📋 SKIP | Raises error |\n| 343 | `replicaof` | `NoReturn` | `NoReturn` | 📋 SKIP | Raises error |\n| 344 | `swapdb` | `NoReturn` | `NoReturn` | 📋 SKIP | Raises error |\n| 345 | `cluster_myid` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ | No callback - raw |\n| 346 | `cluster_addslots` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 347 | `cluster_addslotsrange` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 348 | `cluster_countkeysinslot` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 349 | `cluster_count_failure_report` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 350 | `cluster_delslots` | `list[bool]` | `Awaitable[list[bool]]` | ⚠️ SKIP | Complex multi-node |\n| 351 | `cluster_delslotsrange` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 352 | `cluster_failover` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 353 | `cluster_info` | `dict[str, str]` | `Awaitable[dict[str, str]]` | ✅ | Base: parse_cluster_info |\n| 354 | `cluster_keyslot` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 355 | `cluster_meet` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 356 | `cluster_nodes` | `str` | `Awaitable[str]` | ✅ | Base: parse_cluster_nodes |\n| 357 | `cluster_replicate` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 358 | `cluster_reset` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 359 | `cluster_save_config` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 360 | `cluster_get_keys_in_slot` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ | RESP2: str_if_bytes / RESP3: raw |\n| 361 | `cluster_set_config_epoch` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 362 | `cluster_setslot` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 363 | `cluster_setslot_stable` | `bool` | `Awaitable[bool]` | ✅ | No callback - OK |\n| 364 | `cluster_replicas` | `str` | `Awaitable[str]` | ✅ | Base: parse_cluster_nodes |\n| 365 | `cluster_slots` | `list` | `Awaitable[list]` | ✅ | No callback - raw |\n| 366 | `cluster_shards` | `list[dict]` | `Awaitable[list[dict]]` | ✅ | No callback - raw |\n| 367 | `cluster_myshardid` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ | No callback - raw |\n| 368 | `cluster_links` | `list[dict]` | `Awaitable[list[dict]]` | ✅ | No callback - raw |\n| 369 | `cluster_flushslots` | `bool` | `Awaitable[bool]` | ✅ | No callback - OK |\n| 370 | `cluster_bumpepoch` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ | No callback - raw |\n| 371 | `client_tracking_on` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ⚠️ SKIP | Cluster-specific, no callback |\n| 372 | `client_tracking_off` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ⚠️ SKIP | Cluster-specific, no callback |\n| 373 | `hotkeys_start` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ⚠️ SKIP | Cluster-specific |\n| 374 | `hotkeys_stop` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ⚠️ SKIP | Cluster-specific |\n| 375 | `hotkeys_reset` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ⚠️ SKIP | Cluster-specific |\n| 376 | `hotkeys_get` | `list[dict]` | `Awaitable[list[dict]]` | ⚠️ SKIP | Cluster-specific |\n| 377 | `stralgo` | `dict \\| bytes \\| str \\| int` | `Awaitable[dict \\| bytes \\| str \\| int]` | ✅ | RESP2: parse_stralgo / RESP3: lambda |\n| 378 | `scan_iter` | `Iterator` | `AsyncIterator` | 🔄 SKIP | Iterator |\n\n### BATCH 14: SentinelCommands (sentinel.py) - Methods 379-391\n| # | Method | Sync Type | Async Type | Status | Notes |\n|---|--------|-----------|------------|--------|-------|\n| 379 | `sentinel` | `Any` | `Awaitable[Any]` | ⚠️ SKIP | Async differs |\n| 380 | `sentinel_get_master_addr_by_name` | `tuple[str, int] \\| None` | `Awaitable[tuple[str, int] \\| None]` | ✅ | Base: parse_sentinel_get_master |\n| 381 | `sentinel_master` | `dict` | `Awaitable[dict]` | ✅ | RESP2: parse_sentinel_master / RESP3: parse_sentinel_state_resp3 |\n| 382 | `sentinel_masters` | `list[dict]` | `Awaitable[list[dict]]` | ✅ | RESP2: parse_sentinel_masters / RESP3: parse_sentinel_masters_resp3 |\n| 383 | `sentinel_monitor` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 384 | `sentinel_remove` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 385 | `sentinel_sentinels` | `list[dict]` | `Awaitable[list[dict]]` | ✅ | RESP2: parse_sentinel_slaves_and_sentinels / RESP3: _resp3 |\n| 386 | `sentinel_set` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 387 | `sentinel_slaves` | `list[dict]` | `Awaitable[list[dict]]` | ✅ | RESP2: parse_sentinel_slaves_and_sentinels / RESP3: _resp3 |\n| 388 | `sentinel_reset` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 389 | `sentinel_failover` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 390 | `sentinel_ckquorum` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n| 391 | `sentinel_flushconfig` | `bool` | `Awaitable[bool]` | ✅ | Base: bool_ok |\n\n### BATCH 15: SearchCommands (search/commands.py) - Methods 392-424\n| # | Method | Sync Type | Async Type | Status | Notes |\n|---|--------|-----------|------------|--------|-------|\n| 392 | `batch_indexer` | `BatchIndexer` | `BatchIndexer` | 📋 SKIP | Builder pattern |\n| 393 | `create_index` | `bool` | `Awaitable[bool]` | ✅ | Module OK |\n| 394 | `alter_schema_add` | `bool` | `Awaitable[bool]` | ✅ | Module OK |\n| 395 | `dropindex` | `bool` | `Awaitable[bool]` | ✅ | Module OK |\n| 396 | `add_document` | `bool` | `Awaitable[bool]` | ✅ | Module OK |\n| 397 | `add_document_hash` | `bool` | `Awaitable[bool]` | ✅ | Module OK |\n| 398 | `delete_document` | `bool` | `Awaitable[bool]` | ✅ | Module OK |\n| 399 | `load_document` | `Document` | `Awaitable[Document]` | ⚠️ SKIP | Async result parsing |\n| 400 | `get` | `Document` | `Awaitable[Document]` | ✅ | Module-specific |\n| 401 | `info` | `dict` | `Awaitable[dict]` | ⚠️ SKIP | Async result parsing |\n| 402 | `get_params_args` | `list` | `list` | 📋 SKIP | Helper method |\n| 403 | `search` | `Result` | `Awaitable[Result]` | ⚠️ SKIP | Async result parsing |\n| 404 | `hybrid_search` | `HybridResult` | `Awaitable[HybridResult]` | ⚠️ SKIP | Async result parsing |\n| 405 | `explain` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ | No callback - raw |\n| 406 | `explain_cli` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ | No callback - raw |\n| 407 | `aggregate` | `AggregateResult` | `Awaitable[AggregateResult]` | ⚠️ SKIP | Async result parsing |\n| 408 | `profile` | `tuple` | `Awaitable[tuple]` | ✅ | Module-specific |\n| 409 | `spellcheck` | `dict` | `Awaitable[dict]` | ⚠️ SKIP | Async result parsing |\n| 410 | `dict_add` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 411 | `dict_del` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 412 | `dict_dump` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ | No callback - raw |\n| 413 | `config_set` | `bool` | `Awaitable[bool]` | ⚠️ SKIP | Async result parsing |\n| 414 | `config_get` | `dict` | `Awaitable[dict]` | ⚠️ SKIP | Async result parsing |\n| 415 | `tagvals` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ | No callback - raw |\n| 416 | `aliasadd` | `bool` | `Awaitable[bool]` | ✅ | Module OK |\n| 417 | `aliasupdate` | `bool` | `Awaitable[bool]` | ✅ | Module OK |\n| 418 | `aliasdel` | `bool` | `Awaitable[bool]` | ✅ | Module OK |\n| 419 | `sugadd` | `int` | `Awaitable[int]` | ⚠️ SKIP | Async result parsing |\n| 420 | `suglen` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 421 | `sugdel` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 422 | `sugget` | `list` | `Awaitable[list]` | ⚠️ SKIP | Async result parsing |\n| 423 | `synupdate` | `bool` | `Awaitable[bool]` | ✅ | Module OK |\n| 424 | `syndump` | `dict[bytes \\| str, list[bytes \\| str]]` | `Awaitable[dict[bytes \\| str, list[bytes \\| str]]]` | ✅ | No callback - raw |\n\n### BATCH 16: JSONCommands (json/commands.py) - Methods 425-452\n| # | Method | Sync Type | Async Type | Status | Notes |\n|---|--------|-----------|------------|--------|-------|\n| 425 | `arrappend` | `list[int \\| None]` | `Awaitable[list[int \\| None]]` | ✅ | Module int array |\n| 426 | `arrindex` | `list[int \\| None]` | `Awaitable[list[int \\| None]]` | ✅ | Module int array |\n| 427 | `arrinsert` | `list[int \\| None]` | `Awaitable[list[int \\| None]]` | ✅ | Module int array |\n| 428 | `arrlen` | `list[int \\| None]` | `Awaitable[list[int \\| None]]` | ✅ | Module int array |\n| 429 | `arrpop` | `list[bytes \\| str \\| None]` | `Awaitable[list[bytes \\| str \\| None]]` | ✅ | Depends on decode_responses |\n| 430 | `arrtrim` | `list[int \\| None]` | `Awaitable[list[int \\| None]]` | ✅ | Module int array |\n| 431 | `type` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ | Depends on decode_responses |\n| 432 | `resp` | `list` | `Awaitable[list]` | ✅ | JSON structure |\n| 433 | `objkeys` | `list[list[bytes \\| str] \\| None]` | `Awaitable[list[list[bytes \\| str] \\| None]]` | ✅ | Depends on decode_responses |\n| 434 | `objlen` | `list[int \\| None]` | `Awaitable[list[int \\| None]]` | ✅ | Module int array |\n| 435 | `numincrby` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ | Depends on decode_responses |\n| 436 | `nummultby` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ | Depends on decode_responses |\n| 437 | `clear` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 438 | `delete` | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 439 | `get` | `Any` | `Awaitable[Any]` | ✅ | JSON parsed |\n| 440 | `mget` | `list[Any]` | `Awaitable[list[Any]]` | ✅ | JSON parsed |\n| 441 | `set` | `bool \\| None` | `Awaitable[bool \\| None]` | ✅ | OK or None |\n| 442 | `mset` | `bool` | `Awaitable[bool]` | ✅ | Module OK |\n| 443 | `merge` | `bool` | `Awaitable[bool]` | ✅ | Module OK |\n| 444 | `set_file` | `bool \\| None` | `Awaitable[bool \\| None]` | 📋 | Explicit |\n| 445 | `set_path` | `dict[str, bool]` | `Awaitable[dict[str, bool]]` | 📋 | Explicit |\n| 446 | `strlen` | `list[int \\| None]` | `Awaitable[list[int \\| None]]` | ✅ | Module int array |\n| 447 | `toggle` | `bool \\| list[bool]` | `Awaitable[bool \\| list[bool]]` | ✅ | Module bool |\n| 448 | `strappend` | `int \\| list[int \\| None]` | `Awaitable[int \\| list[int \\| None]]` | ✅ | Module int |\n| 449 | `debug` | `int \\| list[bytes \\| str]` | `Awaitable[int \\| list[bytes \\| str]]` | ✅ | Module mixed |\n| 450 | `jsonget` | `Any` | `Awaitable[Any]` | ✅ | Deprecated alias |\n| 451 | `jsonmget` | `Any` | `Awaitable[Any]` | ✅ | Deprecated alias |\n| 452 | `jsonset` | `Any` | `Awaitable[Any]` | ✅ | Deprecated alias |\n\n### BATCH 17: TimeSeriesCommands (timeseries/commands.py) - Methods 453-469\n| # | Method | Sync Type | Async Type | Status | Notes |\n|---|--------|-----------|------------|--------|-------|\n| 453 | `create` | `bool` | `Awaitable[bool]` | ✅ | Module OK |\n| 454 | `alter` | `bool` | `Awaitable[bool]` | ✅ | Module OK |\n| 455 | `add` | `int` | `Awaitable[int]` | ✅ | Timestamp |\n| 456 | `madd` | `list[int]` | `Awaitable[list[int]]` | ✅ | Timestamps |\n| 457 | `incrby` | `int` | `Awaitable[int]` | ✅ | Timestamp |\n| 458 | `decrby` | `int` | `Awaitable[int]` | ✅ | Timestamp |\n| 459 | `delete` | `int` | `Awaitable[int]` | ✅ | Count deleted |\n| 460 | `createrule` | `bool` | `Awaitable[bool]` | ✅ | Module OK |\n| 461 | `deleterule` | `bool` | `Awaitable[bool]` | ✅ | Module OK |\n| 462 | `range` | `list[tuple[int, float]]` | `Awaitable[list[tuple[int, float]]]` | ✅ | Parsed samples |\n| 463 | `revrange` | `list[tuple[int, float]]` | `Awaitable[list[tuple[int, float]]]` | ✅ | Parsed samples |\n| 464 | `mrange` | `list` | `Awaitable[list]` | ✅ | Complex structure |\n| 465 | `mrevrange` | `list` | `Awaitable[list]` | ✅ | Complex structure |\n| 466 | `get` | `tuple[int, float] \\| list` | `Awaitable[tuple[int, float] \\| list]` | ✅ | Parsed sample |\n| 467 | `mget` | `list` | `Awaitable[list]` | ✅ | Complex structure |\n| 468 | `info` | `TSInfo` | `Awaitable[TSInfo]` | 📋 | Explicit type |\n| 469 | `queryindex` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ | Depends on decode_responses |\n\n### BATCH 18: BloomFilter Commands (bf/commands.py) - Methods 470-491\n| # | Method | Sync Type | Async Type | Status | Notes |\n|---|--------|-----------|------------|--------|-------|\n| 470 | `create` (BF) | `bool` | `Awaitable[bool]` | ✅ | Module OK |\n| 471 | `add` (BF) | `int` | `Awaitable[int]` | ✅ | 0 or 1 |\n| 472 | `madd` (BF) | `list[int]` | `Awaitable[list[int]]` | ✅ | 0s and 1s |\n| 473 | `insert` (BF) | `list[int]` | `Awaitable[list[int]]` | ✅ | 0s and 1s |\n| 474 | `exists` (BF) | `int` | `Awaitable[int]` | ✅ | 0 or 1 |\n| 475 | `mexists` (BF) | `list[int]` | `Awaitable[list[int]]` | ✅ | 0s and 1s |\n| 476 | `scandump` (BF) | `tuple[int, bytes \\| None]` | `Awaitable[tuple[int, bytes \\| None]]` | ✅ | Cursor + data |\n| 477 | `loadchunk` (BF) | `bool` | `Awaitable[bool]` | ✅ | Module OK |\n| 478 | `info` (BF) | `dict` | `Awaitable[dict]` | ✅ | Parsed info |\n| 479 | `card` (BF) | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 480 | `create` (CF) | `bool` | `Awaitable[bool]` | ✅ | Module OK |\n| 481 | `add` (CF) | `int` | `Awaitable[int]` | ✅ | 0 or 1 |\n| 482 | `addnx` (CF) | `int` | `Awaitable[int]` | ✅ | 0 or 1 |\n| 483 | `insert` (CF) | `list[int]` | `Awaitable[list[int]]` | ✅ | 0s and 1s |\n| 484 | `insertnx` (CF) | `list[int]` | `Awaitable[list[int]]` | ✅ | 0s and 1s |\n| 485 | `exists` (CF) | `int` | `Awaitable[int]` | ✅ | 0 or 1 |\n| 486 | `mexists` (CF) | `list[int]` | `Awaitable[list[int]]` | ✅ | 0s and 1s |\n| 487 | `delete` (CF) | `int` | `Awaitable[int]` | ✅ | 0 or 1 |\n| 488 | `count` (CF) | `int` | `Awaitable[int]` | ✅ | Integer reply |\n| 489 | `scandump` (CF) | `tuple[int, bytes \\| None]` | `Awaitable[tuple[int, bytes \\| None]]` | ✅ | Cursor + data |\n| 490 | `loadchunk` (CF) | `bool` | `Awaitable[bool]` | ✅ | Module OK |\n| 491 | `info` (CF) | `dict` | `Awaitable[dict]` | ✅ | Parsed info |\n\n### BATCH 19: TOPKCommands + TDigestCommands + CMSCommands (bf/commands.py) - Methods 492-518\n| # | Method | Sync Type | Async Type | Status | Notes |\n|---|--------|-----------|------------|--------|-------|\n| 492 | `reserve` (TOPK) | `bool` | `Awaitable[bool]` | ✅ | Module OK |\n| 493 | `add` (TOPK) | `list[bytes \\| str \\| None]` | `Awaitable[list[bytes \\| str \\| None]]` | ✅ | Depends on decode_responses |\n| 494 | `incrby` (TOPK) | `list[bytes \\| str \\| None]` | `Awaitable[list[bytes \\| str \\| None]]` | ✅ | Depends on decode_responses |\n| 495 | `query` (TOPK) | `list[int]` | `Awaitable[list[int]]` | ✅ | 0s and 1s |\n| 496 | `count` (TOPK) | `list[int]` | `Awaitable[list[int]]` | ✅ | Counts |\n| 497 | `list` (TOPK) | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ | Depends on decode_responses |\n| 498 | `info` (TOPK) | `dict` | `Awaitable[dict]` | ✅ | Parsed info |\n| 499 | `create` (TD) | `bool` | `Awaitable[bool]` | ✅ | Module OK |\n| 500 | `reset` (TD) | `bool` | `Awaitable[bool]` | ✅ | Module OK |\n| 501 | `add` (TD) | `bool` | `Awaitable[bool]` | ✅ | Module OK |\n| 502 | `merge` (TD) | `bool` | `Awaitable[bool]` | ✅ | Module OK |\n| 503 | `min` (TD) | `float` | `Awaitable[float]` | ✅ | Float reply |\n| 504 | `max` (TD) | `float` | `Awaitable[float]` | ✅ | Float reply |\n| 505 | `quantile` (TD) | `list[float]` | `Awaitable[list[float]]` | ✅ | Floats |\n| 506 | `cdf` (TD) | `list[float]` | `Awaitable[list[float]]` | ✅ | Floats |\n| 507 | `info` (TD) | `dict` | `Awaitable[dict]` | ✅ | Parsed info |\n| 508 | `trimmed_mean` (TD) | `float` | `Awaitable[float]` | ✅ | Float reply |\n| 509 | `rank` (TD) | `list[int]` | `Awaitable[list[int]]` | ✅ | Integers |\n| 510 | `revrank` (TD) | `list[int]` | `Awaitable[list[int]]` | ✅ | Integers |\n| 511 | `byrank` (TD) | `list[float]` | `Awaitable[list[float]]` | ✅ | Floats |\n| 512 | `byrevrank` (TD) | `list[float]` | `Awaitable[list[float]]` | ✅ | Floats |\n| 513 | `initbydim` (CMS) | `bool` | `Awaitable[bool]` | ✅ | Module OK |\n| 514 | `initbyprob` (CMS) | `bool` | `Awaitable[bool]` | ✅ | Module OK |\n| 515 | `incrby` (CMS) | `list[int]` | `Awaitable[list[int]]` | ✅ | Integers |\n| 516 | `query` (CMS) | `list[int]` | `Awaitable[list[int]]` | ✅ | Integers |\n| 517 | `merge` (CMS) | `bool` | `Awaitable[bool]` | ✅ | Module OK |\n| 518 | `info` (CMS) | `dict` | `Awaitable[dict]` | ✅ | Parsed info |\n\n### BATCH 20: VectorSetCommands (vectorset/commands.py) - Methods 519-530 ✅ DONE\n| # | Method | Sync Type | Async Type | Status | Notes |\n|---|--------|-----------|------------|--------|-------|\n| 519 | `vadd` | `int` | `Awaitable[int]` | ✅ DONE | Integer reply |\n| 520 | `vsim` | `VSimResult` | `Awaitable[VSimResult]` | 📋 DONE | Explicit type |\n| 521 | `vdim` | `int` | `Awaitable[int]` | ✅ DONE | Integer reply |\n| 522 | `vcard` | `int` | `Awaitable[int]` | ✅ DONE | Integer reply |\n| 523 | `vrem` | `int` | `Awaitable[int]` | ✅ DONE | Integer reply |\n| 524 | `vemb` | `VEmbResult` | `Awaitable[VEmbResult]` | 📋 DONE | Explicit type |\n| 525 | `vlinks` | `VLinksResult` | `Awaitable[VLinksResult]` | 📋 DONE | Explicit type |\n| 526 | `vinfo` | `dict` | `Awaitable[dict]` | ✅ DONE | Standard dict |\n| 527 | `vsetattr` | `int` | `Awaitable[int]` | ✅ DONE | Integer reply |\n| 528 | `vgetattr` | `VGetAttrResult` | `Awaitable[VGetAttrResult]` | 📋 DONE | Explicit type |\n| 529 | `vrandmember` | `VRandMemberResult` | `Awaitable[VRandMemberResult]` | 📋 DONE | Explicit type |\n| 530 | `vrange` | `list[str]` | `Awaitable[list[str]]` | ✅ DONE | Standard list |\n\n---\n\n## Batch Summary\n\n| Batch | File | Command Class | Methods | Count |\n|-------|------|---------------|---------|-------|\n| 1 | core.py | ACLCommands | 1-14 | 14 |\n| 2 | core.py | ManagementCommands (Part 1) | 15-50 | 36 |\n| 3 | core.py | ManagementCommands (Part 2) | 51-93 | 43 |\n| 4 | core.py | BasicKeyCommands (Part 1) | 94-130 | 37 |\n| 5 | core.py | BasicKeyCommands (Part 2) | 131-156 | 26 |\n| 6 | core.py | ListCommands | 157-178 | 22 |\n| 7 | core.py | ScanCommands + SetCommands | 179-202 | 24 |\n| 8 | core.py | StreamCommands | 203-226 | 24 |\n| 9 | core.py | SortedSetCommands | 227-260 | 34 |\n| 10 | core.py | HyperlogCommands + HashCommands | 261-289 | 29 |\n| 11 | core.py | PubSubCommands + ScriptCommands | 290-306 | 17 |\n| 12 | core.py | GeoCommands + ModuleCommands + ClusterCommands + FunctionCommands | 307-335 | 29 |\n| 13 | cluster.py | ClusterCommands | 336-378 | 43 |\n| 14 | sentinel.py | SentinelCommands | 379-391 | 13 |\n| 15 | search/commands.py | SearchCommands | 392-424 | 33 |\n| 16 | json/commands.py | JSONCommands | 425-452 | 28 |\n| 17 | timeseries/commands.py | TimeSeriesCommands | 453-469 | 17 |\n| 18 | bf/commands.py | BFCommands + CFCommands | 470-491 | 22 |\n| 19 | bf/commands.py | TOPKCommands + TDigestCommands + CMSCommands | 492-518 | 27 |\n| 20 | vectorset/commands.py | VectorSetCommands | 519-530 | 12 ✅ DONE |\n\n---\n\n## Implementation Checklist\n\nFor each batch:\n- [ ] Review all methods in the batch\n- [ ] Skip methods marked with ⚠️ SKIP, 🔄 SKIP, ❌ SKIP\n- [ ] For each ✅ method:\n  - [ ] Check the Notes column for callback info\n  - [ ] Verify return type against `redis/_parsers/helpers.py` callback dictionaries\n  - [ ] Add two `@overload` signatures\n  - [ ] Run type checker\n- [ ] Test changes\n- [ ] Mark batch complete\n\n---\n\n## Notes\n\n1. **Callback System is Critical** - Always check the three-tier callback hierarchy in `redis/_parsers/helpers.py`:\n   - `_RedisCallbacks` (base) - shared by RESP2 and RESP3\n   - `_RedisCallbacksRESP2` - RESP2-specific overrides\n   - `_RedisCallbacksRESP3` - RESP3-specific overrides\n2. **Protocol-Specific Types** - When RESP2 and RESP3 have different callbacks, use the most permissive union type\n3. **Import considerations** - Make sure `TYPE_CHECKING`, `overload`, and `Awaitable` are imported\n4. **Self-type discrimination** - Use `self: SyncClientProtocol` and `self: AsyncClientProtocol`\n5. **Keep original method** - Only add overloads, don't modify the actual implementation\n6. **No callback = raw response** - If a command has no callback, return type depends on `decode_responses`\n"
  },
  {
    "path": ".dockerignore",
    "content": "**/__pycache__\n**/*.pyc\n.coverage\n.coverage.*\n"
  },
  {
    "path": ".github/CODEOWNERS",
    "content": "doctests/* @dmaier-redislabs\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE.md",
    "content": "Thanks for wanting to report an issue you've found in redis-py. Please delete this text and fill in the template below.  \nIt is of course not always possible to reduce your code to a small test case, but it's highly appreciated to have as much data as possible. Thank you!\n\n**Version**: What redis-py and what redis version is the issue happening on?\n\n**Platform**: What platform / version? (For example Python 3.5.1 on Windows 7 / Ubuntu 15.10 / Azure)\n\n**Description**: Description of your issue, stack traces from errors and code that reproduces the issue\n"
  },
  {
    "path": ".github/PULL_REQUEST_TEMPLATE.md",
    "content": "### Description of change\n\n_Please provide a description of the change here._\n\n### Pull Request check-list\n\n_Please make sure to review and check all of these items:_\n\n- [ ] Do tests and lints pass with this change?\n- [ ] Do the CI tests pass with this change (enable it first in your forked repo and wait for the github action build to finish)?\n- [ ] Is the new or changed code fully tested?\n- [ ] Is a documentation update included (if this change modifies existing APIs, or introduces new ones)?\n- [ ] Is there an example added to the examples folder (if applicable)?\n\n_NOTE: these things are not required to open a PR and can be done\nafterwards / while the PR is open._\n"
  },
  {
    "path": ".github/actions/run-tests/action.yml",
    "content": "name: 'Run redis-py tests'\ndescription: 'Runs redis-py tests against different Redis versions and configurations'\ninputs:\n  python-version:\n    description: 'Python version to use for running tests'\n    default: '3.12'\n  parser-backend:\n    description: 'Parser backend to use: plain or hiredis'\n    required: true\n  redis-version:\n    description: 'Redis version to test against'\n    required: true\n  hiredis-version:\n    description: 'hiredis version to test against'\n    required: false\n    default: '>3.0.0'\n  hiredis-branch:\n    description: 'hiredis branch to test against'\n    required: false\n    default: 'master'\n  event-loop:\n    description: 'Event loop to use'\n    required: false\n    default: 'asyncio'\n  protocol:\n    description: 'RESP protocol version to use'\n    required: false\n    default: 'all'\n  repository:\n    description: 'Repository to checkout'\n    required: false\n  ref:\n    description: 'Branch to checkout'\n    required: false\n  client-libs-test-image:\n    description: 'Client libs test image tag'\n    required: false\nruns:\n  using: \"composite\"\n  steps:\n    - uses: actions/checkout@v4\n      with:\n        repository: ${{ inputs.repository }}\n        ref: ${{ inputs.ref }}\n\n    - uses: actions/setup-python@v5\n      with:\n        python-version: ${{ inputs.python-version }}\n        cache: 'pip'\n\n    - uses: actions/checkout@v4\n      if: ${{ inputs.parser-backend == 'hiredis' && inputs.hiredis-version == 'unstable' }}\n      with:\n        repository: redis/hiredis-py\n        submodules: true\n        path: hiredis-py\n        ref: ${{ inputs.hiredis-branch }}\n\n    - name: Setup Test environment\n      env:\n        REDIS_VERSION: ${{ inputs.redis-version }}\n        CLIENT_LIBS_TEST_IMAGE_TAG: ${{ inputs.client-libs-test-image || inputs.redis-version }}\n        CLIENT_LIBS_TEST_STACK_IMAGE_TAG: ${{ inputs.client-libs-test-image }}\n      run: |\n        set -e\n\n        echo \"::group::Installing dependencies\"\n        pip install -r dev_requirements.txt\n        pip uninstall -y redis  # uninstall Redis package installed via redis-entraid\n        pip install -e .[jwt]  # install the working copy\n        if [ \"${{inputs.parser-backend}}\" == \"hiredis\" ]; then\n          if [[ \"${{inputs.hiredis-version}}\" == \"unstable\" ]]; then\n            echo \"Installing unstable version of hiredis from local directory\"\n            pip install -e ./hiredis-py\n          else\n            pip install \"hiredis${{inputs.hiredis-version}}\"\n          fi\n          echo \"PARSER_BACKEND=$(echo \"${{inputs.parser-backend}}_${{inputs.hiredis-version}}\" | sed 's/[^a-zA-Z0-9]/_/g')\" >> $GITHUB_ENV\n        else\n          echo \"PARSER_BACKEND=${{inputs.parser-backend}}\" >> $GITHUB_ENV\n        fi\n        echo \"::endgroup::\"\n\n        echo \"::group::Starting Redis servers\"\n        # Check if REDIS_VERSION is in the custom map\n        mapped_version=\"\"\n        if [[ -n \"${REDIS_VERSION_CUSTOM_MAP:-}\" ]]; then\n          for mapping in $REDIS_VERSION_CUSTOM_MAP; do\n            tag=\"${mapping%%:*}\"\n            version=\"${mapping##*:}\"\n            if [[ \"$REDIS_VERSION\" == \"$tag\" ]]; then\n              mapped_version=\"$version\"\n              echo \"Found custom mapping: $REDIS_VERSION -> $mapped_version\"\n              break\n            fi\n          done\n        fi\n        # Use mapped version if found, otherwise use REDIS_VERSION\n        version_to_parse=\"${mapped_version:-$REDIS_VERSION}\"\n        redis_major_version=$(echo \"$version_to_parse\" | grep -oP '^\\d+')\n        echo \"REDIS_MAJOR_VERSION=${redis_major_version}\" >> $GITHUB_ENV\n\n        if (( redis_major_version < 8 )); then\n          echo \"Using redis-stack for module tests\"\n\n          # Mapping of redis version to stack version\n          declare -A redis_stack_version_mapping=(\n            [\"7.4.4\"]=\"rs-7.4.0-v5\"\n            [\"7.2.9\"]=\"rs-7.2.0-v17\"\n          )\n\n          if [[ -v redis_stack_version_mapping[$REDIS_VERSION] ]]; then\n            if [[ -z \"${CLIENT_LIBS_TEST_STACK_IMAGE_TAG}\" ]]; then\n              export CLIENT_LIBS_TEST_STACK_IMAGE_TAG=${redis_stack_version_mapping[$REDIS_VERSION]}\n            fi\n            echo \"REDIS_MOD_URL=redis://127.0.0.1:6479/0\" >> $GITHUB_ENV\n          else\n            echo \"Version not found in the mapping.\"\n            exit 1\n          fi\n\n          if (( redis_major_version < 7 )); then\n            export REDIS_STACK_EXTRA_ARGS=\"--tls-auth-clients optional --save ''\"\n            export REDIS_EXTRA_ARGS=\"--tls-auth-clients optional --save ''\"\n          fi\n\n          invoke devenv --endpoints=all-stack\n\n        else\n          echo \"Using redis CE for module tests\"\n          if [[ -z \"${CLIENT_LIBS_TEST_STACK_IMAGE_TAG}\" ]]; then\n            export CLIENT_LIBS_TEST_STACK_IMAGE_TAG=$REDIS_VERSION\n          fi\n          echo \"REDIS_MOD_URL=redis://127.0.0.1:6379\" >> $GITHUB_ENV\n          invoke devenv --endpoints all\n        fi\n\n        sleep 10 # time to settle\n        echo \"::endgroup::\"\n      shell: bash\n\n    - name: Run tests\n      run: |\n        set -e\n\n        run_tests() {\n          local protocol=$1\n          local eventloop=\"\"\n\n          if [ \"${{inputs.event-loop}}\" == \"uvloop\" ]; then\n            eventloop=\"--uvloop\"\n          fi\n\n          echo \"::group::RESP${protocol} standalone tests\"\n          echo \"REDIS_MOD_URL=${REDIS_MOD_URL}\"\n\n          if (( $REDIS_MAJOR_VERSION < 7 )) && [ \"$protocol\" == \"3\" ]; then\n            echo \"Skipping module tests: Modules doesn't support RESP3 for Redis versions < 7\"\n            invoke standalone-tests --redis-mod-url=${REDIS_MOD_URL} $eventloop --protocol=\"${protocol}\" --extra-markers=\"not redismod and not cp_integration\"\n          else\n            invoke standalone-tests --redis-mod-url=${REDIS_MOD_URL} $eventloop --protocol=\"${protocol}\"\n          fi\n\n          echo \"::endgroup::\"\n\n          echo \"::group::RESP${protocol} cluster tests\"\n          invoke cluster-tests $eventloop --protocol=${protocol}\n          echo \"::endgroup::\"\n        }\n\n        if [ \"${{inputs.protocol}}\" == \"all\" ]; then\n          run_tests 2 \"${{inputs.event-loop}}\"\n          run_tests 3 \"${{inputs.event-loop}}\"\n        else\n          run_tests \"${{inputs.protocol}}\" \"${{inputs.event-loop}}\"\n        fi\n      shell: bash\n\n    - name: Debug\n      if: failure()\n      run: |\n        sudo apt-get install -y redis-tools\n        echo \"Docker Containers:\"\n        docker ps\n        echo \"Cluster nodes:\"\n        redis-cli -p 16379 CLUSTER NODES\n      shell: bash\n\n    - name: Upload test results and profiling data\n      uses: actions/upload-artifact@v4\n      with:\n        name: pytest-results-redis_${{inputs.redis-version}}-python_${{inputs.python-version}}-parser_${{env.PARSER_BACKEND}}-el_${{inputs.event-loop}}-protocol_${{inputs.protocol}}\n        path: |\n          *-results.xml\n          prof/**\n          profile_output*\n        if-no-files-found: error\n        retention-days: 10\n\n    - name: Upload codecov coverage\n      uses: codecov/codecov-action@v4\n      with:\n        fail_ci_if_error: false\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\"\n    labels:\n      - \"maintenance\"\n    schedule:\n      interval: \"monthly\"\n"
  },
  {
    "path": ".github/release-drafter-config.yml",
    "content": "name-template: '$NEXT_MINOR_VERSION'\ntag-template: 'v$NEXT_MINOR_VERSION'\nfilter-by-commitish: true\ncommitish: master\nautolabeler:\n  - label: 'maintenance'\n    files:\n      - '*.md'\n      - '.github/*'\n  - label: 'bug'\n    branch:\n      - '/bug-.+'\n  - label: 'maintenance'\n    branch:\n      - '/maintenance-.+'\n  - label: 'feature'\n    branch:\n      - '/feature-.+'\ncategories:\n  - title: '🔥 Breaking Changes'\n    labels:\n      - 'breakingchange'\n  - title: '🧪 Experimental Features'\n    labels:\n      - 'experimental'\n  - title: '🚀 New Features'\n    labels:\n      - 'feature'\n      - 'enhancement'\n  - title: '🐛 Bug Fixes'\n    labels:\n      - 'fix'\n      - 'bugfix'\n      - 'bug'\n      - 'BUG'\n  - title: '🧰 Maintenance'\n    labels:\n      - 'maintenance'\n      - 'dependencies'\n      - 'documentation'\n      - 'docs'\n      - 'testing'\nchange-template: '- $TITLE (#$NUMBER)'\nexclude-labels:\n  - 'skip-changelog'\ntemplate: |\n  # Changes\n\n  $CHANGES\n\n  ## Contributors\n  We'd like to thank all the contributors who worked on this release!\n\n  $CONTRIBUTORS\n\n"
  },
  {
    "path": ".github/spellcheck-settings.yml",
    "content": "matrix:\n- name: Markdown\n  expect_match: false\n  apsell:\n    lang: en\n    d: en_US\n    ignore-case: true\n  dictionary:\n    wordlists:\n    - .github/wordlist.txt\n    output: wordlist.dic\n  pipeline:\n  - pyspelling.filters.markdown:\n      markdown_extensions:\n      - markdown.extensions.extra:\n  - pyspelling.filters.html:\n      comments: false\n      attributes:\n      - alt\n      ignores:\n      - ':matches(code, pre)'\n      - code\n      - pre\n      - blockquote\n      - img\n  sources:\n  - '*.md'\n  - 'docs/*.rst'\n  - 'docs/*.ipynb'\n"
  },
  {
    "path": ".github/wordlist.txt",
    "content": "APM\nARGV\nBFCommands\nbalancer\nCacheImpl\ncancelling\nCAS\nCFCommands\nCMSCommands\nClusterNode\nClusterNodes\nClusterPipeline\nClusterPubSub\nConnectionPool\nconfig\nCoreCommands\nCSC\nDatabaseConfig\nDNS\nEchoHealthCheck\nEVAL\nEVALSHA\nfailover\nFQDN\nGrokzen's\nhandoff\nHealthcheck\nHealthCheckPolicies\nhealthcheck\nhealthchecks\nINCR\ninit\nIOError\nInstrumentations\nJSONCommands\nJaeger\nLudovico\nMagnocavallo\nMeterProvider\nMetricGroup\nmillis\nMultiDbConfig\nMultiDBClient\nMcCurdy\nNOSCRIPT\nNoValidDatabaseException\nNUMPAT\nNUMPT\nNUMSUB\nOSS\nOpenCensus\nOpenTelemetry\nOpenTracing\nOtel\notel\nOTelConfig\notlp\nOTLPMetricExporter\nPeriodicExportingMetricReader\nproto\nPubSub\nREADONLY\nRediSearch\nRedisBloom\nRedisCluster\nRedisClusterCommands\nRedisClusterException\nRedisClusters\nRedisInstrumentor\nRedisJSON\nRedisTimeSeries\nSHA\nSLA\nSearchCommands\nSentinelCommands\nSentinelConnectionPool\nSharded\nSolovyov\nSpanKind\nSpecfiying\nStatusCode\nTCP\nTemporaryUnavailableException\nTLS\nTOPKCommands\nTimeSeriesCommands\nUptrace\nValueError\nWATCHed\nWatchError\napi\nargs\nasync\nasyncio\nautoclass\nautomodule\nbackoff\nbdb\nbehaviour\nbool\nboolean\nbooleans\nbysource\ncharset\ndel\ndev\ndocstring\ndocstrings\neg\nexc\nfirsttimersonly\nfo\ngenindex\ngmail\nhiredis\nhttp\nidx\niff\nini\njson\nkeyslot\nkeyspace\nkwarg\nkwargs\nlinters\nlocalhost\nlua\nmakeapullrequest\nmaxdepth\nmget\nmicroservice\nmicroservices\nmset\nmultikey\nmykey\nnonatomic\nobservability\nopentelemetry\noss\nperformant\npmessage\npng\npre\npsubscribe\npubsub\npunsubscribe\npy\npypi\nquickstart\nreadonly\nreadwrite\nredis\nredismodules\nreinitialization\nreplicaof\nrepo\nruntime\nsedrik\nsharded\nsdk\nssl\nstr\nstunnel\nsubcommands\nthevalueofmykey\ntimeseries\ntoctree\ntopk\ntriaging\ntxt\nun\nunicode\nurl\nvirtualenv\nwww\nXREAD\nXREADGROUP\nyaml\n"
  },
  {
    "path": ".github/workflows/codeql-analysis.yml",
    "content": "# For most projects, this workflow file will not need changing; you simply need\n# to commit it to your repository.\n#\n# You may wish to alter this file to override the set of languages analyzed,\n# or to provide custom queries or build logic.\n#\n# ******** NOTE ********\n# We have attempted to detect the languages in your repository. Please check\n# the `language` matrix defined below to confirm you have the correct set of\n# supported CodeQL languages.\n#\nname: \"CodeQL\"\n\non:\n  push:\n    branches: [ master ]\n  pull_request:\n    # The branches below must be a subset of the branches above\n    branches: [ master ]\n\njobs:\n  analyze:\n    name: Analyze\n    runs-on: ubuntu-latest\n    permissions:\n      actions: read\n      contents: read\n      security-events: write\n\n    strategy:\n      fail-fast: false\n      matrix:\n        language: [ 'python' ]\n        # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]\n        # Learn more about CodeQL language support at https://git.io/codeql-language-support\n\n    steps:\n    - name: Checkout repository\n      uses: actions/checkout@v6\n\n    # Initializes the CodeQL tools for scanning.\n    - name: Initialize CodeQL\n      uses: github/codeql-action/init@v4\n      with:\n        languages: ${{ matrix.language }}\n        # If you wish to specify custom queries, you can do so here or in a config file.\n        # By default, queries listed here will override any specified in a config file.\n        # Prefix the list here with \"+\" to use these queries and those in the config file.\n        # queries: ./path/to/local/query, your-org/your-repo/queries@main\n\n    # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java).\n    # If this step fails, then you should remove it and run the build manually (see below)\n    - name: Autobuild\n      uses: github/codeql-action/autobuild@v4\n\n    # ℹ️ Command-line programs to run using the OS shell.\n    # 📚 https://git.io/JvXDl\n\n    # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines\n    #    and modify them (or add more) to build your code if your project\n    #    uses a compiled language\n\n    #- run: |\n    #   make bootstrap\n    #   make release\n\n    - name: Perform CodeQL Analysis\n      uses: github/codeql-action/analyze@v4\n"
  },
  {
    "path": ".github/workflows/docs.yaml",
    "content": "name: Docs CI\n\non:\n  push:\n    branches:\n      - master\n      - '[0-9].[0-9]'\n  pull_request:\n    branches:\n      - master\n      - '[0-9].[0-9]'\n  schedule:\n    - cron: '0 1 * * *' # nightly build\n\nconcurrency:\n  group: ${{ github.event.pull_request.number || github.ref }}-docs\n  cancel-in-progress: true\n\npermissions:\n  contents: read  #  to fetch code (actions/checkout)\n\njobs:\n\n   build-docs:\n     name: Build docs\n     runs-on: ubuntu-latest\n     steps:\n       - uses: actions/checkout@v6\n       - uses: actions/setup-python@v6\n         with:\n           python-version: \"3.10\"\n           cache: 'pip'\n       - name: install deps\n         run: |\n           sudo apt-get update -yqq\n           sudo apt-get install -yqq pandoc make\n       - name: run code linters\n         run: |\n           pip install -r dev_requirements.txt -r docs/requirements.txt\n           invoke build-docs\n\n       - name: upload docs\n         uses: actions/upload-artifact@v7\n         with:\n           name: redis-py-docs\n           path: |\n             docs/_build/html\n"
  },
  {
    "path": ".github/workflows/hiredis-py-integration.yaml",
    "content": "name: Hiredis-py integration tests\n\non:\n  workflow_dispatch:\n    inputs:\n      redis-py-branch:\n        description: 'redis-py branch to run tests on'\n        required: true\n        default: 'master'\n      hiredis-branch:\n        description: 'hiredis-py branch to run tests on'\n        required: true\n        default: 'master'\n\nconcurrency:\n  group: ${{ github.event.pull_request.number || github.ref }}-hiredis-integration\n  cancel-in-progress: true\n\npermissions:\n  contents: read  #  to fetch code (actions/checkout)\n\nenv:\n  CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}\n  # this speeds up coverage with Python 3.12: https://github.com/nedbat/coveragepy/issues/1665\n  COVERAGE_CORE: sysmon\n  CURRENT_CLIENT_LIBS_TEST_STACK_IMAGE_TAG: '8.4.0'\n  CURRENT_REDIS_VERSION: '8.4.0'\n\njobs:\n  redis_version:\n    runs-on: ubuntu-latest\n    outputs:\n      CURRENT: ${{ env.CURRENT_REDIS_VERSION }}\n    steps:\n      - name: Compute outputs\n        run: |\n          echo \"CURRENT=${{ env.CURRENT_REDIS_VERSION }}\" >> $GITHUB_OUTPUT\n\n  hiredis-tests:\n    runs-on: ubuntu-latest\n    needs: [redis_version]\n    timeout-minutes: 60\n    strategy:\n      max-parallel: 15\n      fail-fast: false\n      matrix:\n        redis-version: [ '${{ needs.redis_version.outputs.CURRENT }}' ]\n        python-version: [ '3.10', '3.14']\n        parser-backend: [ 'hiredis' ]\n        hiredis-version: [ 'unstable' ]\n        event-loop: [ 'asyncio' ]\n    env:\n      ACTIONS_ALLOW_UNSECURE_COMMANDS: true\n    name: Redis ${{ matrix.redis-version }}; Python ${{ matrix.python-version }}; RESP Parser:${{matrix.parser-backend}} (${{ matrix.hiredis-version }}); EL:${{matrix.event-loop}}\n    steps:\n      - uses: actions/checkout@v6\n        with:\n          ref: ${{ inputs.redis-py-branch }}\n      - name: Run tests\n        uses: ./.github/actions/run-tests\n        with:\n          python-version: ${{ matrix.python-version }}\n          parser-backend: ${{ matrix.parser-backend }}\n          redis-version: ${{ matrix.redis-version }}\n          hiredis-version: ${{ matrix.hiredis-version }}\n          hiredis-branch: ${{ inputs.hiredis-branch }}\n"
  },
  {
    "path": ".github/workflows/install_and_test.sh",
    "content": "#!/bin/bash\n\nset -e\n\nSUFFIX=$1\nif [ -z ${SUFFIX} ]; then\n    echo \"Supply valid python package extension such as whl or tar.gz. Exiting.\"\n    exit 3\nfi\n\nscript=`pwd`/${BASH_SOURCE[0]}\nHERE=`dirname ${script}`\nROOT=`realpath ${HERE}/../..`\n\ncd ${ROOT}\nDESTENV=${ROOT}/.venvforinstall\nif [ -d ${DESTENV} ]; then\n    rm -rf ${DESTENV}\nfi\npython -m venv ${DESTENV}\nsource ${DESTENV}/bin/activate\npip install --upgrade --quiet pip\npip install --quiet -r dev_requirements.txt\npip uninstall -y redis  # uninstall Redis package installed via redis-entraid\ninvoke devenv --endpoints=all-stack\ninvoke package\n\n# find packages\nPKG=`ls ${ROOT}/dist/*.${SUFFIX}`\nls -l ${PKG}\n\nTESTDIR=${ROOT}/STAGETESTS\nif [ -d ${TESTDIR} ]; then\n    rm -rf ${TESTDIR}\nfi\nmkdir ${TESTDIR}\ncp -R ${ROOT}/tests ${TESTDIR}/tests\ncd ${TESTDIR}\n\n# install, run tests\npip install ${PKG}\n# Redis tests\npytest -m 'not onlycluster' --ignore=tests/test_scenario --ignore=tests/test_asyncio/test_scenario\n# RedisCluster tests\nCLUSTER_URL=\"redis://localhost:16379/0\"\nCLUSTER_SSL_URL=\"rediss://localhost:27379/0\"\npytest -m 'not onlynoncluster and not redismod and not ssl' \\\n  --ignore=tests/test_scenario \\\n  --ignore=tests/test_asyncio/test_scenario \\\n  --redis-url=\"${CLUSTER_URL}\" \\\n  --redis-ssl-url=\"${CLUSTER_SSL_URL}\"\n"
  },
  {
    "path": ".github/workflows/integration.yaml",
    "content": "name: CI\n\non:\n  push:\n    paths-ignore:\n      - 'docs/**'\n      - '**/*.rst'\n      - '**/*.md'\n    branches:\n      - master\n      - '[0-9].[0-9]'\n  pull_request:\n    branches:\n      - master\n      - '[0-9].[0-9]'\n  schedule:\n    - cron: '0 1 * * *' # nightly build\n\nconcurrency:\n  group: ${{ github.event.pull_request.number || github.ref }}-integration\n  cancel-in-progress: true\n\npermissions:\n  contents: read  #  to fetch code (actions/checkout)\n\nenv:\n  CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}\n  # this speeds up coverage with Python 3.12: https://github.com/nedbat/coveragepy/issues/1665\n  COVERAGE_CORE: sysmon\n  # patch releases get included in the base version image when they are published\n  # for example after 8.2.1 is published, 8.2 image contains 8.2.1 content\n  CURRENT_CLIENT_LIBS_TEST_STACK_IMAGE_TAG: '8.4.0'\n  CURRENT_REDIS_VERSION: '8.4.0'\n  REDIS_VERSION_CUSTOM_MAP: 'custom-21860421418-debian-amd64:8.6'\n\njobs:\n  dependency-audit:\n    name: Dependency audit\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n      - uses: pypa/gh-action-pip-audit@v1.0.8\n        with:\n          inputs: dev_requirements.txt\n          ignore-vulns: |\n            GHSA-w596-4wvx-j9j6  # subversion related git pull, dependency for pytest. There is no impact here.\n            CVE-2026-26007 # dependency for entraid tests\n            CVE-2026-32597 # PyJWT does not validate the crit (Critical) Header Parameter defined in RFC 7515, this will be fixed in the next release\n\n  lint:\n    name: Code linters\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n      - uses: actions/setup-python@v6\n        with:\n          python-version: \"3.10\"\n          cache: 'pip'\n      - name: run code linters\n        run: |\n          pip install -r dev_requirements.txt\n          pip uninstall -y redis  # uninstall Redis package installed via redis-entraid\n          invoke linters\n\n  redis_version:\n    runs-on: ubuntu-latest\n    outputs:\n      CURRENT: ${{ env.CURRENT_REDIS_VERSION }}\n    steps:\n      - name: Compute outputs\n        run: |\n          echo \"CURRENT=${{ env.CURRENT_REDIS_VERSION }}\" >> $GITHUB_OUTPUT\n\n  tests:\n    runs-on: ubuntu-latest\n    timeout-minutes: 60\n    needs: redis_version\n    strategy:\n      max-parallel: 30\n      fail-fast: false\n      matrix:\n        redis-version: ['custom-21860421418-debian-amd64', '${{ needs.redis_version.outputs.CURRENT }}', '8.2', '8.0.2' ,'7.4.4', '7.2.9']\n        python-version: ['3.10', '3.14']\n        parser-backend: ['plain']\n        event-loop: ['asyncio']\n        protocol: ['2', '3']\n    env:\n      ACTIONS_ALLOW_UNSECURE_COMMANDS: true\n    name: Redis ${{ matrix.redis-version }}; Python ${{ matrix.python-version }}; RESP Parser:${{matrix.parser-backend}}; EL:${{matrix.event-loop}}; Protocol:${{matrix.protocol}}\n    steps:\n      - uses: actions/checkout@v6\n      - name: Run tests\n        uses: ./.github/actions/run-tests\n        with:\n            python-version: ${{ matrix.python-version }}\n            parser-backend: ${{ matrix.parser-backend }}\n            redis-version: ${{ matrix.redis-version }}\n            protocol: ${{ matrix.protocol }}\n\n  python-compatibility-tests:\n    runs-on: ubuntu-latest\n    needs: [ redis_version ]\n    timeout-minutes: 60\n    strategy:\n      max-parallel: 30\n      fail-fast: false\n      matrix:\n        redis-version: [ '${{ needs.redis_version.outputs.CURRENT }}' ]\n        python-version: ['3.11', '3.12', '3.13']\n        parser-backend: [ 'plain' ]\n        event-loop: [ 'asyncio' ]\n        protocol: ['2', '3']\n    env:\n      ACTIONS_ALLOW_UNSECURE_COMMANDS: true\n    name: Redis ${{ matrix.redis-version }}; Python ${{ matrix.python-version }}; RESP Parser:${{matrix.parser-backend}}; EL:${{matrix.event-loop}}; Protocol:${{matrix.protocol}}\n    steps:\n      - uses: actions/checkout@v6\n      - name: Run tests\n        uses: ./.github/actions/run-tests\n        with:\n          python-version: ${{ matrix.python-version }}\n          parser-backend: ${{ matrix.parser-backend }}\n          redis-version: ${{ matrix.redis-version }}\n          protocol: ${{ matrix.protocol }}\n\n  pypy-compatibility-tests:\n    runs-on: ubuntu-latest\n    needs: [ redis_version ]\n    # in pipeline ofter these pypy jobs hang, so their timeout is set\n    # to just 50 minutes - close to the max time the jobs are running at the moment\n    # adding more tests will make them even slower, and we will need to adjust the timeout\n    timeout-minutes: 50\n    strategy:\n      max-parallel: 30\n      fail-fast: false\n      matrix:\n        redis-version: [ '${{ needs.redis_version.outputs.CURRENT }}' ]\n        python-version: ['pypy-3.10', 'pypy-3.11']\n        parser-backend: [ 'plain' ]\n        event-loop: [ 'asyncio' ]\n        protocol: ['2', '3']\n    env:\n      ACTIONS_ALLOW_UNSECURE_COMMANDS: true\n    name: PyPy Redis ${{ matrix.redis-version }}; ${{ matrix.python-version }}; RESP Parser:${{matrix.parser-backend}}; EL:${{matrix.event-loop}}; Protocol:${{matrix.protocol}}\n    steps:\n      - uses: actions/checkout@v6\n      - name: Run tests\n        uses: ./.github/actions/run-tests\n        with:\n          python-version: ${{ matrix.python-version }}\n          parser-backend: ${{ matrix.parser-backend }}\n          redis-version: ${{ matrix.redis-version }}\n          protocol: ${{ matrix.protocol }}\n\n  hiredis-tests:\n    runs-on: ubuntu-latest\n    needs: [redis_version]\n    timeout-minutes: 60\n    strategy:\n      max-parallel: 30\n      fail-fast: false\n      matrix:\n        redis-version: [ '${{ needs.redis_version.outputs.CURRENT }}' ]\n        python-version: [ '3.10', '3.14']\n        parser-backend: [ 'hiredis' ]\n        hiredis-version: [ '>=3.2.0', '<3.0.0' ]\n        event-loop: [ 'asyncio' ]\n        protocol: ['2', '3']\n    env:\n      ACTIONS_ALLOW_UNSECURE_COMMANDS: true\n    name: Redis ${{ matrix.redis-version }}; Python ${{ matrix.python-version }}; RESP Parser:${{matrix.parser-backend}} (${{ matrix.hiredis-version }}); EL:${{matrix.event-loop}}; Protocol:${{matrix.protocol}}\n    steps:\n      - uses: actions/checkout@v6\n      - name: Run tests\n        uses: ./.github/actions/run-tests\n        with:\n          python-version: ${{ matrix.python-version }}\n          parser-backend: ${{ matrix.parser-backend }}\n          redis-version: ${{ matrix.redis-version }}\n          hiredis-version: ${{ matrix.hiredis-version }}\n          protocol: ${{ matrix.protocol }}\n\n  uvloop-tests:\n    runs-on: ubuntu-latest\n    needs: [redis_version]\n    timeout-minutes: 60\n    strategy:\n      max-parallel: 30\n      fail-fast: false\n      matrix:\n        redis-version: [ '${{ needs.redis_version.outputs.CURRENT }}' ]\n        python-version: [ '3.10', '3.14' ]\n        parser-backend: [ 'plain' ]\n        event-loop: [ 'uvloop' ]\n        protocol: ['2', '3']\n    env:\n      ACTIONS_ALLOW_UNSECURE_COMMANDS: true\n    name: Redis ${{ matrix.redis-version }}; Python ${{ matrix.python-version }}; RESP Parser:${{matrix.parser-backend}}; EL:${{matrix.event-loop}}; Protocol:${{matrix.protocol}}\n    steps:\n      - uses: actions/checkout@v6\n      - name: Run tests\n        uses: ./.github/actions/run-tests\n        with:\n          python-version: ${{ matrix.python-version }}\n          parser-backend: ${{ matrix.parser-backend }}\n          redis-version: ${{ matrix.redis-version }}\n          event-loop: ${{ matrix.event-loop }}\n          protocol: ${{ matrix.protocol }}\n\n  build-and-test-package:\n    name: Validate building and installing the package\n    runs-on: ubuntu-latest\n    needs: [redis_version]\n    strategy:\n      fail-fast: false\n      matrix:\n        extension: ['tar.gz', 'whl']\n    steps:\n      - uses: actions/checkout@v6\n      - uses: actions/setup-python@v6\n        with:\n          python-version: \"3.10\"\n      - name: Run installed unit tests\n        env:\n          CLIENT_LIBS_TEST_IMAGE_TAG: ${{ env.CURRENT_REDIS_VERSION }}\n          CLIENT_LIBS_TEST_STACK_IMAGE_TAG: ${{ env.CURRENT_CLIENT_LIBS_TEST_STACK_IMAGE_TAG }}\n        run: |\n          bash .github/workflows/install_and_test.sh ${{ matrix.extension }}\n\n  install-package-from-commit:\n    name: Install package from commit hash\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: false\n      matrix:\n        python-version: ['3.10', '3.11', '3.12', '3.13', '3.14', 'pypy-3.10', 'pypy-3.11']\n    steps:\n      - uses: actions/checkout@v6\n      - uses: actions/setup-python@v6\n        with:\n          python-version: ${{ matrix.python-version }}\n          cache: 'pip'\n      - name: install from pip\n        run: |\n          pip install --quiet git+${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git@${GITHUB_SHA}\n"
  },
  {
    "path": ".github/workflows/pypi-publish.yaml",
    "content": "name: Publish tag to Pypi\n\non:\n  release:\n    types: [published]\n  workflow_dispatch:\n\npermissions:\n  contents: read  #  to fetch code (actions/checkout)\n\njobs:\n\n  build_and_package:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n      - name: install python\n        uses: actions/setup-python@v6\n        with:\n          python-version: \"3.10\"\n      - run: pip install build twine\n\n      - name: Build package\n        run: python -m build .\n\n      - name: Basic package test prior to upload\n        run: |\n          twine check dist/*\n\n      - name: Publish to Pypi\n        uses: pypa/gh-action-pypi-publish@release/v1\n        with:\n          user: __token__\n          password: ${{ secrets.PYPI_API_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/release-drafter.yml",
    "content": "name: Release Drafter\n\non:\n  push:\n    # branches to consider in the event; optional, defaults to all\n    branches:\n      - master\n\npermissions: {}\njobs:\n  update_release_draft:\n    permissions:\n      pull-requests: write  #  to add label to PR (release-drafter/release-drafter)\n      contents: write  #  to create a github release (release-drafter/release-drafter)\n\n    runs-on: ubuntu-latest\n    steps:\n      # Drafts your next Release notes as Pull Requests are merged into \"master\"\n      - uses: release-drafter/release-drafter@v6\n        with:\n          # (Optional) specify config name to use, relative to .github/. Default: release-drafter.yml\n           config-name: release-drafter-config.yml\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/spellcheck.yml",
    "content": "name: spellcheck\non:\n  pull_request:\njobs:\n  check-spelling:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n      - name: Check Spelling\n        uses: rojopolis/spellcheck-github-actions@0.58.0\n        with:\n          config_path: .github/spellcheck-settings.yml\n          task_name: Markdown\n"
  },
  {
    "path": ".github/workflows/stale-issues.yml",
    "content": "name: \"Stale Issue Management\"\non:\n  schedule:\n    # Run daily at midnight UTC\n    - cron: \"0 0 * * *\"\n  workflow_dispatch: # Allow manual triggering\n\nenv:\n  # Default stale policy timeframes\n  DAYS_BEFORE_STALE: 365\n  DAYS_BEFORE_CLOSE: 30\n\n  # Accelerated timeline for needs-information issues\n  NEEDS_INFO_DAYS_BEFORE_STALE: 30\n  NEEDS_INFO_DAYS_BEFORE_CLOSE: 7\n\njobs:\n  stale:\n    runs-on: ubuntu-latest\n    steps:\n      # First step: Handle regular issues (excluding needs-information)\n      - name: Mark regular issues as stale\n        uses: actions/stale@v10\n        with:\n          repo-token: ${{ secrets.GITHUB_TOKEN }}\n\n          # Default stale policy\n          days-before-stale: ${{ env.DAYS_BEFORE_STALE }}\n          days-before-close: ${{ env.DAYS_BEFORE_CLOSE }}\n\n          # Explicit stale label configuration\n          stale-issue-label: \"stale\"\n          stale-pr-label: \"stale\"\n\n          stale-issue-message: |\n            This issue has been automatically marked as stale due to inactivity.\n            It will be closed in 30 days if no further activity occurs.\n            If you believe this issue is still relevant, please add a comment to keep it open.\n\n          close-issue-message: |\n            This issue has been automatically closed due to inactivity.\n            If you believe this issue is still relevant, please reopen it or create a new issue with updated information.\n\n          # Exclude needs-information issues from this step\n          exempt-issue-labels: 'no-stale,needs-information'\n\n          # Remove stale label when issue/PR becomes active again\n          remove-stale-when-updated: true\n\n          # Apply to pull requests with same timeline\n          days-before-pr-stale: ${{ env.DAYS_BEFORE_STALE }}\n          days-before-pr-close: ${{ env.DAYS_BEFORE_CLOSE }}\n\n          stale-pr-message: |\n            This pull request has been automatically marked as stale due to inactivity.\n            It will be closed in 30 days if no further activity occurs.\n\n          close-pr-message: |\n            This pull request has been automatically closed due to inactivity.\n            If you would like to continue this work, please reopen the PR or create a new one.\n\n          # Only exclude no-stale PRs (needs-information PRs follow standard timeline)\n          exempt-pr-labels: 'no-stale'\n\n      # Second step: Handle needs-information issues with accelerated timeline\n      - name: Mark needs-information issues as stale\n        uses: actions/stale@v10\n        with:\n          repo-token: ${{ secrets.GITHUB_TOKEN }}\n\n          # Accelerated timeline for needs-information\n          days-before-stale: ${{ env.NEEDS_INFO_DAYS_BEFORE_STALE }}\n          days-before-close: ${{ env.NEEDS_INFO_DAYS_BEFORE_CLOSE }}\n\n          # Explicit stale label configuration\n          stale-issue-label: \"stale\"\n\n          # Only target ISSUES with needs-information label (not PRs)\n          only-issue-labels: 'needs-information'\n\n          stale-issue-message: |\n            This issue has been marked as stale because it requires additional information\n            that has not been provided for 30 days. It will be closed in 7 days if the\n            requested information is not provided.\n\n          close-issue-message: |\n            This issue has been closed because the requested information was not provided within the specified timeframe.\n            If you can provide the missing information, please reopen this issue or create a new one.\n\n          # Disable PR processing for this step\n          days-before-pr-stale: -1\n          days-before-pr-close: -1\n\n          # Remove stale label when issue becomes active again\n          remove-stale-when-updated: true\n"
  },
  {
    "path": ".gitignore",
    "content": "*.pyc\nredis.egg-info\nbuild/\ndist/\ndump.rdb\n_build\nvagrant/.vagrant\n.python-version\n.cache\n.eggs\n.idea\n.vscode\n.coverage\nenv\nvenv\ncoverage.xml\n.venv*\n*.xml\n.coverage*\nprof\nprofile_output*\ndocker/stunnel/keys\n/dockers/*/node-*/*\n/dockers/*/tls/*\n/dockers/standalone/\n/dockers/cluster/\n/dockers/replica/\n/dockers/sentinel/\n/dockers/redis-stack/\n/experimenting/\n"
  },
  {
    "path": ".mypy.ini",
    "content": "[mypy]\n#, docs/examples, tests\nfiles = redis\ncheck_untyped_defs = True\nfollow_imports_for_stubs asyncio.= True\n#disallow_any_decorated = True\ndisallow_subclassing_any = True\n#disallow_untyped_calls = True\ndisallow_untyped_decorators = True\n#disallow_untyped_defs = True\nimplicit_reexport = False\nno_implicit_optional = True\nshow_error_codes = True\nstrict_equality = True\nwarn_incomplete_stub = True\nwarn_redundant_casts = True\nwarn_unreachable = True\nwarn_unused_ignores = True\ndisallow_any_unimported = True\n#warn_return_any = True\n\n[mypy-redis.asyncio.lock]\n# TODO: Remove once locks has been rewritten\nignore_errors = True\n"
  },
  {
    "path": ".readthedocs.yml",
    "content": "version: 2\n\npython:\n  install:\n    - requirements: docs/requirements.txt\n    - method: pip\n      path: .\n\nbuild:\n  os: ubuntu-20.04\n  tools:\n    python: \"3.10\"\n\nsphinx:\n  configuration: docs/conf.py\n"
  },
  {
    "path": "CHANGES",
    "content": "This file contains only the changes history before redis-py version 4.0.0\nAfter redis-py version 4.0.0 all changes can be found and tracked in the Release notes pubished in GitHub\n\n* 3.5.3 (June 1, 2020)\n  * Restore try/except clauses to __del__ methods. These will be removed\n      in 4.0 when more explicit resource management if enforced. #1339\n  * Update the master_address when Sentinels promote a new master. #847\n  * Update SentinelConnectionPool to not forcefully disconnect other in-use\n      connections which can negatively affect threaded applications. #1345\n* 3.5.2 (May 14, 2020)\n  * Tune the locking in ConnectionPool.get_connection so that the lock is\n      not held while waiting for the socket to establish and validate the\n      TCP connection.\n* 3.5.1 (May 9, 2020)\n  * Fix for HSET argument validation to allow any non-None key. Thanks\n      @AleksMat, #1337, #1341\n* 3.5.0 (April 29, 2020)\n  * Removed exception trapping from __del__ methods. redis-py objects that\n      hold various resources implement __del__ cleanup methods to release\n      those resources when the object goes out of scope. This provides a\n      fallback for when these objects aren't explicitly closed by user code.\n      Prior to this change any errors encountered in closing these resources\n      would be hidden from the user. Thanks @jdufresne. #1281\n  * Expanded support for connection strings specifying a username connecting\n      to pre-v6 servers. #1274\n  * Optimized Lock's blocking_timeout and sleep. If the lock cannot be\n      acquired and the sleep value would cause the loop to sleep beyond\n      blocking_timeout, fail immediately. Thanks @clslgrnc. #1263\n  * Added support for passing Python memoryviews to Redis command args that\n      expect strings or bytes. The memoryview instance is sent directly to\n      the socket such that there are zero copies made of the underlying data\n      during command packing. Thanks @Cody-G. #1265, #1285\n  * HSET command now can accept multiple pairs. HMSET has been marked as\n      deprecated now. Thanks to @laixintao #1271\n  * Don't manually DISCARD when encountering an ExecAbortError.\n      Thanks @nickgaya, #1300/#1301\n  * Reset the watched state of pipelines after calling exec. This saves\n      a roundtrip to the server by not having to call UNWATCH within\n      Pipeline.reset(). Thanks @nickgaya, #1299/#1302\n  * Added the KEEPTTL option for the SET command. Thanks\n      @laixintao #1304/#1280\n  * Added the MEMORY STATS command. #1268\n  * Lock.extend() now has a new option, `replace_ttl`. When False (the\n      default), Lock.extend() adds the `additional_time` to the lock's existing\n      TTL. When replace_ttl=True, the lock's existing TTL is replaced with\n      the value of `additional_time`.\n  * Add testing and support for PyPy.\n* 3.4.1\n  * Move the username argument in the Redis and Connection classes to the\n      end of the argument list. This helps those poor souls that specify all\n      their connection options as non-keyword arguments. #1276\n  * Prior to ACL support, redis-py ignored the username component of\n      Connection URLs. With ACL support, usernames are no longer ignored and\n      are used to authenticate against an ACL rule. Some cloud vendors with\n      managed Redis instances (like Heroku) provide connection URLs with a\n      username component pre-ACL that is not intended to be used. Sending that\n      username to Redis servers < 6.0.0 results in an error. Attempt to detect\n      this condition and retry the AUTH command with only the password such\n      that authentication continues to work for these users. #1274\n  * Removed the __eq__ hooks to Redis and ConnectionPool that were added\n      in 3.4.0. This ended up being a bad idea as two separate connection\n      pools be considered equal yet manage a completely separate set of\n      connections.\n* 3.4.0\n  * Allow empty pipelines to be executed if there are WATCHed keys.\n      This is a convenient way to test if any of the watched keys changed\n      without actually running any other commands. Thanks @brianmaissy.\n      #1233, #1234\n  * Removed support for end of life Python 3.4.\n  * Added support for all ACL commands in Redis 6. Thanks @IAmATeaPot418\n      for helping.\n  * Pipeline instances now always evaluate to True. Prior to this change,\n      pipeline instances relied on __len__ for boolean evaluation which\n      meant that pipelines with no commands on the stack would be considered\n      False. #994\n  * Client instances and Connection pools now support a 'client_name'\n      argument. If supplied, all connections created will call CLIENT SETNAME\n      as soon as the connection is opened. Thanks to @Habbie for supplying\n      the basis of this change. #802\n  * Added the 'ssl_check_hostname' argument to specify whether SSL\n      connections should require the server hostname to match the hostname\n      specified in the SSL cert. By default 'ssl_check_hostname' is False\n      for backwards compatibility. #1196\n  * Slightly optimized command packing. Thanks @Deneby67. #1255\n  * Added support for the TYPE argument to SCAN. Thanks @netocp. #1220\n  * Better thread and fork safety in ConnectionPool and\n      BlockingConnectionPool. Added better locking to synchronize critical\n      sections rather than relying on CPython-specific implementation details\n      relating to atomic operations. Adjusted how the pools identify and\n      deal with a fork. Added a ChildDeadlockedError exception that is\n      raised by child processes in the very unlikely chance that a deadlock\n      is encountered. Thanks @gmbnomis, @mdellweg, @yht804421715. #1270,\n      #1138, #1178, #906, #1262\n  * Added __eq__ hooks to the Redis and ConnectionPool classes.\n      Thanks @brainix. #1240\n* 3.3.11\n  * Further fix for the SSLError -> TimeoutError mapping to work\n      on obscure releases of Python 2.7.\n* 3.3.10\n  * Fixed a potential error handling bug for the SSLError -> TimeoutError\n      mapping introduced in 3.3.9. Thanks @zbristow. #1224\n* 3.3.9\n  * Mapped Python 2.7 SSLError to TimeoutError where appropriate. Timeouts\n      should now consistently raise TimeoutErrors on Python 2.7 for both\n      unsecured and secured connections. Thanks @zbristow. #1222\n* 3.3.8\n  * Fixed MONITOR parsing to properly parse IPv6 client addresses, unix\n      socket connections and commands issued from Lua. Thanks @kukey. #1201\n* 3.3.7\n  * Fixed a regression introduced in 3.3.0 where socket.error exceptions\n      (or subclasses) could potentially be raised instead of\n      redis.exceptions.ConnectionError. #1202\n* 3.3.6\n  * Fixed a regression in 3.3.5 that caused PubSub.get_message() to raise\n      a socket.timeout exception when passing a timeout value. #1200\n* 3.3.5\n  * Fix an issue where socket.timeout errors could be handled by the wrong\n      exception handler in Python 2.7.\n* 3.3.4\n  * More specifically identify nonblocking read errors for both SSL and\n      non-SSL connections. 3.3.1, 3.3.2 and 3.3.3 on Python 2.7 could\n      potentially mask a ConnectionError. #1197\n* 3.3.3\n  * The SSL module in Python < 2.7.9 handles non-blocking sockets\n      differently than 2.7.9+. This patch accommodates older versions. #1197\n* 3.3.2\n  * Further fixed a regression introduced in 3.3.0 involving SSL and\n      non-blocking sockets. #1197\n* 3.3.1\n  * Fixed a regression introduced in 3.3.0 involving SSL and non-blocking\n      sockets. #1197\n* 3.3.0\n  * Resolve a race condition with the PubSubWorkerThread. #1150\n  * Cleanup socket read error messages. Thanks Vic Yu. #1159\n  * Cleanup the Connection's selector correctly. Thanks Bruce Merry. #1153\n  * Added a Monitor object to make working with MONITOR output easy.\n      Thanks Roey Prat #1033\n  * Internal cleanup: Removed the legacy Token class which was necessary\n      with older version of Python that are no longer supported. #1066\n  * Response callbacks are now case insensitive. This allows users that\n      call Redis.execute_command() directly to pass lower-case command\n      names and still get reasonable responses. #1168\n  * Added support for hiredis-py 1.0.0 encoding error support. This should\n      make the PythonParser and the HiredisParser behave identically\n      when encountering encoding errors. Thanks Brian Candler. #1161/#1162\n  * All authentication errors now properly raise AuthenticationError.\n      AuthenticationError is now a subclass of ConnectionError, which will\n      cause the connection to be disconnected and cleaned up appropriately.\n      #923\n  * Add READONLY and READWRITE commands. Thanks @theodesp. #1114\n  * Remove selectors in favor of nonblocking sockets. Selectors had\n      issues in some environments including eventlet and gevent. This should\n      resolve those issues with no other side effects.\n  * Fixed an issue with XCLAIM and previously claimed but not removed\n      messages. Thanks @thomdask. #1192/#1191\n  * Allow for single connection client instances. These instances\n      are not thread safe but offer other benefits including a subtle\n      performance increase.\n  * Added extensive health checks that keep the connections lively.\n      Passing the \"health_check_interval=N\" option to the Redis client class\n      or to a ConnectionPool ensures that a round trip PING/PONG is successful\n      before any command if the underlying connection has been idle for more\n      than N seconds. ConnectionErrors and TimeoutErrors are automatically\n      retried once for health checks.\n  * Changed the PubSubWorkerThread to use a threading.Event object rather\n      than a boolean to control the thread's life cycle. Thanks Timothy\n      Rule. #1194/#1195.\n  * Fixed a bug in Pipeline error handling that would incorrectly retry\n      ConnectionErrors.\n* 3.2.1\n  * Fix SentinelConnectionPool to work in multiprocess/forked environments.\n* 3.2.0\n  * Added support for `select.poll` to test whether data can be read\n      on a socket. This should allow for significantly more connections to\n      be used with pubsub. Fixes #486/#1115\n  * Attempt to guarantee that the ConnectionPool hands out healthy\n      connections. Healthy connections are those that have an established\n      socket connection to the Redis server, are ready to accept a command\n      and have no data available to read. Fixes #1127/#886\n  * Use the socket.IPPROTO_TCP constant instead of socket.SOL_TCP.\n      IPPROTO_TCP is available on more interpreters (Jython for instance).\n      Thanks @Junnplus. #1130\n  * Fixed a regression introduced in 3.0 that mishandles exceptions not\n      derived from the base Exception class. KeyboardInterrupt and\n      gevent.timeout notable. Thanks Christian Fersch. #1128/#1129\n  * Significant improvements to handing connections with forked processes.\n      Parent and child processes no longer trample on each others' connections.\n      Thanks to Jay Rolette for the patch and highlighting this issue.\n      #504/#732/#784/#863\n  * PythonParser no longer closes the associated connection's socket. The\n      connection itself will close the socket. #1108/#1085\n* 3.1.0\n  * Connection URLs must have one of the following schemes:\n      redis://, rediss://, unix://. Thanks @jdupl123. #961/#969\n  * Fixed an issue with retry_on_timeout logic that caused some TimeoutErrors\n      to be retried. Thanks Aaron Yang. #1022/#1023\n  * Added support for SNI for SSL. Thanks @oridistor and Roey Prat. #1087\n  * Fixed ConnectionPool repr for pools with no connections. Thanks\n      Cody Scott. #1043/#995\n  * Fixed GEOHASH to return a None value when specifying a place that\n      doesn't exist on the server. Thanks @guybe7. #1126\n  * Fixed XREADGROUP to return an empty dictionary for messages that\n      have been deleted but still exist in the unacknowledged queue. Thanks\n      @xeizmendi. #1116\n  * Added an owned method to Lock objects. owned returns a boolean\n      indicating whether the current lock instance still owns the lock.\n      Thanks Dave Johansen. #1112\n  * Allow lock.acquire() to accept an optional token argument. If\n      provided, the token argument is used as the unique value used to claim\n      the lock. Thankd Dave Johansen. #1112\n  * Added a reacquire method to Lock objects. reacquire attempts to renew\n      the lock such that the timeout is extended to the same value that the\n      lock was initially acquired with. Thanks Ihor Kalnytskyi. #1014\n  * Stream names found within XREAD and XREADGROUP responses now properly\n      respect the decode_responses flag.\n  * XPENDING_RANGE now requires the user the specify the min, max and\n      count arguments. Newer versions of Redis prevent count from being\n      infinite so it's left to the user to specify these values explicitly.\n  * ZADD now returns None when xx=True and incr=True and an element\n      is specified that doesn't exist in the sorted set. This matches\n      what the server returns in this case. #1084\n  * Added client_kill_filter that accepts various filters to identify\n      and kill clients. Thanks Theofanis Despoudis. #1098\n  * Fixed a race condition that occurred when unsubscribing and\n      resubscribing to the same channel or pattern in rapid succession.\n      Thanks Marcin Raczyński. #764\n  * Added a LockNotOwnedError that is raised when trying to extend or\n      release a lock that is no longer owned. This is a subclass of LockError\n      so previous code should continue to work as expected. Thanks Joshua\n      Harlow. #1095\n  * Fixed a bug in GEORADIUS that forced decoding of places without\n      respecting the decode_responses option. Thanks Bo Bayles. #1082\n* 3.0.1\n  * Fixed regression with UnixDomainSocketConnection caused by 3.0.0.\n      Thanks Jyrki Muukkonen\n  * Fixed an issue with the new asynchronous flag on flushdb and flushall.\n      Thanks rogeryen\n  * Updated Lock.locked() method to indicate whether *any* process has\n      acquired the lock, not just the current one. This is in line with\n      the behavior of threading.Lock. Thanks Alan Justino da Silva\n* 3.0.0\n  BACKWARDS INCOMPATIBLE CHANGES\n  * When using a Lock as a context manager and the lock fails to be acquired\n      a LockError is now raised. This prevents the code block inside the\n      context manager from being executed if the lock could not be acquired.\n  * Renamed LuaLock to Lock.\n  * Removed the pipeline based Lock implementation in favor of the LuaLock\n      implementation.\n  * Only bytes, strings and numbers (ints, longs and floats) are acceptable\n      for keys and values. Previously redis-py attempted to cast other types\n      to str() and store the result. This caused must confusion and frustration\n      when passing boolean values (cast to 'True' and 'False') or None values\n      (cast to 'None'). It is now the user's responsibility to cast all\n      key names and values to bytes, strings or numbers before passing the\n      value to redis-py.\n  * The StrictRedis class has been renamed to Redis. StrictRedis will\n      continue to exist as an alias of Redis for the foreseeable future.\n  * The legacy Redis client class has been removed. It caused much confusion\n      to users.\n  * ZINCRBY arguments 'value' and 'amount' have swapped order to match the\n      the Redis server. The new argument order is: keyname, amount, value.\n  * MGET no longer raises an error if zero keys are passed in. Instead an\n      empty list is returned.\n  * MSET and MSETNX now require all keys/values to be specified in a single\n      dictionary argument named mapping. This was changed to allow for future\n      options to these commands in the future.\n  * ZADD now requires all element names/scores be specified in a single\n      dictionary argument named mapping. This was required to allow the NX,\n      XX, CH and INCR options to be specified.\n  * ssl_cert_reqs now has a default value of 'required' by default. This\n      should make connecting to a remote Redis server over SSL more secure.\n      Thanks u2mejc\n  * Removed support for EOL Python 2.6 and 3.3. Thanks jdufresne\n  OTHER CHANGES\n  * Added missing DECRBY command. Thanks derek-dchu\n  * CLUSTER INFO and CLUSTER NODES responses are now properly decoded to\n      strings.\n  * Added a 'locked()' method to Lock objects. This method returns True\n      if the lock has been acquired and owned by the current process,\n      otherwise False.\n  * EXISTS now supports multiple keys. It's return value is now the number\n      of keys in the list that exist.\n  * Ensure all commands can accept key names as bytes. This fixes issues\n      with BLPOP, BRPOP and SORT.\n  * All errors resulting from bad user input are raised as DataError\n      exceptions. DataError is a subclass of RedisError so this should be\n      transparent to anyone previously catching these.\n  * Added support for NX, XX, CH and INCR options to ZADD\n  * Added support for the MIGRATE command\n  * Added support for the MEMORY USAGE and MEMORY PURGE commands. Thanks\n      Itamar Haber\n  * Added support for the 'asynchronous' argument to FLUSHDB and FLUSHALL\n      commands. Thanks Itamar Haber\n  * Added support for the BITFIELD command. Thanks Charles Leifer and\n      Itamar Haber\n  * Improved performance on pipeline requests with large chunks of data.\n      Thanks tzickel\n  * Fixed test suite to not fail if another client is connected to the\n      server the tests are running against.\n  * Added support for SWAPDB. Thanks Itamar Haber\n  * Added support for all STREAM commands. Thanks Roey Prat and Itamar Haber\n  * SHUTDOWN now accepts the 'save' and 'nosave' arguments. Thanks\n      dwilliams-kenzan\n  * Added support for ZPOPMAX, ZPOPMIN, BZPOPMAX, BZPOPMIN. Thanks\n      Itamar Haber\n  * Added support for the 'type' argument in CLIENT LIST. Thanks Roey Prat\n  * Added support for CLIENT PAUSE. Thanks Roey Prat\n  * Added support for CLIENT ID and CLIENT UNBLOCK. Thanks Itamar Haber\n  * GEODIST now returns a None value when referencing a place that does\n      not exist. Thanks qingping209\n  * Added a ping() method to pubsub objects. Thanks krishan-carbon\n  * Fixed a bug with keys in the INFO dict that contained ':' symbols.\n      Thanks mzalimeni\n  * Fixed the select system call retry compatibility with Python 2.x.\n      Thanks lddubeau\n  * max_connections is now a valid querystring argument for creating\n      connection pools from URLs. Thanks mmaslowskicc\n  * Added the UNLINK command. Thanks yozel\n  * Added socket_type option to Connection for configurability.\n      Thanks garlicnation\n  * Lock.do_acquire now atomically sets acquires the lock and sets the\n      expire value via set(nx=True, px=timeout). Thanks 23doors\n  * Added 'count' argument to SPOP. Thanks AlirezaSadeghi\n  * Fixed an issue parsing client_list responses that contained an '='.\n      Thanks swilly22\n* 2.10.6\n  * Various performance improvements. Thanks cjsimpson\n  * Fixed a bug with SRANDMEMBER where the behavior for `number=0` did\n      not match the spec. Thanks Alex Wang\n  * Added HSTRLEN command. Thanks Alexander Putilin\n  * Added the TOUCH command. Thanks Anis Jonischkeit\n  * Remove unnecessary calls to the server when registering Lua scripts.\n      Thanks Ben Greenberg\n  * SET's EX and PX arguments now allow values of zero. Thanks huangqiyin\n  * Added PUBSUB {CHANNELS, NUMPAT, NUMSUB} commands. Thanks Angus Pearson\n  * PubSub connections that encounter `InterruptedError`s now\n      retry automatically. Thanks Carlton Gibson and Seth M. Larson\n  * LPUSH and RPUSH commands run on PyPy now correctly returns the number\n      of items of the list. Thanks Jeong YunWon\n  * Added support to automatically retry socket EINTR errors. Thanks\n      Thomas Steinacher\n  * PubSubWorker threads started with `run_in_thread` are now daemonized\n      so the thread shuts down when the running process goes away. Thanks\n      Keith Ainsworth\n  * Added support for GEO commands. Thanks Pau Freixes, Alex DeBrie and\n      Abraham Toriz\n  * Made client construction from URLs smarter. Thanks Tim Savage\n  * Added support for CLUSTER * commands. Thanks Andy Huang\n  * The RESTORE command now accepts an optional `replace` boolean.\n      Thanks Yoshinari Takaoka\n  * Attempt to connect to a new Sentinel if a TimeoutError occurs. Thanks\n      Bo Lopker\n  * Fixed a bug in the client's `__getitem__` where a KeyError would be\n      raised if the value returned by the server is an empty string.\n      Thanks Javier Candeira.\n  * Socket timeouts when connecting to a server are now properly raised\n      as TimeoutErrors.\n* 2.10.5\n  * Allow URL encoded parameters in Redis URLs. Characters like a \"/\" can\n      now be URL encoded and redis-py will correctly decode them. Thanks\n      Paul Keene.\n  * Added support for the WAIT command. Thanks <https://github.com/eshizhan>\n  * Better shutdown support for the PubSub Worker Thread. It now properly\n      cleans up the connection, unsubscribes from any channels and patterns\n      previously subscribed to and consumes any waiting messages on the socket.\n  * Added the ability to sleep for a brief period in the event of a\n      WatchError occurring. Thanks Joshua Harlow.\n  * Fixed a bug with pipeline error reporting when dealing with characters\n      in error messages that could not be encoded to the connection's\n      character set. Thanks Hendrik Muhs.\n  * Fixed a bug in Sentinel connections that would inadvertently connect\n      to the master when the connection pool resets. Thanks\n      <https://github.com/df3n5>\n  * Better timeout support in Pubsub get_message. Thanks Andy Isaacson.\n  * Fixed a bug with the HiredisParser that would cause the parser to\n      get stuck in an endless loop if a specific number of bytes were\n      delivered from the socket. This fix also increases performance of\n      parsing large responses from the Redis server.\n  * Added support for ZREVRANGEBYLEX.\n  * ConnectionErrors are now raised if Redis refuses a connection due to\n      the maxclients limit being exceeded. Thanks Roman Karpovich.\n  * max_connections can now be set when instantiating client instances.\n      Thanks Ohad Perry.\n* 2.10.4\n    (skipped due to a PyPI snafu)\n* 2.10.3\n  * Fixed a bug with the bytearray support introduced in 2.10.2. Thanks\n      Josh Owen.\n* 2.10.2\n  * Added support for Hiredis's new bytearray support. Thanks\n      <https://github.com/tzickel>\n  * POSSIBLE BACKWARDS INCOMPATIBLE CHANGE: Fixed a possible race condition\n      when multiple threads share the same Lock instance with a timeout. Lock\n      tokens are now stored in thread local storage by default. If you have\n      code that acquires a lock in one thread and passes that lock instance to\n      another thread to release it, you need to disable thread local storage.\n      Refer to the doc strings on the Lock class about the thread_local\n      argument information.\n  * Fixed a regression in from_url where \"charset\" and \"errors\" weren't\n      valid options. \"encoding\" and \"encoding_errors\" are still accepted\n      and preferred.\n  * The \"charset\" and \"errors\" options have been deprecated. Passing\n      either to StrictRedis.__init__ or from_url will still work but will\n      also emit a DeprecationWarning. Instead use the \"encoding\" and\n      \"encoding_errors\" options.\n  * Fixed a compatibility bug with Python 3 when the server closes a\n      connection.\n  * Added BITPOS command. Thanks <https://github.com/jettify>.\n  * Fixed a bug when attempting to send large values to Redis in a Pipeline.\n* 2.10.1\n  * Fixed a bug where Sentinel connections to a server that's no longer a\n      master and receives a READONLY error will disconnect and reconnect to\n      the master.\n* 2.10.0\n  * Discontinued support for Python 2.5. Upgrade. You'll be happier.\n  * The HiRedis parser will now properly raise ConnectionErrors.\n  * Completely refactored PubSub support. Fixes all known PubSub bugs and\n      adds a bunch of new features. Docs can be found in the README under the\n      new \"Publish / Subscribe\" section.\n  * Added the new HyperLogLog commands (PFADD, PFCOUNT, PFMERGE). Thanks\n      Pepijn de Vos and Vincent Ohprecio.\n  * Updated TTL and PTTL commands with Redis 2.8+ semantics. Thanks Markus\n      Kaiserswerth.\n  * *SCAN commands now return a long (int on Python3) cursor value rather\n      than the string representation. This might be slightly backwards\nincompatible in code using*SCAN commands loops such as\n      \"while cursor != '0':\".\n  * Added extra *SCAN commands that return iterators instead of the normal\n      [cursor, data] type. Use scan_iter, hscan_iter, sscan_iter, and\n      zscan_iter for iterators. Thanks Mathieu Longtin.\n  * Added support for SLOWLOG commands. Thanks Rick van Hattem.\n  * Added lexicographical commands ZRANGEBYLEX, ZREMRANGEBYLEX, and ZLEXCOUNT\n      for sorted sets.\n  * Connection objects now support an optional argument, socket_read_size,\n      indicating how much data to read during each socket.recv() call. After\n      benchmarking, increased the default size to 64k, which dramatically\n      improves performance when fetching large values, such as many results\n      in a pipeline or a large (>1MB) string value.\n  * Improved the pack_command and send_packed_command functions to increase\n      performance when sending large (>1MB) values.\n  * Sentinel Connections to master servers now detect when a READONLY error\n      is encountered and disconnect themselves and all other active connections\n      to the same master so that the new master can be discovered.\n  * Fixed Sentinel state parsing on Python 3.\n  * Added support for SENTINEL MONITOR, SENTINEL REMOVE, and SENTINEL SET\n      commands. Thanks Greg Murphy.\n  * INFO output that doesn't follow the \"key:value\" format will now be\n      appended to a key named \"__raw__\" in the INFO dictionary. Thanks Pedro\n      Larroy.\n  * The \"vagrant\" directory contains a complete vagrant environment for\n      redis-py developers. The environment runs a Redis master, a Redis slave,\n      and 3 Sentinels. Future iterations of the test suite will incorporate\n      more integration style tests, ensuring things like failover happen\n      correctly.\n  * It's now possible to create connection pool instances from a URL.\n      StrictRedis.from_url() now uses this feature to create a connection pool\n      instance and use that when creating a new client instance. Thanks\n      <https://github.com/chillipino>\n  * When creating client instances or connection pool instances from an URL,\n      it's now possible to pass additional options to the connection pool with\n      querystring arguments.\n  * Fixed a bug where some encodings (like utf-16) were unusable on Python 3\n      as command names and literals would get encoded.\n  * Added an SSLConnection class that allows for secure connections through\n      stunnel or other means. Construct an SSL connection with the ssl=True\n      option on client classes, using the rediss:// scheme from an URL, or\n      by passing the SSLConnection class to a connection pool's\n      connection_class argument. Thanks <https://github.com/oranagra>.\n  * Added a socket_connect_timeout option to control how long to wait while\n      establishing a TCP connection before timing out. This lets the client\n      fail fast when attempting to connect to a downed server while keeping\n      a more lenient timeout for all other socket operations.\n  * Added TCP Keep-alive support by passing use the socket_keepalive=True\n      option. Finer grain control can be achieved using the\n      socket_keepalive_options option which expects a dictionary with any of\n      the keys (socket.TCP_KEEPIDLE, socket.TCP_KEEPCNT, socket.TCP_KEEPINTVL)\n      and integers for values. Thanks Yossi Gottlieb.\n  * Added a `retry_on_timeout` option that controls how socket.timeout errors\n      are handled. By default it is set to False and will cause the client to\n      raise a TimeoutError anytime a socket.timeout is encountered. If\n      `retry_on_timeout` is set to True, the client will retry a command that\n      timed out once like other `socket.error`s.\n  * Completely refactored the Lock system. There is now a LuaLock class\n      that's used when the Redis server is capable of running Lua scripts along\n      with a fallback class for Redis servers < 2.6. The new locks fix several\n      subtle race consider that the old lock could face. In additional, a\n      new method, \"extend\" is available on lock instances that all a lock\n      owner to extend the amount of time they have the lock for. Thanks to\n      Eli Finkelshteyn and <https://github.com/chillipino> for contributions.\n* 2.9.1\n  * IPv6 support. Thanks <https://github.com/amashinchi>\n* 2.9.0\n  * Performance improvement for packing commands when using the PythonParser.\n      Thanks Guillaume Viot.\n  * Executing an empty pipeline transaction no longer sends MULTI/EXEC to\n      the server. Thanks EliFinkelshteyn.\n  * Errors when authenticating (incorrect password) and selecting a database\n      now close the socket.\n  * Full Sentinel support thanks to Vitja Makarov. Thanks!\n  * Better repr support for client and connection pool instances. Thanks\n      Mark Roberts.\n  * Error messages that the server sends to the client are now included\n      in the client error message. Thanks Sangjin Lim.\n  * Added the SCAN, SSCAN, HSCAN, and ZSCAN commands. Thanks Jingchao Hu.\n  * ResponseErrors generated by pipeline execution provide addition context\n      including the position of the command in the pipeline and the actual\n      command text generated the error.\n  * ConnectionPools now play nicer in threaded environments that fork. Thanks\n      Christian Joergensen.\n* 2.8.0\n  * redis-py should play better with gevent when a gevent Timeout is raised.\n      Thanks leifkb.\n  * Added SENTINEL command. Thanks Anna Janackova.\n  * Fixed a bug where pipelines could potentially corrupt a connection\n      if the MULTI command generated a ResponseError. Thanks EliFinkelshteyn\n      for the report.\n  * Connections now call socket.shutdown() prior to socket.close() to\n      ensure communication ends immediately per the note at\n      <https://docs.python.org/2/library/socket.html#socket.socket.close>\n      Thanks to David Martin for pointing this out.\n  * Lock checks are now based on floats rather than ints. Thanks\n      Vitja Makarov.\n* 2.7.6\n  * Added CONFIG RESETSTAT command. Thanks Yossi Gottlieb.\n  * Fixed a bug introduced in 2.7.3 that caused issues with script objects\n      and pipelines. Thanks Carpentier Pierre-Francois.\n  * Converted redis-py's test suite to use the awesome py.test library.\n  * Fixed a bug introduced in 2.7.5 that prevented a ConnectionError from\n      being raised when the Redis server is LOADING data.\n  * Added a BusyLoadingError exception that's raised when the Redis server\n      is starting up and not accepting commands yet. BusyLoadingError\n      subclasses ConnectionError, which this state previously returned.\n      Thanks Yossi Gottlieb.\n* 2.7.5\n  * DEL, HDEL and ZREM commands now return the numbers of keys deleted\n      instead of just True/False.\n  * from_url now supports URIs with a port number. Thanks Aaron Westendorf.\n* 2.7.4\n  * Added missing INCRBY method. Thanks Krzysztof Dorosz.\n  * SET now accepts the EX, PX, NX and XX options from Redis 2.6.12. These\n      options will generate errors if these options are used when connected\n      to a Redis server < 2.6.12. Thanks George Yoshida.\n* 2.7.3\n  * Fixed a bug with BRPOPLPUSH and lists with empty strings.\n  * All empty except: clauses have been replaced to only catch Exception\n      subclasses. This prevents a KeyboardInterrupt from triggering exception\n      handlers. Thanks Lucian Branescu Mihaila.\n  * All exceptions that are the result of redis server errors now share a\n      command Exception subclass, ServerError. Thanks Matt Robenolt.\n  * Prevent DISCARD from being called if MULTI wasn't also called. Thanks\n      Pete Aykroyd.\n  * SREM now returns an integer indicating the number of items removed from\n      the set. Thanks <https://github.com/ronniekk>.\n  * Fixed a bug with BGSAVE and BGREWRITEAOF response callbacks with Python3.\n      Thanks Nathan Wan.\n  * Added CLIENT GETNAME and CLIENT SETNAME commands.\n      Thanks <https://github.com/bitterb>.\n  * It's now possible to use len() on a pipeline instance to determine the\n      number of commands that will be executed. Thanks Jon Parise.\n  * Fixed a bug in INFO's parse routine with floating point numbers. Thanks\n      Ali Onur Uyar.\n  * Fixed a bug with BITCOUNT to allow `start` and `end` to both be zero.\n      Thanks Tim Bart.\n  * The transaction() method now accepts a boolean keyword argument,\n      value_from_callable. By default, or if False is passes, the transaction()\n      method will return the value of the pipelines execution. Otherwise, it\n      will return whatever func() returns.\n  * Python3 compatibility fix ensuring we're not already bytes(). Thanks\n      Salimane Adjao Moustapha.\n  * Added PSETEX. Thanks YAMAMOTO Takashi.\n  * Added a BlockingConnectionPool to limit the number of connections that\n      can be created. Thanks James Arthur.\n  * SORT now accepts a `groups` option that if specified, will return\n      tuples of n-length, where n is the number of keys specified in the GET\n      argument. This allows for convenient row-based iteration. Thanks\n      Ionuț Arțăriși.\n* 2.7.2\n  * Parse errors are now *always* raised on multi/exec pipelines, regardless\n      of the `raise_on_error` flag. See\n      <https://groups.google.com/forum/?hl=en&fromgroups=#!topic/redis-db/VUiEFT8U8U0>\n      for more info.\n* 2.7.1\n  * Packaged tests with source code\n* 2.7.0\n  * Added BITOP and BITCOUNT commands. Thanks Mark Tozzi.\n  * Added the TIME command. Thanks Jason Knight.\n  * Added support for LUA scripting. Thanks to Angus Peart, Drew Smathers,\n      Issac Kelly, Louis-Philippe Perron, Sean Bleier, Jeffrey Kaditz, and\n      Dvir Volk for various patches and contributions to this feature.\n  * Changed the default error handling in pipelines. By default, the first\n      error in a pipeline will now be raised. A new parameter to the\n      pipeline's execute, `raise_on_error`, can be set to False to keep the\n      old behavior of embeedding the exception instances in the result.\n  * Fixed a bug with pipelines where parse errors won't corrupt the\n      socket.\n  * Added the optional `number` argument to SRANDMEMBER for use with\n      Redis 2.6+ servers.\n  * Added PEXPIRE/PEXPIREAT/PTTL commands. Thanks Luper Rouch.\n  * Added INCRBYFLOAT/HINCRBYFLOAT commands. Thanks Nikita Uvarov.\n  * High precision floating point values won't lose their precision when\n      being sent to the Redis server. Thanks Jason Oster and Oleg Pudeyev.\n  * Added CLIENT LIST/CLIENT KILL commands\n* 2.6.2\n  * `from_url` is now available as a classmethod on client classes. Thanks\n      Jon Parise for the patch.\n  * Fixed several encoding errors resulting from the Python 3.x support.\n* 2.6.1\n  * Python 3.x support! Big thanks to Alex Grönholm.\n  * Fixed a bug in the PythonParser's read_response that could hide an error\n      from the client (#251).\n* 2.6.0\n  * Changed (p)subscribe and (p)unsubscribe to no longer return messages\n      indicating the channel was subscribed/unsubscribed to. These messages\n      are available in the listen() loop instead. This is to prevent the\n      following scenario:\n    * Client A is subscribed to \"foo\"\n    * Client B publishes message to \"foo\"\n    * Client A subscribes to channel \"bar\" at the same time.\n      Prior to this change, the subscribe() call would return the published\n      messages on \"foo\" rather than the subscription confirmation to \"bar\".\n  * Added support for GETRANGE, thanks Jean-Philippe Caruana\n  * A new setting \"decode_responses\" specifies whether return values from\n      Redis commands get decoded automatically using the client's charset\n      value. Thanks to Frankie Dintino for the patch.\n* 2.4.13\n  * redis.from_url() can take an URL representing a Redis connection string\n      and return a client object. Thanks Kenneth Reitz for the patch.\n* 2.4.12\n  * ConnectionPool is now fork-safe. Thanks Josiah Carson for the patch.\n* 2.4.11\n  * AuthenticationError will now be correctly raised if an invalid password\n      is supplied.\n  * If Hiredis is unavailable, the HiredisParser will raise a RedisError\n      if selected manually.\n  * Made the INFO command more tolerant of Redis changes formatting. Fix\n      for #217.\n* 2.4.10\n  * Buffer reads from socket in the PythonParser. Fix for a Windows-specific\n      bug (#205).\n  * Added the OBJECT and DEBUG OBJECT commands.\n  * Added __del__ methods for classes that hold on to resources that need to\n      be cleaned up. This should prevent resource leakage when these objects\n      leave scope due to misuse or unhandled exceptions. Thanks David Wolever\n      for the suggestion.\n  * Added the ECHO command for completeness.\n  * Fixed a bug where attempting to subscribe to a PubSub channel of a Redis\n      server that's down would blow out the stack. Fixes #179 and #195. Thanks\n      Ovidiu Predescu for the test case.\n  * StrictRedis's TTL command now returns a -1 when querying a key with no\n      expiration. The Redis class continues to return None.\n  * ZADD and SADD now return integer values indicating the number of items\n      added. Thanks Homer Strong.\n  * Renamed the base client class to StrictRedis, replacing ZADD and LREM in\n      favor of their official argument order. The Redis class is now a subclass\n      of StrictRedis, implementing the legacy redis-py implementations of ZADD\n      and LREM. Docs have been updated to suggesting the use of StrictRedis.\n  * SETEX in StrictRedis is now compliant with official Redis SETEX command.\n      the name, value, time implementation moved to \"Redis\" for backwards\n      compatibility.\n* 2.4.9\n  * Removed socket retry logic in Connection. This is the responsibility of\n      the caller to determine if the command is safe and can be retried. Thanks\n      David Wolver.\n  * Added some extra guards around various types of exceptions being raised\n      when sending or parsing data. Thanks David Wolver and Denis Bilenko.\n* 2.4.8\n  * Imported with_statement from __future__ for Python 2.5 compatibility.\n* 2.4.7\n  * Fixed a bug where some connections were not getting released back to the\n      connection pool after pipeline execution.\n  * Pipelines can now be used as context managers. This is the preferred way\n      of use to ensure that connections get cleaned up properly. Thanks\n      David Wolever.\n  * Added a convenience method called transaction() on the base Redis class.\n      This method eliminates much of the boilerplate used when using pipelines\n      to watch Redis keys. See the documentation for details on usage.\n* 2.4.6\n  * Variadic arguments for SADD, SREM, ZREN, HDEL, LPUSH, and RPUSH. Thanks\n      Raphaël Vinot.\n  * (CRITICAL) Fixed an error in the Hiredis parser that occasionally caused\n      the socket connection to become corrupted and unusable. This became\n      noticeable once connection pools started to be used.\n  * ZRANGE, ZREVRANGE, ZRANGEBYSCORE, and ZREVRANGEBYSCORE now take an\n      additional optional argument, score_cast_func, which is a callable used\n      to cast the score value in the return type. The default is float.\n  * Removed the PUBLISH method from the PubSub class. Connections that are\n      [P]SUBSCRIBEd cannot issue PUBLISH commands, so it doesn't make sense\n      to have it here.\n  * Pipelines now contain WATCH and UNWATCH. Calling WATCH or UNWATCH from\n      the base client class will result in a deprecation warning. After\n      WATCHing one or more keys, the pipeline will be placed in immediate\n      execution mode until UNWATCH or MULTI are called. Refer to the new\n      pipeline docs in the README for more information. Thanks to David Wolever\n      and Randall Leeds for greatly helping with this.\n* 2.4.5\n  * The PythonParser now works better when reading zero length strings.\n* 2.4.4\n  * Fixed a typo introduced in 2.4.3\n* 2.4.3\n  * Fixed a bug in the UnixDomainSocketConnection caused when trying to\n      form an error message after a socket error.\n* 2.4.2\n  * Fixed a bug in pipeline that caused an exception while trying to\n      reconnect after a connection timeout.\n* 2.4.1\n  * Fixed a bug in the PythonParser if disconnect is called before connect.\n* 2.4.0\n  * WARNING: 2.4 contains several backwards incompatible changes.\n  * Completely refactored Connection objects. Moved much of the Redis\n      protocol packing for requests here, and eliminated the nasty dependencies\n      it had on the client to do AUTH and SELECT commands on connect.\n  * Connection objects now have a parser attribute. Parsers are responsible\n      for reading data Redis sends. Two parsers ship with redis-py: a\n      PythonParser and the HiRedis parser. redis-py will automatically use the\n      HiRedis parser if you have the Python hiredis module installed, otherwise\n      it will fall back to the PythonParser. You can force or the other, or even\n      an external one by passing the `parser_class` argument to ConnectionPool.\n  * Added a UnixDomainSocketConnection for users wanting to talk to the Redis\n      instance running on a local machine only. You can use this connection\n      by passing it to the `connection_class` argument of the ConnectionPool.\n  * Connections no longer derive from threading.local. See threading.local\n      note below.\n  * ConnectionPool has been completely refactored. The ConnectionPool now\n      maintains a list of connections. The redis-py client only hangs on to\n      a ConnectionPool instance, calling get_connection() anytime it needs to\n      send a command. When get_connection() is called, the command name and\n      any keys involved in the command are passed as arguments. Subclasses of\n      ConnectionPool could use this information to identify the shard the keys\n      belong to and return a connection to it. ConnectionPool also implements\n      disconnect() to force all connections in the pool to disconnect from\n      the Redis server.\n  * redis-py no longer support the SELECT command. You can still connect to\n      a specific database by specifying it when instantiating a client instance\n      or by creating a connection pool. If you need to talk to multiple\n      databases within your application, you should use a separate client\n      instance for each database you want to talk to.\n  * Completely refactored Publish/Subscribe support. The subscribe and listen\n      commands are no longer available on the redis-py Client class. Instead,\n      the `pubsub` method returns an instance of the PubSub class which contains\n      all publish/subscribe support. Note, you can still PUBLISH from the\n      redis-py client class if you desire.\n  * Removed support for all previously deprecated commands or options.\n  * redis-py no longer uses threading.local in any way. Since the Client\n      class no longer holds on to a connection, it's no longer needed. You can\n      now pass client instances between threads, and commands run on those\n      threads will retrieve an available connection from the pool, use it and\n      release it. It should now be trivial to use redis-py with eventlet or\n      greenlet.\n  * ZADD now accepts pairs of value=score keyword arguments. This should help\n      resolve the long standing #72. The older value and score arguments have\n      been deprecated in favor of the keyword argument style.\n  * Client instances now get their own copy of RESPONSE_CALLBACKS. The new\n      set_response_callback method adds a user defined callback to the instance.\n  * Support Jython, fixing #97. Thanks to Adam Vandenberg for the patch.\n  * Using __getitem__ now properly raises a KeyError when the key is not\n      found. Thanks Ionuț Arțăriși for the patch.\n  * Newer Redis versions return a LOADING message for some commands while\n      the database is loading from disk during server start. This could cause\n      problems with SELECT. We now force a socket disconnection prior to\n      raising a ResponseError so subsequent connections have to reconnect and\n      re-select the appropriate database. Thanks to Benjamin Anderson for\n      finding this and fixing.\n* 2.2.4\n  * WARNING: Potential backwards incompatible change - Changed order of\n      parameters of ZREVRANGEBYSCORE to match those of the actual Redis command.\n      This is only backwards-incompatible if you were passing max and min via\n      keyword args. If passing by normal args, nothing in user code should have\n      to change. Thanks Stéphane Angel for the fix.\n  * Fixed INFO to properly parse the Redis data correctly for both 2.2.x and\n      2.3+. Thanks Stéphane Angel for the fix.\n  * Lock objects now store their timeout value as a float. This allows floats\n      to be used as timeout values. No changes to existing code required.\n  * WATCH now supports multiple keys. Thanks Rich Schumacher.\n  * Broke out some code that was Python 2.4 incompatible. redis-py should\n      now be usable on 2.4, but this hasn't actually been tested. Thanks\n      Dan Colish for the patch.\n  * Optimized some code using izip and islice. Should have a pretty good\n      speed up on larger data sets. Thanks Dan Colish.\n  * Better error handling when submitting an empty mapping to HMSET. Thanks\n      Dan Colish.\n  * Subscription status is now reset after every (re)connection.\n* 2.2.3\n  * Added support for Hiredis. To use, simply \"pip install hiredis\" or\n      \"easy_install hiredis\". Thanks for Pieter Noordhuis for the hiredis-py\n      bindings and the patch to redis-py.\n  * The connection class is chosen based on whether hiredis is installed\n      or not. To force the use of the PythonConnection, simply create\n      your own ConnectionPool instance with the connection_class argument\n      assigned to to PythonConnection class.\n  * Added missing command ZREVRANGEBYSCORE. Thanks Jay Baird for the patch.\n  * The INFO command should be parsed correctly on 2.2.x server versions\n      and is backwards compatible with older versions. Thanks Brett Hoerner.\n* 2.2.2\n  * Fixed a bug in ZREVRANK where retrieving the rank of a value not in\n      the zset would raise an error.\n  * Fixed a bug in Connection.send where the errno import was getting\n      overwritten by a local variable.\n  * Fixed a bug in SLAVEOF when promoting an existing slave to a master.\n  * Reverted change of download URL back to redis-VERSION.tar.gz. 2.2.1's\n      change of this actually broke Pypi for Pip installs. Sorry!\n* 2.2.1\n  * Changed archive name to redis-py-VERSION.tar.gz to not conflict\n      with the Redis server archive.\n* 2.2.0\n  * Implemented SLAVEOF\n  * Implemented CONFIG as config_get and config_set\n  * Implemented GETBIT/SETBIT\n  * Implemented BRPOPLPUSH\n  * Implemented STRLEN\n  * Implemented PERSIST\n  * Implemented SETRANGE\n  * Changed type annotation of the `num` parameter in `zrange` from `int` to `Optional[int]"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing\n\n## Introduction\n\nWe appreciate your interest in considering contributing to redis-py.\nCommunity contributions mean a lot to us.\n\n## Contributions we need\n\nYou may already know how you'd like to contribute, whether it's a fix for a bug you\nencountered, or a new feature your team wants to use.\n\nIf you don't know where to start, consider improving\ndocumentation, bug triaging, and writing tutorials are all examples of\nhelpful contributions that mean less work for you.\n\n## Your First Contribution\n\nUnsure where to begin contributing? You can start by looking through\n[help-wanted\nissues](https://github.com/andymccurdy/redis-py/issues?q=is%3Aopen+is%3Aissue+label%3ahelp-wanted).\n\nNever contributed to open source before? Here are a couple of friendly\ntutorials:\n\n-   <http://makeapullrequest.com/>\n-   <http://www.firsttimersonly.com/>\n\n## Getting Started\n\nHere's how to get started with your code contribution:\n\n1.  Create your own fork of redis-py\n2.  Do the changes in your fork\n3.  Create a virtualenv and install the development dependencies from the dev_requirements.txt file:\n    ```\n    python -m venv .venv\n    source .venv/bin/activate\n    pip install -r dev_requirements.txt\n    pip install -e .[jwt]\n    ```\n\n4.  If you need a development environment, run `invoke devenv`. Note: this relies on docker-compose to build environments, and assumes that you have a version supporting [docker profiles](https://docs.docker.com/compose/profiles/).\n5.  While developing, make sure the tests pass by running `invoke tests`\n6.  If you like the change and think the project could use it, send a\n    pull request\n\nTo see what else is part of the automation, run `invoke -l`\n\n## The Development Environment\n\nRunning `invoke devenv` installs the development dependencies specified\nin the dev_requirements.txt. It starts all of the dockers used by this\nproject, and leaves them running. These can be easily cleaned up with\n`invoke clean`. NOTE: it is assumed that the user running these tests,\ncan execute docker and its various commands.\n\n-   A master Redis node\n-   A Redis replica node\n-   Three sentinel Redis nodes\n-   A redis cluster\n-   An stunnel docker, fronting the master Redis node\n\nThe replica node, is a replica of the master node, using the\n[leader-follower replication](https://redis.io/topics/replication)\nfeature.\n\nThe sentinels monitor the master node in a [sentinel high-availability\nconfiguration](https://redis.io/topics/sentinel).\n\n## Testing\n\nCall `invoke tests` to run all tests, or `invoke all-tests` to run linters\ntests as well. With the 'tests' and 'all-tests' targets, all Redis and\nRedisCluster tests will be run.\n\nIt is possible to run only Redis client tests (with cluster mode disabled) by\nusing `invoke standalone-tests`; similarly, RedisCluster tests can be run by using\n`invoke cluster-tests`.\n\nEach run of tests starts and stops the various dockers required. Sometimes\nthings get stuck, an `invoke clean` can help.\n\n## Linting\n\nCall `invoke linters` to run linters without also running tests.  \nCall `invoke linters-fix` to run linters and automatically fix issues.\n\n## Documentation\n\nIf relevant, update the code documentation, via docstrings, or in `/docs`.\n\nYou can check how the documentation looks locally by running `invoke build-docs`\nand loading the generated HTML files in a browser.\n\nHistorically there is a mix of styles in the docstrings, but the preferred way\nof documenting code is by applying the\n[Google style](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html).\nType hints should be added according to PEP484, and should not be repeated in\nthe docstrings.\n\n### Docker Tips\n\nFollowing are a few tips that can help you work with the Docker-based\ndevelopment environment.\n\nTo get a bash shell inside of a container:\n\n`$ docker run -it <service> /bin/bash`\n\nContainers run a minimal Debian image that probably lacks tools you want\nto use. To install packages, first get a bash session (see previous tip)\nand then run:\n\n`$ apt update && apt install <package>`\n\nYou can see the logging output of a containers like this:\n\n`$ docker logs -f <service>`\n\n### Troubleshooting\n\nIf you get any errors when running `make dev` or `make test`, make sure\nthat you are using supported versions of Docker.\n\nPlease try at least versions of Docker.\n\n-   Docker 19.03.12\n\n## How to Report a Bug\n\n### Security Vulnerabilities\n\n**NOTE**: If you find a security vulnerability, do NOT open an issue.\nEmail [Redis Open Source (<oss@redis.com>)](mailto:oss@redis.com) instead.\n\nIn order to determine whether you are dealing with a security issue, ask\nyourself these two questions:\n\n-   Can I access something that's not mine, or something I shouldn't\n    have access to?\n-   Can I disable something for other people?\n\nIf the answer to either of those two questions are *yes*, then you're\nprobably dealing with a security issue. Note that even if you answer\n*no*  to both questions, you may still be dealing with a security\nissue, so if you're unsure, just email [us](mailto:oss@redis.com).\n\n### Everything Else\n\nWhen filing an issue, make sure to answer these five questions:\n\n1.  What version of redis-py are you using?\n2.  What version of redis are you using?\n3.  What did you do?\n4.  What did you expect to see?\n5.  What did you see instead?\n\n## Suggest a feature or enhancement\n\nIf you'd like to contribute a new feature, make sure you check our\nissue list to see if someone has already proposed it. Work may already\nbe underway on the feature you want or we may have rejected a\nfeature like it already.\n\nIf you don't see anything, open a new issue that describes the feature\nyou would like and how it should work.\n\n## Code review process\n\nThe core team regularly looks at pull requests. We will provide\nfeedback as soon as possible. After receiving our feedback, please respond\nwithin two weeks. After that time, we may close your PR if it isn't\nshowing any activity.\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2022-2023, Redis, inc.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# redis-py\n\nThe Python interface to the Redis key-value store.\n\n[![CI](https://github.com/redis/redis-py/workflows/CI/badge.svg?branch=master)](https://github.com/redis/redis-py/actions?query=workflow%3ACI+branch%3Amaster)\n[![docs](https://readthedocs.org/projects/redis/badge/?version=stable&style=flat)](https://redis.readthedocs.io/en/stable/)\n[![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/redis/redis-py/blob/master/LICENSE)\n[![pypi](https://badge.fury.io/py/redis.svg)](https://pypi.org/project/redis/)\n[![pre-release](https://img.shields.io/github/v/release/redis/redis-py?include_prereleases&label=latest-prerelease)](https://github.com/redis/redis-py/releases)\n[![codecov](https://codecov.io/gh/redis/redis-py/branch/master/graph/badge.svg?token=yenl5fzxxr)](https://codecov.io/gh/redis/redis-py)\n\n[Installation](#installation) |  [Usage](#usage) | [Advanced Topics](#advanced-topics) | [Contributing](https://github.com/redis/redis-py/blob/master/CONTRIBUTING.md)\n\n---------------------------------------------\n\n**Note:** redis-py 5.0 is the last version of redis-py that supports Python 3.7, as it has reached [end of life](https://devguide.python.org/versions/). redis-py 5.1 supports Python 3.8+.<br>\n**Note:** redis-py 6.1.0 is the last version of redis-py that supports Python 3.8, as it has reached [end of life](https://devguide.python.org/versions/). redis-py 6.2.0 supports Python 3.9+.\n\n---------------------------------------------\n\n## How do I Redis?\n\n[Learn for free at Redis University](https://redis.io/learn/university)\n\n[Try the Redis Cloud](https://redis.io/try-free/)\n\n[Dive in developer tutorials](https://redis.io/learn)\n\n[Join the Redis community](https://redis.io/community/)\n\n[Work at Redis](https://redis.io/careers/)\n\n## Installation\n\nStart a redis via docker (for Redis versions >= 8.0):\n\n``` bash\ndocker run -p 6379:6379 -it redis:latest\n```\n\nStart a redis via docker (for Redis versions < 8.0):\n\n``` bash\ndocker run -p 6379:6379 -it redis/redis-stack:latest\n```\nTo install redis-py, simply:\n\n``` bash\n$ pip install redis\n```\n\nFor faster performance, install redis with hiredis support, this provides a compiled response parser, and *for most cases* requires zero code changes.\nBy default, if hiredis >= 1.0 is available, redis-py will attempt to use it for response parsing.\n\n``` bash\n$ pip install \"redis[hiredis]\"\n```\n\nLooking for a high-level library to handle object mapping? See [redis-om-python](https://github.com/redis/redis-om-python)!\n\n## Supported Redis Versions\n\nThe most recent version of this library supports Redis version [7.2](https://github.com/redis/redis/blob/7.2/00-RELEASENOTES), [7.4](https://github.com/redis/redis/blob/7.4/00-RELEASENOTES), [8.0](https://github.com/redis/redis/blob/8.0/00-RELEASENOTES) and [8.2](https://github.com/redis/redis/blob/8.2/00-RELEASENOTES).\n\nThe table below highlights version compatibility of the most-recent library versions and redis versions.\n\n| Library version | Supported redis versions |\n|-----------------|-------------------|\n| 3.5.3 | <= 6.2 Family of releases |\n| >= 4.5.0 | Version 5.0 to 7.0 |\n| >= 5.0.0 | Version 5.0 to 7.4 |\n| >= 6.0.0 | Version 7.2 to current |\n\n\n## Usage\n\n### Basic Example\n\n``` python\n>>> import redis\n>>> r = redis.Redis(host='localhost', port=6379, db=0)\n>>> r.set('foo', 'bar')\nTrue\n>>> r.get('foo')\nb'bar'\n```\n\nThe above code connects to localhost on port 6379, sets a value in Redis, and retrieves it. All responses are returned as bytes in Python, to receive decoded strings, set *decode_responses=True*.  For this, and more connection options, see [these examples](https://redis.readthedocs.io/en/stable/examples.html).\n\n\n#### RESP3 Support\nTo enable support for RESP3, ensure you have at least version 5.0 of the client, and change your connection object to include *protocol=3*\n\n``` python\n>>> import redis\n>>> r = redis.Redis(host='localhost', port=6379, db=0, protocol=3)\n```\n\n### Connection Pools\n\nBy default, redis-py uses a connection pool to manage connections. Each instance of a Redis class receives its own connection pool. You can however define your own [redis.ConnectionPool](https://redis.readthedocs.io/en/stable/connections.html#connection-pools).\n\n``` python\n>>> pool = redis.ConnectionPool(host='localhost', port=6379, db=0)\n>>> r = redis.Redis(connection_pool=pool)\n```\n\nAlternatively, you might want to look at [Async connections](https://redis.readthedocs.io/en/stable/examples/asyncio_examples.html), or [Cluster connections](https://redis.readthedocs.io/en/stable/connections.html#cluster-client), or even [Async Cluster connections](https://redis.readthedocs.io/en/stable/connections.html#async-cluster-client).\n\n### Redis Commands\n\nThere is built-in support for all of the [out-of-the-box Redis commands](https://redis.io/commands). They are exposed using the raw Redis command names (`HSET`, `HGETALL`, etc.) except where a word (i.e. del) is reserved by the language. The complete set of commands can be found [here](https://github.com/redis/redis-py/tree/master/redis/commands), or [the documentation](https://redis.readthedocs.io/en/stable/commands.html).\n\n## Advanced Topics\n\nThe [official Redis command documentation](https://redis.io/commands)\ndoes a great job of explaining each command in detail. redis-py attempts\nto adhere to the official command syntax. There are a few exceptions:\n\n-   **MULTI/EXEC**: These are implemented as part of the Pipeline class.\n    The pipeline is wrapped with the MULTI and EXEC statements by\n    default when it is executed, which can be disabled by specifying\n    transaction=False. See more about Pipelines below.\n\n-   **SUBSCRIBE/LISTEN**: Similar to pipelines, PubSub is implemented as\n    a separate class as it places the underlying connection in a state\n    where it can\\'t execute non-pubsub commands. Calling the pubsub\n    method from the Redis client will return a PubSub instance where you\n    can subscribe to channels and listen for messages. You can only call\n    PUBLISH from the Redis client (see [this comment on issue\n    #151](https://github.com/redis/redis-py/issues/151#issuecomment-1545015)\n    for details).\n\nFor more details, please see the documentation on [advanced topics page](https://redis.readthedocs.io/en/stable/advanced_features.html).\n\n### Pipelines\n\nThe following is a basic example of a [Redis pipeline](https://redis.io/docs/manual/pipelining/), a method to optimize round-trip calls, by batching Redis commands, and receiving their results as a list.\n\n\n``` python\n>>> pipe = r.pipeline()\n>>> pipe.set('foo', 5)\n>>> pipe.set('bar', 18.5)\n>>> pipe.set('blee', \"hello world!\")\n>>> pipe.execute()\n[True, True, True]\n```\n\n### PubSub\n\nThe following example shows how to utilize [Redis Pub/Sub](https://redis.io/docs/manual/pubsub/) to subscribe to specific channels.\n\n``` python\n>>> r = redis.Redis(...)\n>>> p = r.pubsub()\n>>> p.subscribe('my-first-channel', 'my-second-channel', ...)\n>>> p.get_message()\n{'pattern': None, 'type': 'subscribe', 'channel': b'my-second-channel', 'data': 1}\n```\n\n### Redis’ search and query capabilities default dialect\n\nRelease 6.0.0 introduces a client-side default dialect for Redis’ search and query capabilities.\nBy default, the client now overrides the server-side dialect with version 2, automatically appending *DIALECT 2* to commands like *FT.AGGREGATE* and *FT.SEARCH*.\n\n**Important**: Be aware that the query dialect may impact the results returned. If needed, you can revert to a different dialect version by configuring the client accordingly.\n\n``` python\n>>> from redis.commands.search.field import TextField\n>>> from redis.commands.search.query import Query\n>>> from redis.commands.search.index_definition import IndexDefinition\n>>> import redis\n\n>>> r = redis.Redis(host='localhost', port=6379, db=0)\n>>> r.ft().create_index(\n>>>     (TextField(\"name\"), TextField(\"lastname\")),\n>>>     definition=IndexDefinition(prefix=[\"test:\"]),\n>>> )\n\n>>> r.hset(\"test:1\", \"name\", \"James\")\n>>> r.hset(\"test:1\", \"lastname\", \"Brown\")\n\n>>> # Query with default DIALECT 2\n>>> query = \"@name: James Brown\"\n>>> q = Query(query)\n>>> res = r.ft().search(q)\n\n>>> # Query with explicit DIALECT 1\n>>> query = \"@name: James Brown\"\n>>> q = Query(query).dialect(1)\n>>> res = r.ft().search(q)\n```\n\nYou can find further details in the [query dialect documentation](https://redis.io/docs/latest/develop/interact/search-and-query/advanced-concepts/dialects/).\n\n### Multi-database client (Active-Active)\n\nThe multi-database client allows your application to connect to multiple Redis databases, which are typically replicas of each other. It is designed to work with Redis Software and Redis Cloud Active-Active setups. The client continuously monitors database health, detects failures, and automatically fails over to the next healthy database using a configurable strategy. When the original database becomes healthy again, the client can automatically switch back to it.<br>\nThis is useful when:\n\n1. You have more than one Redis deployment. This might include two independent Redis servers or two or more Redis databases replicated across multiple [active-active Redis Enterprise](https://redis.io/docs/latest/operate/rs/databases/active-active/) clusters.\n2. You want your application to connect to one deployment at a time and to fail over to the next available deployment if the first deployment becomes unavailable.\n\nFor the complete failover configuration options and examples, see the [Multi-database client docs](https://redis.readthedocs.io/en/latest/multi_database.html).\n\n---------------------------------------------\n\n### Author\n\nredis-py is developed and maintained by [Redis Inc](https://redis.io). It can be found [here](\nhttps://github.com/redis/redis-py), or downloaded from [pypi](https://pypi.org/project/redis/).\n\nSpecial thanks to:\n\n-   Andy McCurdy (<sedrik@gmail.com>) the original author of redis-py.\n-   Ludovico Magnocavallo, author of the original Python Redis client,\n    from which some of the socket code is still used.\n-   Alexander Solovyov for ideas on the generic response callback\n    system.\n-   Paul Hubbard for initial packaging support.\n\n[![Redis](https://github.com/redis/redis-py/blob/master/docs/_static/logo-redis.svg)](https://redis.io)\n"
  },
  {
    "path": "benchmarks/__init__.py",
    "content": ""
  },
  {
    "path": "benchmarks/base.py",
    "content": "import functools\nimport itertools\nimport sys\nimport timeit\n\nimport redis\n\n\nclass Benchmark:\n    ARGUMENTS = ()\n\n    def __init__(self):\n        self._client = None\n\n    def get_client(self, **kwargs):\n        # eventually make this more robust and take optional args from\n        # argparse\n        if self._client is None or kwargs:\n            defaults = {\"db\": 9}\n            defaults.update(kwargs)\n            pool = redis.ConnectionPool(**kwargs)\n            self._client = redis.Redis(connection_pool=pool)\n        return self._client\n\n    def setup(self, **kwargs):\n        pass\n\n    def run(self, **kwargs):\n        pass\n\n    def run_benchmark(self):\n        group_names = [group[\"name\"] for group in self.ARGUMENTS]\n        group_values = [group[\"values\"] for group in self.ARGUMENTS]\n        for value_set in itertools.product(*group_values):\n            pairs = list(zip(group_names, value_set))\n            arg_string = \", \".join(f\"{p[0]}={p[1]}\" for p in pairs)\n            sys.stdout.write(f\"Benchmark: {arg_string}... \")\n            sys.stdout.flush()\n            kwargs = dict(pairs)\n            setup = functools.partial(self.setup, **kwargs)\n            run = functools.partial(self.run, **kwargs)\n            t = timeit.timeit(stmt=run, setup=setup, number=1000)\n            sys.stdout.write(f\"{t:f}\\n\")\n            sys.stdout.flush()\n"
  },
  {
    "path": "benchmarks/basic_operations.py",
    "content": "import time\nfrom argparse import ArgumentParser\nfrom functools import wraps\n\nimport redis\n\n\ndef parse_args():\n    parser = ArgumentParser()\n    parser.add_argument(\n        \"-n\", type=int, help=\"Total number of requests (default 100000)\", default=100000\n    )\n    parser.add_argument(\n        \"-P\",\n        type=int,\n        help=(\"Pipeline <numreq> requests. Default 1 (no pipeline).\"),\n        default=1,\n    )\n    parser.add_argument(\n        \"-s\",\n        type=int,\n        help=\"Data size of SET/GET value in bytes (default 2)\",\n        default=2,\n    )\n\n    args = parser.parse_args()\n    return args\n\n\ndef run():\n    args = parse_args()\n    r = redis.Redis()\n    r.flushall()\n    set_str(conn=r, num=args.n, pipeline_size=args.P, data_size=args.s)\n    set_int(conn=r, num=args.n, pipeline_size=args.P, data_size=args.s)\n    get_str(conn=r, num=args.n, pipeline_size=args.P, data_size=args.s)\n    get_int(conn=r, num=args.n, pipeline_size=args.P, data_size=args.s)\n    incr(conn=r, num=args.n, pipeline_size=args.P, data_size=args.s)\n    lpush(conn=r, num=args.n, pipeline_size=args.P, data_size=args.s)\n    lrange_300(conn=r, num=args.n, pipeline_size=args.P, data_size=args.s)\n    lpop(conn=r, num=args.n, pipeline_size=args.P, data_size=args.s)\n    hmset(conn=r, num=args.n, pipeline_size=args.P, data_size=args.s)\n\n\ndef timer(func):\n    @wraps(func)\n    def wrapper(*args, **kwargs):\n        start = time.monotonic()\n        ret = func(*args, **kwargs)\n        duration = time.monotonic() - start\n        if \"num\" in kwargs:\n            count = kwargs[\"num\"]\n        else:\n            count = args[1]\n        print(f\"{func.__name__} - {count} Requests\")\n        print(f\"Duration  = {duration}\")\n        print(f\"Rate = {count / duration}\")\n        print()\n        return ret\n\n    return wrapper\n\n\n@timer\ndef set_str(conn, num, pipeline_size, data_size):\n    if pipeline_size > 1:\n        conn = conn.pipeline()\n\n    set_data = \"a\".ljust(data_size, \"0\")\n    for i in range(num):\n        conn.set(f\"set_str:{i}\", set_data)\n        if pipeline_size > 1 and i % pipeline_size == 0:\n            conn.execute()\n\n    if pipeline_size > 1:\n        conn.execute()\n\n\n@timer\ndef set_int(conn, num, pipeline_size, data_size):\n    if pipeline_size > 1:\n        conn = conn.pipeline()\n\n    set_data = 10 ** (data_size - 1)\n    for i in range(num):\n        conn.set(f\"set_int:{i}\", set_data)\n        if pipeline_size > 1 and i % pipeline_size == 0:\n            conn.execute()\n\n    if pipeline_size > 1:\n        conn.execute()\n\n\n@timer\ndef get_str(conn, num, pipeline_size, data_size):\n    if pipeline_size > 1:\n        conn = conn.pipeline()\n\n    for i in range(num):\n        conn.get(f\"set_str:{i}\")\n        if pipeline_size > 1 and i % pipeline_size == 0:\n            conn.execute()\n\n    if pipeline_size > 1:\n        conn.execute()\n\n\n@timer\ndef get_int(conn, num, pipeline_size, data_size):\n    if pipeline_size > 1:\n        conn = conn.pipeline()\n\n    for i in range(num):\n        conn.get(f\"set_int:{i}\")\n        if pipeline_size > 1 and i % pipeline_size == 0:\n            conn.execute()\n\n    if pipeline_size > 1:\n        conn.execute()\n\n\n@timer\ndef incr(conn, num, pipeline_size, *args, **kwargs):\n    if pipeline_size > 1:\n        conn = conn.pipeline()\n\n    for i in range(num):\n        conn.incr(\"incr_key\")\n        if pipeline_size > 1 and i % pipeline_size == 0:\n            conn.execute()\n\n    if pipeline_size > 1:\n        conn.execute()\n\n\n@timer\ndef lpush(conn, num, pipeline_size, data_size):\n    if pipeline_size > 1:\n        conn = conn.pipeline()\n\n    set_data = 10 ** (data_size - 1)\n    for i in range(num):\n        conn.lpush(\"lpush_key\", set_data)\n        if pipeline_size > 1 and i % pipeline_size == 0:\n            conn.execute()\n\n    if pipeline_size > 1:\n        conn.execute()\n\n\n@timer\ndef lrange_300(conn, num, pipeline_size, data_size):\n    if pipeline_size > 1:\n        conn = conn.pipeline()\n\n    for i in range(num):\n        conn.lrange(\"lpush_key\", i, i + 300)\n        if pipeline_size > 1 and i % pipeline_size == 0:\n            conn.execute()\n\n    if pipeline_size > 1:\n        conn.execute()\n\n\n@timer\ndef lpop(conn, num, pipeline_size, data_size):\n    if pipeline_size > 1:\n        conn = conn.pipeline()\n    for i in range(num):\n        conn.lpop(\"lpush_key\")\n        if pipeline_size > 1 and i % pipeline_size == 0:\n            conn.execute()\n    if pipeline_size > 1:\n        conn.execute()\n\n\n@timer\ndef hmset(conn, num, pipeline_size, data_size):\n    if pipeline_size > 1:\n        conn = conn.pipeline()\n\n    set_data = {\"str_value\": \"string\", \"int_value\": 123456, \"float_value\": 123456.0}\n    for i in range(num):\n        conn.hmset(\"hmset_key\", set_data)\n        if pipeline_size > 1 and i % pipeline_size == 0:\n            conn.execute()\n\n    if pipeline_size > 1:\n        conn.execute()\n\n\nif __name__ == \"__main__\":\n    run()\n"
  },
  {
    "path": "benchmarks/cluster_async.py",
    "content": "import asyncio\nimport functools\nimport time\n\nimport aioredis_cluster\nimport aredis\nimport uvloop\n\nimport redis.asyncio as redispy\n\n\ndef timer(func):\n    @functools.wraps(func)\n    async def wrapper(*args, **kwargs):\n        tic = time.perf_counter()\n        await func(*args, **kwargs)\n        toc = time.perf_counter()\n        return f\"{toc - tic:.4f}\"\n\n    return wrapper\n\n\n@timer\nasync def set_str(client, gather, data):\n    if gather:\n        for _ in range(count // 100):\n            await asyncio.gather(\n                *(\n                    asyncio.create_task(client.set(f\"bench:str_{i}\", data))\n                    for i in range(100)\n                )\n            )\n    else:\n        for i in range(count):\n            await client.set(f\"bench:str_{i}\", data)\n\n\n@timer\nasync def set_int(client, gather, data):\n    if gather:\n        for _ in range(count // 100):\n            await asyncio.gather(\n                *(\n                    asyncio.create_task(client.set(f\"bench:int_{i}\", data))\n                    for i in range(100)\n                )\n            )\n    else:\n        for i in range(count):\n            await client.set(f\"bench:int_{i}\", data)\n\n\n@timer\nasync def get_str(client, gather):\n    if gather:\n        for _ in range(count // 100):\n            await asyncio.gather(\n                *(asyncio.create_task(client.get(f\"bench:str_{i}\")) for i in range(100))\n            )\n    else:\n        for i in range(count):\n            await client.get(f\"bench:str_{i}\")\n\n\n@timer\nasync def get_int(client, gather):\n    if gather:\n        for _ in range(count // 100):\n            await asyncio.gather(\n                *(asyncio.create_task(client.get(f\"bench:int_{i}\")) for i in range(100))\n            )\n    else:\n        for i in range(count):\n            await client.get(f\"bench:int_{i}\")\n\n\n@timer\nasync def hset(client, gather, data):\n    if gather:\n        for _ in range(count // 100):\n            await asyncio.gather(\n                *(\n                    asyncio.create_task(client.hset(\"bench:hset\", str(i), data))\n                    for i in range(100)\n                )\n            )\n    else:\n        for i in range(count):\n            await client.hset(\"bench:hset\", str(i), data)\n\n\n@timer\nasync def hget(client, gather):\n    if gather:\n        for _ in range(count // 100):\n            await asyncio.gather(\n                *(\n                    asyncio.create_task(client.hget(\"bench:hset\", str(i)))\n                    for i in range(100)\n                )\n            )\n    else:\n        for i in range(count):\n            await client.hget(\"bench:hset\", str(i))\n\n\n@timer\nasync def incr(client, gather):\n    if gather:\n        for _ in range(count // 100):\n            await asyncio.gather(\n                *(asyncio.create_task(client.incr(\"bench:incr\")) for i in range(100))\n            )\n    else:\n        for i in range(count):\n            await client.incr(\"bench:incr\")\n\n\n@timer\nasync def lpush(client, gather, data):\n    if gather:\n        for _ in range(count // 100):\n            await asyncio.gather(\n                *(\n                    asyncio.create_task(client.lpush(\"bench:lpush\", data))\n                    for i in range(100)\n                )\n            )\n    else:\n        for i in range(count):\n            await client.lpush(\"bench:lpush\", data)\n\n\n@timer\nasync def lrange_300(client, gather):\n    if gather:\n        for _ in range(count // 100):\n            await asyncio.gather(\n                *(\n                    asyncio.create_task(client.lrange(\"bench:lpush\", i, i + 300))\n                    for i in range(100)\n                )\n            )\n    else:\n        for i in range(count):\n            await client.lrange(\"bench:lpush\", i, i + 300)\n\n\n@timer\nasync def lpop(client, gather):\n    if gather:\n        for _ in range(count // 100):\n            await asyncio.gather(\n                *(asyncio.create_task(client.lpop(\"bench:lpush\")) for i in range(100))\n            )\n    else:\n        for i in range(count):\n            await client.lpop(\"bench:lpush\")\n\n\n@timer\nasync def warmup(client):\n    await asyncio.gather(\n        *(asyncio.create_task(client.exists(f\"bench:warmup_{i}\")) for i in range(100))\n    )\n\n\n@timer\nasync def run(client, gather):\n    data_str = \"a\" * size\n    data_int = int(\"1\" * size)\n\n    if gather is False:\n        for ret in await asyncio.gather(\n            asyncio.create_task(set_str(client, gather, data_str)),\n            asyncio.create_task(set_int(client, gather, data_int)),\n            asyncio.create_task(hset(client, gather, data_str)),\n            asyncio.create_task(incr(client, gather)),\n            asyncio.create_task(lpush(client, gather, data_int)),\n        ):\n            print(ret)\n        for ret in await asyncio.gather(\n            asyncio.create_task(get_str(client, gather)),\n            asyncio.create_task(get_int(client, gather)),\n            asyncio.create_task(hget(client, gather)),\n            asyncio.create_task(lrange_300(client, gather)),\n            asyncio.create_task(lpop(client, gather)),\n        ):\n            print(ret)\n    else:\n        print(await set_str(client, gather, data_str))\n        print(await set_int(client, gather, data_int))\n        print(await hset(client, gather, data_str))\n        print(await incr(client, gather))\n        print(await lpush(client, gather, data_int))\n\n        print(await get_str(client, gather))\n        print(await get_int(client, gather))\n        print(await hget(client, gather))\n        print(await lrange_300(client, gather))\n        print(await lpop(client, gather))\n\n\nasync def main(loop, gather=None):\n    arc = aredis.StrictRedisCluster(\n        host=host,\n        port=port,\n        password=password,\n        max_connections=2**31,\n        max_connections_per_node=2**31,\n        readonly=False,\n        reinitialize_steps=count,\n        skip_full_coverage_check=True,\n        decode_responses=False,\n        max_idle_time=count,\n        idle_check_interval=count,\n    )\n    print(f\"{loop} {gather} {await warmup(arc)} aredis\")\n    print(await run(arc, gather=gather))\n    arc.connection_pool.disconnect()\n\n    aiorc = await aioredis_cluster.create_redis_cluster(\n        [(host, port)],\n        password=password,\n        state_reload_interval=count,\n        idle_connection_timeout=count,\n        pool_maxsize=2**31,\n    )\n    print(f\"{loop} {gather} {await warmup(aiorc)} aioredis-cluster\")\n    print(await run(aiorc, gather=gather))\n    aiorc.close()\n    await aiorc.wait_closed()\n\n    async with redispy.RedisCluster(\n        host=host,\n        port=port,\n        password=password,\n        reinitialize_steps=count,\n        read_from_replicas=False,\n        decode_responses=False,\n        max_connections=2**31,\n    ) as rca:\n        print(f\"{loop} {gather} {await warmup(rca)} redispy\")\n        print(await run(rca, gather=gather))\n\n\nif __name__ == \"__main__\":\n    host = \"localhost\"\n    port = 16379\n    password = None\n\n    count = 10000\n    size = 256\n\n    asyncio.run(main(\"asyncio\"))\n    asyncio.run(main(\"asyncio\", gather=False))\n    asyncio.run(main(\"asyncio\", gather=True))\n\n    uvloop.install()\n\n    asyncio.run(main(\"uvloop\"))\n    asyncio.run(main(\"uvloop\", gather=False))\n    asyncio.run(main(\"uvloop\", gather=True))\n"
  },
  {
    "path": "benchmarks/cluster_async_pipeline.py",
    "content": "import asyncio\nimport functools\nimport time\n\nimport aioredis_cluster\nimport aredis\nimport uvloop\n\nimport redis.asyncio as redispy\n\n\ndef timer(func):\n    @functools.wraps(func)\n    async def wrapper(*args, **kwargs):\n        tic = time.perf_counter()\n        await func(*args, **kwargs)\n        toc = time.perf_counter()\n        return f\"{toc - tic:.4f}\"\n\n    return wrapper\n\n\n@timer\nasync def warmup(client):\n    await asyncio.gather(\n        *(asyncio.create_task(client.exists(f\"bench:warmup_{i}\")) for i in range(100))\n    )\n\n\n@timer\nasync def run(client):\n    data_str = \"a\" * size\n    data_int = int(\"1\" * size)\n\n    for i in range(count):\n        with client.pipeline() as pipe:\n            await (\n                pipe.set(f\"bench:str_{i}\", data_str)\n                .set(f\"bench:int_{i}\", data_int)\n                .get(f\"bench:str_{i}\")\n                .get(f\"bench:int_{i}\")\n                .hset(\"bench:hset\", str(i), data_str)\n                .hget(\"bench:hset\", str(i))\n                .incr(\"bench:incr\")\n                .lpush(\"bench:lpush\", data_int)\n                .lrange(\"bench:lpush\", 0, 300)\n                .lpop(\"bench:lpush\")\n                .execute()\n            )\n\n\nasync def main(loop):\n    arc = aredis.StrictRedisCluster(\n        host=host,\n        port=port,\n        password=password,\n        max_connections=2**31,\n        max_connections_per_node=2**31,\n        readonly=False,\n        reinitialize_steps=count,\n        skip_full_coverage_check=True,\n        decode_responses=False,\n        max_idle_time=count,\n        idle_check_interval=count,\n    )\n    print(f\"{loop} {await warmup(arc)} aredis\")\n    print(await run(arc))\n    arc.connection_pool.disconnect()\n\n    aiorc = await aioredis_cluster.create_redis_cluster(\n        [(host, port)],\n        password=password,\n        state_reload_interval=count,\n        idle_connection_timeout=count,\n        pool_maxsize=2**31,\n    )\n    print(f\"{loop} {await warmup(aiorc)} aioredis-cluster\")\n    print(await run(aiorc))\n    aiorc.close()\n    await aiorc.wait_closed()\n\n    async with redispy.RedisCluster(\n        host=host,\n        port=port,\n        password=password,\n        reinitialize_steps=count,\n        read_from_replicas=False,\n        decode_responses=False,\n        max_connections=2**31,\n    ) as rca:\n        print(f\"{loop} {await warmup(rca)} redispy\")\n        print(await run(rca))\n\n\nif __name__ == \"__main__\":\n    host = \"localhost\"\n    port = 16379\n    password = None\n\n    count = 10000\n    size = 256\n\n    asyncio.run(main(\"asyncio\"))\n\n    uvloop.install()\n\n    asyncio.run(main(\"uvloop\"))\n"
  },
  {
    "path": "benchmarks/command_packer_benchmark.py",
    "content": "from base import Benchmark\n\nfrom redis.connection import SYM_CRLF, SYM_DOLLAR, SYM_EMPTY, SYM_STAR, Connection\n\n\nclass StringJoiningConnection(Connection):\n    def send_packed_command(self, command, check_health=True):\n        \"Send an already packed command to the Redis server\"\n        if not self._sock:\n            self.connect()\n        try:\n            self._sock.sendall(command)\n        except OSError as e:\n            self.disconnect()\n            if len(e.args) == 1:\n                _errno, errmsg = \"UNKNOWN\", e.args[0]\n            else:\n                _errno, errmsg = e.args\n            raise ConnectionError(f\"Error {_errno} while writing to socket. {errmsg}.\")\n        except Exception:\n            self.disconnect()\n            raise\n\n    def pack_command(self, *args):\n        \"Pack a series of arguments into a value Redis command\"\n        args_output = SYM_EMPTY.join(\n            [\n                SYM_EMPTY.join(\n                    (SYM_DOLLAR, str(len(k)).encode(), SYM_CRLF, k, SYM_CRLF)\n                )\n                for k in map(self.encoder.encode, args)\n            ]\n        )\n        output = SYM_EMPTY.join(\n            (SYM_STAR, str(len(args)).encode(), SYM_CRLF, args_output)\n        )\n        return output\n\n\nclass ListJoiningConnection(Connection):\n    def send_packed_command(self, command, check_health=True):\n        if not self._sock:\n            self.connect()\n        try:\n            if isinstance(command, str):\n                command = [command]\n            for item in command:\n                self._sock.sendall(item)\n        except OSError as e:\n            self.disconnect()\n            if len(e.args) == 1:\n                _errno, errmsg = \"UNKNOWN\", e.args[0]\n            else:\n                _errno, errmsg = e.args\n            raise ConnectionError(f\"Error {_errno} while writing to socket. {errmsg}.\")\n        except Exception:\n            self.disconnect()\n            raise\n\n    def pack_command(self, *args):\n        output = []\n        buff = SYM_EMPTY.join((SYM_STAR, str(len(args)).encode(), SYM_CRLF))\n\n        for k in map(self.encoder.encode, args):\n            if len(buff) > 6000 or len(k) > 6000:\n                buff = SYM_EMPTY.join(\n                    (buff, SYM_DOLLAR, str(len(k)).encode(), SYM_CRLF)\n                )\n                output.append(buff)\n                output.append(k)\n                buff = SYM_CRLF\n            else:\n                buff = SYM_EMPTY.join(\n                    (buff, SYM_DOLLAR, str(len(k)).encode(), SYM_CRLF, k, SYM_CRLF)\n                )\n        output.append(buff)\n        return output\n\n\nclass CommandPackerBenchmark(Benchmark):\n    ARGUMENTS = (\n        {\n            \"name\": \"connection_class\",\n            \"values\": [StringJoiningConnection, ListJoiningConnection],\n        },\n        {\n            \"name\": \"value_size\",\n            \"values\": [10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000],\n        },\n    )\n\n    def setup(self, connection_class, value_size):\n        self.get_client(connection_class=connection_class)\n\n    def run(self, connection_class, value_size):\n        r = self.get_client()\n        x = \"a\" * value_size\n        r.set(\"benchmark\", x)\n\n\nif __name__ == \"__main__\":\n    CommandPackerBenchmark().run_benchmark()\n"
  },
  {
    "path": "benchmarks/otel_benchmark.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nOpenTelemetry Benchmark for redis-py.\n\nThis module provides benchmarking infrastructure to measure the performance impact\nof OpenTelemetry instrumentation on redis-py operations.\n\nThe benchmark uses a comprehensive load generator that exercises all Redis operations\n(commands, pubsub, streaming, CSC, connections) in each iteration. This ensures\nconsistent test conditions when comparing different OTel configurations.\n\nRun one scenario at a time (sync client - default):\n    python -m benchmarks.otel_benchmark --scenario baseline --baseline-tag v5.2.1\n    python -m benchmarks.otel_benchmark --scenario otel_disabled\n    python -m benchmarks.otel_benchmark --scenario otel_noop\n    python -m benchmarks.otel_benchmark --scenario otel_inmemory\n    python -m benchmarks.otel_benchmark --scenario otel_enabled_http\n    python -m benchmarks.otel_benchmark --scenario otel_enabled_grpc\n\nRun with async client:\n    python -m benchmarks.otel_benchmark --scenario baseline --baseline-tag v5.2.1 --async\n    python -m benchmarks.otel_benchmark --scenario otel_disabled --async\n    python -m benchmarks.otel_benchmark --scenario otel_enabled_http --async\n\nSpecify which OTel metric groups to enable:\n    python -m benchmarks.otel_benchmark --scenario otel_enabled_http --metric-groups command,pubsub\n    python -m benchmarks.otel_benchmark --scenario otel_enabled_http --metric-groups all\n\"\"\"\n\nimport argparse\nimport asyncio\nimport json\nimport os\nimport statistics\nimport subprocess\nimport sys\nimport time\nfrom dataclasses import dataclass, field, asdict\nfrom pathlib import Path\nfrom tempfile import TemporaryDirectory\nfrom typing import Dict, List, Optional, Any\n\ntry:\n    import psutil\n    PSUTIL_AVAILABLE = True\nexcept ImportError:\n    PSUTIL_AVAILABLE = False\n\n\n@dataclass\nclass BenchmarkResult:\n    \"\"\"Results from a single benchmark run.\"\"\"\n    scenario: str\n    duration_seconds: float\n    total_operations: int\n    operations_per_second: float\n    avg_latency_ms: float\n    p50_latency_ms: float\n    p95_latency_ms: float\n    p99_latency_ms: float\n    min_latency_ms: float\n    max_latency_ms: float\n    errors: int = 0\n    first_error: Optional[str] = None\n    # Resource usage metrics\n    cpu_percent_avg: Optional[float] = None\n    cpu_percent_max: Optional[float] = None\n    memory_mb_avg: Optional[float] = None\n    memory_mb_max: Optional[float] = None\n    metadata: Dict = field(default_factory=dict)\n\n\n@dataclass\nclass LoadGeneratorConfig:\n    \"\"\"Configuration for the load generator.\"\"\"\n    duration_seconds: float = 30.0\n    value_size_bytes: int = 100\n    key_prefix: str = \"otel_bench\"\n    warmup_seconds: float = 5.0\n    redis_host: str = \"localhost\"\n    redis_port: int = 6379\n\n\nclass ComprehensiveLoadGenerator:\n    \"\"\"\n    Comprehensive load generator that exercises all Redis operations.\n\n    Each iteration performs:\n    - Command operations (SET/GET) - triggers COMMAND metrics\n    - PubSub operations (PUBLISH) - triggers PUBSUB metrics\n    - Streaming operations (XADD/XREAD) - triggers STREAMING metrics\n    - Connection pool operations - triggers CONNECTION metrics\n\n    CSC (Client-Side Caching) requires RESP3 protocol and is tested separately\n    when available.\n\n    This ensures consistent test conditions across all OTel configurations.\n    \"\"\"\n\n    def __init__(self, config: LoadGeneratorConfig, redis_module: Any = None):\n        \"\"\"\n        Initialize the comprehensive load generator.\n\n        Args:\n            config: Load generator configuration\n            redis_module: Optional redis module to use (for baseline testing with\n                         a different redis-py version). If None, imports redis normally.\n        \"\"\"\n        self.config = config\n        self.redis_module = redis_module\n        self.latencies: List[float] = []\n        self.errors: int = 0\n        self.first_error: Optional[str] = None\n        self._value = \"x\" * config.value_size_bytes\n        self._key_counter = 0\n        self._message_counter = 0\n\n        # Resources (initialized in setup)\n        self.client = None\n        self.pubsub_publisher = None\n        self.pubsub = None\n        self.stream_name = f\"{config.key_prefix}:stream\"\n        self.pubsub_channel = f\"{config.key_prefix}:channel\"\n        self.consumer_group = \"benchmark_group\"\n        self.consumer_name = \"benchmark_consumer\"\n\n        # CSC client (optional, requires RESP3)\n        self.csc_client = None\n\n        # Resource tracking\n        self.cpu_samples: List[float] = []\n        self.memory_samples: List[float] = []\n        self._process = psutil.Process() if PSUTIL_AVAILABLE else None\n\n    def _get_redis_module(self) -> Any:\n        \"\"\"Get the redis module to use.\"\"\"\n        if self.redis_module is not None:\n            return self.redis_module\n        import redis\n        return redis\n\n    def _get_key(self) -> str:\n        \"\"\"Generate a key for the current operation.\"\"\"\n        key = f\"{self.config.key_prefix}:{self._key_counter % 1000}\"\n        self._key_counter += 1\n        return key\n\n    def setup(self) -> None:\n        \"\"\"Set up all Redis resources.\"\"\"\n        redis = self._get_redis_module()\n\n        # Main client for commands\n        self.client = redis.Redis(\n            host=self.config.redis_host,\n            port=self.config.redis_port,\n            decode_responses=True\n        )\n\n        # PubSub publisher (separate connection)\n        self.pubsub_publisher = redis.Redis(\n            host=self.config.redis_host,\n            port=self.config.redis_port,\n            decode_responses=True\n        )\n\n        # PubSub subscriber\n        subscriber = redis.Redis(\n            host=self.config.redis_host,\n            port=self.config.redis_port,\n            decode_responses=True\n        )\n        self.pubsub = subscriber.pubsub()\n        self.pubsub.subscribe(self.pubsub_channel)\n        # Consume subscription confirmation\n        self.pubsub.get_message(timeout=1.0)\n\n        # Create stream and consumer group\n        try:\n            self.client.xgroup_create(\n                self.stream_name,\n                self.consumer_group,\n                id=\"0\",\n                mkstream=True\n            )\n        except Exception:\n            # Group may already exist\n            pass\n\n        # Try to set up CSC client (requires RESP3, may not be available)\n        try:\n            from redis.cache import CacheConfig\n            self.csc_client = redis.Redis(\n                host=self.config.redis_host,\n                port=self.config.redis_port,\n                decode_responses=True,\n                protocol=3,\n                cache_config=CacheConfig(max_size=1000)\n            )\n            # Test that CSC works\n            self.csc_client.ping()\n        except Exception:\n            # CSC not available (old redis-py version or server doesn't support RESP3)\n            self.csc_client = None\n\n    def teardown(self) -> None:\n        \"\"\"Clean up all Redis resources.\"\"\"\n        if self.pubsub:\n            try:\n                self.pubsub.unsubscribe()\n                self.pubsub.close()\n            except Exception:\n                pass\n\n        if self.pubsub_publisher:\n            try:\n                self.pubsub_publisher.close()\n            except Exception:\n                pass\n\n        if self.client:\n            try:\n                self.client.delete(self.stream_name)\n            except Exception:\n                pass\n            try:\n                self.client.close()\n            except Exception:\n                pass\n\n        if self.csc_client:\n            try:\n                self.csc_client.close()\n            except Exception:\n                pass\n\n    def _run_operation(self) -> float:\n        \"\"\"\n        Run a comprehensive operation cycle and return latency in ms.\n\n        Each cycle includes:\n        1. SET + GET (command metrics)\n        2. PUBLISH + get_message (pubsub metrics)\n        3. XADD + XREADGROUP + XACK (streaming metrics)\n        4. CSC GET operations if available (csc metrics)\n        \"\"\"\n        key = self._get_key()\n        start = time.perf_counter()\n\n        try:\n            # 1. Command operations (SET/GET)\n            self.client.set(key, self._value)\n            self.client.get(key)\n\n            # 2. PubSub operations\n            self._message_counter += 1\n            self.pubsub_publisher.publish(self.pubsub_channel, f\"msg:{self._message_counter}\")\n            self.pubsub.get_message(timeout=0.001)  # Non-blocking receive\n\n            # 3. Streaming operations\n            entry_id = self.client.xadd(\n                self.stream_name,\n                {\"data\": self._value[:50]},  # Smaller payload for streams\n                maxlen=1000\n            )\n            messages = self.client.xreadgroup(\n                self.consumer_group,\n                self.consumer_name,\n                {self.stream_name: \">\"},\n                count=1,\n                block=1  # 1ms timeout\n            )\n            if messages:\n                for stream_name, entries in messages:\n                    for eid, _ in entries:\n                        self.client.xack(self.stream_name, self.consumer_group, eid)\n\n            # 4. CSC operations (if available)\n            if self.csc_client:\n                csc_key = f\"{self.config.key_prefix}:csc:{self._key_counter % 100}\"\n                self.csc_client.set(csc_key, self._value)\n                self.csc_client.get(csc_key)  # Cache miss\n                self.csc_client.get(csc_key)  # Cache hit\n\n        except Exception as e:\n            if self.first_error is None:\n                self.first_error = str(e)\n            self.errors += 1\n\n        end = time.perf_counter()\n        return (end - start) * 1000  # Convert to milliseconds\n\n    def warmup(self) -> None:\n        \"\"\"Run warmup operations to stabilize connections.\"\"\"\n        print(f\"  Warming up for {self.config.warmup_seconds}s...\")\n        end_time = time.monotonic() + self.config.warmup_seconds\n        while time.monotonic() < end_time:\n            self._run_operation()\n        self.latencies.clear()\n        self.errors = 0\n        self.first_error = None\n        self._key_counter = 0\n        self._message_counter = 0\n\n    def _sample_resources(self) -> None:\n        \"\"\"Sample current CPU and memory usage.\"\"\"\n        if self._process is None:\n            return\n        try:\n            # CPU percent since last call (non-blocking)\n            cpu = self._process.cpu_percent(interval=None)\n            if cpu > 0:  # Skip first sample which is always 0\n                self.cpu_samples.append(cpu)\n            # Memory in MB\n            mem_info = self._process.memory_info()\n            self.memory_samples.append(mem_info.rss / (1024 * 1024))\n        except Exception:\n            pass  # Ignore errors in resource sampling\n\n    def run(self) -> BenchmarkResult:\n        \"\"\"Run the load generator for the configured duration.\"\"\"\n        self.latencies = []\n        self.errors = 0\n        self.first_error = None\n        self.cpu_samples = []\n        self.memory_samples = []\n\n        # Initialize CPU percent tracking\n        if self._process:\n            try:\n                self._process.cpu_percent(interval=None)\n            except Exception:\n                pass\n\n        print(f\"  Running load for {self.config.duration_seconds}s...\")\n        start_time = time.monotonic()\n        end_time = start_time + self.config.duration_seconds\n        last_sample_time = start_time\n        sample_interval = 0.5  # Sample resources every 500ms\n\n        while time.monotonic() < end_time:\n            latency = self._run_operation()\n            if latency > 0:\n                self.latencies.append(latency)\n\n            # Sample resources periodically\n            current_time = time.monotonic()\n            if current_time - last_sample_time >= sample_interval:\n                self._sample_resources()\n                last_sample_time = current_time\n\n        # Final resource sample\n        self._sample_resources()\n\n        actual_duration = time.monotonic() - start_time\n        return self._calculate_results(actual_duration)\n\n    def _calculate_results(self, duration: float) -> BenchmarkResult:\n        \"\"\"Calculate benchmark results from collected latencies.\"\"\"\n        if not self.latencies:\n            return BenchmarkResult(\n                scenario=\"unknown\", duration_seconds=duration, total_operations=0,\n                operations_per_second=0, avg_latency_ms=0, p50_latency_ms=0,\n                p95_latency_ms=0, p99_latency_ms=0, min_latency_ms=0,\n                max_latency_ms=0, errors=self.errors, first_error=self.first_error,\n            )\n\n        sorted_latencies = sorted(self.latencies)\n        # Count operations per cycle:\n        # - 2 command ops (SET + GET)\n        # - 2 pubsub ops (PUBLISH + get_message)\n        # - 3 stream ops (XADD + XREADGROUP + XACK)\n        # - 3 CSC ops if available (SET + 2x GET)\n        ops_per_cycle = 7 + (3 if self.csc_client else 0)\n        total_ops = len(self.latencies) * ops_per_cycle\n\n        # Calculate resource usage stats\n        cpu_avg = statistics.mean(self.cpu_samples) if self.cpu_samples else None\n        cpu_max = max(self.cpu_samples) if self.cpu_samples else None\n        mem_avg = statistics.mean(self.memory_samples) if self.memory_samples else None\n        mem_max = max(self.memory_samples) if self.memory_samples else None\n\n        return BenchmarkResult(\n            scenario=\"unknown\",\n            duration_seconds=duration,\n            total_operations=total_ops,\n            operations_per_second=total_ops / duration,\n            avg_latency_ms=statistics.mean(self.latencies),\n            p50_latency_ms=sorted_latencies[len(sorted_latencies) // 2],\n            p95_latency_ms=sorted_latencies[int(len(sorted_latencies) * 0.95)],\n            p99_latency_ms=sorted_latencies[int(len(sorted_latencies) * 0.99)],\n            min_latency_ms=min(self.latencies),\n            max_latency_ms=max(self.latencies),\n            errors=self.errors,\n            first_error=self.first_error,\n            cpu_percent_avg=cpu_avg,\n            cpu_percent_max=cpu_max,\n            memory_mb_avg=mem_avg,\n            memory_mb_max=mem_max,\n        )\n\n\nclass AsyncComprehensiveLoadGenerator:\n    \"\"\"\n    Async comprehensive load generator that exercises all Redis operations concurrently.\n\n    Each iteration performs operations concurrently where possible:\n    - Command operations (SET/GET) - triggers COMMAND metrics\n    - PubSub operations (PUBLISH) - triggers PUBSUB metrics\n    - Streaming operations (XADD/XREAD) - triggers STREAMING metrics\n    - Connection pool operations - triggers CONNECTION metrics\n\n    This ensures consistent test conditions across all OTel configurations\n    while maximizing throughput through concurrent execution.\n    \"\"\"\n\n    def __init__(self, config: LoadGeneratorConfig, redis_module: Any = None):\n        \"\"\"\n        Initialize the async comprehensive load generator.\n\n        Args:\n            config: Load generator configuration\n            redis_module: Optional redis module to use (for baseline testing with\n                         a different redis-py version). If None, imports redis normally.\n        \"\"\"\n        self.config = config\n        self.redis_module = redis_module\n        self.latencies: List[float] = []\n        self.errors: int = 0\n        self.first_error: Optional[str] = None\n        self._value = \"x\" * config.value_size_bytes\n        self._key_counter = 0\n        self._message_counter = 0\n\n        # Resources (initialized in setup)\n        self.client = None\n        self.pubsub_publisher = None\n        self.pubsub = None\n        self.stream_name = f\"{config.key_prefix}:stream\"\n        self.pubsub_channel = f\"{config.key_prefix}:channel\"\n        self.consumer_group = \"benchmark_group\"\n        self.consumer_name = \"benchmark_consumer\"\n\n        # Resource tracking\n        self.cpu_samples: List[float] = []\n        self.memory_samples: List[float] = []\n        self._process = psutil.Process() if PSUTIL_AVAILABLE else None\n\n    def _get_redis_module(self) -> Any:\n        \"\"\"Get the redis module to use.\"\"\"\n        if self.redis_module is not None:\n            return self.redis_module\n        import redis\n        return redis\n\n    def _get_key(self) -> str:\n        \"\"\"Generate a key for the current operation.\"\"\"\n        key = f\"{self.config.key_prefix}:{self._key_counter % 1000}\"\n        self._key_counter += 1\n        return key\n\n    async def setup(self) -> None:\n        \"\"\"Set up all Redis resources.\"\"\"\n        redis_mod = self._get_redis_module()\n        Redis = redis_mod.asyncio.Redis\n\n        # Main client for commands\n        self.client = Redis(\n            host=self.config.redis_host,\n            port=self.config.redis_port,\n            decode_responses=True\n        )\n\n        # PubSub publisher (separate connection)\n        self.pubsub_publisher = Redis(\n            host=self.config.redis_host,\n            port=self.config.redis_port,\n            decode_responses=True\n        )\n\n        # PubSub subscriber\n        subscriber = Redis(\n            host=self.config.redis_host,\n            port=self.config.redis_port,\n            decode_responses=True\n        )\n        self.pubsub = subscriber.pubsub()\n        await self.pubsub.subscribe(self.pubsub_channel)\n        # Consume subscription confirmation\n        await self.pubsub.get_message(timeout=1.0)\n\n        # Create stream and consumer group\n        try:\n            await self.client.xgroup_create(\n                self.stream_name,\n                self.consumer_group,\n                id=\"0\",\n                mkstream=True\n            )\n        except Exception:\n            # Group may already exist\n            pass\n\n    async def teardown(self) -> None:\n        \"\"\"Clean up all Redis resources.\"\"\"\n        if self.pubsub:\n            try:\n                await self.pubsub.unsubscribe()\n                await self.pubsub.aclose()\n            except Exception:\n                pass\n\n        if self.pubsub_publisher:\n            try:\n                await self.pubsub_publisher.aclose()\n            except Exception:\n                pass\n\n        if self.client:\n            try:\n                await self.client.delete(self.stream_name)\n            except Exception:\n                pass\n            try:\n                await self.client.aclose()\n            except Exception:\n                pass\n\n    async def _run_operation(self) -> float:\n        \"\"\"\n        Run a comprehensive operation cycle concurrently and return latency in ms.\n\n        Each cycle includes concurrent execution of:\n        1. SET + GET (command metrics)\n        2. PUBLISH + get_message (pubsub metrics)\n        3. XADD + XREADGROUP + XACK (streaming metrics)\n\n        Note: CSC (Client-Side Caching) is not included in async benchmark\n        as CSC metrics export is not supported for async client.\n        \"\"\"\n        key = self._get_key()\n        start = time.perf_counter()\n\n        try:\n            self._message_counter += 1\n\n            # Group 1: Independent write operations (run concurrently)\n            write_tasks = [\n                self.client.set(key, self._value),\n                self.pubsub_publisher.publish(self.pubsub_channel, f\"msg:{self._message_counter}\"),\n                self.client.xadd(\n                    self.stream_name,\n                    {\"data\": self._value[:50]},\n                    maxlen=1000\n                ),\n            ]\n\n            await asyncio.gather(*write_tasks)\n\n            # Group 2: Independent read operations (run concurrently)\n            read_tasks = [\n                self.client.get(key),\n                self.pubsub.get_message(timeout=0.001),\n            ]\n\n            await asyncio.gather(*read_tasks)\n\n            # Group 3: Stream read (depends on xadd completing first)\n            messages = await self.client.xreadgroup(\n                self.consumer_group,\n                self.consumer_name,\n                {self.stream_name: \">\"},\n                count=1,\n                block=1\n            )\n\n            # Acknowledge messages concurrently\n            if messages:\n                ack_tasks = [\n                    self.client.xack(self.stream_name, self.consumer_group, eid)\n                    for stream_name, entries in messages\n                    for eid, _ in entries\n                ]\n                if ack_tasks:\n                    await asyncio.gather(*ack_tasks)\n\n        except Exception as e:\n            if self.first_error is None:\n                self.first_error = str(e)\n            self.errors += 1\n\n        end = time.perf_counter()\n        return (end - start) * 1000  # Convert to milliseconds\n\n    async def warmup(self) -> None:\n        \"\"\"Run warmup operations to stabilize connections.\"\"\"\n        print(f\"  Warming up for {self.config.warmup_seconds}s...\")\n        end_time = time.monotonic() + self.config.warmup_seconds\n        while time.monotonic() < end_time:\n            await self._run_operation()\n        self.latencies.clear()\n        self.errors = 0\n        self.first_error = None\n        self._key_counter = 0\n        self._message_counter = 0\n\n    def _sample_resources(self) -> None:\n        \"\"\"Sample current CPU and memory usage.\"\"\"\n        if self._process is None:\n            return\n        try:\n            cpu = self._process.cpu_percent(interval=None)\n            if cpu > 0:\n                self.cpu_samples.append(cpu)\n            mem_info = self._process.memory_info()\n            self.memory_samples.append(mem_info.rss / (1024 * 1024))\n        except Exception:\n            pass\n\n    async def run(self) -> BenchmarkResult:\n        \"\"\"Run the load generator for the configured duration.\"\"\"\n        self.latencies = []\n        self.errors = 0\n        self.first_error = None\n        self.cpu_samples = []\n        self.memory_samples = []\n\n        # Initialize CPU percent tracking\n        if self._process:\n            try:\n                self._process.cpu_percent(interval=None)\n            except Exception:\n                pass\n\n        print(f\"  Running load for {self.config.duration_seconds}s...\")\n        start_time = time.monotonic()\n        end_time = start_time + self.config.duration_seconds\n        last_sample_time = start_time\n        sample_interval = 0.5\n\n        while time.monotonic() < end_time:\n            latency = await self._run_operation()\n            if latency > 0:\n                self.latencies.append(latency)\n\n            # Sample resources periodically\n            current_time = time.monotonic()\n            if current_time - last_sample_time >= sample_interval:\n                self._sample_resources()\n                last_sample_time = current_time\n\n        # Final resource sample\n        self._sample_resources()\n\n        actual_duration = time.monotonic() - start_time\n        return self._calculate_results(actual_duration)\n\n    def _calculate_results(self, duration: float) -> BenchmarkResult:\n        \"\"\"Calculate benchmark results from collected latencies.\"\"\"\n        if not self.latencies:\n            return BenchmarkResult(\n                scenario=\"unknown\", duration_seconds=duration, total_operations=0,\n                operations_per_second=0, avg_latency_ms=0, p50_latency_ms=0,\n                p95_latency_ms=0, p99_latency_ms=0, min_latency_ms=0,\n                max_latency_ms=0, errors=self.errors, first_error=self.first_error,\n            )\n\n        sorted_latencies = sorted(self.latencies)\n        # Count operations per cycle:\n        # - 2 command ops (SET + GET)\n        # - 2 pubsub ops (PUBLISH + get_message)\n        # - 3 stream ops (XADD + XREADGROUP + XACK)\n        # Note: CSC is not included in async benchmark (not supported)\n        ops_per_cycle = 7\n        total_ops = len(self.latencies) * ops_per_cycle\n\n        # Calculate resource usage stats\n        cpu_avg = statistics.mean(self.cpu_samples) if self.cpu_samples else None\n        cpu_max = max(self.cpu_samples) if self.cpu_samples else None\n        mem_avg = statistics.mean(self.memory_samples) if self.memory_samples else None\n        mem_max = max(self.memory_samples) if self.memory_samples else None\n\n        return BenchmarkResult(\n            scenario=\"unknown\",\n            duration_seconds=duration,\n            total_operations=total_ops,\n            operations_per_second=total_ops / duration,\n            avg_latency_ms=statistics.mean(self.latencies),\n            p50_latency_ms=sorted_latencies[len(sorted_latencies) // 2],\n            p95_latency_ms=sorted_latencies[int(len(sorted_latencies) * 0.95)],\n            p99_latency_ms=sorted_latencies[int(len(sorted_latencies) * 0.99)],\n            min_latency_ms=min(self.latencies),\n            max_latency_ms=max(self.latencies),\n            errors=self.errors,\n            first_error=self.first_error,\n            cpu_percent_avg=cpu_avg,\n            cpu_percent_max=cpu_max,\n            memory_mb_avg=mem_avg,\n            memory_mb_max=mem_max,\n        )\n\n\ndef print_result(result: BenchmarkResult, iterations: int = 1) -> None:\n    \"\"\"Print a single benchmark result.\"\"\"\n    print(\"\\n\" + \"=\" * 60)\n    print(f\"BENCHMARK RESULT: {result.scenario}\")\n    print(\"=\" * 60)\n    if iterations > 1:\n        print(f\"  Iterations:   {iterations} (averaged)\")\n    print(f\"  Duration:     {result.duration_seconds:.2f}s\")\n    print(f\"  Operations:   {result.total_operations:,}\")\n    print(f\"  Ops/sec:      {result.operations_per_second:,.0f}\")\n    print(f\"  Avg latency:  {result.avg_latency_ms:.3f}ms\")\n    print(f\"  P50 latency:  {result.p50_latency_ms:.3f}ms\")\n    print(f\"  P95 latency:  {result.p95_latency_ms:.3f}ms\")\n    print(f\"  P99 latency:  {result.p99_latency_ms:.3f}ms\")\n    print(f\"  Min latency:  {result.min_latency_ms:.3f}ms\")\n    print(f\"  Max latency:  {result.max_latency_ms:.3f}ms\")\n    print(f\"  Errors:       {result.errors}\")\n    if result.first_error:\n        print(f\"  First error:  {result.first_error}\")\n    # Resource usage\n    if result.cpu_percent_avg is not None:\n        print(f\"  CPU avg:      {result.cpu_percent_avg:.1f}%\")\n    if result.cpu_percent_max is not None:\n        print(f\"  CPU max:      {result.cpu_percent_max:.1f}%\")\n    if result.memory_mb_avg is not None:\n        print(f\"  Memory avg:   {result.memory_mb_avg:.1f} MB\")\n    if result.memory_mb_max is not None:\n        print(f\"  Memory max:   {result.memory_mb_max:.1f} MB\")\n    if result.metadata.get(\"description\"):\n        print(f\"  Description:  {result.metadata['description']}\")\n    print(\"=\" * 60)\n\n\ndef average_results(results: List[BenchmarkResult]) -> BenchmarkResult:\n    \"\"\"Average multiple benchmark results into a single result.\"\"\"\n    if not results:\n        raise ValueError(\"Cannot average empty results list\")\n    if len(results) == 1:\n        return results[0]\n\n    n = len(results)\n    # Find first error from any iteration\n    first_error = None\n    for r in results:\n        if r.first_error:\n            first_error = r.first_error\n            break\n\n    # Average CPU and memory (only from results that have them)\n    cpu_avgs = [r.cpu_percent_avg for r in results if r.cpu_percent_avg is not None]\n    cpu_maxs = [r.cpu_percent_max for r in results if r.cpu_percent_max is not None]\n    mem_avgs = [r.memory_mb_avg for r in results if r.memory_mb_avg is not None]\n    mem_maxs = [r.memory_mb_max for r in results if r.memory_mb_max is not None]\n\n    return BenchmarkResult(\n        scenario=results[0].scenario,\n        duration_seconds=sum(r.duration_seconds for r in results) / n,\n        total_operations=int(sum(r.total_operations for r in results) / n),\n        operations_per_second=sum(r.operations_per_second for r in results) / n,\n        avg_latency_ms=sum(r.avg_latency_ms for r in results) / n,\n        p50_latency_ms=sum(r.p50_latency_ms for r in results) / n,\n        p95_latency_ms=sum(r.p95_latency_ms for r in results) / n,\n        p99_latency_ms=sum(r.p99_latency_ms for r in results) / n,\n        min_latency_ms=min(r.min_latency_ms for r in results),\n        max_latency_ms=max(r.max_latency_ms for r in results),\n        errors=sum(r.errors for r in results),\n        first_error=first_error,\n        cpu_percent_avg=sum(cpu_avgs) / len(cpu_avgs) if cpu_avgs else None,\n        cpu_percent_max=max(cpu_maxs) if cpu_maxs else None,\n        memory_mb_avg=sum(mem_avgs) / len(mem_avgs) if mem_avgs else None,\n        memory_mb_max=max(mem_maxs) if mem_maxs else None,\n        metadata=results[0].metadata,\n    )\n\n\ndef parse_args() -> argparse.Namespace:\n    \"\"\"Parse command line arguments.\"\"\"\n    parser = argparse.ArgumentParser(\n        description=\"Benchmark OTel instrumentation overhead in redis-py. Run one scenario at a time.\"\n    )\n    parser.add_argument(\n        \"--scenario\", type=str, required=True,\n        choices=[\"baseline\", \"otel_disabled\", \"otel_noop\", \"otel_inmemory\", \"otel_enabled_http\", \"otel_enabled_grpc\"],\n        help=\"Scenario to run (required)\"\n    )\n    parser.add_argument(\n        \"--baseline-tag\", type=str, default=None,\n        help=\"Git tag to use for baseline scenario (required when --scenario baseline)\"\n    )\n    parser.add_argument(\n        \"--duration\", type=float, default=30.0,\n        help=\"Duration of benchmark in seconds (default: 30)\"\n    )\n    parser.add_argument(\n        \"--warmup\", type=float, default=5.0,\n        help=\"Warmup duration in seconds (default: 5)\"\n    )\n    parser.add_argument(\n        \"--value-size\", type=int, default=100,\n        help=\"Size of values in bytes (default: 100)\"\n    )\n    parser.add_argument(\n        \"--host\", type=str, default=\"localhost\",\n        help=\"Redis host (default: localhost)\"\n    )\n    parser.add_argument(\n        \"--port\", type=int, default=6379,\n        help=\"Redis port (default: 6379)\"\n    )\n    parser.add_argument(\n        \"--json\", action=\"store_true\",\n        help=\"Output result as JSON\"\n    )\n    parser.add_argument(\n        \"--iterations\", type=int, default=5,\n        help=\"Number of iterations to run (default: 5). Final result is averaged.\"\n    )\n    parser.add_argument(\n        \"--metric-groups\", type=str, default=None,\n        help=(\n            \"Comma-separated list of metric groups to enable. \"\n            \"Options: command, pubsub, streaming, csc, connection_basic, connection_advanced, resiliency, all. \"\n            \"Default: resiliency,connection_basic. \"\n            \"Example: --metric-groups command,pubsub,connection_basic\"\n        )\n    )\n    parser.add_argument(\n        \"--export-interval\", type=int, default=10000,\n        help=\"OTel metric export interval in milliseconds (default: 10000)\"\n    )\n    parser.add_argument(\n        \"--async\", dest=\"use_async\", action=\"store_true\",\n        help=\"Use async Redis client instead of sync client\"\n    )\n    return parser.parse_args()\n\n\ndef _clear_redis_modules() -> None:\n    \"\"\"Remove all redis.* modules from sys.modules to allow fresh import.\"\"\"\n    to_remove = [key for key in sys.modules if key == \"redis\" or key.startswith(\"redis.\")]\n    for key in to_remove:\n        del sys.modules[key]\n\n\ndef run_baseline_scenario(tag: str, config: LoadGeneratorConfig) -> Optional[BenchmarkResult]:\n    \"\"\"\n    Run benchmark against a baseline git tag using ComprehensiveLoadGenerator.\n\n    This clones the repo at the specified tag, manipulates sys.path to import\n    the old redis-py version, and runs the benchmark using the same comprehensive\n    load generator for consistent comparison with other scenarios.\n\n    Returns:\n        BenchmarkResult or None on failure\n    \"\"\"\n    repo_root = Path(__file__).parent.parent\n\n    with TemporaryDirectory() as tmpdir:\n        print(f\"  Cloning repository at tag {tag}...\")\n        try:\n            subprocess.run(\n                [\"git\", \"clone\", \"--depth\", \"1\", \"--branch\", tag, str(repo_root), tmpdir],\n                check=True, capture_output=True, text=True\n            )\n        except subprocess.CalledProcessError as e:\n            print(f\"  ERROR: Failed to clone tag {tag}: {e.stderr}\")\n            return None\n\n        # Save original sys.path and modules state\n        original_path = sys.path.copy()\n\n        try:\n            # Clear any existing redis imports and prepend cloned directory\n            _clear_redis_modules()\n            sys.path.insert(0, tmpdir)\n\n            # Import redis from the cloned directory\n            import redis as baseline_redis\n\n            print(f\"  Using redis from: {baseline_redis.__file__}\")\n\n            # Create comprehensive generator with baseline redis module\n            generator = ComprehensiveLoadGenerator(config, redis_module=baseline_redis)\n            generator.setup()\n            try:\n                generator.warmup()\n                result = generator.run()\n                result.scenario = \"baseline\"\n                result.metadata[\"tag\"] = tag\n                result.metadata[\"description\"] = \"Baseline without OTel code\"\n                return result\n            finally:\n                generator.teardown()\n\n        finally:\n            # Restore original sys.path and clear redis modules again\n            sys.path[:] = original_path\n            _clear_redis_modules()\n\n\nasync def run_baseline_scenario_async(tag: str, config: LoadGeneratorConfig) -> Optional[BenchmarkResult]:\n    \"\"\"\n    Run async benchmark against a baseline git tag using AsyncComprehensiveLoadGenerator.\n\n    This clones the repo at the specified tag, manipulates sys.path to import\n    the old redis-py version, and runs the benchmark using the async comprehensive\n    load generator for consistent comparison with other scenarios.\n\n    Returns:\n        BenchmarkResult or None on failure\n    \"\"\"\n    repo_root = Path(__file__).parent.parent\n\n    with TemporaryDirectory() as tmpdir:\n        print(f\"  Cloning repository at tag {tag}...\")\n        try:\n            subprocess.run(\n                [\"git\", \"clone\", \"--depth\", \"1\", \"--branch\", tag, str(repo_root), tmpdir],\n                check=True, capture_output=True, text=True\n            )\n        except subprocess.CalledProcessError as e:\n            print(f\"  ERROR: Failed to clone tag {tag}: {e.stderr}\")\n            return None\n\n        # Save original sys.path and modules state\n        original_path = sys.path.copy()\n\n        try:\n            # Clear any existing redis imports and prepend cloned directory\n            _clear_redis_modules()\n            sys.path.insert(0, tmpdir)\n\n            # Import redis from the cloned directory\n            import redis as baseline_redis\n\n            print(f\"  Using redis from: {baseline_redis.__file__}\")\n\n            # Create async comprehensive generator with baseline redis module\n            generator = AsyncComprehensiveLoadGenerator(config, redis_module=baseline_redis)\n            await generator.setup()\n            try:\n                await generator.warmup()\n                result = await generator.run()\n                result.scenario = \"baseline\"\n                result.metadata[\"tag\"] = tag\n                result.metadata[\"description\"] = \"Baseline without OTel code (async)\"\n                result.metadata[\"client_type\"] = \"async\"\n                return result\n            finally:\n                await generator.teardown()\n\n        finally:\n            # Restore original sys.path and clear redis modules again\n            sys.path[:] = original_path\n            _clear_redis_modules()\n\n\ndef _get_metric_groups_for_benchmark(metric_group_names: Optional[List[str]]) -> Optional[List[Any]]:\n    \"\"\"\n    Convert metric group names to MetricGroup enum values.\n\n    Args:\n        metric_group_names: List of metric group names (command, pubsub, streaming, csc,\n                           connection_basic, connection_advanced, resiliency, all)\n\n    Returns:\n        List of MetricGroup enum values, or None for defaults\n    \"\"\"\n    if not metric_group_names:\n        return None\n\n    from redis.observability.config import MetricGroup\n\n    name_to_group = {\n        \"command\": MetricGroup.COMMAND,\n        \"pubsub\": MetricGroup.PUBSUB,\n        \"streaming\": MetricGroup.STREAMING,\n        \"csc\": MetricGroup.CSC,\n        \"connection_basic\": MetricGroup.CONNECTION_BASIC,\n        \"connection_advanced\": MetricGroup.CONNECTION_ADVANCED,\n        \"resiliency\": MetricGroup.RESILIENCY,\n    }\n\n    # Handle \"all\" - return all metric groups\n    if \"all\" in metric_group_names:\n        return list(name_to_group.values())\n\n    groups = []\n    for name in metric_group_names:\n        if name in name_to_group:\n            groups.append(name_to_group[name])\n\n    return groups if groups else None\n\n\ndef setup_scenario(\n    scenario: str,\n    metric_group_names: Optional[List[str]] = None,\n    export_interval_millis: int = 10000\n) -> str:\n    \"\"\"\n    Set up OTel for a scenario. Returns the description.\n    This should only be called once per process.\n\n    Args:\n        scenario: The scenario name\n        metric_group_names: List of metric group names to enable\n        export_interval_millis: Export interval in milliseconds for PeriodicExportingMetricReader\n    \"\"\"\n    if scenario == \"otel_disabled\":\n        return \"OTel not initialized\"\n\n    # Determine which metric groups to enable\n    metric_groups = _get_metric_groups_for_benchmark(metric_group_names)\n    groups_desc = \"\"\n    if metric_group_names:\n        groups_desc = f\" [groups: {', '.join(metric_group_names)}]\"\n\n    if scenario == \"otel_noop\":\n        from opentelemetry import metrics\n        from opentelemetry.metrics import NoOpMeterProvider\n        metrics.set_meter_provider(NoOpMeterProvider())\n\n        from redis.observability.providers import get_observability_instance\n        from redis.observability.config import OTelConfig\n        otel = get_observability_instance()\n        if metric_groups:\n            otel.init(OTelConfig(metric_groups=metric_groups))\n        else:\n            otel.init(OTelConfig())\n        return f\"OTel with NoOpMeterProvider{groups_desc}\"\n\n    elif scenario == \"otel_inmemory\":\n        from opentelemetry import metrics\n        from opentelemetry.sdk.metrics import MeterProvider\n        from opentelemetry.sdk.metrics.export import InMemoryMetricReader\n\n        reader = InMemoryMetricReader()\n        provider = MeterProvider(metric_readers=[reader])\n        metrics.set_meter_provider(provider)\n\n        from redis.observability.providers import get_observability_instance\n        from redis.observability.config import OTelConfig\n        otel = get_observability_instance()\n        if metric_groups:\n            otel.init(OTelConfig(metric_groups=metric_groups))\n        else:\n            otel.init(OTelConfig())\n        return f\"OTel with InMemoryMetricReader{groups_desc}\"\n\n    elif scenario == \"otel_enabled_http\":\n        from opentelemetry import metrics\n        from opentelemetry.sdk.metrics import MeterProvider\n        from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader\n        from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter\n\n        # HTTP exporter - host configurable via OTEL_COLLECTOR_HOST env var,\n        # default is localhost (port 4318)\n        host = os.environ.get(\"OTEL_COLLECTOR_HOST\", \"localhost\")\n        endpoint = f\"http://{host}:4318/v1/metrics\"\n        exporter = OTLPMetricExporter(endpoint=endpoint)\n        reader = PeriodicExportingMetricReader(exporter, export_interval_millis=export_interval_millis)\n        provider = MeterProvider(metric_readers=[reader])\n        metrics.set_meter_provider(provider)\n\n        from redis.observability.providers import get_observability_instance\n        from redis.observability.config import OTelConfig\n        otel = get_observability_instance()\n        if metric_groups:\n            otel.init(OTelConfig(metric_groups=metric_groups))\n        else:\n            otel.init(OTelConfig())\n        return f\"OTel with PeriodicExportingMetricReader (HTTP) -> {endpoint} [export: {export_interval_millis}ms]{groups_desc}\"\n\n    elif scenario == \"otel_enabled_grpc\":\n        from opentelemetry import metrics\n        from opentelemetry.sdk.metrics import MeterProvider\n        from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader\n        from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter\n\n        # gRPC exporter - host configurable via OTEL_COLLECTOR_HOST env var,\n        # default is localhost (port 4317)\n        host = os.environ.get(\"OTEL_COLLECTOR_HOST\", \"localhost\")\n        endpoint = f\"{host}:4317\"\n        exporter = OTLPMetricExporter(endpoint=endpoint, insecure=True)\n        reader = PeriodicExportingMetricReader(exporter, export_interval_millis=export_interval_millis)\n        provider = MeterProvider(metric_readers=[reader])\n        metrics.set_meter_provider(provider)\n\n        from redis.observability.providers import get_observability_instance\n        from redis.observability.config import OTelConfig\n        otel = get_observability_instance()\n        if metric_groups:\n            otel.init(OTelConfig(metric_groups=metric_groups))\n        else:\n            otel.init(OTelConfig())\n        return f\"OTel with PeriodicExportingMetricReader (gRPC) -> {endpoint} [export: {export_interval_millis}ms]{groups_desc}\"\n\n    else:\n        raise ValueError(f\"Unknown scenario: {scenario}\")\n\n\ndef run_iteration(scenario: str, config: LoadGeneratorConfig, description: str) -> BenchmarkResult:\n    \"\"\"\n    Run a single benchmark iteration using the ComprehensiveLoadGenerator.\n\n    Args:\n        scenario: The scenario name\n        config: Load generator configuration\n        description: Description of the scenario\n\n    Returns:\n        BenchmarkResult from the comprehensive load generator\n    \"\"\"\n    generator = ComprehensiveLoadGenerator(config)\n    generator.setup()\n    try:\n        generator.warmup()\n        result = generator.run()\n        result.scenario = scenario\n        result.metadata[\"description\"] = description\n        return result\n    finally:\n        generator.teardown()\n\n\nasync def run_iteration_async(scenario: str, config: LoadGeneratorConfig, description: str) -> BenchmarkResult:\n    \"\"\"\n    Run a single async benchmark iteration using the AsyncComprehensiveLoadGenerator.\n\n    Args:\n        scenario: The scenario name\n        config: Load generator configuration\n        description: Description of the scenario\n\n    Returns:\n        BenchmarkResult from the async comprehensive load generator\n    \"\"\"\n    generator = AsyncComprehensiveLoadGenerator(config)\n    await generator.setup()\n    try:\n        await generator.warmup()\n        result = await generator.run()\n        result.scenario = scenario\n        result.metadata[\"description\"] = description\n        result.metadata[\"client_type\"] = \"async\"\n        return result\n    finally:\n        await generator.teardown()\n\n\ndef main() -> int:\n    \"\"\"Main entry point for the benchmark.\"\"\"\n    args = parse_args()\n\n    # Validate baseline scenario requires --baseline-tag\n    if args.scenario == \"baseline\" and not args.baseline_tag:\n        print(\"ERROR: --baseline-tag is required when --scenario baseline\")\n        return 1\n\n    # Parse metric groups\n    metric_group_names: Optional[List[str]] = None\n    if args.metric_groups:\n        metric_group_names = [g.strip().lower() for g in args.metric_groups.split(\",\")]\n        # Validate metric group names\n        valid_groups = {\"command\", \"pubsub\", \"streaming\", \"csc\", \"connection_basic\", \"connection_advanced\", \"resiliency\", \"all\"}\n        invalid_groups = [g for g in metric_group_names if g not in valid_groups]\n        if invalid_groups:\n            print(f\"ERROR: Invalid metric groups: {', '.join(invalid_groups)}\")\n            print(f\"Valid options: {', '.join(sorted(valid_groups))}\")\n            return 1\n\n    client_type = \"async\" if args.use_async else \"sync\"\n    print(\"=\" * 60)\n    print(f\"OTel Benchmark: {args.scenario} ({client_type} client)\")\n    if args.baseline_tag:\n        print(f\"Baseline tag: {args.baseline_tag}\")\n    if metric_group_names:\n        print(f\"Metric groups: {', '.join(metric_group_names)}\")\n    print(\"=\" * 60)\n    print(f\"Client type: {client_type}\")\n    print(f\"Duration: {args.duration}s per iteration\")\n    print(f\"Warmup: {args.warmup}s per iteration\")\n    print(f\"Iterations: {args.iterations}\")\n    print(f\"Value size: {args.value_size} bytes\")\n    print(f\"Redis: {args.host}:{args.port}\")\n\n    config = LoadGeneratorConfig(\n        duration_seconds=args.duration,\n        warmup_seconds=args.warmup,\n        value_size_bytes=args.value_size,\n        redis_host=args.host,\n        redis_port=args.port,\n    )\n\n    # Set up OTel once (for non-baseline scenarios)\n    description = \"\"\n    if args.scenario != \"baseline\":\n        print(\"\\nSetting up OTel...\")\n        description = setup_scenario(\n            args.scenario,\n            metric_group_names=metric_group_names,\n            export_interval_millis=args.export_interval\n        )\n        print(f\"  {description}\")\n\n    # Run benchmark iterations\n    if args.use_async:\n        return asyncio.run(_run_async_benchmark(args, config, description))\n    else:\n        return _run_sync_benchmark(args, config, description)\n\n\ndef _run_sync_benchmark(args: argparse.Namespace, config: LoadGeneratorConfig, description: str) -> int:\n    \"\"\"Run sync benchmark iterations.\"\"\"\n    results: List[BenchmarkResult] = []\n    for i in range(args.iterations):\n        print(f\"\\n--- Iteration {i + 1}/{args.iterations} ---\")\n\n        if args.scenario == \"baseline\":\n            result = run_baseline_scenario(tag=args.baseline_tag, config=config)\n            if result is None:\n                print(\"ERROR: Baseline benchmark failed\")\n                return 1\n        else:\n            result = run_iteration(args.scenario, config, description)\n\n        results.append(result)\n        print(f\"  Ops/sec: {result.operations_per_second:,.0f}\")\n\n    # Average results across all iterations\n    final_result = average_results(results)\n\n    # Output results\n    if args.json:\n        print(json.dumps(asdict(final_result), indent=2))\n    else:\n        print_result(final_result, iterations=args.iterations)\n\n    return 0\n\n\nasync def _run_async_benchmark(args: argparse.Namespace, config: LoadGeneratorConfig, description: str) -> int:\n    \"\"\"Run async benchmark iterations.\"\"\"\n    results: List[BenchmarkResult] = []\n    for i in range(args.iterations):\n        print(f\"\\n--- Iteration {i + 1}/{args.iterations} ---\")\n\n        if args.scenario == \"baseline\":\n            result = await run_baseline_scenario_async(tag=args.baseline_tag, config=config)\n            if result is None:\n                print(\"ERROR: Baseline benchmark failed\")\n                return 1\n        else:\n            result = await run_iteration_async(args.scenario, config, description)\n\n        results.append(result)\n        print(f\"  Ops/sec: {result.operations_per_second:,.0f}\")\n\n    # Average results across all iterations\n    final_result = average_results(results)\n\n    # Output results\n    if args.json:\n        print(json.dumps(asdict(final_result), indent=2))\n    else:\n        print_result(final_result, iterations=args.iterations)\n\n    return 0\n\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n"
  },
  {
    "path": "benchmarks/socket_read_size.py",
    "content": "from base import Benchmark\n\nfrom redis.connection import PythonParser, _HiredisParser\n\n\nclass SocketReadBenchmark(Benchmark):\n    ARGUMENTS = (\n        {\"name\": \"parser\", \"values\": [PythonParser, _HiredisParser]},\n        {\n            \"name\": \"value_size\",\n            \"values\": [10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000],\n        },\n        {\"name\": \"read_size\", \"values\": [4096, 8192, 16384, 32768, 65536, 131072]},\n    )\n\n    def setup(self, value_size, read_size, parser):\n        r = self.get_client(parser_class=parser, socket_read_size=read_size)\n        r.set(\"benchmark\", \"a\" * value_size)\n\n    def run(self, value_size, read_size, parser):\n        r = self.get_client()\n        r.get(\"benchmark\")\n\n\nif __name__ == \"__main__\":\n    SocketReadBenchmark().run_benchmark()\n"
  },
  {
    "path": "codecov.yml",
    "content": "ignore:\n  - \"benchmarks/**\"\n  - \"tasks.py\"\n\ncodecov:\n  require_ci_to_pass: yes\n\ncoverage:\n  precision: 2\n  round: down\n  range: \"80...100\"\n  status:\n    patch: off  # off for now as it yells about everything\n"
  },
  {
    "path": "dev_requirements.txt",
    "content": "build\nbuild==1.2.2.post1 ; platform_python_implementation == \"PyPy\"\nclick==8.0.4\ninvoke==2.2.0\npackaging>=20.4\npackaging==24.2 ; platform_python_implementation == \"PyPy\"\n\npytest\npytest==8.3.4 ; platform_python_implementation == \"PyPy\"\npytest-asyncio>=0.24.0\npytest-asyncio==1.1.0 ; platform_python_implementation == \"PyPy\"\npytest-cov\ncoverage<7.11.1\npytest-cov==6.0.0 ; platform_python_implementation == \"PyPy\"\ncoverage==7.6.12 ; platform_python_implementation == \"PyPy\"\npytest-profiling==1.8.1\npytest-timeout\npytest-timeout==2.3.1 ; platform_python_implementation == \"PyPy\"\n\nruff==0.9.6\nujson>=4.2.0\nuvloop<=0.21.0; platform_python_implementation == \"CPython\" and python_version < \"3.14\"\nuvloop>=0.22; platform_python_implementation == \"CPython\" and python_version >= \"3.14\"\nvulture>=2.3.0\n\nnumpy>=1.24.0 ; platform_python_implementation == \"CPython\"\nnumpy>=1.24.0,<2.0 ; platform_python_implementation == \"PyPy\"\n\nredis-entraid==1.0.0\npybreaker>=1.4.0\n\nxxhash==3.6.0\n\nopentelemetry-api>=1.30.0\nopentelemetry-sdk>=1.30.0\nopentelemetry-exporter-otlp>=1.30.0\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "---\n# image tag 8.0-RC2-pre is the one matching the 8.0 GA release\nx-client-libs-stack-image: &client-libs-stack-image\n  image: \"redislabs/client-libs-test:${CLIENT_LIBS_TEST_STACK_IMAGE_TAG:-8.4.0}\"\n\nx-client-libs-image: &client-libs-image\n  image: \"redislabs/client-libs-test:${CLIENT_LIBS_TEST_IMAGE_TAG:-8.4.0}\"\n\nnetworks:\n  redis-net:\n    driver: bridge\nservices:\n\n  redis:\n    <<: *client-libs-image\n    container_name: redis-standalone\n    environment:\n      - TLS_ENABLED=yes\n      - TLS_CLIENT_CNS=test_user\n      - REDIS_CLUSTER=no\n      - PORT=6379\n      - TLS_PORT=6666\n    command: ${REDIS_EXTRA_ARGS:---enable-debug-command yes --enable-module-command yes --tls-auth-clients optional --save \"\"}\n    ports:\n      - 6379:6379\n      - 6666:6666 # TLS port\n    volumes:\n      - \"./dockers/standalone:/redis/work\"\n    profiles:\n      - standalone\n      - sentinel\n      - replica\n      - all-stack\n      - all\n\n  replica:\n    <<: *client-libs-image\n    container_name: redis-replica\n    depends_on:\n      - redis\n    environment:\n      - TLS_ENABLED=no\n      - REDIS_CLUSTER=no\n      - PORT=6380\n    command: ${REDIS_EXTRA_ARGS:---enable-debug-command yes --replicaof redis 6379 --protected-mode no --save \"\"}\n    ports:\n      - 6380:6380\n    volumes:\n      - \"./dockers/replica:/redis/work\"\n    profiles:\n      - replica\n      - all-stack\n      - all\n\n  cluster:\n    <<: *client-libs-image\n    container_name: redis-cluster\n    environment:\n      - REDIS_CLUSTER=yes\n      - NODES=6\n      - REPLICAS=1\n      - TLS_ENABLED=yes\n      - TLS_CLIENT_CNS=test_user\n      - PORT=16379\n      - TLS_PORT=27379\n    command: ${REDIS_EXTRA_ARGS:---enable-debug-command yes --enable-module-command yes --tls-auth-clients optional --save \"\" --tls-cluster yes}\n    ports:\n      - \"16379-16384:16379-16384\"\n      - \"27379-27384:27379-27384\"\n    volumes:\n      - \"./dockers/cluster:/redis/work\"\n    profiles:\n      - cluster\n      - all-stack\n      - all\n\n  sentinel:\n    <<: *client-libs-image\n    container_name: redis-sentinel\n    depends_on:\n      - redis\n    environment:\n      - REDIS_CLUSTER=no\n      - NODES=3\n      - PORT=26379\n    command: ${REDIS_EXTRA_ARGS:---sentinel}\n    ports:\n      - 26379:26379\n      - 26380:26380\n      - 26381:26381\n    volumes:\n      - \"./dockers/sentinel.conf:/redis/config-default/redis.conf\"\n      - \"./dockers/sentinel:/redis/work\"\n    profiles:\n      - sentinel\n      - all-stack\n      - all\n\n  redis-stack:\n    <<: *client-libs-stack-image\n    container_name: redis-stack\n    environment:\n      - REDIS_CLUSTER=no\n      - PORT=6379\n    command: ${REDIS_EXTRA_ARGS:---enable-debug-command yes --enable-module-command yes --save \"\"}\n    ports:\n      - 6479:6379\n    volumes:\n      - \"./dockers/redis-stack:/redis/work\"\n    profiles:\n      - standalone\n      - all-stack\n      - all\n\n  redis-proxied:\n    <<: *client-libs-image\n    container_name: redis-proxied\n    ports:\n      - \"3000:3000\"\n    networks:\n      - redis-net\n    healthcheck:\n      test: [\"CMD\", \"redis-cli\", \"-p\", \"3000\", \"PING\"]\n      interval: 10s\n      timeout: 3s\n      retries: 5\n\n  resp-proxy:\n    image: redislabs/client-resp-proxy\n    container_name: resp-proxy\n    environment:\n      LISTEN_HOST: \"0.0.0.0\"\n      LISTEN_PORT: \"15379,15380,15381\"\n      TARGET_HOST: \"redis-proxied\"\n      TARGET_PORT: \"3000\"\n      API_PORT: \"4000\"\n      ENABLE_LOGGING: true\n      SIMULATE_CLUSTER: true\n      DEFAULT_INTERCEPTORS: \"cluster,hitless,logger\"\n\n    ports:\n      - \"15379:15379\"\n      - \"15380:15380\"\n      - \"15381:15381\"\n      - \"4000:4000\"\n    depends_on:\n      - redis-proxied\n    networks:\n      - redis-net\n    healthcheck:\n      test: [\"CMD\", \"sh\", \"-c\", \"wget -qO- http://localhost:4000/stats || exit 1\"]\n      interval: 10s\n      timeout: 3s\n      retries: 5\n"
  },
  {
    "path": "dockers/sentinel.conf",
    "content": "sentinel resolve-hostnames yes\nsentinel monitor redis-py-test redis 6379 2\n# Be much more tolerant to transient stalls (index builds, GC, I/O)\nsentinel down-after-milliseconds redis-py-test 60000\n# Avoid rapid repeated failover attempts\nsentinel failover-timeout redis-py-test 180000\n# Keep it conservative: sync one replica at a time\nsentinel parallel-syncs redis-py-test 1"
  },
  {
    "path": "docs/Makefile",
    "content": "# Makefile for Sphinx documentation\n#\n\n# You can set these variables from the command line.\nSPHINXOPTS    =\nSPHINXBUILD   = sphinx-build\nPAPER         =\nBUILDDIR      = _build\n\n# Internal variables.\nPAPEROPT_a4     = -D latex_paper_size=a4\nPAPEROPT_letter = -D latex_paper_size=letter\nALLSPHINXOPTS   = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .\n# the i18n builder cannot share the environment and doctrees with the others\nI18NSPHINXOPTS  = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .\n\n.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext\n\nhelp:\n\t@echo \"Please use \\`make <target>' where <target> is one of\"\n\t@echo \"  html       to make standalone HTML files\"\n\t@echo \"  dirhtml    to make HTML files named index.html in directories\"\n\t@echo \"  singlehtml to make a single large HTML file\"\n\t@echo \"  pickle     to make pickle files\"\n\t@echo \"  json       to make JSON files\"\n\t@echo \"  htmlhelp   to make HTML files and a HTML help project\"\n\t@echo \"  qthelp     to make HTML files and a qthelp project\"\n\t@echo \"  devhelp    to make HTML files and a Devhelp project\"\n\t@echo \"  epub       to make an epub\"\n\t@echo \"  latex      to make LaTeX files, you can set PAPER=a4 or PAPER=letter\"\n\t@echo \"  latexpdf   to make LaTeX files and run them through pdflatex\"\n\t@echo \"  text       to make text files\"\n\t@echo \"  man        to make manual pages\"\n\t@echo \"  texinfo    to make Texinfo files\"\n\t@echo \"  info       to make Texinfo files and run them through makeinfo\"\n\t@echo \"  gettext    to make PO message catalogs\"\n\t@echo \"  changes    to make an overview of all changed/added/deprecated items\"\n\t@echo \"  linkcheck  to check all external links for integrity\"\n\t@echo \"  doctest    to run all doctests embedded in the documentation (if enabled)\"\n\nclean:\n\t-rm -rf $(BUILDDIR)/*\n\nhtml:\n\t$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html\n\t@echo\n\t@echo \"Build finished. The HTML pages are in $(BUILDDIR)/html.\"\n\ndirhtml:\n\t$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml\n\t@echo\n\t@echo \"Build finished. The HTML pages are in $(BUILDDIR)/dirhtml.\"\n\nsinglehtml:\n\t$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml\n\t@echo\n\t@echo \"Build finished. The HTML page is in $(BUILDDIR)/singlehtml.\"\n\npickle:\n\t$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle\n\t@echo\n\t@echo \"Build finished; now you can process the pickle files.\"\n\njson:\n\t$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json\n\t@echo\n\t@echo \"Build finished; now you can process the JSON files.\"\n\nhtmlhelp:\n\t$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp\n\t@echo\n\t@echo \"Build finished; now you can run HTML Help Workshop with the\" \\\n\t      \".hhp project file in $(BUILDDIR)/htmlhelp.\"\n\nqthelp:\n\t$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp\n\t@echo\n\t@echo \"Build finished; now you can run \"qcollectiongenerator\" with the\" \\\n\t      \".qhcp project file in $(BUILDDIR)/qthelp, like this:\"\n\t@echo \"# qcollectiongenerator $(BUILDDIR)/qthelp/redis-py.qhcp\"\n\t@echo \"To view the help file:\"\n\t@echo \"# assistant -collectionFile $(BUILDDIR)/qthelp/redis-py.qhc\"\n\ndevhelp:\n\t$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp\n\t@echo\n\t@echo \"Build finished.\"\n\t@echo \"To view the help file:\"\n\t@echo \"# mkdir -p $$HOME/.local/share/devhelp/redis-py\"\n\t@echo \"# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/redis-py\"\n\t@echo \"# devhelp\"\n\nepub:\n\t$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub\n\t@echo\n\t@echo \"Build finished. The epub file is in $(BUILDDIR)/epub.\"\n\nlatex:\n\t$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex\n\t@echo\n\t@echo \"Build finished; the LaTeX files are in $(BUILDDIR)/latex.\"\n\t@echo \"Run \\`make' in that directory to run these through (pdf)latex\" \\\n\t      \"(use \\`make latexpdf' here to do that automatically).\"\n\nlatexpdf:\n\t$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex\n\t@echo \"Running LaTeX files through pdflatex...\"\n\t$(MAKE) -C $(BUILDDIR)/latex all-pdf\n\t@echo \"pdflatex finished; the PDF files are in $(BUILDDIR)/latex.\"\n\ntext:\n\t$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text\n\t@echo\n\t@echo \"Build finished. The text files are in $(BUILDDIR)/text.\"\n\nman:\n\t$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man\n\t@echo\n\t@echo \"Build finished. The manual pages are in $(BUILDDIR)/man.\"\n\ntexinfo:\n\t$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo\n\t@echo\n\t@echo \"Build finished. The Texinfo files are in $(BUILDDIR)/texinfo.\"\n\t@echo \"Run \\`make' in that directory to run these through makeinfo\" \\\n\t      \"(use \\`make info' here to do that automatically).\"\n\ninfo:\n\t$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo\n\t@echo \"Running Texinfo files through makeinfo...\"\n\tmake -C $(BUILDDIR)/texinfo info\n\t@echo \"makeinfo finished; the Info files are in $(BUILDDIR)/texinfo.\"\n\ngettext:\n\t$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale\n\t@echo\n\t@echo \"Build finished. The message catalogs are in $(BUILDDIR)/locale.\"\n\nchanges:\n\t$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes\n\t@echo\n\t@echo \"The overview file is in $(BUILDDIR)/changes.\"\n\nlinkcheck:\n\t$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck\n\t@echo\n\t@echo \"Link check complete; look for any errors in the above output \" \\\n\t      \"or in $(BUILDDIR)/linkcheck/output.txt.\"\n\ndoctest:\n\t$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest\n\t@echo \"Testing of doctests in the sources finished, look at the \" \\\n\t      \"results in $(BUILDDIR)/doctest/output.txt.\"\n"
  },
  {
    "path": "docs/_static/.keep",
    "content": ""
  },
  {
    "path": "docs/_templates/.keep",
    "content": ""
  },
  {
    "path": "docs/advanced_features.rst",
    "content": "Advanced Features\n=================\n\nA note about threading\n----------------------\n\nRedis client instances can safely be shared between threads. Internally,\nconnection instances are only retrieved from the connection pool during\ncommand execution, and returned to the pool directly after. Command\nexecution never modifies state on the client instance.\n\nHowever, there is one caveat: the Redis SELECT command. The SELECT\ncommand allows you to switch the database currently in use by the\nconnection. That database remains selected until another is selected or\nuntil the connection is closed. This creates an issue in that\nconnections could be returned to the pool that are connected to a\ndifferent database.\n\nAs a result, redis-py does not implement the SELECT command on client\ninstances. If you use multiple Redis databases within the same\napplication, you should create a separate client instance (and possibly\na separate connection pool) for each database.\n\nIt is not safe to pass PubSub or Pipeline objects between threads.\n\nPipelines\n---------\n\nDefault pipelines\n~~~~~~~~~~~~~~~~~\n\nPipelines are a subclass of the base Redis class that provide support\nfor buffering multiple commands to the server in a single request. They\ncan be used to dramatically increase the performance of groups of\ncommands by reducing the number of back-and-forth TCP packets between\nthe client and server.\n\nPipelines are quite simple to use:\n\n.. code:: python\n\n   >>> r = redis.Redis(...)\n   >>> r.set('bing', 'baz')\n   >>> # Use the pipeline() method to create a pipeline instance\n   >>> pipe = r.pipeline()\n   >>> # The following SET commands are buffered\n   >>> pipe.set('foo', 'bar')\n   >>> pipe.get('bing')\n   >>> # the EXECUTE call sends all buffered commands to the server, returning\n   >>> # a list of responses, one for each command.\n   >>> pipe.execute()\n   [True, b'baz']\n\nFor ease of use, all commands being buffered into the pipeline return\nthe pipeline object itself. Therefore calls can be chained like:\n\n.. code:: python\n\n   >>> pipe.set('foo', 'bar').sadd('faz', 'baz').incr('auto_number').execute()\n   [True, True, 6]\n\nIn addition, pipelines can also ensure the buffered commands are\nexecuted atomically as a group. This happens by default. If you want to\ndisable the atomic nature of a pipeline but still want to buffer\ncommands, you can turn off transactions.\n\n.. code:: python\n\n   >>> pipe = r.pipeline(transaction=False)\n\nA common issue occurs when requiring atomic transactions but needing to\nretrieve values in Redis prior for use within the transaction. For\ninstance, let's assume that the INCR command didn't exist and we need to\nbuild an atomic version of INCR in Python.\n\nThe completely naive implementation could GET the value, increment it in\nPython, and SET the new value back. However, this is not atomic because\nmultiple clients could be doing this at the same time, each getting the\nsame value from GET.\n\nEnter the WATCH command. WATCH provides the ability to monitor one or\nmore keys prior to starting a transaction. If any of those keys change\nprior the execution of that transaction, the entire transaction will be\ncanceled and a WatchError will be raised. To implement our own\nclient-side INCR command, we could do something like this:\n\n.. code:: python\n\n   >>> with r.pipeline() as pipe:\n   ...     while True:\n   ...         try:\n   ...             # put a WATCH on the key that holds our sequence value\n   ...             pipe.watch('OUR-SEQUENCE-KEY')\n   ...             # after WATCHing, the pipeline is put into immediate execution\n   ...             # mode until we tell it to start buffering commands again.\n   ...             # this allows us to get the current value of our sequence\n   ...             current_value = pipe.get('OUR-SEQUENCE-KEY')\n   ...             next_value = int(current_value) + 1\n   ...             # now we can put the pipeline back into buffered mode with MULTI\n   ...             pipe.multi()\n   ...             pipe.set('OUR-SEQUENCE-KEY', next_value)\n   ...             # and finally, execute the pipeline (the set command)\n   ...             pipe.execute()\n   ...             # if a WatchError wasn't raised during execution, everything\n   ...             # we just did happened atomically.\n   ...             break\n   ...        except WatchError:\n   ...             # another client must have changed 'OUR-SEQUENCE-KEY' between\n   ...             # the time we started WATCHing it and the pipeline's execution.\n   ...             # our best bet is to just retry.\n   ...             continue\n\nNote that, because the Pipeline must bind to a single connection for the\nduration of a WATCH, care must be taken to ensure that the connection is\nreturned to the connection pool by calling the reset() method. If the\nPipeline is used as a context manager (as in the example above) reset()\nwill be called automatically. Of course you can do this the manual way\nby explicitly calling reset():\n\n.. code:: python\n\n   >>> pipe = r.pipeline()\n   >>> while True:\n   ...     try:\n   ...         pipe.watch('OUR-SEQUENCE-KEY')\n   ...         ...\n   ...         pipe.execute()\n   ...         break\n   ...     except WatchError:\n   ...         continue\n   ...     finally:\n   ...         pipe.reset()\n\nA convenience method named \"transaction\" exists for handling all the\nboilerplate of handling and retrying watch errors. It takes a callable\nthat should expect a single parameter, a pipeline object, and any number\nof keys to be WATCHed. Our client-side INCR command above can be written\nlike this, which is much easier to read:\n\n.. code:: python\n\n   >>> def client_side_incr(pipe):\n   ...     current_value = pipe.get('OUR-SEQUENCE-KEY')\n   ...     next_value = int(current_value) + 1\n   ...     pipe.multi()\n   ...     pipe.set('OUR-SEQUENCE-KEY', next_value)\n   >>>\n   >>> r.transaction(client_side_incr, 'OUR-SEQUENCE-KEY')\n   [True]\n\nBe sure to call pipe.multi() in the callable passed to Redis.transaction\nprior to any write commands.\n\nPipelines in clusters\n~~~~~~~~~~~~~~~~~~~~~\n\nClusterPipeline is a subclass of RedisCluster that provides support for\nRedis pipelines in cluster mode. When calling the execute() command, all\nthe commands are grouped by the node on which they will be executed, and\nare then executed by the respective nodes in parallel. The pipeline\ninstance will wait for all the nodes to respond before returning the\nresult to the caller. Command responses are returned as a list sorted in\nthe same order in which they were sent. Pipelines can be used to\ndramatically increase the throughput of Redis Cluster by significantly\nreducing the number of network round trips between the client and\nthe server.\n\n.. code:: python\n\n   >>> rc = RedisCluster()\n   >>> with rc.pipeline() as pipe:\n   ...     pipe.set('foo', 'value1')\n   ...     pipe.set('bar', 'value2')\n   ...     pipe.get('foo')\n   ...     pipe.get('bar')\n   ...     print(pipe.execute())\n   [True, True, b'value1', b'value2']\n   ...     pipe.set('foo1', 'bar1').get('foo1').execute()\n   [True, b'bar1']\n\nPlease note:\n\n-  RedisCluster pipelines currently only support key-based commands.\n-  The pipeline gets its ‘load_balancing_strategy’ value from the\n   cluster’s parameter. Thus, if read from replications is enabled in\n   the cluster instance, the pipeline will also direct read commands to\n   replicas.\n\n\nTransactions in clusters\n~~~~~~~~~~~~~~~~~~~~~~~~\n\nTransactions are supported in cluster-mode with one caveat: all keys of\nall commands issued on a transaction pipeline must reside on the\nsame slot. This is similar to the limitation of multikey commands in\ncluster. The reason behind this is that the Redis engine does not offer\na mechanism to block or exchange key data across nodes on the fly. A\nclient may add some logic to abstract engine limitations when running\non a cluster, such as the pipeline behavior explained on the previous\nblock, but there is no simple way that a client can enforce atomicity\nacross nodes on a distributed system.\n\nThe compromise of limiting the transaction pipeline to same-slot keys\nis exactly that: a compromise. While this behavior is different from\nnon-transactional cluster pipelines, it simplifies migration of clients\nfrom standalone to cluster under some circumstances. Note that application\ncode that issues multi/exec commands on a standalone client without\nembedding them within a pipeline would eventually get ‘AttributeError’.\nWith this approach, if the application uses ‘client.pipeline(transaction=True)’,\nthen switching the client with a cluster-aware instance would simplify\ncode changes (to some extent). This may be true for application code that\nmakes use of hash keys, since its transactions may already be\nmapping all commands to the same slot.\n\nAn alternative is some kind of two-step commit solution, where a slot\nvalidation is run before the actual commands are run. This could work\nwith controlled node maintenance but does not cover single node failures.\n\nGiven the cluster limitations for transactions, by default pipeline isn't in\ntransactional mode. To enable transactional context set:\n\n.. code:: python\n\n   >>> p = rc.pipeline(transaction=True)\n\nAfter entering the transactional context you can add commands to a transactional\ncontext, by one of the following ways:\n\n.. code:: python\n\n   >>> p = rc.pipeline(transaction=True) # Chaining commands\n   >>> p.set(\"key\", \"value\")\n   >>> p.get(\"key\")\n   >>> response = p.execute()\n\nOr\n\n.. code:: python\n\n   >>> with rc.pipeline(transaction=True) as pipe: # Using context manager\n   ...     pipe.set(\"key\", \"value\")\n   ...     pipe.get(\"key\")\n   ...     response = pipe.execute()\n\nAs you see there's no need to explicitly send `MULTI/EXEC` commands to control context start/end\n`ClusterPipeline` will take care of it.\n\nTo ensure that different keys will be mapped to a same hash slot on the server side\nprepend your keys with the same hash tag, the technique that allows you to control\nkeys distribution.\nMore information `here <https://redis.io/docs/latest/operate/oss_and_stack/reference/cluster-spec/#hash-tags>`_\n\n.. code:: python\n\n   >>> with rc.pipeline(transaction=True) as pipe:\n   ...     pipe.set(\"{tag}foo\", \"bar\")\n   ...     pipe.set(\"{tag}bar\", \"foo\")\n   ...     pipe.get(\"{tag}foo\")\n   ...     pipe.get(\"{tag}bar\")\n   ...     response = pipe.execute()\n\nCAS Transactions\n~~~~~~~~~~~~~~~~~~~~~~~~\n\nIf you want to apply optimistic locking for certain keys, you have to execute\n`WATCH` command in transactional context. `WATCH` command follows the same limitations\nas any other multi key command - all keys should be mapped to the same hash slot.\n\nHowever, the difference between CAS transaction and normal one is that you have to\nexplicitly call MULTI command to indicate the start of transactional context, WATCH\ncommand itself and any subsequent commands before MULTI will be immediately executed\non the server side so you can apply optimistic locking and get necessary data before\ntransaction execution.\n\n.. code:: python\n\n   >>> with rc.pipeline(transaction=True) as pipe:\n   ...     pipe.watch(\"mykey\")       # Apply locking by immediately executing command\n   ...     val = pipe.get(\"mykey\")   # Immediately retrieves value\n   ...     val = val + 1             # Increment value\n   ...     pipe.multi()              # Starting transaction context\n   ...     pipe.set(\"mykey\", val)    # Command will be pipelined\n   ...     response = pipe.execute() # Returns OK or None if key was modified in the meantime\n\n\nPublish / Subscribe\n-------------------\n\nredis-py includes a PubSub object that subscribes to channels and\nlistens for new messages. Creating a PubSub object is easy.\n\n.. code:: python\n\n   >>> r = redis.Redis(...)\n   >>> p = r.pubsub()\n\nOnce a PubSub instance is created, channels and patterns can be\nsubscribed to.\n\n.. code:: python\n\n   >>> p.subscribe('my-first-channel', 'my-second-channel', ...)\n   >>> p.psubscribe('my-*', ...)\n\nThe PubSub instance is now subscribed to those channels/patterns. The\nsubscription confirmations can be seen by reading messages from the\nPubSub instance.\n\n.. code:: python\n\n   >>> p.get_message()\n   {'pattern': None, 'type': 'subscribe', 'channel': b'my-second-channel', 'data': 1}\n   >>> p.get_message()\n   {'pattern': None, 'type': 'subscribe', 'channel': b'my-first-channel', 'data': 2}\n   >>> p.get_message()\n   {'pattern': None, 'type': 'psubscribe', 'channel': b'my-*', 'data': 3}\n\nEvery message read from a PubSub instance will be a dictionary with the\nfollowing keys.\n\n-  **type**: One of the following: 'subscribe', 'unsubscribe',\n   'psubscribe', 'punsubscribe', 'message', 'pmessage'\n-  **channel**: The channel [un]subscribed to or the channel a message\n   was published to\n-  **pattern**: The pattern that matched a published message's channel.\n   Will be None in all cases except for 'pmessage' types.\n-  **data**: The message data. With [un]subscribe messages, this value\n   will be the number of channels and patterns the connection is\n   currently subscribed to. With [p]message messages, this value will be\n   the actual published message.\n\nLet's send a message now.\n\n.. code:: python\n\n   # the publish method returns the number matching channel and pattern\n   # subscriptions. 'my-first-channel' matches both the 'my-first-channel'\n   # subscription and the 'my-*' pattern subscription, so this message will\n   # be delivered to 2 channels/patterns\n   >>> r.publish('my-first-channel', 'some data')\n   2\n   >>> p.get_message()\n   {'channel': b'my-first-channel', 'data': b'some data', 'pattern': None, 'type': 'message'}\n   >>> p.get_message()\n   {'channel': b'my-first-channel', 'data': b'some data', 'pattern': b'my-*', 'type': 'pmessage'}\n\nUnsubscribing works just like subscribing. If no arguments are passed to\n[p]unsubscribe, all channels or patterns will be unsubscribed from.\n\n.. code:: python\n\n   >>> p.unsubscribe()\n   >>> p.punsubscribe('my-*')\n   >>> p.get_message()\n   {'channel': b'my-second-channel', 'data': 2, 'pattern': None, 'type': 'unsubscribe'}\n   >>> p.get_message()\n   {'channel': b'my-first-channel', 'data': 1, 'pattern': None, 'type': 'unsubscribe'}\n   >>> p.get_message()\n   {'channel': b'my-*', 'data': 0, 'pattern': None, 'type': 'punsubscribe'}\n\nredis-py also allows you to register callback functions to handle\npublished messages. Message handlers take a single argument, the\nmessage, which is a dictionary just like the examples above. To\nsubscribe to a channel or pattern with a message handler, pass the\nchannel or pattern name as a keyword argument with its value being the\ncallback function.\n\nWhen a message is read on a channel or pattern with a message handler,\nthe message dictionary is created and passed to the message handler. In\nthis case, a None value is returned from get_message() since the message\nwas already handled.\n\n.. code:: python\n\n   >>> def my_handler(message):\n   ...     print('MY HANDLER: ', message['data'])\n   >>> p.subscribe(**{'my-channel': my_handler})\n   # read the subscribe confirmation message\n   >>> p.get_message()\n   {'pattern': None, 'type': 'subscribe', 'channel': b'my-channel', 'data': 1}\n   >>> r.publish('my-channel', 'awesome data')\n   1\n   # for the message handler to work, we need tell the instance to read data.\n   # this can be done in several ways (read more below). we'll just use\n   # the familiar get_message() function for now\n   >>> message = p.get_message()\n   MY HANDLER:  awesome data\n   # note here that the my_handler callback printed the string above.\n   # `message` is None because the message was handled by our handler.\n   >>> print(message)\n   None\n\nIf your application is not interested in the (sometimes noisy)\nsubscribe/unsubscribe confirmation messages, you can ignore them by\npassing ignore_subscribe_messages=True to r.pubsub(). This will cause\nall subscribe/unsubscribe messages to be read, but they won't bubble up\nto your application.\n\n.. code:: python\n\n   >>> p = r.pubsub(ignore_subscribe_messages=True)\n   >>> p.subscribe('my-channel')\n   >>> p.get_message()  # hides the subscribe message and returns None\n   >>> r.publish('my-channel', 'my data')\n   1\n   >>> p.get_message()\n   {'channel': b'my-channel', 'data': b'my data', 'pattern': None, 'type': 'message'}\n\nThere are three different strategies for reading messages.\n\nThe examples above have been using pubsub.get_message(). Behind the\nscenes, get_message() uses the system's 'select' module to quickly poll\nthe connection's socket. If there's data available to be read,\nget_message() will read it, format the message and return it or pass it\nto a message handler. If there's no data to be read, get_message() will\nimmediately return None. This makes it trivial to integrate into an\nexisting event loop inside your application.\n\n.. code:: python\n\n   >>> while True:\n   >>>     message = p.get_message()\n   >>>     if message:\n   >>>         # do something with the message\n   >>>     time.sleep(0.001)  # be nice to the system :)\n\nOlder versions of redis-py only read messages with pubsub.listen().\nlisten() is a generator that blocks until a message is available. If\nyour application doesn't need to do anything else but receive and act on\nmessages received from redis, listen() is an easy way to get up an\nrunning.\n\n.. code:: python\n\n   >>> for message in p.listen():\n   ...     # do something with the message\n\nThe third option runs an event loop in a separate thread.\npubsub.run_in_thread() creates a new thread and starts the event loop.\nThe thread object is returned to the caller of run_in_thread(). The\ncaller can use the thread.stop() method to shut down the event loop and\nthread. Behind the scenes, this is simply a wrapper around get_message()\nthat runs in a separate thread, essentially creating a tiny non-blocking\nevent loop for you. run_in_thread() takes an optional sleep_time\nargument. If specified, the event loop will call time.sleep() with the\nvalue in each iteration of the loop.\n\nNote: Since we're running in a separate thread, there's no way to handle\nmessages that aren't automatically handled with registered message\nhandlers. Therefore, redis-py prevents you from calling run_in_thread()\nif you're subscribed to patterns or channels that don't have message\nhandlers attached.\n\n.. code:: python\n\n   >>> p.subscribe(**{'my-channel': my_handler})\n   >>> thread = p.run_in_thread(sleep_time=0.001)\n   # the event loop is now running in the background processing messages\n   # when it's time to shut it down...\n   >>> thread.stop()\n\nrun_in_thread also supports an optional exception handler, which lets\nyou catch exceptions that occur within the worker thread and handle them\nappropriately. The exception handler will take as arguments the\nexception itself, the pubsub object, and the worker thread returned by\nrun_in_thread.\n\n.. code:: python\n\n   >>> p.subscribe(**{'my-channel': my_handler})\n   >>> def exception_handler(ex, pubsub, thread):\n   >>>     print(ex)\n   >>>     thread.stop()\n   >>> thread = p.run_in_thread(exception_handler=exception_handler)\n\nA PubSub object adheres to the same encoding semantics as the client\ninstance it was created from. Any channel or pattern that's unicode will\nbe encoded using the encoding specified on the client before being sent\nto Redis. If the client's decode_responses flag is set the False (the\ndefault), the 'channel', 'pattern' and 'data' values in message\ndictionaries will be byte strings (str on Python 2, bytes on Python 3).\nIf the client's decode_responses is True, then the 'channel', 'pattern'\nand 'data' values will be automatically decoded to unicode strings using\nthe client's encoding.\n\nPubSub objects remember what channels and patterns they are subscribed\nto. In the event of a disconnection such as a network error or timeout,\nthe PubSub object will re-subscribe to all prior channels and patterns\nwhen reconnecting. Messages that were published while the client was\ndisconnected cannot be delivered. When you're finished with a PubSub\nobject, call its .close() method to shutdown the connection.\n\n.. code:: python\n\n   >>> p = r.pubsub()\n   >>> ...\n   >>> p.close()\n\nThe PUBSUB set of subcommands CHANNELS, NUMSUB and NUMPAT are also\nsupported:\n\n.. code:: python\n\n   >>> r.pubsub_channels()\n   [b'foo', b'bar']\n   >>> r.pubsub_numsub('foo', 'bar')\n   [(b'foo', 9001), (b'bar', 42)]\n   >>> r.pubsub_numsub('baz')\n   [(b'baz', 0)]\n   >>> r.pubsub_numpat()\n   1204\n\nSharded pubsub\n~~~~~~~~~~~~~~\n\n`Sharded pubsub <https://redis.io/docs/interact/pubsub/#:~:text=Sharded%20Pub%2FSub%20helps%20to,the%20shard%20of%20a%20cluster.>`_ is a feature introduced with Redis 7.0, and fully supported by redis-py as of 5.0. It helps scale the usage of pub/sub in cluster mode, by having the cluster shard messages to nodes that own a slot for a shard channel. Here, the cluster ensures the published shard messages are forwarded to the appropriate nodes. Clients subscribe to a channel by connecting to either the master responsible for the slot, or any of its replicas.\n\nThis makes use of the `SSUBSCRIBE <https://redis.io/commands/ssubscribe>`_ and `SPUBLISH <https://redis.io/commands/spublish>`_ commands within Redis.\n\nThe following, is a simplified example:\n\n.. code:: python\n\n    >>> from redis.cluster import RedisCluster, ClusterNode\n    >>> r = RedisCluster(startup_nodes=[ClusterNode('localhost', 6379), ClusterNode('localhost', 6380)])\n    >>> p = r.pubsub()\n    >>> p.ssubscribe('foo')\n    >>> # assume someone sends a message along the channel via a publish\n    >>> message = p.get_sharded_message()\n\nSimilarly, the same process can be used to acquire sharded pubsub messages, that have already been sent to a specific node, by passing the node to get_sharded_message:\n\n.. code:: python\n\n    >>> from redis.cluster import RedisCluster, ClusterNode\n    >>> first_node = ClusterNode['localhost', 6379]\n    >>> second_node = ClusterNode['localhost', 6380]\n    >>> r = RedisCluster(startup_nodes=[first_node, second_node])\n    >>> p = r.pubsub()\n    >>> p.ssubscribe('foo')\n    >>> # assume someone sends a message along the channel via a publish\n    >>> message = p.get_sharded_message(target_node=second_node)\n\n\nMonitor\n~~~~~~~\n\nredis-py includes a Monitor object that streams every command processed\nby the Redis server. Use listen() on the Monitor object to block until a\ncommand is received.\n\n.. code:: python\n\n   >>> r = redis.Redis(...)\n   >>> with r.monitor() as m:\n   >>>     for command in m.listen():\n   >>>         print(command)\n"
  },
  {
    "path": "docs/backoff.rst",
    "content": ".. _backoff-label:\n\nBackoff\n#############\n\n.. automodule:: redis.backoff\n    :members: "
  },
  {
    "path": "docs/clustering.rst",
    "content": "Clustering\n==========\n\nredis-py now supports cluster mode and provides a client for `Redis\nCluster <https://redis.io/topics/cluster-tutorial>`__.\n\nThe cluster client is based on Grokzen’s\n`redis-py-cluster <https://github.com/Grokzen/redis-py-cluster>`__, has\nadded bug fixes, and now supersedes that library. Support for these\nchanges is thanks to his contributions.\n\nTo learn more about Redis Cluster, see `Redis Cluster\nspecifications <https://redis.io/topics/cluster-spec>`__.\n\n`Creating clusters <#creating-clusters>`__ \\| `Specifying Target\nNodes <#specifying-target-nodes>`__ \\| `Multi-key\nCommands <#multi-key-commands>`__ \\| `Known PubSub\nLimitations <#known-pubsub-limitations>`__\n\nConnecting to cluster\n---------------------\n\nConnecting redis-py to a Redis Cluster instance(s) requires at a minimum\na single node for cluster discovery. There are multiple ways in which a\ncluster instance can be created:\n\n-  Using ‘host’ and ‘port’ arguments:\n\n.. code:: python\n\n   >>> from redis.cluster import RedisCluster as Redis\n   >>> rc = Redis(host='localhost', port=6379)\n   >>> print(rc.get_nodes())\n       [[host=127.0.0.1,port=6379,name=127.0.0.1:6379,server_type=primary,redis_connection=Redis<ConnectionPool<Connection<host=127.0.0.1,port=6379,db=0>>>], [host=127.0.0.1,port=6378,name=127.0.0.1:6378,server_type=primary,redis_connection=Redis<ConnectionPool<Connection<host=127.0.0.1,port=6378,db=0>>>], [host=127.0.0.1,port=6377,name=127.0.0.1:6377,server_type=replica,redis_connection=Redis<ConnectionPool<Connection<host=127.0.0.1,port=6377,db=0>>>]]\n\n-  Using the Redis URL specification:\n\n.. code:: python\n\n   >>> from redis.cluster import RedisCluster as Redis\n   >>> rc = Redis.from_url(\"redis://localhost:6379/0\")\n\n-  Directly, via the ClusterNode class:\n\n.. code:: python\n\n   >>> from redis.cluster import RedisCluster as Redis\n   >>> from redis.cluster import ClusterNode\n   >>> nodes = [ClusterNode('localhost', 6379), ClusterNode('localhost', 6378)]\n   >>> rc = Redis(startup_nodes=nodes)\n\nWhen a RedisCluster instance is being created it first attempts to\nestablish a connection to one of the provided startup nodes. If none of\nthe startup nodes are reachable, a ‘RedisClusterException’ will be\nthrown. After a connection to the one of the cluster’s nodes is\nestablished, the RedisCluster instance will be initialized with 3\ncaches: a slots cache which maps each of the 16384 slots to the node/s\nhandling them, a nodes cache that contains ClusterNode objects (name,\nhost, port, redis connection) for all of the cluster’s nodes, and a\ncommands cache contains all the server supported commands that were\nretrieved using the Redis ‘COMMAND’ output. See *RedisCluster specific\noptions* below for more.\n\nRedisCluster instance can be directly used to execute Redis commands.\nWhen a command is being executed through the cluster instance, the\ntarget node(s) will be internally determined. When using a key-based\ncommand, the target node will be the node that holds the key’s slot.\nCluster management commands and other commands that are not key-based\nhave a parameter called ‘target_nodes’ where you can specify which nodes\nto execute the command on. In the absence of target_nodes, the command\nwill be executed on the default cluster node. As part of cluster\ninstance initialization, the cluster’s default node is randomly selected\nfrom the cluster’s primaries, and will be updated upon reinitialization.\nUsing r.get_default_node(), you can get the cluster’s default node, or\nyou can change it using the ‘set_default_node’ method.\n\nThe ‘target_nodes’ parameter is explained in the following section,\n‘Specifying Target Nodes’.\n\n.. code:: python\n\n   >>> # target-nodes: the node that holds 'foo1's key slot\n   >>> rc.set('foo1', 'bar1')\n   >>> # target-nodes: the node that holds 'foo2's key slot\n   >>> rc.set('foo2', 'bar2')\n   >>> # target-nodes: the node that holds 'foo1's key slot\n   >>> print(rc.get('foo1'))\n   b'bar'\n   >>> # target-node: default-node\n   >>> print(rc.keys())\n   [b'foo1']\n   >>> # target-node: default-node\n   >>> rc.ping()\n\nSpecifying Target Nodes\n-----------------------\n\nAs mentioned above, all non key-based RedisCluster commands accept the\nkwarg parameter ‘target_nodes’ that specifies the node/nodes that the\ncommand should be executed on. The best practice is to specify target\nnodes using RedisCluster class’s node flags: PRIMARIES, REPLICAS,\nALL_NODES, RANDOM. When a nodes flag is passed along with a command, it\nwill be internally resolved to the relevant node/s. If the nodes\ntopology of the cluster changes during the execution of a command, the\nclient will be able to resolve the nodes flag again with the new\ntopology and attempt to retry executing the command.\n\n.. code:: python\n\n   >>> from redis.cluster import RedisCluster as Redis\n   >>> # run cluster-meet command on all of the cluster's nodes\n   >>> rc.cluster_meet('127.0.0.1', 6379, target_nodes=Redis.ALL_NODES)\n   >>> # ping all replicas\n   >>> rc.ping(target_nodes=Redis.REPLICAS)\n   >>> # ping a random node\n   >>> rc.ping(target_nodes=Redis.RANDOM)\n   >>> # get the keys from all cluster nodes\n   >>> rc.keys(target_nodes=Redis.ALL_NODES)\n   [b'foo1', b'foo2']\n   >>> # execute bgsave in all primaries\n   >>> rc.bgsave(Redis.PRIMARIES)\n\nYou could also pass ClusterNodes directly if you want to execute a\ncommand on a specific node / node group that isn’t addressed by the\nnodes flag. However, if the command execution fails due to cluster\ntopology changes, a retry attempt will not be made, since the passed\ntarget node/s may no longer be valid, and the relevant cluster or\nconnection error will be returned.\n\n.. code:: python\n\n   >>> node = rc.get_node('localhost', 6379)\n   >>> # Get the keys only for that specific node\n   >>> rc.keys(target_nodes=node)\n   >>> # get Redis info from a subset of primaries\n   >>> subset_primaries = [node for node in rc.get_primaries() if node.port > 6378]\n   >>> rc.info(target_nodes=subset_primaries)\n\nIn addition, the RedisCluster instance can query the Redis instance of a\nspecific node and execute commands on that node directly. The Redis\nclient, however, does not handle cluster failures and retries.\n\n.. code:: python\n\n   >>> cluster_node = rc.get_node(host='localhost', port=6379)\n   >>> print(cluster_node)\n   [host=127.0.0.1,port=6379,name=127.0.0.1:6379,server_type=primary,redis_connection=Redis<ConnectionPool<Connection<host=127.0.0.1,port=6379,db=0>>>]\n   >>> r = cluster_node.redis_connection\n   >>> r.client_list()\n   [{'id': '276', 'addr': '127.0.0.1:64108', 'fd': '16', 'name': '', 'age': '0', 'idle': '0', 'flags': 'N', 'db': '0', 'sub': '0', 'psub': '0', 'multi': '-1', 'qbuf': '26', 'qbuf-free': '32742', 'argv-mem': '10', 'obl': '0', 'oll': '0', 'omem': '0', 'tot-mem': '54298', 'events': 'r', 'cmd': 'client', 'user': 'default'}]\n   >>> # Get the keys only for that specific node\n   >>> r.keys()\n   [b'foo1']\n\nMulti-key Commands\n------------------\n\nRedis supports multi-key commands in Cluster Mode, such as Set type\nunions or intersections, mset and mget, as long as the keys all hash to\nthe same slot. By using RedisCluster client, you can use the known\nfunctions (e.g. mget, mset) to perform an atomic multi-key operation.\nHowever, you must ensure all keys are mapped to the same slot, otherwise\na RedisClusterException will be thrown. Redis Cluster implements a\nconcept called hash tags that can be used in order to force certain keys\nto be stored in the same hash slot, see `Keys hash\ntag <https://redis.io/topics/cluster-spec#keys-hash-tags>`__. You can\nalso use nonatomic for some of the multikey operations, and pass keys\nthat aren’t mapped to the same slot. The client will then map the keys\nto the relevant slots, sending the commands to the slots’ node owners.\nNon-atomic operations batch the keys according to their hash value, and\nthen each batch is sent separately to the slot’s owner.\n\n.. code:: python\n\n   # Atomic operations can be used when all keys are mapped to the same slot\n   >>> rc.mset({'{foo}1': 'bar1', '{foo}2': 'bar2'})\n   >>> rc.mget('{foo}1', '{foo}2')\n   [b'bar1', b'bar2']\n   # Non-atomic multi-key operations splits the keys into different slots\n   >>> rc.mset_nonatomic({'foo': 'value1', 'bar': 'value2', 'zzz': 'value3')\n   >>> rc.mget_nonatomic('foo', 'bar', 'zzz')\n   [b'value1', b'value2', b'value3']\n\n**Cluster PubSub:**\n\nWhen a ClusterPubSub instance is created without specifying a node, a\nsingle node will be transparently chosen for the pubsub connection on\nthe first command execution. The node will be determined by: 1. Hashing\nthe channel name in the request to find its keyslot 2. Selecting a node\nthat handles the keyslot: If read_from_replicas is set to true or\nload_balancing_strategy is provided, a replica can be selected.\n\nKnown PubSub Limitations\n------------------------\n\nPattern subscribe and publish do not currently work properly due to key\nslots. If we hash a pattern like fo\\* we will receive a keyslot for that\nstring but there are endless possibilities for channel names based on\nthis pattern - unknowable in advance. This feature is not disabled but\nthe commands are not currently recommended for use. See\n`redis-py-cluster\ndocumentation <https://redis-py-cluster.readthedocs.io/en/stable/pubsub.html>`__\nfor more.\n\n.. code:: python\n\n   >>> p1 = rc.pubsub()\n   # p1 connection will be set to the node that holds 'foo' keyslot\n   >>> p1.subscribe('foo')\n   # p2 connection will be set to node 'localhost:6379'\n   >>> p2 = rc.pubsub(rc.get_node('localhost', 6379))\n\n**Read Only Mode**\n\nBy default, Redis Cluster always returns MOVE redirection response on\naccessing a replica node. You can overcome this limitation and scale\nread commands by triggering READONLY mode.\n\nTo enable READONLY mode pass read_from_replicas=True or define\na load_balancing_strategy to RedisCluster constructor.\nWhen read_from_replicas is set to true read commands will be assigned between\nthe primary and its replications in a Round-Robin manner.\nWith load_balancing_strategy you can define a custom strategy for\nassigning read commands to the replicas and primary nodes.\n\nREADONLY mode can be set at runtime by calling the readonly() method\nwith target_nodes=‘replicas’, and read-write access can be restored by\ncalling the readwrite() method.\n\n.. code:: python\n\n   >>> from cluster import RedisCluster as Redis\n   # Use 'debug' log level to print the node that the command is executed on\n   >>> rc_readonly = Redis(startup_nodes=startup_nodes,\n   ...                     read_from_replicas=True)\n   >>> rc_readonly.set('{foo}1', 'bar1')\n   >>> for i in range(0, 4):\n   ...     # Assigns read command to the slot's hosts in a Round-Robin manner\n   ...     rc_readonly.get('{foo}1')\n   # set command would be directed only to the slot's primary node\n   >>> rc_readonly.set('{foo}2', 'bar2')\n   # reset READONLY flag\n   >>> rc_readonly.readwrite(target_nodes='replicas')\n   # now the get command would be directed only to the slot's primary node\n   >>> rc_readonly.get('{foo}1')\n"
  },
  {
    "path": "docs/commands.rst",
    "content": "Redis Commands\n##############\n\nCore Commands\n*************\n\nThe following functions can be used to replicate their equivalent `Redis command <https://redis.io/commands>`_.  Generally they can be used as functions on your redis connection.  For the simplest example, see below:\n\nGetting and settings data in redis::\n\n   import redis\n   r = redis.Redis(decode_responses=True)\n   r.set('mykey', 'thevalueofmykey')\n   r.get('mykey')\n\n.. autoclass:: redis.commands.core.CoreCommands\n   :inherited-members:\n\nSentinel Commands\n*****************\n.. autoclass:: redis.commands.sentinel.SentinelCommands\n   :inherited-members:\n\nRedis Cluster Commands\n**********************\n\nThe following `Redis commands <https://redis.io/commands>`_ are available within a `Redis Cluster <https://redis.io/topics/cluster-tutorial>`_.  Generally they can be used as functions on your redis connection.\n\n.. autoclass:: redis.commands.cluster.RedisClusterCommands\n   :inherited-members:\n"
  },
  {
    "path": "docs/conf.py",
    "content": "# redis-py documentation build configuration file, created by\n# sphinx-quickstart on Fri Feb  8 00:47:08 2013.\n#\n# This file is execfile()d with the current directory set to its containing\n# dir.\n#\n# Note that not all possible configuration values are present in this\n# autogenerated file.\n#\n# All configuration values have a default; values that are commented out\n# serve to show the default.\n\nimport datetime\nimport os\nimport sys\n\n# If extensions (or modules to document with autodoc) are in another directory,\n# add these directories to sys.path here. If the directory is relative to the\n# documentation root, use os.path.abspath to make it absolute, like shown here.\n# sys.path.insert(0, os.path.abspath('.'))\nsys.path.append(os.path.abspath(os.path.pardir))\n\n# -- General configuration ----------------------------------------------------\n\n# If your documentation needs a minimal Sphinx version, state it here.\n# needs_sphinx = '1.0'\n\n# Add any Sphinx extension module names here, as strings. They can be\n# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones.\nextensions = [\n    \"nbsphinx\",\n    \"sphinx_gallery.load_style\",\n    \"sphinx.ext.autodoc\",\n    \"sphinx.ext.viewcode\",\n    \"sphinx.ext.autosectionlabel\",\n    \"sphinx.ext.napoleon\",\n]\n\n# Napoleon settings. We only accept Google-style docstrings.\nnapoleon_google_docstring = True\nnapoleon_numpy_docstring = False\n\n# AutosectionLabel settings.\n# Uses a <page>:<label> schema which doesn't work for duplicate sub-section\n# labels, so set max depth.\nautosectionlabel_prefix_document = True\nautosectionlabel_maxdepth = 2\n\n# AutodocTypehints settings.\nautodoc_typehints = 'description'\nalways_document_param_types = True\ntypehints_defaults = \"comma\"\n\n# Add any paths that contain templates here, relative to this directory.\ntemplates_path = [\"_templates\"]\n\n# The suffix of source filenames.\nsource_suffix = \".rst\"\n\n# The encoding of source files.\n# source_encoding = 'utf-8-sig'\n\n# The master toctree document.\nmaster_doc = \"index\"\n\n# General information about the project.\nproject = \"redis-py\"\ncurrent_year = datetime.datetime.now().year\ncopyright = f\"{current_year}, Redis Inc\"\n\n# The version info for the project you're documenting, acts as replacement for\n# |version| and |release|, also used in various other places throughout the\n# built documents.\n#\n# The short X.Y version.\nimport redis\n\nversion = \".\".join(redis.__version__.split(\".\")[0:3])\nrelease = version\nif version == \"99.99.99\":\n    release = \"dev\"\n\n# The language for content autogenerated by Sphinx. Refer to documentation\n# for a list of supported languages.\n# language = None\n\n# There are two options for replacing |today|: either, you set today to some\n# non-false value, then it is used:\n# today = ''\n# Else, today_fmt is used as the format for a strftime call.\n# today_fmt = '%B %d, %Y'\n\n# List of patterns, relative to source directory, that match files and\n# directories to ignore when looking for source files.\nexclude_patterns = [\"_build\", \"**.ipynb_checkpoints\"]\n\n# The reST default role (used for this markup: `text`) to use for all\n# documents.\n# default_role = None\n\n# If true, '()' will be appended to :func: etc. cross-reference text.\n# add_function_parentheses = True\n\n# If true, the current module name will be prepended to all description\n# unit titles (such as .. function::).\n# add_module_names = True\n\n# If true, sectionauthor and moduleauthor directives will be shown in the\n# output. They are ignored by default.\n# show_authors = False\n\n# The name of the Pygments (syntax highlighting) style to use.\npygments_style = \"tango\"\n\n# A list of ignored prefixes for module index sorting.\n# modindex_common_prefix = []\n\nnitpicky = True\n\n\n# -- Options for HTML output --------------------------------------------------\n\n# The theme to use for HTML and HTML Help pages.  See the documentation for\n# a list of builtin themes.\nhtml_theme = \"furo\"\n\n# Theme options are theme-specific and customize the look and feel of a theme\n# further.  For a list of options available for each theme, see the\n# documentation.\nhtml_theme_options = {\n    \"footer_icons\": [\n        {\n            \"name\": \"GitHub\",\n            \"url\": \"https://github.com/redis/redis-py\",\n            \"html\": \"\"\"\n            <svg stroke=\"currentColor\" fill=\"currentColor\" stroke-width=\"0\" viewBox=\"0 0 16 16\">\n                <path fill-rule=\"evenodd\" d=\"M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z\"></path>\n            </svg>\n        \"\"\",\n            \"class\": \"\",\n        },\n    ],\n    \"source_repository\": \"https://github.com/redis/redis-py/\",\n    \"source_branch\": \"master\",\n    \"source_directory\": \"docs/\",\n}\n\n# Add any paths that contain custom themes here, relative to this directory.\n# html_theme_path = []\n\n# The name for this set of Sphinx documents.  If None, it defaults to\n# \"<project> v<release> documentation\".\n# html_title = None\n\n# A shorter title for the navigation bar.  Default is the same as html_title.\n# html_short_title = None\n\n# The name of an image file (relative to this directory) to place at the top\n# of the sidebar.\nhtml_logo = \"_static/logo-redis.svg\"\n\n# The name of an image file (within the static path) to use as favicon of the\n# docs.  This file should be a Windows icon file (.ico) being 16x16 or 32x32\n# pixels large.\n# html_favicon = None\n\n# Add any paths that contain custom static files (such as style sheets) here,\n# relative to this directory. They are copied after the builtin static files,\n# so a file named \"default.css\" will overwrite the builtin \"default.css\".\nhtml_static_path = [\"_static\", \"images\"]\n\n# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,\n# using the given strftime format.\n# html_last_updated_fmt = '%b %d, %Y'\n\n# If true, SmartyPants will be used to convert quotes and dashes to\n# typographically correct entities.\n# html_use_smartypants = True\n\n# Custom sidebar templates, maps document names to template names.\n# html_sidebars = {}\n\n# Additional templates that should be rendered to pages, maps page names to\n# template names.\n# html_additional_pages = {}\n\n# If false, no module index is generated.\n# html_domain_indices = True\n\n# If false, no index is generated.\n# html_use_index = True\n\n# If true, the index is split into individual pages for each letter.\n# html_split_index = False\n\n# If true, links to the reST sources are added to the pages.\n# html_show_sourcelink = True\n\n# If true, \"Created using Sphinx\" is shown in the HTML footer. Default is True.\n# html_show_sphinx = True\n\n# If true, \"(C) Copyright ...\" is shown in the HTML footer. Default is True.\n# html_show_copyright = True\n\n# If true, an OpenSearch description file will be output, and all pages will\n# contain a <link> tag referring to it.  The value of this option must be the\n# base URL from which the finished HTML is served.\n# html_use_opensearch = ''\n\n# This is the file name suffix for HTML files (e.g. \".xhtml\").\n# html_file_suffix = None\n\n# Output file base name for HTML help builder.\nhtmlhelp_basename = \"redis-pydoc\"\n\n\n# -- Options for LaTeX output -------------------------------------------------\n\nlatex_elements = {\n    # The paper size ('letterpaper' or 'a4paper').\n    # 'papersize': 'letterpaper',\n    # The font size ('10pt', '11pt' or '12pt').\n    # 'pointsize': '10pt',\n    # Additional stuff for the LaTeX preamble.\n    # 'preamble': '',\n}\n\n# Grouping the document tree into LaTeX files. List of tuples\n# (source start file, target name, title, author, documentclass\n# [howto/manual]).\nlatex_documents = [\n    (\"index\", \"redis-py.tex\", \"redis-py Documentation\", \"Redis Inc\", \"manual\")\n]\n\n# The name of an image file (relative to this directory) to place at the top of\n# the title page.\n# latex_logo = None\n\n# For \"manual\" documents, if this is true, then toplevel headings are parts,\n# not chapters.\n# latex_use_parts = False\n\n# If true, show page references after internal links.\n# latex_show_pagerefs = False\n\n# If true, show URL addresses after external links.\n# latex_show_urls = False\n\n# Documents to append as an appendix to all manuals.\n# latex_appendices = []\n\n# If false, no module index is generated.\n# latex_domain_indices = True\n\n\n# -- Options for manual page output -------------------------------------------\n\n# One entry per manual page. List of tuples\n# (source start file, name, description, authors, manual section).\nman_pages = [(\"index\", \"redis-py\", \"redis-py Documentation\", [\"Andy McCurdy\"], 1)]\n\n# If true, show URL addresses after external links.\n# man_show_urls = False\n\n\n# -- Options for Texinfo output -----------------------------------------------\n\n# Grouping the document tree into Texinfo files. List of tuples\n# (source start file, target name, title, author,\n#  dir menu entry, description, category)\ntexinfo_documents = [\n    (\n        \"index\",\n        \"redis-py\",\n        \"redis-py Documentation\",\n        \"Redis Inc\",\n        \"redis-py\",\n        \"One line description of project.\",\n        \"Miscellaneous\",\n    )\n]\n\n# Documents to append as an appendix to all manuals.\n# texinfo_appendices = []\n\n# If false, no module index is generated.\n# texinfo_domain_indices = True\n\n# How to display URL addresses: 'footnote', 'no', or 'inline'.\n# texinfo_show_urls = 'footnote'\n\nepub_title = \"redis-py\"\nepub_author = \"Redis Inc\"\nepub_publisher = \"Redis Inc\"\nepub_copyright = \"2023, Redis Inc\"\n"
  },
  {
    "path": "docs/connections.rst",
    "content": "Connecting to Redis\n###################\n\n\nGeneric Client\n**************\n\nThis is the client used to connect directly to a standard Redis node.\n\n.. autoclass:: redis.Redis\n   :members:\n\n\nSentinel Client\n***************\n\nRedis `Sentinel <https://redis.io/topics/sentinel>`_ provides high availability for Redis. There are commands that can only be executed against a Redis node running in sentinel mode. Connecting to those nodes, and executing commands against them requires a Sentinel connection.\n\nConnection example (assumes Redis exists on the ports listed below):\n\n   >>> from redis import Sentinel\n   >>> sentinel = Sentinel([('localhost', 26379)], socket_timeout=0.1)\n   >>> sentinel.discover_master('mymaster')\n   ('127.0.0.1', 6379)\n   >>> sentinel.discover_slaves('mymaster')\n   [('127.0.0.1', 6380)]\n\nSentinel\n========\n.. autoclass:: redis.sentinel.Sentinel\n    :members:\n\nSentinelConnectionPool\n======================\n.. autoclass:: redis.sentinel.SentinelConnectionPool\n    :members:\n\n\nCluster Client\n**************\n\nThis client is used for connecting to a Redis Cluster.\n\nRedisCluster\n============\n.. autoclass:: redis.cluster.RedisCluster\n    :members:\n\nClusterNode\n===========\n.. autoclass:: redis.cluster.ClusterNode\n    :members:\n\n\nAsync Client\n************\n\nSee complete example: `here <examples/asyncio_examples.html>`__\n\nThis client is used for communicating with Redis, asynchronously.\n\n.. autoclass:: redis.asyncio.client.Redis\n    :members:\n\n\nAsync Cluster Client\n********************\n\nRedisCluster (Async)\n====================\n.. autoclass:: redis.asyncio.cluster.RedisCluster\n    :members:\n    :member-order: bysource\n\nClusterNode (Async)\n===================\n.. autoclass:: redis.asyncio.cluster.ClusterNode\n    :members:\n    :member-order: bysource\n\nClusterPipeline (Async)\n=======================\n.. autoclass:: redis.asyncio.cluster.ClusterPipeline\n    :members: execute_command, execute\n    :member-order: bysource\n\n\nConnection\n**********\n\nSee complete example: `here <examples/connection_examples.html>`__\n\nConnection\n==========\n.. autoclass:: redis.connection.Connection\n    :members:\n\nConnection (Async)\n==================\n.. autoclass:: redis.asyncio.connection.Connection\n    :members:\n\n\nConnection Pools\n****************\n\nSee complete example: `here <examples/connection_examples.html>`__\n\nConnectionPool\n==============\n.. autoclass:: redis.connection.ConnectionPool\n    :members:\n\nConnectionPool (Async)\n======================\n.. autoclass:: redis.asyncio.connection.ConnectionPool\n    :members:\n"
  },
  {
    "path": "docs/examples/README.md",
    "content": "# Examples\n\nExamples of redis-py usage go here. They're being linked to the [generated documentation](https://redis.readthedocs.org).\n"
  },
  {
    "path": "docs/examples/asyncio_examples.ipynb",
    "content": "{\n \"cells\": [\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {\n    \"collapsed\": true,\n    \"pycharm\": {\n     \"name\": \"#%% md\\n\"\n    }\n   },\n   \"source\": [\n    \"# Asyncio Examples\\n\",\n    \"\\n\",\n    \"All commands are coroutine functions.\\n\",\n    \"\\n\",\n    \"## Connecting and Disconnecting\\n\",\n    \"\\n\",\n    \"Using asyncio Redis requires an explicit disconnect of the connection since there is no asyncio deconstructor magic method. By default, an internal connection pool is created on `redis.Redis()` and attached to the `Redis` instance. When calling `Redis.aclose` this internal connection pool closes automatically, which disconnects all connections.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 1,\n   \"metadata\": {\n    \"collapsed\": false,\n    \"pycharm\": {\n     \"name\": \"#%%\\n\"\n    }\n   },\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"Ping successful: True\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"import redis.asyncio as redis\\n\",\n    \"\\n\",\n    \"client = redis.Redis()\\n\",\n    \"print(f\\\"Ping successful: {await client.ping()}\\\")\\n\",\n    \"await client.aclose()\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"If you create a custom `ConnectionPool` to be used by a single `Redis` instance, use the `Redis.from_pool` class method. The Redis client will take ownership of the connection pool. This will cause the pool to be disconnected along with the Redis instance. Disconnecting the connection pool simply disconnects all connections hosted in the pool.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"import redis.asyncio as redis\\n\",\n    \"\\n\",\n    \"pool = redis.ConnectionPool.from_url(\\\"redis://localhost\\\")\\n\",\n    \"client = redis.Redis.from_pool(pool)\\n\",\n    \"await client.aclose()\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {\n    \"collapsed\": false,\n    \"pycharm\": {\n     \"name\": \"#%% md\\n\"\n    }\n   },\n   \"source\": [\n    \"\\n\",\n    \"However, if the `ConnectionPool` is to be shared by several `Redis` instances, you should use the `connection_pool` argument, and you may want to disconnect the connection pool explicitly.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 2,\n   \"metadata\": {\n    \"collapsed\": false,\n    \"pycharm\": {\n     \"name\": \"#%%\\n\"\n    }\n   },\n   \"outputs\": [],\n   \"source\": [\n    \"import redis.asyncio as redis\\n\",\n    \"\\n\",\n    \"pool = redis.ConnectionPool.from_url(\\\"redis://localhost\\\")\\n\",\n    \"client1 = redis.Redis(connection_pool=pool)\\n\",\n    \"client2 = redis.Redis(connection_pool=pool)\\n\",\n    \"await client1.aclose()\\n\",\n    \"await client2.aclose()\\n\",\n    \"await pool.aclose()\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"By default, this library uses version 2 of the RESP protocol. To enable RESP version 3, you will want to set `protocol` to 3\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"import redis.asyncio as redis\\n\",\n    \"\\n\",\n    \"client = redis.Redis(protocol=3)\\n\",\n    \"await client.aclose()\\n\",\n    \"await client.ping()\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {\n    \"collapsed\": false,\n    \"pycharm\": {\n     \"name\": \"#%% md\\n\"\n    }\n   },\n   \"source\": [\n    \"## Transactions (Multi/Exec)\\n\",\n    \"\\n\",\n    \"The aioredis.Redis.pipeline will return a aioredis.Pipeline object, which will buffer all commands in-memory and compile them into batches using the Redis Bulk String protocol. Additionally, each command will return the Pipeline instance, allowing you to chain your commands, i.e., p.set('foo', 1).set('bar', 2).mget('foo', 'bar').\\n\",\n    \"\\n\",\n    \"The commands will not be reflected in Redis until execute() is called & awaited.\\n\",\n    \"\\n\",\n    \"Usually, when performing a bulk operation, taking advantage of a “transaction” (e.g., Multi/Exec) is to be desired, as it will also add a layer of atomicity to your bulk operation.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 3,\n   \"metadata\": {\n    \"collapsed\": false,\n    \"pycharm\": {\n     \"name\": \"#%%\\n\"\n    }\n   },\n   \"outputs\": [],\n   \"source\": [\n    \"import redis.asyncio as redis\\n\",\n    \"\\n\",\n    \"r = await redis.from_url(\\\"redis://localhost\\\")\\n\",\n    \"async with r.pipeline(transaction=True) as pipe:\\n\",\n    \"    ok1, ok2 = await (pipe.set(\\\"key1\\\", \\\"value1\\\").set(\\\"key2\\\", \\\"value2\\\").execute())\\n\",\n    \"assert ok1\\n\",\n    \"assert ok2\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {\n    \"collapsed\": false,\n    \"pycharm\": {\n     \"name\": \"#%% md\\n\"\n    }\n   },\n   \"source\": [\n    \"## Pub/Sub Mode\\n\",\n    \"\\n\",\n    \"Subscribing to specific channels:\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 4,\n   \"metadata\": {\n    \"collapsed\": false,\n    \"pycharm\": {\n     \"name\": \"#%%\\n\"\n    }\n   },\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"(Reader) Message Received: {'type': 'message', 'pattern': None, 'channel': b'channel:1', 'data': b'Hello'}\\n\",\n      \"(Reader) Message Received: {'type': 'message', 'pattern': None, 'channel': b'channel:2', 'data': b'World'}\\n\",\n      \"(Reader) Message Received: {'type': 'message', 'pattern': None, 'channel': b'channel:1', 'data': b'STOP'}\\n\",\n      \"(Reader) STOP\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"import asyncio\\n\",\n    \"\\n\",\n    \"import redis.asyncio as redis\\n\",\n    \"\\n\",\n    \"STOPWORD = \\\"STOP\\\"\\n\",\n    \"\\n\",\n    \"\\n\",\n    \"async def reader(channel: redis.client.PubSub):\\n\",\n    \"    while True:\\n\",\n    \"        message = await channel.get_message(ignore_subscribe_messages=True, timeout=None)\\n\",\n    \"        if message is not None:\\n\",\n    \"            print(f\\\"(Reader) Message Received: {message}\\\")\\n\",\n    \"            if message[\\\"data\\\"].decode() == STOPWORD:\\n\",\n    \"                print(\\\"(Reader) STOP\\\")\\n\",\n    \"                break\\n\",\n    \"\\n\",\n    \"r = redis.from_url(\\\"redis://localhost\\\")\\n\",\n    \"async with r.pubsub() as pubsub:\\n\",\n    \"    await pubsub.subscribe(\\\"channel:1\\\", \\\"channel:2\\\")\\n\",\n    \"\\n\",\n    \"    future = asyncio.create_task(reader(pubsub))\\n\",\n    \"\\n\",\n    \"    await r.publish(\\\"channel:1\\\", \\\"Hello\\\")\\n\",\n    \"    await r.publish(\\\"channel:2\\\", \\\"World\\\")\\n\",\n    \"    await r.publish(\\\"channel:1\\\", STOPWORD)\\n\",\n    \"\\n\",\n    \"    await future\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {\n    \"collapsed\": false,\n    \"pycharm\": {\n     \"name\": \"#%% md\\n\"\n    }\n   },\n   \"source\": [\n    \"Subscribing to channels matching a glob-style pattern:\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 5,\n   \"metadata\": {\n    \"collapsed\": false,\n    \"pycharm\": {\n     \"name\": \"#%%\\n\"\n    }\n   },\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"(Reader) Message Received: {'type': 'pmessage', 'pattern': b'channel:*', 'channel': b'channel:1', 'data': b'Hello'}\\n\",\n      \"(Reader) Message Received: {'type': 'pmessage', 'pattern': b'channel:*', 'channel': b'channel:2', 'data': b'World'}\\n\",\n      \"(Reader) Message Received: {'type': 'pmessage', 'pattern': b'channel:*', 'channel': b'channel:1', 'data': b'STOP'}\\n\",\n      \"(Reader) STOP\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"import asyncio\\n\",\n    \"\\n\",\n    \"import redis.asyncio as redis\\n\",\n    \"\\n\",\n    \"STOPWORD = \\\"STOP\\\"\\n\",\n    \"\\n\",\n    \"\\n\",\n    \"async def reader(channel: redis.client.PubSub):\\n\",\n    \"    while True:\\n\",\n    \"        message = await channel.get_message(ignore_subscribe_messages=True, timeout=None)\\n\",\n    \"        if message is not None:\\n\",\n    \"            print(f\\\"(Reader) Message Received: {message}\\\")\\n\",\n    \"            if message[\\\"data\\\"].decode() == STOPWORD:\\n\",\n    \"                print(\\\"(Reader) STOP\\\")\\n\",\n    \"                break\\n\",\n    \"\\n\",\n    \"\\n\",\n    \"r = await redis.from_url(\\\"redis://localhost\\\")\\n\",\n    \"async with r.pubsub() as pubsub:\\n\",\n    \"    await pubsub.psubscribe(\\\"channel:*\\\")\\n\",\n    \"\\n\",\n    \"    future = asyncio.create_task(reader(pubsub))\\n\",\n    \"\\n\",\n    \"    await r.publish(\\\"channel:1\\\", \\\"Hello\\\")\\n\",\n    \"    await r.publish(\\\"channel:2\\\", \\\"World\\\")\\n\",\n    \"    await r.publish(\\\"channel:1\\\", STOPWORD)\\n\",\n    \"\\n\",\n    \"    await future\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {\n    \"collapsed\": false,\n    \"pycharm\": {\n     \"name\": \"#%% md\\n\"\n    }\n   },\n   \"source\": [\n    \"## Sentinel Client\\n\",\n    \"\\n\",\n    \"The Sentinel client requires a list of Redis Sentinel addresses to connect to and start discovering services.\\n\",\n    \"\\n\",\n    \"Calling aioredis.sentinel.Sentinel.master_for or aioredis.sentinel.Sentinel.slave_for methods will return Redis clients connected to specified services monitored by Sentinel.\\n\",\n    \"\\n\",\n    \"Sentinel client will detect failover and reconnect Redis clients automatically.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {\n    \"collapsed\": false,\n    \"pycharm\": {\n     \"name\": \"#%%\\n\"\n    }\n   },\n   \"outputs\": [],\n   \"source\": [\n    \"import asyncio\\n\",\n    \"\\n\",\n    \"from redis.asyncio.sentinel import Sentinel\\n\",\n    \"\\n\",\n    \"\\n\",\n    \"sentinel = Sentinel([(\\\"localhost\\\", 26379), (\\\"sentinel2\\\", 26379)])\\n\",\n    \"r = sentinel.master_for(\\\"mymaster\\\")\\n\",\n    \"\\n\",\n    \"ok = await r.set(\\\"key\\\", \\\"value\\\")\\n\",\n    \"assert ok\\n\",\n    \"val = await r.get(\\\"key\\\")\\n\",\n    \"assert val == b\\\"value\\\"\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Connecting to Redis instances by specifying a URL scheme.\\n\",\n    \"Parameters are passed to the following schems, as parameters to the url scheme.\\n\",\n    \"\\n\",\n    \"Three URL schemes are supported:\\n\",\n    \"\\n\",\n    \"- `redis://` creates a TCP socket connection. <https://www.iana.org/assignments/uri-schemes/prov/redis>\\n\",\n    \"- `rediss://` creates a SSL wrapped TCP socket connection. <https://www.iana.org/assignments/uri-schemes/prov/rediss>\\n\",\n    \"- ``unix://``: creates a Unix Domain Socket connection.\\n\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"True\"\n      ]\n     },\n     \"metadata\": {},\n     \"output_type\": \"display_data\"\n    }\n   ],\n   \"source\": [\n    \"import redis.asyncio as redis\\n\",\n    \"url_connection = redis.from_url(\\\"redis://localhost:6379?decode_responses=True\\\")\\n\",\n    \"url_connection.ping()\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"To enable the RESP 3 protocol, append `protocol=3` to the URL.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"import redis.asyncio as redis\\n\",\n    \"\\n\",\n    \"url_connection = redis.from_url(\\\"redis://localhost:6379?decode_responses=True&protocol=3\\\")\\n\",\n    \"url_connection.ping()\"\n   ]\n  }\n ],\n \"metadata\": {\n  \"kernelspec\": {\n   \"display_name\": \"Python 3 (ipykernel)\",\n   \"language\": \"python\",\n   \"name\": \"python3\"\n  },\n  \"language_info\": {\n   \"codemirror_mode\": {\n    \"name\": \"ipython\",\n    \"version\": 3\n   },\n   \"file_extension\": \".py\",\n   \"mimetype\": \"text/x-python\",\n   \"name\": \"python\",\n   \"nbconvert_exporter\": \"python\",\n   \"pygments_lexer\": \"ipython3\",\n   \"version\": \"3.9.7\"\n  }\n },\n \"nbformat\": 4,\n \"nbformat_minor\": 1\n}\n"
  },
  {
    "path": "docs/examples/connection_examples.ipynb",
    "content": "{\n \"cells\": [\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"# Connection Examples\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Connecting to a default Redis instance, running locally.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 2,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"True\"\n      ]\n     },\n     \"execution_count\": 2,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"import redis\\n\",\n    \"\\n\",\n    \"connection = redis.Redis()\\n\",\n    \"connection.ping()\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"### By default Redis return binary responses, to decode them use decode_responses=True\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 3,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"True\"\n      ]\n     },\n     \"execution_count\": 3,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"import redis\\n\",\n    \"\\n\",\n    \"decoded_connection = redis.Redis(decode_responses=True)\\n\",\n    \"decoded_connection.ping()\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"### By default this library uses the RESP 2 protocol. To enable RESP3, set protocol=3.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 4,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"True\"\n      ]\n    },\n     \"execution_count\": 4,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"import redis\\n\",\n    \"\\n\",\n    \"r = redis.Redis(protocol=3)\\n\",\n    \"r.ping()\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Connecting to a redis instance, specifying a host and port with credentials.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 5,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"True\"\n      ]\n     },\n     \"execution_count\": 5,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"import redis\\n\",\n    \"\\n\",\n    \"user_connection = redis.Redis(host='localhost', port=6380, username='dvora', password='redis', decode_responses=True)\\n\",\n    \"user_connection.ping()\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Connecting to a redis instance with username and password credential provider\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"import redis\\n\",\n    \"\\n\",\n    \"creds_provider = redis.UsernamePasswordCredentialProvider(\\\"username\\\", \\\"password\\\")\\n\",\n    \"user_connection = redis.Redis(host=\\\"localhost\\\", port=6379, credential_provider=creds_provider)\\n\",\n    \"user_connection.ping()\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Connecting to a redis instance with standard credential provider\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"from typing import Tuple\\n\",\n    \"import redis\\n\",\n    \"\\n\",\n    \"creds_map = {\\\"user_1\\\": \\\"pass_1\\\",\\n\",\n    \"             \\\"user_2\\\": \\\"pass_2\\\"}\\n\",\n    \"\\n\",\n    \"class UserMapCredentialProvider(redis.CredentialProvider):\\n\",\n    \"    def __init__(self, username: str):\\n\",\n    \"        self.username = username\\n\",\n    \"\\n\",\n    \"    def get_credentials(self) -> Tuple[str, str]:\\n\",\n    \"        return self.username, creds_map.get(self.username)\\n\",\n    \"\\n\",\n    \"# Create a default connection to set the ACL user\\n\",\n    \"default_connection = redis.Redis(host=\\\"localhost\\\", port=6379)\\n\",\n    \"default_connection.acl_setuser(\\n\",\n    \"    \\\"user_1\\\",\\n\",\n    \"    enabled=True,\\n\",\n    \"    passwords=[\\\"+\\\" + \\\"pass_1\\\"],\\n\",\n    \"    keys=\\\"~*\\\",\\n\",\n    \"    commands=[\\\"+ping\\\", \\\"+command\\\", \\\"+info\\\", \\\"+select\\\", \\\"+flushdb\\\"],\\n\",\n    \")\\n\",\n    \"\\n\",\n    \"# Create a UserMapCredentialProvider instance for user_1\\n\",\n    \"creds_provider = UserMapCredentialProvider(\\\"user_1\\\")\\n\",\n    \"# Initiate user connection with the credential provider\\n\",\n    \"user_connection = redis.Redis(host=\\\"localhost\\\", port=6379,\\n\",\n    \"                              credential_provider=creds_provider)\\n\",\n    \"user_connection.ping()\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Connecting to a redis instance first with an initial credential set and then calling the credential provider\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"from typing import Union\\n\",\n    \"import redis\\n\",\n    \"\\n\",\n    \"class InitCredsSetCredentialProvider(redis.CredentialProvider):\\n\",\n    \"    def __init__(self, username, password):\\n\",\n    \"        self.username = username\\n\",\n    \"        self.password = password\\n\",\n    \"        self.call_supplier = False\\n\",\n    \"\\n\",\n    \"    def call_external_supplier(self) -> Union[Tuple[str], Tuple[str, str]]:\\n\",\n    \"        # Call to an external credential supplier\\n\",\n    \"        raise NotImplementedError\\n\",\n    \"\\n\",\n    \"    def get_credentials(self) -> Union[Tuple[str], Tuple[str, str]]:\\n\",\n    \"        if self.call_supplier:\\n\",\n    \"            return self.call_external_supplier()\\n\",\n    \"        # Use the init set only for the first time\\n\",\n    \"        self.call_supplier = True\\n\",\n    \"        return self.username, self.password\\n\",\n    \"\\n\",\n    \"cred_provider = InitCredsSetCredentialProvider(username=\\\"init_user\\\", password=\\\"init_pass\\\")\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {\n    \"collapsed\": false\n   },\n   \"source\": [\n    \"## Connecting to a redis instance with AWS Secrets Manager credential provider.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {\n    \"collapsed\": false,\n    \"pycharm\": {\n     \"name\": \"#%%\\n\"\n    }\n   },\n   \"outputs\": [],\n   \"source\": [\n    \"import redis\\n\",\n    \"import boto3\\n\",\n    \"import json\\n\",\n    \"import cachetools.func\\n\",\n    \"\\n\",\n    \"class SecretsManagerProvider(redis.CredentialProvider):\\n\",\n    \"    def __init__(self, secret_id, version_id=None, version_stage='AWSCURRENT'):\\n\",\n    \"        self.sm_client = boto3.client('secretsmanager')\\n\",\n    \"        self.secret_id = secret_id\\n\",\n    \"        self.version_id = version_id\\n\",\n    \"        self.version_stage = version_stage\\n\",\n    \"\\n\",\n    \"    def get_credentials(self) -> Union[Tuple[str], Tuple[str, str]]:\\n\",\n    \"        @cachetools.func.ttl_cache(maxsize=128, ttl=24 * 60 * 60) #24h\\n\",\n    \"        def get_sm_user_credentials(secret_id, version_id, version_stage):\\n\",\n    \"            secret = self.sm_client.get_secret_value(secret_id, version_id)\\n\",\n    \"            return json.loads(secret['SecretString'])\\n\",\n    \"        creds = get_sm_user_credentials(self.secret_id, self.version_id, self.version_stage)\\n\",\n    \"        return creds['username'], creds['password']\\n\",\n    \"\\n\",\n    \"my_secret_id = \\\"EXAMPLE1-90ab-cdef-fedc-ba987SECRET1\\\"\\n\",\n    \"creds_provider = SecretsManagerProvider(secret_id=my_secret_id)\\n\",\n    \"user_connection = redis.Redis(host=\\\"localhost\\\", port=6379, credential_provider=creds_provider)\\n\",\n    \"user_connection.ping()\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Connecting to a redis instance with ElastiCache IAM credential provider.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 4,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"True\"\n      ]\n     },\n     \"execution_count\": 4,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"from typing import Tuple, Union\\n\",\n    \"from urllib.parse import ParseResult, urlencode, urlunparse\\n\",\n    \"\\n\",\n    \"import botocore.session\\n\",\n    \"import redis\\n\",\n    \"from botocore.model import ServiceId\\n\",\n    \"from botocore.signers import RequestSigner\\n\",\n    \"from cachetools import TTLCache, cached\\n\",\n    \"\\n\",\n    \"class ElastiCacheIAMProvider(redis.CredentialProvider):\\n\",\n    \"    def __init__(self, user, cluster_name, region=\\\"us-east-1\\\"):\\n\",\n    \"        self.user = user\\n\",\n    \"        self.cluster_name = cluster_name\\n\",\n    \"        self.region = region\\n\",\n    \"\\n\",\n    \"        session = botocore.session.get_session()\\n\",\n    \"        self.request_signer = RequestSigner(\\n\",\n    \"            ServiceId(\\\"elasticache\\\"),\\n\",\n    \"            self.region,\\n\",\n    \"            \\\"elasticache\\\",\\n\",\n    \"            \\\"v4\\\",\\n\",\n    \"            session.get_credentials(),\\n\",\n    \"            session.get_component(\\\"event_emitter\\\"),\\n\",\n    \"        )\\n\",\n    \"\\n\",\n    \"    # Generated IAM tokens are valid for 15 minutes\\n\",\n    \"    @cached(cache=TTLCache(maxsize=128, ttl=900))\\n\",\n    \"    def get_credentials(self) -> Union[Tuple[str], Tuple[str, str]]:\\n\",\n    \"        query_params = {\\\"Action\\\": \\\"connect\\\", \\\"User\\\": self.user}\\n\",\n    \"        url = urlunparse(\\n\",\n    \"            ParseResult(\\n\",\n    \"                scheme=\\\"https\\\",\\n\",\n    \"                netloc=self.cluster_name,\\n\",\n    \"                path=\\\"/\\\",\\n\",\n    \"                query=urlencode(query_params),\\n\",\n    \"                params=\\\"\\\",\\n\",\n    \"                fragment=\\\"\\\",\\n\",\n    \"            )\\n\",\n    \"        )\\n\",\n    \"        signed_url = self.request_signer.generate_presigned_url(\\n\",\n    \"            {\\\"method\\\": \\\"GET\\\", \\\"url\\\": url, \\\"body\\\": {}, \\\"headers\\\": {}, \\\"context\\\": {}},\\n\",\n    \"            operation_name=\\\"connect\\\",\\n\",\n    \"            expires_in=900,\\n\",\n    \"            region_name=self.region,\\n\",\n    \"        )\\n\",\n    \"        # RequestSigner only seems to work if the URL has a protocol, but\\n\",\n    \"        # Elasticache only accepts the URL without a protocol\\n\",\n    \"        # So strip it off the signed URL before returning\\n\",\n    \"        return (self.user, signed_url.removeprefix(\\\"https://\\\"))\\n\",\n    \"\\n\",\n    \"username = \\\"barshaul\\\"\\n\",\n    \"cluster_name = \\\"test-001\\\"\\n\",\n    \"endpoint = \\\"test-001.use1.cache.amazonaws.com\\\"\\n\",\n    \"creds_provider = ElastiCacheIAMProvider(user=username, cluster_name=cluster_name)\\n\",\n    \"user_connection = redis.Redis(host=endpoint, port=6379, credential_provider=creds_provider)\\n\",\n    \"user_connection.ping()\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Connecting to Redis instances by specifying a URL scheme.\\n\",\n    \"Parameters are passed to the following schems, as parameters to the url scheme.\\n\",\n    \"\\n\",\n    \"Three URL schemes are supported:\\n\",\n    \"\\n\",\n    \"- `redis://` creates a TCP socket connection. <https://www.iana.org/assignments/uri-schemes/prov/redis>\\n\",\n    \"- `rediss://` creates a SSL wrapped TCP socket connection. <https://www.iana.org/assignments/uri-schemes/prov/rediss>\\n\",\n    \"- ``unix://``: creates a Unix Domain Socket connection.\\n\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 7,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"True\"\n      ]\n     },\n     \"execution_count\": 7,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"url_connection = redis.from_url(\\\"redis://localhost:6379?decode_responses=True&health_check_interval=2\\\")\\n\",\n    \"url_connection.ping()\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Connecting to Redis instances by specifying a URL scheme and the RESP3 protocol.\\n\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"url_connection = redis.from_url(\\\"redis://localhost:6379?decode_responses=True&health_check_interval=2&protocol=3\\\")\\n\",\n    \"url_connection.ping()\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Connecting to a Sentinel instance\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"from redis.sentinel import Sentinel\\n\",\n    \"sentinel = Sentinel([('localhost', 26379)], socket_timeout=0.1)\\n\",\n    \"sentinel.discover_master(\\\"redis-py-test\\\")\"\n   ]\n  }\n ],\n \"metadata\": {\n  \"interpreter\": {\n   \"hash\": \"d45c99ba0feda92868abafa8257cbb4709c97f1a0b5dc62bbeebdf89d4fad7fe\"\n  },\n  \"kernelspec\": {\n   \"display_name\": \"Python 3 (ipykernel)\",\n   \"language\": \"python\",\n   \"name\": \"python3\"\n  },\n  \"language_info\": {\n   \"codemirror_mode\": {\n    \"name\": \"ipython\",\n    \"version\": 3\n   },\n   \"file_extension\": \".py\",\n   \"mimetype\": \"text/x-python\",\n   \"name\": \"python\",\n   \"nbconvert_exporter\": \"python\",\n   \"pygments_lexer\": \"ipython3\",\n   \"version\": \"3.8.12\"\n  }\n },\n \"nbformat\": 4,\n \"nbformat_minor\": 2\n}\n"
  },
  {
    "path": "docs/examples/opentelemetry/README.md",
    "content": "# Example for redis-py OpenTelemetry instrumentation\n\nThis example demonstrates how to monitor Redis using [OpenTelemetry](https://opentelemetry.io/) and\n[Uptrace](https://github.com/uptrace/uptrace). It requires Docker to start Redis Server and Uptrace.\n\nSee\n[Monitoring redis-py performance with OpenTelemetry](https://redis.readthedocs.io/en/latest/opentelemetry.html)\nfor details.\n\n**Step 1**. Download the example using Git:\n\n```shell\ngit clone https://github.com/redis/redis-py.git\ncd example/opentelemetry\n```\n\n**Step 2**. Optionally, create a virtualenv:\n\n```shell\npython3 -m venv .venv\nsource .venv/bin/active\n```\n\n**Step 3**. Install dependencies:\n\n```shell\npip install -e .\n```\n\n**Step 4**. Start the services using Docker and make sure Uptrace is running:\n\n```shell\ndocker-compose up -d\ndocker-compose logs uptrace\n```\n\n**Step 5**. Run the Redis client example and follow the link from the CLI to view the trace:\n\n```shell\npython3 main.py\ntrace: http://localhost:14318/traces/ee029d8782242c8ed38b16d961093b35\n```\n\n![Redis trace](./image/redis-py-trace.png)\n\nYou can also open Uptrace UI at [http://localhost:14318](http://localhost:14318) to view available\nspans, logs, and metrics.\n"
  },
  {
    "path": "docs/examples/opentelemetry/config/alertmanager.yml",
    "content": "# See https://prometheus.io/docs/alerting/latest/configuration/ for details.\n\nglobal:\n  # The smarthost and SMTP sender used for mail notifications.\n  smtp_smarthost: \"mailhog:1025\"\n  smtp_from: \"alertmanager@example.com\"\n  smtp_require_tls: false\n\nreceivers:\n  - name: \"team-X\"\n    email_configs:\n      - to: \"some-receiver@example.com\"\n        send_resolved: true\n\n# The root route on which each incoming alert enters.\nroute:\n  # The labels by which incoming alerts are grouped together. For example,\n  # multiple alerts coming in for cluster=A and alertname=LatencyHigh would\n  # be batched into a single group.\n  group_by: [\"alertname\", \"cluster\", \"service\"]\n\n  # When a new group of alerts is created by an incoming alert, wait at\n  # least 'group_wait' to send the initial notification.\n  # This way ensures that you get multiple alerts for the same group that start\n  # firing shortly after another are batched together on the first\n  # notification.\n  group_wait: 30s\n\n  # When the first notification was sent, wait 'group_interval' to send a batch\n  # of new alerts that started firing for that group.\n  group_interval: 5m\n\n  # If an alert has successfully been sent, wait 'repeat_interval' to\n  # resend them.\n  repeat_interval: 3h\n\n  # A default receiver\n  receiver: team-X\n\n  # All the above attributes are inherited by all child routes and can\n  # overwritten on each.\n\n  # The child route trees.\n  routes:\n    # This route matches error alerts created from spans or logs.\n    - matchers:\n        - alert_kind=\"error\"\n      group_interval: 24h\n      receiver: team-X\n\n# The directory from which notification templates are read.\ntemplates:\n  - \"/etc/alertmanager/template/*.tmpl\"\n"
  },
  {
    "path": "docs/examples/opentelemetry/config/otel-collector.yaml",
    "content": "extensions:\n  health_check:\n  pprof:\n    endpoint: 0.0.0.0:1777\n  zpages:\n    endpoint: 0.0.0.0:55679\n\nreceivers:\n  otlp:\n    protocols:\n      grpc:\n      http:\n  hostmetrics:\n    collection_interval: 10s\n    scrapers:\n      cpu:\n      disk:\n      load:\n      filesystem:\n      memory:\n      network:\n      paging:\n  redis:\n    endpoint: \"redis-server:6379\"\n    collection_interval: 10s\n  jaeger:\n    protocols:\n      grpc:\n\nprocessors:\n  resourcedetection:\n    detectors: [\"system\"]\n  batch:\n    send_batch_size: 10000\n    timeout: 10s\n\nexporters:\n  logging:\n    logLevel: debug\n  otlp:\n    endpoint: uptrace:14317\n    tls:\n      insecure: true\n    headers: { \"uptrace-dsn\": \"http://project2_secret_token@localhost:14317/2\" }\n\nservice:\n  # telemetry:\n  #   logs:\n  #     level: DEBUG\n  pipelines:\n    traces:\n      receivers: [otlp, jaeger]\n      processors: [batch]\n      exporters: [otlp, logging]\n    metrics:\n      receivers: [otlp]\n      processors: [batch]\n      exporters: [otlp]\n    metrics/hostmetrics:\n      receivers: [hostmetrics, redis]\n      processors: [batch, resourcedetection]\n      exporters: [otlp]\n    logs:\n      receivers: [otlp]\n      processors: [batch]\n      exporters: [otlp]\n\n  extensions: [health_check, pprof, zpages]\n"
  },
  {
    "path": "docs/examples/opentelemetry/config/vector.toml",
    "content": "[sources.syslog_logs]\ntype = \"demo_logs\"\nformat = \"syslog\"\ninterval = 0.1\n\n[sources.apache_common_logs]\ntype = \"demo_logs\"\nformat = \"apache_common\"\ninterval = 0.1\n\n[sources.apache_error_logs]\ntype = \"demo_logs\"\nformat = \"apache_error\"\ninterval = 0.1\n\n[sources.json_logs]\ntype = \"demo_logs\"\nformat = \"json\"\ninterval = 0.1\n\n# Parse Syslog logs\n# See the Vector Remap Language reference for more info: https://vrl.dev\n[transforms.parse_logs]\ntype = \"remap\"\ninputs = [\"syslog_logs\"]\nsource = '''\n. = parse_syslog!(string!(.message))\n'''\n\n# Export data to Uptrace.\n[sinks.uptrace]\ntype = \"http\"\ninputs = [\"parse_logs\", \"apache_common_logs\", \"apache_error_logs\", \"json_logs\"]\nencoding.codec = \"json\"\nframing.method = \"newline_delimited\"\ncompression = \"gzip\"\nuri = \"http://uptrace:14318/api/v1/vector/logs\"\n#uri = \"https://api.uptrace.dev/api/v1/vector/logs\"\nheaders.uptrace-dsn = \"http://project2_secret_token@localhost:14317/2\"\n"
  },
  {
    "path": "docs/examples/opentelemetry/docker-compose.yml",
    "content": "version: \"3\"\n\nservices:\n  clickhouse:\n    image: clickhouse/clickhouse-server:22.7\n    restart: on-failure\n    environment:\n      CLICKHOUSE_DB: uptrace\n    healthcheck:\n      test: [\"CMD\", \"wget\", \"--spider\", \"-q\", \"localhost:8123/ping\"]\n      interval: 1s\n      timeout: 1s\n      retries: 30\n    volumes:\n      - ch_data:/var/lib/clickhouse\n    ports:\n      - \"8123:8123\"\n      - \"9000:9000\"\n\n  uptrace:\n    image: \"uptrace/uptrace:1.2.0\"\n    #image: 'uptrace/uptrace-dev:latest'\n    restart: on-failure\n    volumes:\n      - uptrace_data:/var/lib/uptrace\n      - ./uptrace.yml:/etc/uptrace/uptrace.yml\n    #environment:\n    #  - DEBUG=2\n    ports:\n      - \"14317:14317\"\n      - \"14318:14318\"\n    depends_on:\n      clickhouse:\n        condition: service_healthy\n\n  otel-collector:\n    image: otel/opentelemetry-collector-contrib:0.58.0\n    restart: on-failure\n    volumes:\n      - ./config/otel-collector.yaml:/etc/otelcol-contrib/config.yaml\n    ports:\n      - \"4317:4317\"\n      - \"4318:4318\"\n\n  vector:\n    image: timberio/vector:0.24.X-alpine\n    volumes:\n      - ./config/vector.toml:/etc/vector/vector.toml:ro\n\n  alertmanager:\n    image: prom/alertmanager:v0.24.0\n    restart: on-failure\n    volumes:\n      - ./config/alertmanager.yml:/etc/alertmanager/config.yml\n      - alertmanager_data:/alertmanager\n    ports:\n      - 9093:9093\n    command:\n      - \"--config.file=/etc/alertmanager/config.yml\"\n      - \"--storage.path=/alertmanager\"\n\n  mailhog:\n    image: mailhog/mailhog:v1.0.1\n    restart: on-failure\n    ports:\n      - \"8025:8025\"\n\n  redis-server:\n    image: redis\n    ports:\n      - \"6379:6379\"\n  redis-cli:\n    image: redis\n\nvolumes:\n  uptrace_data:\n    driver: local\n  ch_data:\n    driver: local\n  alertmanager_data:\n    driver: local\n"
  },
  {
    "path": "docs/examples/opentelemetry/main.py",
    "content": "#!/usr/bin/env python3\n\nimport time\n\nimport redis\nimport uptrace\nfrom opentelemetry import trace\nfrom opentelemetry.instrumentation.redis import RedisInstrumentor\n\ntracer = trace.get_tracer(\"app_or_package_name\", \"1.0.0\")\n\n\ndef main():\n    uptrace.configure_opentelemetry(\n        dsn=\"http://project2_secret_token@localhost:14317/2\",\n        service_name=\"myservice\",\n        service_version=\"1.0.0\",\n    )\n    RedisInstrumentor().instrument()\n\n    client = redis.StrictRedis(host=\"localhost\", port=6379)\n\n    span = handle_request(client)\n    print(\"trace:\", uptrace.trace_url(span))\n\n    for i in range(10000):\n        handle_request(client)\n        time.sleep(1)\n\n\ndef handle_request(client):\n    with tracer.start_as_current_span(\n        \"handle-request\", kind=trace.SpanKind.CLIENT\n    ) as span:\n        client.get(\"my-key\")\n        client.set(\"hello\", \"world\")\n        client.mset(\n            {\n                \"employee_name\": \"Adam Adams\",\n                \"employee_age\": 30,\n                \"position\": \"Software Engineer\",\n            }\n        )\n\n        pipe = client.pipeline()\n        pipe.set(\"foo\", 5)\n        pipe.set(\"bar\", 18.5)\n        pipe.set(\"blee\", \"hello world!\")\n        pipe.execute()\n\n        return span\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "docs/examples/opentelemetry/requirements.txt",
    "content": "redis==4.3.4\nuptrace==1.14.0\nopentelemetry-instrumentation-redis==0.35b0\n"
  },
  {
    "path": "docs/examples/opentelemetry/uptrace.yml",
    "content": "##\n## Uptrace configuration file.\n## See https://uptrace.dev/get/config.html for details.\n##\n## You can use environment variables anywhere in this file, for example:\n##\n##   foo: $FOO\n##   bar: ${BAR}\n##   baz: ${BAZ:default}\n##\n## To escape `$`, use `$$`, for example:\n##\n##   foo: $$FOO_BAR\n##\n\n##\n## ClickHouse database credentials.\n##\nch:\n  # Connection string for ClickHouse database. For example:\n  # clickhouse://<user>:<password>@<host>:<port>/<database>?sslmode=disable\n  #\n  # See https://clickhouse.uptrace.dev/guide/golang-clickhouse.html#options\n  dsn: \"clickhouse://default:@clickhouse:9000/uptrace?sslmode=disable\"\n\n##\n## A list of pre-configured projects. Each project is fully isolated.\n##\nprojects:\n  # Conventionally, the first project is used to monitor Uptrace itself.\n  - id: 1\n    name: Uptrace\n    # Token grants write access to the project. Keep a secret.\n    token: project1_secret_token\n    pinned_attrs:\n      - service.name\n      - host.name\n      - deployment.environment\n    # Group spans by deployment.environment attribute.\n    group_by_env: false\n    # Group funcs spans by service.name attribute.\n    group_funcs_by_service: false\n\n  # Other projects can be used to monitor your applications.\n  # To monitor micro-services or multiple related services, use a single project.\n  - id: 2\n    name: My project\n    token: project2_secret_token\n    pinned_attrs:\n      - service.name\n      - host.name\n      - deployment.environment\n    # Group spans by deployment.environment attribute.\n    group_by_env: false\n    # Group funcs spans by service.name attribute.\n    group_funcs_by_service: false\n\n##\n## Create metrics from spans and events.\n##\nmetrics_from_spans:\n  - name: uptrace.tracing.spans_duration\n    description: Spans duration (excluding events)\n    instrument: histogram\n    unit: microseconds\n    value: span.duration / 1000\n    attrs:\n      - span.system as system\n      - service.name as service\n      - host.name as host\n      - span.status_code as status\n    where: not span.is_event\n\n  - name: uptrace.tracing.spans\n    description: Spans count (excluding events)\n    instrument: counter\n    unit: 1\n    value: span.count\n    attrs:\n      - span.system as system\n      - service.name as service\n      - host.name as host\n      - span.status_code as status\n    where: not span.is_event\n\n  - name: uptrace.tracing.events\n    description: Events count (excluding spans)\n    instrument: counter\n    unit: 1\n    value: span.count\n    attrs:\n      - span.system as system\n      - service.name as service\n      - host.name as host\n    where: span.is_event\n\n##\n## To require authentication, uncomment the following section.\n##\nauth:\n  # users:\n  #   - username: uptrace\n  #     password: uptrace\n  #   - username: admin\n  #     password: admin\n\n  # # Cloudflare user provider: uses Cloudflare Zero Trust Access (Identity)\n  # # See https://developers.cloudflare.com/cloudflare-one/identity/ for more info.\n  # cloudflare:\n  #   # The base URL of the Cloudflare Zero Trust team.\n  #   - team_url: https://myteam.cloudflareaccess.com\n  #     # The Application Audience (AUD) Tag for this application.\n  #     # You can retrieve this from the Cloudflare Zero Trust 'Access' Dashboard.\n  #     audience: bea6df23b944e4a0cd178609ba1bb64dc98dfe1f66ae7b918e563f6cf28b37e0\n\n  # # OpenID Connect (Single Sign-On)\n  # oidc:\n  #   # The ID is used in API endpoints, for example, in redirect URL\n  #   # `http://<uptrace-host>/api/v1/sso/<oidc-id>/callback`.\n  #   - id: keycloak\n  #     # Display name for the button in the login form.\n  #     # Default to 'OpenID Connect'\n  #     display_name: Keycloak\n  #     # The base URL for the OIDC provider.\n  #     issuer_url: http://localhost:8080/realms/uptrace\n  #     # The OAuth 2.0 Client ID\n  #     client_id: uptrace\n  #     # The OAuth 2.0 Client Secret\n  #     client_secret: ogbhd8Q0X0e5AZFGSG3m9oirPvnetqkA\n  #     # Additional OAuth 2.0 scopes to request from the OIDC provider.\n  #     # Defaults to 'profile'. 'openid' is requested by default and need not be specified.\n  #     scopes:\n  #       - profile\n  #     # The OIDC UserInfo claim to use as the user's username.\n  #     # Defaults to 'preferred_username'.\n  #     claim: preferred_username\n\n##\n## Alerting rules for monitoring metrics.\n##\n## See https://uptrace.dev/get/alerting.html for details.\n##\nalerting:\n  rules:\n    - name: Network errors\n      metrics:\n        - system.network.errors as $net_errors\n      query:\n        - $net_errors > 0 group by host.name\n      # for the last 5 minutes\n      for: 5m\n      annotations:\n        summary: \"{{ $labels.host_name }} has high number of net errors: {{ $values.net_errors }}\"\n\n    - name: Filesystem usage >= 90%\n      metrics:\n        - system.filesystem.usage as $fs_usage\n      query:\n        - group by host.name\n        - group by device\n        - where device !~ \"loop\"\n        - $fs_usage{state=\"used\"} / $fs_usage >= 0.9\n      for: 5m\n      annotations:\n        summary: \"{{ $labels.host_name }} has high FS usage: {{ $values.fs_usage }}\"\n\n    - name: Uptrace is dropping spans\n      metrics:\n        - uptrace.projects.spans as $spans\n      query:\n        - $spans{type=dropped} > 0\n      for: 1m\n      annotations:\n        summary: \"Uptrace has dropped {{ $values.spans }} spans\"\n\n    - name: Always firing (for fun and testing)\n      metrics:\n        - process.runtime.go.goroutines as $goroutines\n      query:\n        - $goroutines >= 0 group by host.name\n      for: 1m\n      annotations:\n        summary: \"{{ $labels.host_name }} has high number of goroutines: {{ $values.goroutines }}\"\n\n  # Create alerts from error logs and span events.\n  create_alerts_from_spans:\n    enabled: true\n    labels:\n      alert_kind: error\n\n##\n## AlertManager client configuration.\n## See https://uptrace.dev/get/alerting.html for details.\n##\n## Note that this is NOT an AlertManager config and you need to configure AlertManager separately.\n## See https://prometheus.io/docs/alerting/latest/configuration/ for details.\n##\nalertmanager_client:\n  # AlertManager API endpoints that Uptrace uses to manage alerts.\n  urls:\n    - \"http://alertmanager:9093/api/v2/alerts\"\n\n##\n## Various options to tweak ClickHouse schema.\n## For changes to take effect, you need reset the ClickHouse database with `ch reset`.\n##\nch_schema:\n  # Compression codec, for example, LZ4, ZSTD(3), or Default.\n  compression: ZSTD(3)\n\n  # Whether to use ReplicatedMergeTree instead of MergeTree.\n  replicated: false\n\n  # Cluster name for Distributed tables and ON CLUSTER clause.\n  #cluster: uptrace1\n\n  spans:\n    storage_policy: \"default\"\n    # Delete spans data after 30 days.\n    ttl_delete: 30 DAY\n\n  metrics:\n    storage_policy: \"default\"\n    # Delete metrics data after 90 days.\n    ttl_delete: 90 DAY\n\n##\n## Addresses on which Uptrace receives gRPC and HTTP requests.\n##\nlisten:\n  # OTLP/gRPC API.\n  grpc:\n    addr: \":14317\"\n    # tls:\n    #   cert_file: config/tls/uptrace.crt\n    #   key_file: config/tls/uptrace.key\n\n  # OTLP/HTTP API and Uptrace API with UI.\n  http:\n    addr: \":14318\"\n    # tls:\n    #   cert_file: config/tls/uptrace.crt\n    #   key_file: config/tls/uptrace.key\n\n##\n## Various options for Uptrace UI.\n##\nsite:\n  # Overrides public URL for Vue-powered UI in case you put Uptrace behind a proxy.\n  #addr: 'https://uptrace.mydomain.com'\n\n##\n## Spans processing options.\n##\nspans:\n  # The size of the Go chan used to buffer incoming spans.\n  # If the buffer is full, Uptrace starts to drop spans.\n  #buffer_size: 100000\n\n  # The number of spans to insert in a single query.\n  #batch_size: 10000\n\n##\n## Metrics processing options.\n##\nmetrics:\n  # List of attributes to drop for being noisy.\n  drop_attrs:\n    - telemetry.sdk.language\n    - telemetry.sdk.name\n    - telemetry.sdk.version\n\n  # The size of the Go chan used to buffer incoming measures.\n  # If the buffer is full, Uptrace starts to drop measures.\n  #buffer_size: 100000\n\n  # The number of measures to insert in a single query.\n  #batch_size: 10000\n\n##\n## SQLite/PostgreSQL db that is used to store metadata such us metric names, dashboards, alerts,\n## and so on.\n##\ndb:\n  # Either sqlite or postgres.\n  driver: sqlite\n  # Database connection string.\n  #\n  # Uptrace automatically creates SQLite database file in the current working directory.\n  # Make sure the directory is writable by Uptrace process.\n  dsn: \"file:uptrace.sqlite3?_pragma=foreign_keys(1)&_pragma=busy_timeout(1000)\"\n\n# Secret key that is used to sign JWT tokens etc.\nsecret_key: 102c1a557c314fc28198acd017960843\n\n# Enable to log HTTP requests and database queries.\ndebug: false\n"
  },
  {
    "path": "docs/examples/opentelemetry_api_examples.ipynb",
    "content": "{\n \"cells\": [\n  {\n   \"cell_type\": \"markdown\",\n   \"id\": \"7b02ea52\",\n   \"metadata\": {},\n   \"source\": [\n    \"# OpenTelemetry Python API\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"id\": \"56520927\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Install OpenTelemetry\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 1,\n   \"id\": \"c0ed8440\",\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"Defaulting to user installation because normal site-packages is not writeable\\n\",\n      \"Requirement already satisfied: opentelemetry-api in /home/vmihailenco/.local/lib/python3.10/site-packages (1.14.0)\\n\",\n      \"Requirement already satisfied: opentelemetry-sdk in /home/vmihailenco/.local/lib/python3.10/site-packages (1.14.0)\\n\",\n      \"Requirement already satisfied: setuptools>=16.0 in /usr/lib/python3/dist-packages (from opentelemetry-api) (59.6.0)\\n\",\n      \"Requirement already satisfied: deprecated>=1.2.6 in /home/vmihailenco/.local/lib/python3.10/site-packages (from opentelemetry-api) (1.2.13)\\n\",\n      \"Requirement already satisfied: opentelemetry-semantic-conventions==0.35b0 in /home/vmihailenco/.local/lib/python3.10/site-packages (from opentelemetry-sdk) (0.35b0)\\n\",\n      \"Requirement already satisfied: typing-extensions>=3.7.4 in /home/vmihailenco/.local/lib/python3.10/site-packages (from opentelemetry-sdk) (4.4.0)\\n\",\n      \"Requirement already satisfied: wrapt<2,>=1.10 in /home/vmihailenco/.local/lib/python3.10/site-packages (from deprecated>=1.2.6->opentelemetry-api) (1.14.1)\\n\",\n      \"Note: you may need to restart the kernel to use updated packages.\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"pip install opentelemetry-api opentelemetry-sdk\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"id\": \"861fa9cb\",\n   \"metadata\": {},\n   \"source\": [\n    \"### Configure OpenTelemetry with console exporter\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 2,\n   \"id\": \"c061b6cb\",\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"from opentelemetry import trace\\n\",\n    \"from opentelemetry.sdk.trace import TracerProvider\\n\",\n    \"from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter\\n\",\n    \"\\n\",\n    \"trace.set_tracer_provider(TracerProvider())\\n\",\n    \"trace.get_tracer_provider().add_span_processor(\\n\",\n    \"    BatchSpanProcessor(ConsoleSpanExporter())\\n\",\n    \")\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"id\": \"ae4a626c\",\n   \"metadata\": {},\n   \"source\": [\n    \"### Create a span using the tracer\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 3,\n   \"id\": \"f918501b\",\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"{\\n\",\n      \"    \\\"name\\\": \\\"operation-name\\\",\\n\",\n      \"    \\\"context\\\": {\\n\",\n      \"        \\\"trace_id\\\": \\\"0xff14cec5f33afeca0d04ced2c2185b39\\\",\\n\",\n      \"        \\\"span_id\\\": \\\"0xd06e73b03bd55b4a\\\",\\n\",\n      \"        \\\"trace_state\\\": \\\"[]\\\"\\n\",\n      \"    },\\n\",\n      \"    \\\"kind\\\": \\\"SpanKind.INTERNAL\\\",\\n\",\n      \"    \\\"parent_id\\\": null,\\n\",\n      \"    \\\"start_time\\\": \\\"2022-12-07T13:46:11.050878Z\\\",\\n\",\n      \"    \\\"end_time\\\": \\\"2022-12-07T13:46:12.051944Z\\\",\\n\",\n      \"    \\\"status\\\": {\\n\",\n      \"        \\\"status_code\\\": \\\"UNSET\\\"\\n\",\n      \"    },\\n\",\n      \"    \\\"attributes\\\": {},\\n\",\n      \"    \\\"events\\\": [],\\n\",\n      \"    \\\"links\\\": [],\\n\",\n      \"    \\\"resource\\\": {\\n\",\n      \"        \\\"attributes\\\": {\\n\",\n      \"            \\\"telemetry.sdk.language\\\": \\\"python\\\",\\n\",\n      \"            \\\"telemetry.sdk.name\\\": \\\"opentelemetry\\\",\\n\",\n      \"            \\\"telemetry.sdk.version\\\": \\\"1.14.0\\\",\\n\",\n      \"            \\\"service.name\\\": \\\"unknown_service\\\"\\n\",\n      \"        },\\n\",\n      \"        \\\"schema_url\\\": \\\"\\\"\\n\",\n      \"    }\\n\",\n      \"}\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"import time\\n\",\n    \"\\n\",\n    \"tracer = trace.get_tracer(\\\"app_or_package_name\\\", \\\"1.0.0\\\")\\n\",\n    \"\\n\",\n    \"# measure the timing of the operation\\n\",\n    \"with tracer.start_as_current_span(\\\"operation-name\\\") as span:\\n\",\n    \"    time.sleep(1)\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"id\": \"ec4267aa\",\n   \"metadata\": {},\n   \"source\": [\n    \"### Record attributes\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 4,\n   \"id\": \"fa9d265f\",\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"{\\n\",\n      \"    \\\"name\\\": \\\"operation-name\\\",\\n\",\n      \"    \\\"context\\\": {\\n\",\n      \"        \\\"trace_id\\\": \\\"0xfc11f0cc7afeefd79134eea639f5c78b\\\",\\n\",\n      \"        \\\"span_id\\\": \\\"0xee791bf3cab65079\\\",\\n\",\n      \"        \\\"trace_state\\\": \\\"[]\\\"\\n\",\n      \"    },\\n\",\n      \"    \\\"kind\\\": \\\"SpanKind.INTERNAL\\\",\\n\",\n      \"    \\\"parent_id\\\": null,\\n\",\n      \"    \\\"start_time\\\": \\\"2022-12-07T13:46:30.886188Z\\\",\\n\",\n      \"    \\\"end_time\\\": \\\"2022-12-07T13:46:31.887323Z\\\",\\n\",\n      \"    \\\"status\\\": {\\n\",\n      \"        \\\"status_code\\\": \\\"UNSET\\\"\\n\",\n      \"    },\\n\",\n      \"    \\\"attributes\\\": {\\n\",\n      \"        \\\"enduser.id\\\": \\\"jupyter\\\",\\n\",\n      \"        \\\"enduser.email\\\": \\\"jupyter@redis-py\\\"\\n\",\n      \"    },\\n\",\n      \"    \\\"events\\\": [],\\n\",\n      \"    \\\"links\\\": [],\\n\",\n      \"    \\\"resource\\\": {\\n\",\n      \"        \\\"attributes\\\": {\\n\",\n      \"            \\\"telemetry.sdk.language\\\": \\\"python\\\",\\n\",\n      \"            \\\"telemetry.sdk.name\\\": \\\"opentelemetry\\\",\\n\",\n      \"            \\\"telemetry.sdk.version\\\": \\\"1.14.0\\\",\\n\",\n      \"            \\\"service.name\\\": \\\"unknown_service\\\"\\n\",\n      \"        },\\n\",\n      \"        \\\"schema_url\\\": \\\"\\\"\\n\",\n      \"    }\\n\",\n      \"}\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"with tracer.start_as_current_span(\\\"operation-name\\\") as span:\\n\",\n    \"    if span.is_recording():\\n\",\n    \"        span.set_attribute(\\\"enduser.id\\\", \\\"jupyter\\\")\\n\",\n    \"        span.set_attribute(\\\"enduser.email\\\", \\\"jupyter@redis-py\\\")\\n\",\n    \"    time.sleep(1)\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"id\": \"e40655de\",\n   \"metadata\": {},\n   \"source\": [\n    \"### Change the span kind\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 5,\n   \"id\": \"af2980ac\",\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"{\\n\",\n      \"    \\\"name\\\": \\\"operation-name\\\",\\n\",\n      \"    \\\"context\\\": {\\n\",\n      \"        \\\"trace_id\\\": \\\"0x2b4d1ba36423e6c17067079f044b5b62\\\",\\n\",\n      \"        \\\"span_id\\\": \\\"0x323d6107cfe594bd\\\",\\n\",\n      \"        \\\"trace_state\\\": \\\"[]\\\"\\n\",\n      \"    },\\n\",\n      \"    \\\"kind\\\": \\\"SpanKind.SERVER\\\",\\n\",\n      \"    \\\"parent_id\\\": null,\\n\",\n      \"    \\\"start_time\\\": \\\"2022-12-07T13:53:20.538393Z\\\",\\n\",\n      \"    \\\"end_time\\\": \\\"2022-12-07T13:53:20.638595Z\\\",\\n\",\n      \"    \\\"status\\\": {\\n\",\n      \"        \\\"status_code\\\": \\\"UNSET\\\"\\n\",\n      \"    },\\n\",\n      \"    \\\"attributes\\\": {},\\n\",\n      \"    \\\"events\\\": [],\\n\",\n      \"    \\\"links\\\": [],\\n\",\n      \"    \\\"resource\\\": {\\n\",\n      \"        \\\"attributes\\\": {\\n\",\n      \"            \\\"telemetry.sdk.language\\\": \\\"python\\\",\\n\",\n      \"            \\\"telemetry.sdk.name\\\": \\\"opentelemetry\\\",\\n\",\n      \"            \\\"telemetry.sdk.version\\\": \\\"1.14.0\\\",\\n\",\n      \"            \\\"service.name\\\": \\\"unknown_service\\\"\\n\",\n      \"        },\\n\",\n      \"        \\\"schema_url\\\": \\\"\\\"\\n\",\n      \"    }\\n\",\n      \"}\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"with tracer.start_as_current_span(\\\"operation-name\\\", kind=trace.SpanKind.SERVER) as span:\\n\",\n    \"    time.sleep(0.1)\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"id\": \"2a9f1d99\",\n   \"metadata\": {},\n   \"source\": [\n    \"### Exceptions are automatically recorded\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 6,\n   \"id\": \"1b453d66\",\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"ename\": \"ValueError\",\n     \"evalue\": \"\",\n     \"output_type\": \"error\",\n     \"traceback\": [\n      \"\\u001b[0;31m---------------------------------------------------------------------------\\u001b[0m\",\n      \"\\u001b[0;31mValueError\\u001b[0m                                Traceback (most recent call last)\",\n      \"Cell \\u001b[0;32mIn[6], line 3\\u001b[0m\\n\\u001b[1;32m      1\\u001b[0m \\u001b[38;5;28;01mwith\\u001b[39;00m tracer\\u001b[38;5;241m.\\u001b[39mstart_as_current_span(\\u001b[38;5;124m\\\"\\u001b[39m\\u001b[38;5;124moperation-name\\u001b[39m\\u001b[38;5;124m\\\"\\u001b[39m, kind\\u001b[38;5;241m=\\u001b[39mtrace\\u001b[38;5;241m.\\u001b[39mSpanKind\\u001b[38;5;241m.\\u001b[39mSERVER) \\u001b[38;5;28;01mas\\u001b[39;00m span:\\n\\u001b[1;32m      2\\u001b[0m     time\\u001b[38;5;241m.\\u001b[39msleep(\\u001b[38;5;241m0.1\\u001b[39m)\\n\\u001b[0;32m----> 3\\u001b[0m     \\u001b[38;5;28;01mraise\\u001b[39;00m \\u001b[38;5;167;01mValueError\\u001b[39;00m\\n\",\n      \"\\u001b[0;31mValueError\\u001b[0m: \"\n     ]\n    },\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"{\\n\",\n      \"    \\\"name\\\": \\\"operation-name\\\",\\n\",\n      \"    \\\"context\\\": {\\n\",\n      \"        \\\"trace_id\\\": \\\"0x20457d98d4456b99810163027c7899de\\\",\\n\",\n      \"        \\\"span_id\\\": \\\"0xf16e4c1620091c72\\\",\\n\",\n      \"        \\\"trace_state\\\": \\\"[]\\\"\\n\",\n      \"    },\\n\",\n      \"    \\\"kind\\\": \\\"SpanKind.SERVER\\\",\\n\",\n      \"    \\\"parent_id\\\": null,\\n\",\n      \"    \\\"start_time\\\": \\\"2022-12-07T13:55:24.108227Z\\\",\\n\",\n      \"    \\\"end_time\\\": \\\"2022-12-07T13:55:24.208771Z\\\",\\n\",\n      \"    \\\"status\\\": {\\n\",\n      \"        \\\"status_code\\\": \\\"ERROR\\\",\\n\",\n      \"        \\\"description\\\": \\\"ValueError: \\\"\\n\",\n      \"    },\\n\",\n      \"    \\\"attributes\\\": {},\\n\",\n      \"    \\\"events\\\": [\\n\",\n      \"        {\\n\",\n      \"            \\\"name\\\": \\\"exception\\\",\\n\",\n      \"            \\\"timestamp\\\": \\\"2022-12-07T13:55:24.208730Z\\\",\\n\",\n      \"            \\\"attributes\\\": {\\n\",\n      \"                \\\"exception.type\\\": \\\"ValueError\\\",\\n\",\n      \"                \\\"exception.message\\\": \\\"\\\",\\n\",\n      \"                \\\"exception.stacktrace\\\": \\\"Traceback (most recent call last):\\\\n  File \\\\\\\"/home/vmihailenco/.local/lib/python3.10/site-packages/opentelemetry/trace/__init__.py\\\\\\\", line 573, in use_span\\\\n    yield span\\\\n  File \\\\\\\"/home/vmihailenco/.local/lib/python3.10/site-packages/opentelemetry/sdk/trace/__init__.py\\\\\\\", line 1033, in start_as_current_span\\\\n    yield span_context\\\\n  File \\\\\\\"/tmp/ipykernel_241440/2787006841.py\\\\\\\", line 3, in <module>\\\\n    raise ValueError\\\\nValueError\\\\n\\\",\\n\",\n      \"                \\\"exception.escaped\\\": \\\"False\\\"\\n\",\n      \"            }\\n\",\n      \"        }\\n\",\n      \"    ],\\n\",\n      \"    \\\"links\\\": [],\\n\",\n      \"    \\\"resource\\\": {\\n\",\n      \"        \\\"attributes\\\": {\\n\",\n      \"            \\\"telemetry.sdk.language\\\": \\\"python\\\",\\n\",\n      \"            \\\"telemetry.sdk.name\\\": \\\"opentelemetry\\\",\\n\",\n      \"            \\\"telemetry.sdk.version\\\": \\\"1.14.0\\\",\\n\",\n      \"            \\\"service.name\\\": \\\"unknown_service\\\"\\n\",\n      \"        },\\n\",\n      \"        \\\"schema_url\\\": \\\"\\\"\\n\",\n      \"    }\\n\",\n      \"}\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"with tracer.start_as_current_span(\\\"operation-name\\\", kind=trace.SpanKind.SERVER) as span:\\n\",\n    \"    time.sleep(0.1)\\n\",\n    \"    raise ValueError\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"id\": \"23708329\",\n   \"metadata\": {},\n   \"source\": [\n    \"### Use nested blocks to create child spans\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 7,\n   \"id\": \"9eb261d7\",\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"{\\n\",\n      \"    \\\"name\\\": \\\"child-span\\\",\\n\",\n      \"    \\\"context\\\": {\\n\",\n      \"        \\\"trace_id\\\": \\\"0x5625fbd0a1be15b49cda0d2bb236d158\\\",\\n\",\n      \"        \\\"span_id\\\": \\\"0xc13b2c102566ffaf\\\",\\n\",\n      \"        \\\"trace_state\\\": \\\"[]\\\"\\n\",\n      \"    },\\n\",\n      \"    \\\"kind\\\": \\\"SpanKind.INTERNAL\\\",\\n\",\n      \"    \\\"parent_id\\\": \\\"0xa5f1a9afdf26173c\\\",\\n\",\n      \"    \\\"start_time\\\": \\\"2022-12-07T13:57:14.011221Z\\\",\\n\",\n      \"    \\\"end_time\\\": \\\"2022-12-07T13:57:14.011279Z\\\",\\n\",\n      \"    \\\"status\\\": {\\n\",\n      \"        \\\"status_code\\\": \\\"UNSET\\\"\\n\",\n      \"    },\\n\",\n      \"    \\\"attributes\\\": {\\n\",\n      \"        \\\"foo\\\": \\\"bar\\\"\\n\",\n      \"    },\\n\",\n      \"    \\\"events\\\": [],\\n\",\n      \"    \\\"links\\\": [],\\n\",\n      \"    \\\"resource\\\": {\\n\",\n      \"        \\\"attributes\\\": {\\n\",\n      \"            \\\"telemetry.sdk.language\\\": \\\"python\\\",\\n\",\n      \"            \\\"telemetry.sdk.name\\\": \\\"opentelemetry\\\",\\n\",\n      \"            \\\"telemetry.sdk.version\\\": \\\"1.14.0\\\",\\n\",\n      \"            \\\"service.name\\\": \\\"unknown_service\\\"\\n\",\n      \"        },\\n\",\n      \"        \\\"schema_url\\\": \\\"\\\"\\n\",\n      \"    }\\n\",\n      \"}\\n\",\n      \"{\\n\",\n      \"    \\\"name\\\": \\\"operation-name\\\",\\n\",\n      \"    \\\"context\\\": {\\n\",\n      \"        \\\"trace_id\\\": \\\"0x5625fbd0a1be15b49cda0d2bb236d158\\\",\\n\",\n      \"        \\\"span_id\\\": \\\"0xa5f1a9afdf26173c\\\",\\n\",\n      \"        \\\"trace_state\\\": \\\"[]\\\"\\n\",\n      \"    },\\n\",\n      \"    \\\"kind\\\": \\\"SpanKind.INTERNAL\\\",\\n\",\n      \"    \\\"parent_id\\\": null,\\n\",\n      \"    \\\"start_time\\\": \\\"2022-12-07T13:57:13.910849Z\\\",\\n\",\n      \"    \\\"end_time\\\": \\\"2022-12-07T13:57:14.011320Z\\\",\\n\",\n      \"    \\\"status\\\": {\\n\",\n      \"        \\\"status_code\\\": \\\"UNSET\\\"\\n\",\n      \"    },\\n\",\n      \"    \\\"attributes\\\": {},\\n\",\n      \"    \\\"events\\\": [],\\n\",\n      \"    \\\"links\\\": [],\\n\",\n      \"    \\\"resource\\\": {\\n\",\n      \"        \\\"attributes\\\": {\\n\",\n      \"            \\\"telemetry.sdk.language\\\": \\\"python\\\",\\n\",\n      \"            \\\"telemetry.sdk.name\\\": \\\"opentelemetry\\\",\\n\",\n      \"            \\\"telemetry.sdk.version\\\": \\\"1.14.0\\\",\\n\",\n      \"            \\\"service.name\\\": \\\"unknown_service\\\"\\n\",\n      \"        },\\n\",\n      \"        \\\"schema_url\\\": \\\"\\\"\\n\",\n      \"    }\\n\",\n      \"}\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"with tracer.start_as_current_span(\\\"operation-name\\\") as span:\\n\",\n    \"    time.sleep(0.1)\\n\",\n    \"    with tracer.start_as_current_span(\\\"child-span\\\") as span:\\n\",\n    \"        span.set_attribute(\\\"foo\\\", \\\"bar\\\")\"\n   ]\n  }\n ],\n \"metadata\": {\n  \"kernelspec\": {\n   \"display_name\": \"Python 3 (ipykernel)\",\n   \"language\": \"python\",\n   \"name\": \"python3\"\n  },\n  \"language_info\": {\n   \"codemirror_mode\": {\n    \"name\": \"ipython\",\n    \"version\": 3\n   },\n   \"file_extension\": \".py\",\n   \"mimetype\": \"text/x-python\",\n   \"name\": \"python\",\n   \"nbconvert_exporter\": \"python\",\n   \"pygments_lexer\": \"ipython3\",\n   \"version\": \"3.10.6\"\n  }\n },\n \"nbformat\": 4,\n \"nbformat_minor\": 5\n}\n"
  },
  {
    "path": "docs/examples/pipeline_examples.ipynb",
    "content": "{\n \"cells\": [\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"# Pipeline examples\\n\",\n    \"\\n\",\n    \"This example show quickly how to use pipelines in `redis-py`.\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Checking that Redis is running\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 1,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"True\"\n      ]\n     },\n     \"execution_count\": 1,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"import redis \\n\",\n    \"\\n\",\n    \"r = redis.Redis(decode_responses=True)\\n\",\n    \"r.ping()\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Simple example\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"### Creating a pipeline instance\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 2,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"pipe = r.pipeline()\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"### Adding commands to the pipeline\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 3,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"Pipeline<ConnectionPool<Connection<host=localhost,port=6379,db=0>>>\"\n      ]\n     },\n     \"execution_count\": 3,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"pipe.set(\\\"a\\\", \\\"a value\\\")\\n\",\n    \"pipe.set(\\\"b\\\", \\\"b value\\\")\\n\",\n    \"\\n\",\n    \"pipe.get(\\\"a\\\")\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"### Executing the pipeline\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 4,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"[True, True, 'a value']\"\n      ]\n     },\n     \"execution_count\": 4,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"pipe.execute()\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"The responses of the three commands are stored in a list. In the above example, the two first boolean indicates that the `set` commands were successful and the last element of the list is the result of the `get(\\\"a\\\")` comand.\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Chained call\\n\",\n    \"\\n\",\n    \"The same result as above can be obtained in one line of code by chaining the opperations.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 5,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"[True, True, 'a value']\"\n      ]\n     },\n     \"execution_count\": 5,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"pipe = r.pipeline()\\n\",\n    \"pipe.set(\\\"a\\\", \\\"a value\\\").set(\\\"b\\\", \\\"b value\\\").get(\\\"a\\\").execute()\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"Here's a slightly more advanced example for chaining complex operations using the builder pattern.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"from dataclasses import dataclass\\n\",\n    \"from typing import Optional\\n\",\n    \"\\n\",\n    \"@dataclass\\n\",\n    \"class User:\\n\",\n    \"    email: str\\n\",\n    \"    username: Optional[str] = None\\n\",\n    \"\\n\",\n    \"@dataclass\\n\",\n    \"class Post:\\n\",\n    \"    title: str\\n\",\n    \"    body: str\\n\",\n    \"    author: Optional[str] = None\\n\",\n    \"\\n\",\n    \"class RedisRepository:\\n\",\n    \"    def __init__(self):\\n\",\n    \"        self.pipeline = r.pipeline()\\n\",\n    \"\\n\",\n    \"    def add_user(self, user: User):\\n\",\n    \"        if not user.username:\\n\",\n    \"            user.username = user.email.split(\\\"@\\\")[0]\\n\",\n    \"        self.pipeline.hset(f\\\"user:{user.username}\\\", mapping={\\\"username\\\": user.username, \\\"email\\\": user.email})\\n\",\n    \"        return self\\n\",\n    \"    \\n\",\n    \"    def add_post(self, post: Post):\\n\",\n    \"        self.pipeline.hset(f\\\"post:#{post.title}#\\\", mapping={\\\"title\\\": post.title, \\\"body\\\": post.body, \\\"author\\\": post.author})\\n\",\n    \"        if post.author:\\n\",\n    \"            self.pipeline.sadd(f\\\"user:{post.author}:posts\\\", f\\\"post:#{post.title}#\\\")\\n\",\n    \"        return self\\n\",\n    \"\\n\",\n    \"    def add_follow(self, follower: str, following: str):\\n\",\n    \"        self.pipeline.sadd(f\\\"user:{follower}:following\\\", following)\\n\",\n    \"        self.pipeline.sadd(f\\\"user:{following}:followers\\\", follower)\\n\",\n    \"        return self\\n\",\n    \"\\n\",\n    \"    def execute(self):\\n\",\n    \"        return self.pipeline.execute()\\n\",\n    \"\\n\",\n    \"pipe = RedisRepository()\\n\",\n    \"results = (pipe\\n\",\n    \"    .add_user(User(email=\\\"alice@example.com\\\"))\\n\",\n    \"    .add_user(User(email=\\\"bob@example.com\\\"))\\n\",\n    \"    .add_follow(\\\"alice\\\", \\\"bob\\\")\\n\",\n    \"    .add_post(Post(title=\\\"Hello World\\\", body=\\\"I'm using Redis!\\\", author=\\\"alice\\\"))\\n\",\n    \"    .execute()\\n\",\n    \")\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Performance comparison\\n\",\n    \"\\n\",\n    \"Using pipelines can improve performance, for more informations, see [Redis documentation about pipelining](https://redis.io/docs/manual/pipelining/). Here is a simple comparison test of performance between basic and pipelined commands (we simply increment a value and measure the time taken by both method).\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 6,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"from datetime import datetime\\n\",\n    \"\\n\",\n    \"incr_value = 100000\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"### Without pipeline\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 7,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"r.set(\\\"incr_key\\\", \\\"0\\\")\\n\",\n    \"\\n\",\n    \"start = datetime.now()\\n\",\n    \"\\n\",\n    \"for _ in range(incr_value):\\n\",\n    \"    r.incr(\\\"incr_key\\\")\\n\",\n    \"res_without_pipeline = r.get(\\\"incr_key\\\")\\n\",\n    \"\\n\",\n    \"time_without_pipeline = (datetime.now() - start).total_seconds()\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 8,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"Without pipeline\\n\",\n      \"================\\n\",\n      \"Time taken:  21.759733\\n\",\n      \"Increment value:  100000\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"print(\\\"Without pipeline\\\")\\n\",\n    \"print(\\\"================\\\")\\n\",\n    \"print(\\\"Time taken: \\\", time_without_pipeline)\\n\",\n    \"print(\\\"Increment value: \\\", res_without_pipeline)\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"### With pipeline\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 9,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"r.set(\\\"incr_key\\\", \\\"0\\\")\\n\",\n    \"\\n\",\n    \"start = datetime.now()\\n\",\n    \"\\n\",\n    \"pipe = r.pipeline()\\n\",\n    \"for _ in range(incr_value):\\n\",\n    \"    pipe.incr(\\\"incr_key\\\")\\n\",\n    \"pipe.get(\\\"incr_key\\\")\\n\",\n    \"res_with_pipeline = pipe.execute()[-1]\\n\",\n    \"\\n\",\n    \"time_with_pipeline = (datetime.now() - start).total_seconds()\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 10,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"With pipeline\\n\",\n      \"=============\\n\",\n      \"Time taken:  2.357863\\n\",\n      \"Increment value:  100000\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"print(\\\"With pipeline\\\")\\n\",\n    \"print(\\\"=============\\\")\\n\",\n    \"print(\\\"Time taken: \\\", time_with_pipeline)\\n\",\n    \"print(\\\"Increment value: \\\", res_with_pipeline)\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"Using pipelines provides the same result in much less time.\"\n   ]\n  }\n ],\n \"metadata\": {\n  \"interpreter\": {\n   \"hash\": \"84048e2f8e89effc8610b2fb270e4858ef00e9403d223856d62b05266db287ca\"\n  },\n  \"kernelspec\": {\n   \"display_name\": \"Python 3.9.2 ('.venv': venv)\",\n   \"language\": \"python\",\n   \"name\": \"python3\"\n  },\n  \"language_info\": {\n   \"codemirror_mode\": {\n    \"name\": \"ipython\",\n    \"version\": 3\n   },\n   \"file_extension\": \".py\",\n   \"mimetype\": \"text/x-python\",\n   \"name\": \"python\",\n   \"nbconvert_exporter\": \"python\",\n   \"pygments_lexer\": \"ipython3\",\n   \"version\": \"3.9.2\"\n  },\n  \"orig_nbformat\": 4\n },\n \"nbformat\": 4,\n \"nbformat_minor\": 2\n}\n"
  },
  {
    "path": "docs/examples/redis-stream-example.ipynb",
    "content": "{\n \"cells\": [\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"# Redis Stream Examples\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## basic config\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 1,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"redis_host = \\\"redis\\\"\\n\",\n    \"stream_key = \\\"skey\\\"\\n\",\n    \"stream2_key = \\\"s2key\\\"\\n\",\n    \"group1 = \\\"grp1\\\"\\n\",\n    \"group2 = \\\"grp2\\\"\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## connection\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 2,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"True\"\n      ]\n     },\n     \"execution_count\": 2,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"import redis\\n\",\n    \"from time import time\\n\",\n    \"from redis.exceptions import ConnectionError, DataError, NoScriptError, RedisError, ResponseError\\n\",\n    \"\\n\",\n    \"r = redis.Redis( redis_host )\\n\",\n    \"r.ping()\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## xadd and xread\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"### add some data to the stream\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 3,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"stream length: 10\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"for i in range(0,10):\\n\",\n    \"    r.xadd( stream_key, { 'ts': time(), 'v': i } )\\n\",\n    \"print( f\\\"stream length: {r.xlen( stream_key )}\\\")\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"### read some data from the stream\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 4,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"[[b'skey', [(b'1710790167982-0', {b'ts': b'1710790167.9824948', b'v': b'0'}), (b'1710790167983-0', {b'ts': b'1710790167.9830241', b'v': b'1'})]]]\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"## read 2 entries from stream_key\\n\",\n    \"l = r.xread( count=2, streams={stream_key:0} )\\n\",\n    \"print(l)\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"### extract data from the returned structure\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 5,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"got data from stream: b'skey'\\n\",\n      \"id: b'1710790167982-0' value: b'0'\\n\",\n      \"id: b'1710790167983-0' value: b'1'\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"first_stream = l[0]\\n\",\n    \"print( f\\\"got data from stream: {first_stream[0]}\\\")\\n\",\n    \"fs_data = first_stream[1]\\n\",\n    \"for id, value in fs_data:\\n\",\n    \"    print( f\\\"id: {id} value: {value[b'v']}\\\")\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"### read more data from the stream\\n\",\n    \"if we call the `xread` with the same arguments we will get the same data\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 6,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"id: b'1710790167982-0' value: b'0'\\n\",\n      \"id: b'1710790167983-0' value: b'1'\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"l = r.xread( count=2, streams={stream_key:0} )\\n\",\n    \"for id, value in l[0][1]:\\n\",\n    \"    print( f\\\"id: {id} value: {value[b'v']}\\\")\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"to get new data we need to change the key passed to the call\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 7,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"id: b'1710790167983-1' value: b'2'\\n\",\n      \"id: b'1710790167983-2' value: b'3'\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"last_id_returned = l[0][1][-1][0]\\n\",\n    \"l = r.xread( count=2, streams={stream_key: last_id_returned} )\\n\",\n    \"for id, value in l[0][1]:\\n\",\n    \"    print( f\\\"id: {id} value: {value[b'v']}\\\")\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 8,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"id: b'1710790167983-3' value: b'4'\\n\",\n      \"id: b'1710790167983-4' value: b'5'\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"last_id_returned = l[0][1][-1][0]\\n\",\n    \"l = r.xread( count=2, streams={stream_key: last_id_returned} )\\n\",\n    \"for id, value in l[0][1]:\\n\",\n    \"    print( f\\\"id: {id} value: {value[b'v']}\\\")\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"to get only newer entries\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 9,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"stream length: 10\\n\",\n      \"after 5s block, got an empty list [], no *new* messages on the stream\\n\",\n      \"stream length: 10\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"print( f\\\"stream length: {r.xlen( stream_key )}\\\")\\n\",\n    \"# wait for 5s for new messages\\n\",\n    \"l = r.xread( count=1, block=5000, streams={stream_key: '$'} )\\n\",\n    \"print( f\\\"after 5s block, got an empty list {l}, no *new* messages on the stream\\\")\\n\",\n    \"print( f\\\"stream length: {r.xlen( stream_key )}\\\")\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"source\": [\n    \"to get the last entry in the stream\"\n   ],\n   \"metadata\": {}\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 1,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"[[b'skey', [(b'1710790167984-0', {b'ts': b'1710790167.9839962', b'v': b'9'})]]]\\n\",\n      \"stream length: 10\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"# read the last available message\\n\",\n    \"l = r.xread( count=1, streams={stream_key: '+'} )\\n\",\n    \"print(l)\\n\",\n    \"print( f\\\"stream length: {r.xlen( stream_key )}\\\")\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"### 2nd stream\\n\",\n    \"Add some messages to a 2nd stream\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 10,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"stream length: 10\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"for i in range(1000,1010):\\n\",\n    \"    r.xadd( stream2_key, { 'v': i } )\\n\",\n    \"print( f\\\"stream length: {r.xlen( stream2_key )}\\\")\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"get messages from the 2 streams\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 11,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"got from b'skey' the entry [(b'1710790167982-0', {b'ts': b'1710790167.9824948', b'v': b'0'})]\\n\",\n      \"got from b's2key' the entry [(b'1710790173142-0', {b'v': b'1000'})]\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"l = r.xread( count=1, streams={stream_key:0,stream2_key:0} )\\n\",\n    \"for k,d in l:\\n\",\n    \"    print(f\\\"got from {k} the entry {d}\\\")\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"# Stream groups\\n\",\n    \"With the groups is possible track, for many consumers, and at the Redis side, which message have been already consumed.\\n\",\n    \"## add some data to streams\\n\",\n    \"Creating 2 streams with 10 messages each.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 12,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"stream 'skey' length: 20\\n\",\n      \"stream 's2key' length: 20\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"def add_some_data_to_stream( sname, key_range ):\\n\",\n    \"    for i in key_range:\\n\",\n    \"        r.xadd( sname, { 'ts': time(), 'v': i } )\\n\",\n    \"    print( f\\\"stream '{sname}' length: {r.xlen( stream_key )}\\\")\\n\",\n    \"\\n\",\n    \"add_some_data_to_stream( stream_key, range(0,10) )\\n\",\n    \"add_some_data_to_stream( stream2_key, range(1000,1010) )\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## use a group to read from the stream\\n\",\n    \"* create a group `grp1` with the stream `skey`, and\\n\",\n    \"* create a group `grp2` with the streams `skey` and `s2key`\\n\",\n    \"\\n\",\n    \"Use the `xinfo_group` to verify the result of the group creation.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 13,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"skey -> group name: b'grp1' with 0 consumers and b'0-0' as last read id\\n\",\n      \"skey -> group name: b'grp2' with 0 consumers and b'0-0' as last read id\\n\",\n      \"s2key -> group name: b'grp2' with 0 consumers and b'0-0' as last read id\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"## create the group\\n\",\n    \"def create_group( skey, gname ):\\n\",\n    \"    try:\\n\",\n    \"        r.xgroup_create( name=skey, groupname=gname, id=0 )\\n\",\n    \"    except ResponseError as e:\\n\",\n    \"        print(f\\\"raised: {e}\\\")\\n\",\n    \"\\n\",\n    \"# group1 read the stream 'skey'\\n\",\n    \"create_group( stream_key, group1 )\\n\",\n    \"# group2 read the streams 'skey' and 's2key'\\n\",\n    \"create_group( stream_key, group2 )\\n\",\n    \"create_group( stream2_key, group2 )\\n\",\n    \"\\n\",\n    \"def group_info( skey ):\\n\",\n    \"    res = r.xinfo_groups( name=skey )\\n\",\n    \"    for i in res:\\n\",\n    \"        print( f\\\"{skey} -> group name: {i['name']} with {i['consumers']} consumers and {i['last-delivered-id']}\\\"\\n\",\n    \"              + f\\\" as last read id\\\")\\n\",\n    \"    \\n\",\n    \"group_info( stream_key )\\n\",\n    \"group_info( stream2_key )\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## group read\\n\",\n    \"The `xreadgroup` method permit to read from a stream group.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 14,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"def print_xreadgroup_reply( reply, group = None, run = None):\\n\",\n    \"    for d_stream in reply:\\n\",\n    \"        for element in d_stream[1]:\\n\",\n    \"            print(  f\\\"got element {element[0]}\\\"\\n\",\n    \"                  + f\\\"from stream {d_stream[0]}\\\" )\\n\",\n    \"            if run is not None:\\n\",\n    \"                run( d_stream[0], group, element[0] )\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 15,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"got element b'1710790167982-0'from stream b'skey'\\n\",\n      \"got element b'1710790167983-0'from stream b'skey'\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"# read some messages on group1 with consumer 'c' \\n\",\n    \"d = r.xreadgroup( groupname=group1, consumername='c', block=10,\\n\",\n    \"                  count=2, streams={stream_key:'>'})\\n\",\n    \"print_xreadgroup_reply( d )\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"A **2nd consumer** for the same stream group will get not delivered messages.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 16,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"got element b'1710790167983-1'from stream b'skey'\\n\",\n      \"got element b'1710790167983-2'from stream b'skey'\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"# read some messages on group1 with consumer 'c' \\n\",\n    \"d = r.xreadgroup( groupname=group1, consumername='c2', block=10,\\n\",\n    \"                  count=2, streams={stream_key:'>'})\\n\",\n    \"print_xreadgroup_reply( d )\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"But a **2nd stream group** can read the already delivered messages again.\\n\",\n    \"\\n\",\n    \"Note that the 2nd stream group include also the 2nd stream.\\n\",\n    \"That can be identified in the reply (1st element of the reply list).\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 18,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"got element b'1710790167982-0'from stream b'skey'\\n\",\n      \"got element b'1710790167983-0'from stream b'skey'\\n\",\n      \"got element b'1710790173142-0'from stream b's2key'\\n\",\n      \"got element b'1710790173143-0'from stream b's2key'\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"d2 = r.xreadgroup( groupname=group2, consumername='c', block=10,\\n\",\n    \"                   count=2, streams={stream_key:'>',stream2_key:'>'})\\n\",\n    \"print_xreadgroup_reply( d2 )\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"To check for pending messages (delivered messages without acknowledgment) we can use the `xpending`.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 19,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"4 pending messages on 'skey' for group 'grp1'\\n\",\n      \"2 pending messages on 'skey' for group 'grp2'\\n\",\n      \"2 pending messages on 's2key' for group 'grp2'\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"# check pending status (read messages without a ack)\\n\",\n    \"def print_pending_info( key_group ):\\n\",\n    \"    for s,k in key_group:\\n\",\n    \"        pr = r.xpending( name=s, groupname=k )\\n\",\n    \"        print( f\\\"{pr.get('pending')} pending messages on '{s}' for group '{k}'\\\" )\\n\",\n    \"    \\n\",\n    \"print_pending_info( ((stream_key,group1),(stream_key,group2),(stream2_key,group2)) )\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## ack\\n\",\n    \"Acknowledge some messages with `xack`.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 20,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"got element b'1710790167983-1'from stream b'skey'\\n\",\n      \"got element b'1710790167983-2'from stream b'skey'\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"# do acknowledges for group1\\n\",\n    \"toack = lambda k,g,e: r.xack( k,g, e )\\n\",\n    \"print_xreadgroup_reply( d, group=group1, run=toack )\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 21,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"2 pending messages on 'skey' for group 'grp1'\\n\",\n      \"2 pending messages on 'skey' for group 'grp2'\\n\",\n      \"2 pending messages on 's2key' for group 'grp2'\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"# check pending again\\n\",\n    \"print_pending_info( ((stream_key,group1),(stream_key,group2),(stream2_key,group2)) )\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"ack all messages on the `group1`.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 22,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"got element b'1710790167983-3'from stream b'skey'\\n\",\n      \"got element b'1710790167983-4'from stream b'skey'\\n\",\n      \"got element b'1710790167983-5'from stream b'skey'\\n\",\n      \"got element b'1710790167983-6'from stream b'skey'\\n\",\n      \"got element b'1710790167983-7'from stream b'skey'\\n\",\n      \"got element b'1710790167984-0'from stream b'skey'\\n\",\n      \"got element b'1710790173157-0'from stream b'skey'\\n\",\n      \"got element b'1710790173158-0'from stream b'skey'\\n\",\n      \"got element b'1710790173158-1'from stream b'skey'\\n\",\n      \"got element b'1710790173158-2'from stream b'skey'\\n\",\n      \"got element b'1710790173158-3'from stream b'skey'\\n\",\n      \"got element b'1710790173158-4'from stream b'skey'\\n\",\n      \"got element b'1710790173158-5'from stream b'skey'\\n\",\n      \"got element b'1710790173159-0'from stream b'skey'\\n\",\n      \"got element b'1710790173159-1'from stream b'skey'\\n\",\n      \"got element b'1710790173159-2'from stream b'skey'\\n\",\n      \"2 pending messages on 'skey' for group 'grp1'\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"d = r.xreadgroup( groupname=group1, consumername='c', block=10,\\n\",\n    \"                      count=100, streams={stream_key:'>'})\\n\",\n    \"print_xreadgroup_reply( d, group=group1, run=toack)\\n\",\n    \"print_pending_info( ((stream_key,group1),) )\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"But stream length will be the same after the `xack` of all messages on the `group1`.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 24,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"20\"\n      ]\n     },\n     \"execution_count\": 24,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"r.xlen(stream_key)\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## delete all\\n\",\n    \"To remove the messages with need to remove them explicitly with `xdel`.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 25,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"s1 = r.xread( streams={stream_key:0} )\\n\",\n    \"for streams in s1:\\n\",\n    \"    stream_name, messages = streams\\n\",\n    \"    # del all ids from the message list\\n\",\n    \"    [ r.xdel( stream_name, i[0] ) for i in messages ]\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"stream length\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 26,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"0\"\n      ]\n     },\n     \"execution_count\": 26,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"r.xlen(stream_key)\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"But with the `xdel` the 2nd group can read any not processed message from the `skey`.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 27,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"got element b'1657571042113-1'from stream b's2key'\\n\",\n      \"got element b'1657571042114-0'from stream b's2key'\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"d2 = r.xreadgroup( groupname=group2, consumername='c', block=10,\\n\",\n    \"                   count=2, streams={stream_key:'>',stream2_key:'>'})\\n\",\n    \"print_xreadgroup_reply( d2 )\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": []\n  }\n ],\n \"metadata\": {\n  \"kernelspec\": {\n   \"display_name\": \"Python 3\",\n   \"language\": \"python\",\n   \"name\": \"python3\"\n  },\n  \"language_info\": {\n   \"codemirror_mode\": {\n    \"name\": \"ipython\",\n    \"version\": 3\n   },\n   \"file_extension\": \".py\",\n   \"mimetype\": \"text/x-python\",\n   \"name\": \"python\",\n   \"nbconvert_exporter\": \"python\",\n   \"pygments_lexer\": \"ipython3\",\n   \"version\": \"3.8.10\"\n  }\n },\n \"nbformat\": 4,\n \"nbformat_minor\": 4\n}\n"
  },
  {
    "path": "docs/examples/search_json_examples.ipynb",
    "content": "{\n \"cells\": [\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"# Indexing / querying JSON documents\\n\",\n    \"## Adding a JSON document to an index\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 1,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"b'OK'\"\n      ]\n     },\n     \"execution_count\": 1,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"import redis\\n\",\n    \"from redis.commands.json.path import Path\\n\",\n    \"import redis.commands.search.aggregation as aggregations\\n\",\n    \"import redis.commands.search.reducers as reducers\\n\",\n    \"from redis.commands.search.field import TextField, NumericField, TagField\\n\",\n    \"from redis.commands.search.index_definition import IndexDefinition, IndexType\\n\",\n    \"from redis.commands.search.query import NumericFilter, Query\\n\",\n    \"\\n\",\n    \"\\n\",\n    \"r = redis.Redis(host='localhost', port=6379)\\n\",\n    \"user1 = {\\n\",\n    \"    \\\"user\\\":{\\n\",\n    \"        \\\"name\\\": \\\"Paul John\\\",\\n\",\n    \"        \\\"email\\\": \\\"paul.john@example.com\\\",\\n\",\n    \"        \\\"age\\\": 42,\\n\",\n    \"        \\\"city\\\": \\\"London\\\"\\n\",\n    \"    }\\n\",\n    \"}\\n\",\n    \"user2 = {\\n\",\n    \"    \\\"user\\\":{\\n\",\n    \"        \\\"name\\\": \\\"Eden Zamir\\\",\\n\",\n    \"        \\\"email\\\": \\\"eden.zamir@example.com\\\",\\n\",\n    \"        \\\"age\\\": 29,\\n\",\n    \"        \\\"city\\\": \\\"Tel Aviv\\\"\\n\",\n    \"    }\\n\",\n    \"}\\n\",\n    \"user3 = {\\n\",\n    \"    \\\"user\\\":{\\n\",\n    \"        \\\"name\\\": \\\"Paul Zamir\\\",\\n\",\n    \"        \\\"email\\\": \\\"paul.zamir@example.com\\\",\\n\",\n    \"        \\\"age\\\": 35,\\n\",\n    \"        \\\"city\\\": \\\"Tel Aviv\\\"\\n\",\n    \"    }\\n\",\n    \"}\\n\",\n    \"\\n\",\n    \"user4 = {\\n\",\n    \"    \\\"user\\\":{\\n\",\n    \"        \\\"name\\\": \\\"Sarah Zamir\\\",\\n\",\n    \"        \\\"email\\\": \\\"sarah.zamir@example.com\\\",\\n\",\n    \"        \\\"age\\\": 30,\\n\",\n    \"        \\\"city\\\": \\\"Paris\\\"\\n\",\n    \"    }\\n\",\n    \"}\\n\",\n    \"r.json().set(\\\"user:1\\\", Path.root_path(), user1)\\n\",\n    \"r.json().set(\\\"user:2\\\", Path.root_path(), user2)\\n\",\n    \"r.json().set(\\\"user:3\\\", Path.root_path(), user3)\\n\",\n    \"r.json().set(\\\"user:4\\\", Path.root_path(), user4)\\n\",\n    \"\\n\",\n    \"schema = (TextField(\\\"$.user.name\\\", as_name=\\\"name\\\"),TagField(\\\"$.user.city\\\", as_name=\\\"city\\\"), NumericField(\\\"$.user.age\\\", as_name=\\\"age\\\"))\\n\",\n    \"r.ft().create_index(schema, definition=IndexDefinition(prefix=[\\\"user:\\\"], index_type=IndexType.JSON))\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Searching\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"### Simple search\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 2,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"Result{2 total, docs: [Document {'id': 'user:1', 'payload': None, 'json': '{\\\"user\\\":{\\\"name\\\":\\\"Paul John\\\",\\\"email\\\":\\\"paul.john@example.com\\\",\\\"age\\\":42,\\\"city\\\":\\\"London\\\"}}'}, Document {'id': 'user:3', 'payload': None, 'json': '{\\\"user\\\":{\\\"name\\\":\\\"Paul Zamir\\\",\\\"email\\\":\\\"paul.zamir@example.com\\\",\\\"age\\\":35,\\\"city\\\":\\\"Tel Aviv\\\"}}'}]}\"\n      ]\n     },\n     \"execution_count\": 2,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"r.ft().search(\\\"Paul\\\")\"\n   ]\n  },\n  {\n   \"attachments\": {},\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"### Filtering search results\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 3,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"Result{1 total, docs: [Document {'id': 'user:3', 'payload': None, 'json': '{\\\"user\\\":{\\\"name\\\":\\\"Paul Zamir\\\",\\\"email\\\":\\\"paul.zamir@example.com\\\",\\\"age\\\":35,\\\"city\\\":\\\"Tel Aviv\\\"}}'}]}\"\n      ]\n     },\n     \"execution_count\": 3,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"q1 = Query(\\\"Paul\\\").add_filter(NumericFilter(\\\"age\\\", 30, 40))\\n\",\n    \"r.ft().search(q1)\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"### Paginating and Ordering search Results\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 4,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"Result{4 total, docs: [Document {'id': 'user:1', 'payload': None, 'age': '42', 'json': '{\\\"user\\\":{\\\"name\\\":\\\"Paul John\\\",\\\"email\\\":\\\"paul.john@example.com\\\",\\\"age\\\":42,\\\"city\\\":\\\"London\\\"}}'}, Document {'id': 'user:3', 'payload': None, 'age': '35', 'json': '{\\\"user\\\":{\\\"name\\\":\\\"Paul Zamir\\\",\\\"email\\\":\\\"paul.zamir@example.com\\\",\\\"age\\\":35,\\\"city\\\":\\\"Tel Aviv\\\"}}'}]}\"\n      ]\n     },\n     \"execution_count\": 4,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"# Search for all users, returning 2 users at a time and sorting by age in descending order\\n\",\n    \"offset = 0\\n\",\n    \"num = 2\\n\",\n    \"q = Query(\\\"*\\\").paging(offset, num).sort_by(\\\"age\\\", asc=False) # pass asc=True to sort in ascending order\\n\",\n    \"r.ft().search(q)\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"### Counting the total number of Items\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 5,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"4\"\n      ]\n     },\n     \"execution_count\": 5,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"q = Query(\\\"*\\\").paging(0, 0)\\n\",\n    \"r.ft().search(q).total\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"### Projecting using JSON Path expressions \"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 6,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"[Document {'id': 'user:1', 'payload': None, 'city': 'London'},\\n\",\n       \" Document {'id': 'user:3', 'payload': None, 'city': 'Tel Aviv'}]\"\n      ]\n     },\n     \"execution_count\": 6,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"r.ft().search(Query(\\\"Paul\\\").return_field(\\\"$.user.city\\\", as_field=\\\"city\\\")).docs\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Aggregation\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 7,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"[[b'age', b'35'], [b'age', b'42']]\"\n      ]\n     },\n     \"execution_count\": 7,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"req = aggregations.AggregateRequest(\\\"Paul\\\").sort_by(\\\"@age\\\")\\n\",\n    \"r.ft().aggregate(req).rows\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"### Count the total number of Items\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 8,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"[[b'total', b'4']]\"\n      ]\n     },\n     \"execution_count\": 8,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"# The group_by expects a string or list of strings to group the results before applying the aggregation function to\\n\",\n    \"# each group. Passing an empty list here acts as `GROUPBY 0` which applies the aggregation function to the whole results\\n\",\n    \"req = aggregations.AggregateRequest(\\\"*\\\").group_by([], reducers.count().alias(\\\"total\\\"))\\n\",\n    \"r.ft().aggregate(req).rows\"\n   ]\n  }\n ],\n \"metadata\": {\n  \"interpreter\": {\n   \"hash\": \"d45c99ba0feda92868abafa8257cbb4709c97f1a0b5dc62bbeebdf89d4fad7fe\"\n  },\n  \"kernelspec\": {\n   \"display_name\": \"redis-py\",\n   \"language\": \"python\",\n   \"name\": \"redis-py\"\n  },\n  \"language_info\": {\n   \"codemirror_mode\": {\n    \"name\": \"ipython\",\n    \"version\": 3\n   },\n   \"file_extension\": \".py\",\n   \"mimetype\": \"text/x-python\",\n   \"name\": \"python\",\n   \"nbconvert_exporter\": \"python\",\n   \"pygments_lexer\": \"ipython3\",\n   \"version\": \"3.11.3\"\n  }\n },\n \"nbformat\": 4,\n \"nbformat_minor\": 4\n}\n"
  },
  {
    "path": "docs/examples/search_vector_similarity_examples.ipynb",
    "content": "{\n \"cells\": [\n  {\n   \"attachments\": {},\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"# Vector Similarity\\n\",\n    \"**Vectors** (also called \\\"Embeddings\\\"), represent an AI model's impression (or understanding) of a piece of unstructured data like text, images, audio, videos, etc. Vector Similarity Search (VSS) is the process of finding vectors in the vector database that are similar to a given query vector. Popular VSS uses include recommendation systems, image and video search, document retrieval, and question answering.\\n\",\n    \"\\n\",\n    \"## Index Creation\\n\",\n    \"Before doing vector search, first define the schema and create an index.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 1,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"import redis\\n\",\n    \"from redis.commands.search.field import TagField, VectorField\\n\",\n    \"from redis.commands.search.indexDefinition import IndexDefinition, IndexType\\n\",\n    \"from redis.commands.search.query import Query\\n\",\n    \"\\n\",\n    \"r = redis.Redis(host=\\\"localhost\\\", port=6379)\\n\",\n    \"\\n\",\n    \"INDEX_NAME = \\\"index\\\"                              # Vector Index Name\\n\",\n    \"DOC_PREFIX = \\\"doc:\\\"                               # RediSearch Key Prefix for the Index\\n\",\n    \"\\n\",\n    \"def create_index(vector_dimensions: int):\\n\",\n    \"    try:\\n\",\n    \"        # check to see if index exists\\n\",\n    \"        r.ft(INDEX_NAME).info()\\n\",\n    \"        print(\\\"Index already exists!\\\")\\n\",\n    \"    except:\\n\",\n    \"        # schema\\n\",\n    \"        schema = (\\n\",\n    \"            TagField(\\\"tag\\\"),                       # Tag Field Name\\n\",\n    \"            VectorField(\\\"vector\\\",                  # Vector Field Name\\n\",\n    \"                \\\"FLAT\\\", {                          # Vector Index Type: FLAT or HNSW\\n\",\n    \"                    \\\"TYPE\\\": \\\"FLOAT32\\\",             # FLOAT32 or FLOAT64\\n\",\n    \"                    \\\"DIM\\\": vector_dimensions,      # Number of Vector Dimensions\\n\",\n    \"                    \\\"DISTANCE_METRIC\\\": \\\"COSINE\\\",   # Vector Search Distance Metric\\n\",\n    \"                }\\n\",\n    \"            ),\\n\",\n    \"        )\\n\",\n    \"\\n\",\n    \"        # index Definition\\n\",\n    \"        definition = IndexDefinition(prefix=[DOC_PREFIX], index_type=IndexType.HASH)\\n\",\n    \"\\n\",\n    \"        # create Index\\n\",\n    \"        r.ft(INDEX_NAME).create_index(fields=schema, definition=definition)\"\n   ]\n  },\n  {\n   \"attachments\": {},\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"We'll start by working with vectors that have 1536 dimensions.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 2,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"# define vector dimensions\\n\",\n    \"VECTOR_DIMENSIONS = 1536\\n\",\n    \"\\n\",\n    \"# create the index\\n\",\n    \"create_index(vector_dimensions=VECTOR_DIMENSIONS)\"\n   ]\n  },\n  {\n   \"attachments\": {},\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Adding Vectors to Redis\\n\",\n    \"\\n\",\n    \"Next, we add vectors (dummy data) to Redis using `hset`. The search index listens to keyspace notifications and will include any written HASH objects prefixed by `DOC_PREFIX`.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"%pip install numpy\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 3,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"import numpy as np\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 4,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"# instantiate a redis pipeline\\n\",\n    \"pipe = r.pipeline()\\n\",\n    \"\\n\",\n    \"# define some dummy data\\n\",\n    \"objects = [\\n\",\n    \"    {\\\"name\\\": \\\"a\\\", \\\"tag\\\": \\\"foo\\\"},\\n\",\n    \"    {\\\"name\\\": \\\"b\\\", \\\"tag\\\": \\\"foo\\\"},\\n\",\n    \"    {\\\"name\\\": \\\"c\\\", \\\"tag\\\": \\\"bar\\\"},\\n\",\n    \"]\\n\",\n    \"\\n\",\n    \"# write data\\n\",\n    \"for obj in objects:\\n\",\n    \"    # define key\\n\",\n    \"    key = f\\\"doc:{obj['name']}\\\"\\n\",\n    \"    # create a random \\\"dummy\\\" vector\\n\",\n    \"    obj[\\\"vector\\\"] = np.random.rand(VECTOR_DIMENSIONS).astype(np.float32).tobytes()\\n\",\n    \"    # HSET\\n\",\n    \"    pipe.hset(key, mapping=obj)\\n\",\n    \"\\n\",\n    \"res = pipe.execute()\"\n   ]\n  },\n  {\n   \"attachments\": {},\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Searching\\n\",\n    \"You can use VSS queries with the `.ft(...).search(...)` query command. To use a VSS query, you must specify the option `.dialect(2)`.\\n\",\n    \"\\n\",\n    \"There are two supported types of vector queries in Redis: `KNN` and `Range`. `Hybrid` queries can work in both settings and combine elements of traditional search and VSS.\"\n   ]\n  },\n  {\n   \"attachments\": {},\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"### KNN Queries\\n\",\n    \"KNN queries are for finding the topK most similar vectors given a query vector.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 5,\n   \"metadata\": {\n    \"pycharm\": {\n     \"name\": \"#%%\\n\"\n    }\n   },\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"[Document {'id': 'doc:c', 'payload': None, 'score': '0.251625061035', 'vector': b'\\\\xf8\\\\x1ed?\\\\xbf\\\\t\\\\x90<\\\\xfd\\\\x9b\\\\x10?\\\\xe3\\\\x1b\\\\xed>\\\\xbc\\\\xea%=lp\\\\x1a>\\\\x11hC?\\\\x84 :?\\\\x8d\\\\x7f\\\\xe4>\\\\xfd\\\\xff\\\\x94>n\\\\x9c4?\\\\x0e\\\\x9fy?\\\\xd6\\\\x8a\\\\x97<\\\\xf6\\\\x0b\\\"?Kg\\\\x99>\\\\xc4\\\\xde0>\\\\xa5\\\\r\\\\xb9>\\\\xb0R(>\\\\xd3\\\\x1d\\\\xcd>?\\\\xab\\\\xbb>\\\\x9cx\\\\x0c?\\\\xd7\\\\xa3\\\\x9e>\\\\xad\\\\xee\\\\xf4<\\\\x0c\\\\x93\\\\xf6>aW\\\\x0b?\\\\xd8F0<0\\\\x9e(?\\\\xc5Pn>\\\\x03\\\\xf4\\\\xb0>B\\\\xaay?\\\\xa9~\\\\x7f?Gh\\\\x18>\\\\x15\\\\x8e\\\\xf1>]\\\\xc8\\\\xea>x\\\\xc5\\\\x9c>\\\\xa1\\\\xeb>?\\\\xbb\\\\n-?aDZ?\\\\x92\\\\x9bL<4\\\\xa4\\\\r?\\\\x1d\\\\xe1\\\\xcd>cO\\\\xa3>\\\\'\\\\xed<?\\\\x8a\\\\x15\\\\xf5>vPk?\\\\xa7\\\\xdch?\\\\x02\\\\x14\\\\x8a>\\\\xb6:\\\\x07;O\\\\x139?\\\\x8d$5?^e5?\\\\x06\\\\x10\\\\x89>\\\\x88+\\\\xd2>\\\\xea\\\\xb7\\\\xa4>\\\\xf9\\\\x0e-?\\\\x9c\\\\xbf\\\\xb5>\\\\x81\\\\x8e\\\\x03?\\\\x00\\\\xc43?/\\\\xdb\\\\xfb>\\\\xe8\\\\'e>\\\\xbe\\\\xaa9?\\\\xf2\\\\xe88?\\\\x1b\\\\xa8\\\\x03>\\\\x91\\\\x9fO?%\\\\xb2;?\\\\xb7}w?\\\\xd0/\\\\x08?\\\\x1aD\\\\x1c?\\\\xf9E??\\\\x9bB.?\\\\x96)\\\\x19?\\\\x10a\\\\xda>+\\\\xbfV>\\\\x83\\\\xbd}>\\\\x0bTz>\\\\x82Mz?\\\\xf0EY?:\\\\x99\\\\x19?\\\"\\\\x1ep?\\\\xafX\\\\xcb>*\\\\xa0\\\\x0c>X\\\\xf5\\\\xb9>\\\\x8d\\\\t8?Q\\\\xba\\\\xf4>\\\\x1e\\\\x97x>\\\\xc0Q@?\\\\xd2\\\\x1a\\\\xa6>M\\\\xed\\\\xcf>\\\\x15\\\\x90$>\\\\xb7\\\\x99[?o\\\\x84e?\\\\x8a2P?\\\\x8c\\\\x92^?\\\\'\\\\xe3\\\\xd9>@\\\\x83(?E\\\\x91V?\\\\xad\\\\x1b\\\\xa8>\\\\xf5\\\\xca=?\\\\x0c\\\\x10h?m8\\\\xc0>\\\\xaf\\\\xe4f?\\\\xb9\\\\xdcZ?\\\\xce9\\\\xdb>\\\\x9f|\\\\xc1>^\\\\xa9B>\\\\xc3\\\\xce_>s\\\\x93\\\\xd4<\\\\x96\\\\xd7\\\\x02?\\\\x10\\\\t ?\\\\x07\\\\xb9:>\\\\x9f_\\\\xa4=\\\\xcb\\\\xecK>\\\\xb2\\\\xdes>\\\\x9f\\\\x0b*?ln\\\\x15>\\\\x0c;y?L\\\\xe7a?:\\\\x8fg?Hs\\\\x17?`v\\\\t?\\\\xdf\\\\xe8Q?1Od=4\\\\x1c\\\\xba>\\\\x1b\\\\n8=;\\\\x9f??\\\\xbb\\\\xa5X=kLL=u=4?\\\\xdf\\\\x17<>\\\\x1b\\\\x06\\\\xb5>\\\\x91W\\\\xc5>H\\\\nw?\\\\xc8\\\\x05N?w\\\\xf5\\\\xcf>\\\\xee^Y?\\\\x14\\\\x90\\\\xd1;\\\\xc7b\\\">\\\\xfa\\\\xeb_?y\\\\xbb\\\\x95>\\\\xef\\\\xb66>\\\\xe4Pa?c-/?\\\\x88\\\\x9a\\\\xf2=\\\\xe9\\\\x8b\\\\t?fp\\\\xda>B\\\\xcb\\\\xef=\\\\xd0\\\\x1b\\\\x99>\\\\x0e\\\\xb2f?n\\\\x82~>\\\\xd6e\\\\xcb>\\\\x02\\\\\\\\a?\\\\xcbR\\\\x14?cY[?E\\\\xfc\\\\xe7>0v\\\\x14?\\\\x0c\\\\x1e\\\\x07?`\\\\x1f\\\\xdc=\\\\xb7\\\\x1d\\\\r>v/4?\\\\xaa\\\\xb2\\\\r?\\\\xda\\\\x89\\\\xfa=c>x>\\\\x01\\\\xd2\\\\xad>\\\\x04\\\\xd3\\\\xe6>\\\\xa7E\\\\x9d=\\\\xc0|_?\\\\xcf\\\\x96o?\\\\x89dd?\\\\xbb\\\\xad>?UL\\\\'<\\\\xf4$h<\\\\x9fO\\\\x12?\\\\x86*\\\\xd4>\\\\xef\\\\x8dU?\\\\x02\\\\xc9_=5\\\\xf8.?\\\\xe5\\\\xf8\\\\x11?\\\\x96y\\\\xec>\\\\x18S\\\\xac>BT\\\\xec<\\\\xa4bu?\\\\xa3v-?s\\\\x8eq?\\\\xc8\\\\x99b?u\\\\xf6\\\\xc9=\\\\xca\\\\x9b*?\\\\xb9O)?s\\\\xa9\\\\r?\\\\x8d\\\\xf6\\\\x9d> \\\\xba/?\\\\xe9\\\\x7f\\\\xb7>\\\\xd4}\\\\xc3=C\\\\x99a?\\\\xf5\\\\xf4\\\\x1e=(\\\\xb54?X\\\\xe1_?\\\\xd7\\\\xa7a=\\\\xe12:>\\\\xb1,\\\\xb9>R\\\\x0b\\\\x17;\\\\xe7\\\\xe8\\\\x8a=Ew\\\\x05?\\\\x1a\\\\x0es?\\\\xdc&w?\\\\xe2J`?\\\\xde\\\\x9b\\\\xd7>\\\\xb7\\\\xbf\\\\xb5>\\\\x9d\\\\xbd\\\\xdb>\\\\xb4\\\\x10\\\\x19?\\\\xb10D?L_\\\\xc2>#\\\\x0e\\\\x8e>o\\\\x08\\\\x05>\\\\xab\\\\xec\\\\x10?\\\\xbd6)?\\\\xd5i]?\\\\xd4F\\\\xe4=\\\\x93I\\\\xf3>\\\\xcdc\\\\xd3>\\\\x1f\\\\xb5\\\\x11?\\\\xab\\\\xabW?\\\\xden;?\\\\x96h\\\\xf1<\\\\x8d\\\\xf1#?\\\\xa4sA?\\\\xac\\\\x8d<?\\\\'\\\\xef\\\\xa0>?\\\\x86.> \\\\xf9\\\\t?g\\\\xcc\\\\x93=\\\\xc2\\\\x98I?\\\\xcc\\\\xc0P?\\\\xdd9`?@\\\\'\\\\xfa>\\\\xe4\\\\x1c\\\\xf5>\\\\xaa\\\\xcfn>|\\\\xae\\\\x10?\\\\x1a\\\\x9b\\\\x1e?\\\\x86\\\\x10F?6\\\\xc2\\\\xaf>$L\\\\xf2>\\\\xfb\\\\x0c\\\\x8b>\\\\xbb\\\\xb4\\\\xb7>{K\\\\x98> 1\\\\x1a?\\\\xd8N\\\\x8c=h\\\\x0cx?\\\\xfd\\\\x98\\\\xe2=\\\\xe5\\\\x02,>\\\\x97p\\\\x06?\\\\xb6!\\\\xe1>\\\\x95\\\\xfc\\\\x93>\\\\x8co\\\\xf0>\\\\xa54\\\\xfe>\\\\x94\\\\xe7j?\\\\x0c6\\\\x13?\\\\xba\\\\xe0\\\\xc1>%ll?;\\\\xafW?\\\\xf9\\\\xb9\\\\x9d>\\\\xdd:N?SZ~>:p$?1\\\\x00z>C$\\\\xb9>\\\\x8c\\\\xeaL?\\\\x96|!?.6\\\\xe9>\\\\xf2i\\\\xf2> \\\\x0c(?\\\\xb4|\\\\x80>Yx\\\\x88>\\\\xd2 \\\\xe6>\\\\xe1\\\\x94\\\\x00?\\\\xc0\\\\x88C?\\\\x134\\\\'?Oy\\\\x9f>\\\\xb5\\\\xeaw?\\\\x97X\\\\'?\\\\xa3\\\\x07\\\\x07?\\\\x94.h?B\\\\xf5\\\\xd1>\\\\xd9\\\\xae2>\\\\xbf\\\\x9e,?*w\\\\xa3>\\\\xbe\\\\x8e\\\\x12?ZU>?\\\\x83\\\\xaar?\\\\xbe.\\\\x82>\\\\x0e^^?\\\\x07\\\\xe4\\\\xa2>\\\\x04dZ?#)\\\\x1f?\\\\xbd\\\\x0f\\\\xa0=\\\\xac\\\\x9e\\\\xfe>\\\\xb1\\\\x81B>\\\\x827a?\\\\xcb\\\\xfb\\\\x08?\\\\x04\\\\xca\\\\xa2<7Zd?\\\\xdau9?\\\\xe6Z.?4\\\\x96N?\\\\xf6\\\\xcaw>+\\\\xa64?p\\\\xe3\\\\xae>\\\\xaey\\\\x95>!H\\\\xb7>~!\\\\xbe>\\\\xe1\\\\x0eK>\\\\x98\\\\xdaZ?\\\\xe0\\\\x0b\\\\xbc>\\\\xdd\\\\xc8??\\\\x98\\\\xaf >o\\\\x8d\\\\x7f?\\\\x16\\\\xea\\\\xa3>r\\\\x94F>\\\\xfb\\\\xa5i?n\\\\xf0\\\\xdd>u\\\\x19e>\\\\xb3j\\\\x1e=\\\\xf7S\\\\x0b?\\\\xdaY\\\\x0b?\\\\xbd\\\\x11\\\\x9b>\\\\xa77\\\\xa2>\\\\xcc^\\\\xaf>\\\\xf9a\\\\xa7>\\\\xc2N\\\\x13?\\\\xcbf\\\\x12?\\\\xb4\\\\x1bK>j\\\\xb7L?\\\\x99aS?\\\\xeeyY?\\\\x968\\\\x82=R\\\\x0e#>`\\\\xb9c={\\\\nH?b\\\\xb4\\\\x14>d\\\\x02W?\\\\xde\\\\xd4\\\\\\\\>\\\\xe5\\\\xb6F?>\\\\x96\\\\xac>\\\\xc7`\\\\x91>?\\\\x0e\\\\xad>]\\\\x84\\\\x02?\\\\xac\\\\x14{?(2\\\\xad>\\\\x11\\\\xdd\\\\xf6=\\\\xa0\\\\xb5\\\\xbf>df\\\\xad>\\\\xea\\\\xe1\\\\x04?B\\\\x17:?k\\\\xb8\\\\xb6=|\\\\xbft?\\\\x84\\\\x1f\\\\x05>\\\\xab&??\\\\x156\\\\xc2>`Y\\\\x02>J!\\\\x1f?\\\\xc0\\\\xce\\\\x1a?-V2?\\\\xe8\\\\xfen?\\\\xb58\\\\x1c?\\\\x91\\\\x96\\\\x94>\\\\xa1\\\"\\\\x1e?K\\\\xd5\\\\x04?Gk\\\\xa9>\\\\xf0\\\\xd7\\\\x03?{;l?\\\\x88\\\\xa5!>t\\\\x0e\\\\r>%E\\\\xa8>\\\\n\\\\x1cM?\\\\x8b\\\\xbfN?\\\\xc9\\\\x84\\\\xea>\\\\xdb\\\\xebd?\\\\xe7\\\\xe9G?-t\\\\x9b>\\\\xb8\\\\xc80?\\\\xb7w\\\"?\\\\xdcz1>xT\\\\xf2=\\\\xb7\\\\x12\\\\xa1>\\\\x887\\\\x95>8\\\\x19\\\\xdf>\\\\xa9\\\\xbbY?\\\\x89w\\\\x8d>M\\\\xb4r=\\\\xd1~\\\\x02?\\\\xd1\\\\xd6x?\\\\x0c\\\\xb7\\\\x06?\\\\xc1w\\\\x17>v\\\\xba\\\\xe4>\\\\x11\\\\x16\\\\xec>\\\\x9f\\\\x8a\\\\xc7>r\\\\xf0L?d_>>\\\\x01TS?8\\\\x9f\\\\x0b>RBd=\\\"\\\\xc3\\\\xe1;\\\\x9f\\\\xf7\\\\x9c>^\\\\x1cg?\\\\xc5\\\\xee\\\\n?N.\\\"?\\\\x9d\\\\x13\\\\x8c=\\\\x9e\\\\x90W>\\\\'\\\\xf0<>&\\\\x86]?\\\\xb6[\\\\xe2>\\\\xf9S5<N\\\\xe0@>\\\\x1f\\\\x1aN>s\\\\xbfL?\\\\xbb\\\\xec=?\\\\xcf\\\\x08C?\\\\\\\\M\\\\xa8>\\\\xc7\\\\xcf\\\\xe3=\\\\x84\\\\xfe%>d\\\\'\\\\xdc>5\\\\xaa\\\\xc6=\\\\xf7A\\\\xe9=VY^?\\\\xb8\\\\x84f=\\\\xfa\\\\x95L=\\\\x95\\\\xd7x?\\\\xc0A\\\\x9a>\\\\x9c\\\\xa37?K\\\\x8c]?B\\\\xdb\\\\xc5>\\\\xc5\\\\x19\\\\xed>\\\\xfa\\\\x1f#?\\\\xa2-\\\\xf1<\\\\xc4\\\\xf4B?\\\\xfdQ}=7\\\\x06k>-\\\\xa8\\\\x88=4\\\\xe3\\\\xc7=\\\\x91j[?\\\\xaa\\\\xe5I=\\\\x8f\\\\xeaf?\\\\x97\\\\x7f\\\\x9d>\\\\xb8\\\\x9c\\\\x03?\\\\xfc(\\\\x1b?\\\\xce\\\\xe7\\\\xd7>\\\\xf2\\\\xc8X>I\\\\xa6}?\\\\x8f\\\\xb4E==\\\\xb7.>\\\\xd8\\\\n\\\\x18?\\\\xb4\\\\xc1C?\\\\x8f\\\\x16q?\\\\x88C]>\\\\x06\\\\x99\\\\x0e?mz\\\\x93>\\\\xd1\\\\xb2\\\\x18?(\\\\xb8r?\\\\xe7\\\\xcb\\\\xab>{\\\\x87F=\\\\x9a?\\\\x1a?\\\\x86\\\\x82$?\\\\xf9\\\\x0b\\\\xe9>\\\\x85\\\\xa05? X5?\\\\xec<K?\\\\x81\\\\x115?\\\\xc4\\\\x0e^?\\\\xc3\\\\x1a\\\\x04=H\\\\x0f\\\\xfa>5&)?x\\\\xf1G?q\\\\xfc\\\\x91>\\\\'\\\\xd7\\\\x1a>s\\\\x14A>\\\\xe3\\\\xb1\\\\xe0>Sk\\\\x0c?\\\\x14\\\\x973?\\\\x15\\\\xc7\\\\xd7>z*4?\\\\x14Hq?\\\\x1f\\\\x83\\\\xe8>\\\\x01\\\\xd5.=\\\\x17\\\\xde\\\\x08?z\\\\x1d`>o\\\\x14/>\\\\xd1\\\\xbcA>8G0>\\\\xf2Hh?\\\\xdc\\\\xdfd=\\\\xe9R\\\"?\\\\xfb\\\\xa7~?\\\\xd2\\\\xae\\\\x06=\\\\xe8y\\\\xd6>\\\\xc0\\\\xb7\\\\x8c=\\\\xbc\\\\xe9\\\\x03?\\\\xae\\\\x10\\\\x0f?3rh?N\\\\xacf?\\\\xb5\\\\xfd\\\\x06=\\\\xc5\\\\xf6r>\\\\x8c\\\\\\\\\\\\xfc>\\\\xa2I\\\\xd7>\\\\xb6T`?Hn0>V#3>\\\\xf7\\\\xc9C=\\\\xdaR\\\\xa4=\\\\xfdPh>\\\\x18\\\\xe4&?k}1>\\\\x0e\\\\xbdB?\\\\xb7r|?\\\\xf7\\\\xb9\\\\x17?K\\\\x10\\\\xd8>\\\\xd8f\\\\xa9>\\\\xf6,D?,\\\\x1a\\\\xab>\\\\xf2*w?\\\\xd6\\\\xa0\\\\x1d>q\\\\xcd\\\\xf5>\\\\x01\\\\xf6j?G\\\\xe1q>\\\\xd3D\\\\xc0>\\\\x9a!\\\\xdb>\\\\x81_\\\\xe6>\\\\xea\\\\xa8\\\\'>f\\\\xa1W>\\\\xcdzI?O|,>\\\\xfa\\\\xc5}>\\\\xbc# ?F\\\\xfd9>K\\\\xd0\\\\xa2>\\\"\\\\x06i?>\\\\x9bm?c\\\\xe2\\\\xd8>\\\\x11\\\\xca\\\\xf1=\\\\x06\\\\x08E?\\\\xf2\\\\x0cQ?+\\\\x8d\\\\xfe>`\\\\xb0\\\\xfc>K_\\\\x18<\\\\x02\\\\x13g?hx\\\\xd0>\\\\x9a\\\\x8b\\\\xf3>d\\\\x99H>\\\\xcd\\\\x9d)>P\\\\xb4\\\\x84>\\\\xea\\\\x16o?\\\\x18\\\\x9fD?\\\\x16\\\\xa2\\\">)\\\\xc4|?\\\\tIr?\\\\xdf\\\\xd9\\\\x9a>!\\\\xebu?p:\\\\x0b?X3\\\\xca>\\\\x84\\\\xa9g>\\\\xef\\\\x9d\\\\x8e>\\\\x1ekt?\\\\xe1\\\\xa5N?]\\\\xbe\\\\x06?h\\\\xc8\\\\x9e>\\\\xb0(\\\\x12?\\\\xe1A\\\\xa4>V\\\\xefY<:\\\\x19\\\\xc5>O\\\\xeb1?\\\\xd0P\\\\x10?\\\\x1feT?U7x?\\\\x96]t?\\\\xbd\\\\x82c?\\\\xe1\\\\x87+?\\\\xbcVi?6\\\\'+?\\\\xc3PL?A\\\\x1aR=\\\\x1a-]?\\\\xd8\\\\xae\\\\x1e?\\\\x12K>?\\\\xfc\\\\x00\\\\xf4>\\\\xc5\\\"\\\\xf3=\\\\x17\\\\xc0\\\\xa4=\\\\xe6G\\\\x1b=\\\\x0f\\\\xdcP?\\\\x1d*\\\\xe4>pw\\\\x97>\\\\xe8v\\\\x13?\\\\xf00\\\\r?\\\\n\\\\xd0J>\\\\x87~\\\\xa6=k/^?\\\\x1f\\\\xe5\\\\xe3:V\\\\xbc??\\\\xb4\\\\x81=>;\\\\xd4,?]F#?0D\\\\x07?\\\\xb8\\\\xe7=>\\\\xba\\\\x0fC?\\\\x0fe\\\\x8d>I\\\\x80(?\\\\xa4\\\\x9e\\\\t?\\\\x12\\\\xaaJ?\\\\xdca6>!Uc>}\\\\x0c<?\\\\xcf\\\\x11|?\\\\x08\\\\x1f%?\\\\x00\\\\xbf\\\\xed>\\\\xc3\\\\xcd\\\\x8b<\\\\x02\\\\nG?\\\\x0eFY?\\\\x03\\\\xc4\\\\\\\\?\\\\xce\\\\x96\\\\x95>\\\\xb9\\\\xa95?\\\\xc4)\\\\xb0<z\\\\xfbR?\\\\x9cQ\\\\'?\\\\xa8\\\\x12??\\\\xee\\\\xa8\\\\x17?8\\\\x02\\\\xcf>\\\\xdf[R>\\\\x87\\\\xd24?\\\\x1fSk?\\\\x03\\\\xbdG=\\\\x18o6?x\\\\xdf\\\\x1c>\\\\x10Sc?=\\\\x06\\\\x82>j\\\\xee^?\\\\x96\\\\xc8\\\\x8c>L\\\\xf7\\\"?;\\\\xb4e?H\\\\xfbY>\\\\xa6\\\\x14\\\\xfc;\\\\x9d0\\\\x12?R\\\\xab\\\\xd9>\\\\x06\\\\x8d\\\\'?\\\\x7f%\\\\xc6>\\\\xc4;Z>\\\\xeb\\\\xaba?}\\\\x92\\\\x12>P\\\\xdcq>\\\\x93\\\\xa4\\\\x14?7\\\\x8b\\\\xb0>\\\\xf2um=\\\\xc9Q\\\\xe8=\\\\x08\\\\xdag?e\\\\x13j?j\\\\xefL?\\\\x1eeN?s;\\\\xff>\\\\xc5r\\\\x02?\\\\x86@w?\\\\xb8\\\\x00r>#-b?\\\\x87\\\\xc36>%\\\\x93f?\\\\x88\\\\xd8 ?d\\\\xa3d?\\\\xb0\\\\xe2z?b\\\\xb3\\\\xb7>\\\\xban\\\\xaf=\\\\xcf\\\\xaaE>_J\\\\xde>\\\\x80\\\\xa6\\\\x1b?\\\\xd1@\\\\xd8>670>\\\\x11k\\\\xc5>\\\\xfb\\\\x05\\\\xd4>\\\\xb0\\\\x98N?\\\\xcem\\\\x9f=\\\\xbd\\\\xc8r?+\\\\xcd\\\\xc4<\\\"\\\\xebN>J\\\\x0f\\\\xb2>\\\\xec\\\\xb4E?\\\\x807\\\\xef>\\\\xbc\\\\xa2>?;\\\\\\\\\\\\x12?fM\\\\t?\\\\x04}\\\\x19?:\\\\xff\\\\xa6>\\\\xd5\\\\xe3\\\\x96>\\\\x1cX\\\\xcf>\\\\x89\\\\x19\\\\xe8=\\\\xedc??\\\\x00\\\\x18^?\\\\xe7\\\\x0e\\\\xd9>L\\\\x00i?g\\\\x1a(?1P\\\\x12?\\\\xb8\\\\x16L?E\\\\x97N>\\\\xa4\\\\xe6\\\\xbc>\\\\x18\\\\x02\\\\xa8>\\\\xbc\\\\x12f?r[\\\\xd5>5Ii>\\\\xc2\\\\x1b\\\\xe3>\\\\x14\\\\xc6\\\\x13?\\\\xc3{L?\\\\x7fY\\\\xbb>\\\\xbcq\\\\xc3=XR\\\\xfd>A\\\\xcf<?\\\\x1f\\\\xd3@?\\\\x94\\\\xcaq>@@\\\\x88>\\\\xbf\\\\xe0\\\\xd4>\\\\xa2\\\\xb1??wP\\\\xc1=\\\\xd8\\\\xdc\\\\x17?\\\\xe7\\\\xcc??]\\\\xc6 ?%\\\\xd5\\\\x1d?\\\\x9f\\\\x94a?\\\\x1b\\\\xcf1>R\\\\xcf\\\">l\\\\xa7{>\\\\xe8Df<\\\\xc5\\\\x02L?\\\\xa8\\\\xc9\\\"?\\\\x12\\\\x17\\\\x0f?\\\\xd3\\\\x17\\\\x06?2AE?\\\\x1a\\\\xd9\\\\xc0=\\\\xac\\\\x06h>\\\\\\\\\\\\'\\\\x10?H\\\\xb2\\\\x1e>\\\\xe3=S?E\\\\xee\\\"?J\\\\xa2\\\\x02=,l>?\\\\xa1\\\\x97\\\\x83=s\\\\xf3-?/\\\\x04~?\\\\xa2\\\\xac\\\\x9b>k\\\\xaa\\\\x1a<\\\\xe0\\\\x1c\\\\x06?\\\\x00\\\\xa1s?>\\\\x8f\\\\xe1=\\\\n\\\\x9f\\\\xd7=\\\\xd0\\\\xc1\\\\xc1=\\\\xf0\\\\xf1R?\\\\x99$q>l#b?\\\\x9b\\\\xe6\\\\xd8>\\\\xfe\\\\xa2\\\\x1e?\\\\xd8\\\\xdb\\\\x9b>R\\\\x86L?\\\\x95m\\\\xff=\\\\xd8#\\\\x0c?\\\\xd77\\\\xd7>\\\\x83_\\\\xbf>\\\\xece9>\\\\xbc\\\\xe8\\\\xac<q\\\\x00~>E\\\\x0ei?\\\\x01Kg?m\\\\xa1/?\\\\xe8n\\\\x8f>\\\\xc9@\\\\xb1>\\\\t\\\\xdb&>\\\\x84\\\\r\\\\t?\\\\x9a\\\\xa4=?\\\\xce \\\\x10?\\\\x91\\\\xae\\\\xcf>Db\\\\xce>vB\\\\xc8>\\\\xd9\\\\xffs?X\\\\x83\\\\xec=\\\\xbf)\\\\x18?;\\\\xde\\\\xa0>\\\\x19\\\\xac\\\\x14?\\\\x99\\\\xe6\\\\xed>t\\\\x17r>!g\\\\x03?BD\\\\x86>\\\\xe7\\\\xcbk?\\\\xd8\\\\xa2B?\\\\xcd\\\\xc7\\\\x01>m\\\\xc4s?\\\\xf0)\\\\xdb>xuG?\\\\x10\\\\xd8\\\\x10?;\\\\x04+>-`e?\\\\xc4\\\\xf7\\\\xa8>\\\\xf9\\\\x86\\\\x80=\\\\x04\\\\x11M?\\\\x9e\\\\xe9\\\\x16?:\\\\xe2U?W$\\\\x93>\\\\xf7\\\\xbc{?\\\\xa7\\\\x02E?\\\\x95\\\\xf3\\\\'>\\\\xcd\\\\xb0\\\\x0b>\\\\xe2\\\\x0b\\\\xa3>\\\\xbe4V>\\\\x1fJ.?\\\\xbayg?\\\\x0c\\\\x15\\\\x1d>\\\\xfcQn>\\\\x13\\\\xb6\\\\x91>\\\\xad\\\\x01]?\\\\xbcMb?Yw:?,\\\\xed\\\\x81>\\\\xaf\\\\x87\\\\xeb=\\\\xed?\\\\xd6>\\\\x8cQP?a\\\\x89C?\\\\xb9S*?\\\\xf5\\\\xcb&?1\\\\xc4\\\\xcc>%J\\\\x80>Du\\\\x16=\\\\x05\\\\xd6$>u\\\\x8e\\\\xe0>\\\\xd6\\\\x81L?\\\\xa6\\\\xac\\\\x10?\\\\r\\\\x11i?\\\\xb2~2?\\\\xa5\\\\xca]?\\\\xfa}\\\\xc4>\\\\xc5m5?3R3?\\\\xdb\\\\x13\\\\xdb=\\\\xb3\\\\xa0[?\\\\xf0\\\\xc4V?\\\\xe3\\\\x97\\\\'>\\\\xa0\\\\xf1\\\\x16?=W\\\\xf5=\\\\x17O\\\\xfc>\\\\xf1I\\\\xcd>H\\\\x05r=(kg>EE5>)f3?U\\\\xae\\\\xed>\\\\xdd\\\\xe0\\\\x9e>\\\\x9b4#?`E(?-\\\\xcdA?\\\\xba\\\\x81D>\\\\x87bk?Kr\\\\xf2>\\\\x88\\\\x15+=\\\\xc16\\\\xe6>\\\\x93\\\\x05\\\\xa1>\\\\xbf\\\\x7f}>\\\\xb72*?\\\\x0f\\\\xb4;?8\\\\xc3f>{\\\\xff\\\\x00?A&\\\\xfd>+g\\\\xdf>\\\\xc0\\\\xbf7?\\\\x86\\\\xd9F?~dk?\\\\xc7\\\\\\\\e>\\\\xff\\\\x99\\\\xf4>\\\\xfc\\\\x11\\\\x8b=\\\\xfdda?w\\\\xa7\\\\xae=w\\\\xa8\\\\xfc=\\\\xe7\\\\x10\\\\x02?\\\\xfa*E?\\\\x93L\\\\x1f?>\\\\x8dD?\\\\xdd\\\\xed\\\\x9d>\\\\xb1\\\\x8bQ?\\\\x0eV\\\\x99=F\\\\x7f|?m\\\\x10\\\\xd8>V\\\\xe0\\\\x17>\\\\x9b\\\\xca#?h\\\\x83\\\\xd7>\\\\xa4vX>\\\\xec\\\\xce7?\\\\xa5\\\\xf6\\\\x15?\\\\xe5\\\\x9e\\\\xbf>|\\\\x85\\\\xfb>)7\\\\xf4>\\\\xc5\\\\r\\\\x1e?\\\\x89\\\\xde5=\\\\x9f\\\\xc66?\\\\\\\\\\\\x8e\\\\r?\\\\xde\\\\xb58>JXe?\\\\xcd\\\\xe1\\\\xc3>\\\\x9f.p>\\\\nCw?\\\\xd4\\\\x01p>\\\\x81\\\\\\\\\\\\'?\\\\xa4\\\\x8f\\\\\\\\>\\\\x89\\\\xf1\\\\x84;\\\\xf5\\\\xdfu?\\\\xed\\\\x19\\\\xd9>\\\\x03~\\\\xf9>\\\\x8b\\\\xfb\\\\xca=$\\\\xe4\\\\xb2>\\\\xdb\\\\x92\\\\x01>\\\\xd0\\\\x90:?Le\\\\xff:\\\\x84\\\\'\\\\xef=0QM?\\\\xaf\\\\x01x?\\\\xb7C\\\\n?\\\\xc8\\\\xb9(?qea?\\\\x1f\\\\xbe\\\\x17?\\\\x86\\\\x0b\\\\xdb>\\\\x9f&\\\\x08>A\\\\xeb\\\\xc8=~\\\\xc9\\\\xa8>\\\\xd08\\\\x88>\\\\x87\\\\x88O?j\\\\x96>?x5l>\\\\xdf\\\\x8f(?\\\\xd4[a?}T\\\\x07?\\\\xb8\\\\x0c1?V\\\\x8fl>>,F?8\\\\x8a$?\\\\x01\\\\xf6+?9\\\\xfd\\\\xeb>\\\\r\\\\xf0:>\\\\x13\\\\xd8~=u\\\\xb0{>;\\\\xcc\\\\x11?\\\\x94I\\\\xb3>\\\\xcf\\\\x87\\\\xcb<\\\\x82\\\\xdc\\\\xa6>\\\\xb3\\\\xda\\\\x03?G\\\\x92,?\\\\xc4\\\\xcaG?@\\\\xabV?\\\\x86\\\\xb1\\\\xff>G\\\\x82%?\\\\x93m\\\\xe7>\\\\xb6\\\\xb4\\\\xd0<\\\\xc1^Z?M\\\\x06g>\\\\xf7]G>\\\\x92/\\\\x87>\\\\x03\\\\xe4e?1\\\\xb4\\\\xe9>\\\\x12%\\\\r?\\\\xa7)\\\\x89=/\\\\x18O?\\\\xfcW\\\\xf7>\\\\xa1\\\\xdb\\\\x17?\\\\xd7@\\\\x11?\\\\x04\\\\xea\\\\xeb>_\\\\x912?\\\\x0bK\\\\xd8=\\\\xabat?\\\\x89\\\\xc5\\\\xdc>\\\\x93\\\\xc1\\\\xe0<0\\\\x91\\\\x07?\\\\xa2H\\\\xec>\\\\x16E5?\\\"\\\\xfb]?<p\\\\r?y\\\\xa7s?-\\\\x93\\\\x07>iX\\\\xcd>t\\\\x7f\\\\xe8>\\\\xc7\\\\x88\\\\x80>u\\\\xaf\\\\x11?\\\\xb4`\\\\x02=\\\\xf1\\\\xe6\\\\xb8>.\\\\xd7;?\\\\xeb\\\\xe82>KO\\\\x8d>\\\\xc7\\\\t)?\\\\x86\\\\xd7\\\\xed>z\\\\xf99?\\\\'\\\\xf8b?\\\\xaa\\\\x1bC=\\\\x7f9`?tGi?Kc\\\\xf8>\\\\x97\\\\x96a>\\\\x03\\\\x82\\\\xba>\\\\xfc\\\\r\\\\x85>\\\\xbb\\\\xd3u?j\\\\xb7<?>\\\\xf8\\\\x81>\\\\xd9\\\\x16+?g\\\\xdc\\\\xb7>P\\\\x00q?\\\\x1b\\\\x81+>g\\\\xbea?\\\\xcf\\\\x9b\\\\xe6>O\\\\xab\\\\xbf>S\\\\xe1{?\\\\x18\\\\xec\\\\x85>\\\\x92\\\\xc5>?e\\\\xe8J>\\\\xc7\\\\xf2\\\\xbf>\\\\xbc\\\\xf7\\\\x06?\\\\xc0\\\\x91?>$\\\\x18>?\\\\xdc\\\\x8d\\\">]\\\\x1c\\\\xa5>\\\\xb7!l?\\\\x94\\\\xf4\\\\xb2=8\\\\x05b?\\\\xf9j\\\\x02?1\\\\x1b\\\\x9a>\\\\x01O\\\\x05?\\\\xef\\\\xcb.?\\\\xb7\\\\xe0\\\\xeb>\\\\x872H?\\\\x7f\\\\x1a\\\\xb6>Q\\\\xc4P?F+3?X\\\\xab2?^E+?\\\\xaaK\\\\xaa>\\\\xa5\\\\xbf\\\\xbe=\\\\xc3F\\\\'?\\\\xcc\\\\xb2M>q\\\\xd4.?s\\\\xf9K?e\\\\x0es>\\\\xa2]\\\\x1c?~h\\\\xa5=\\\\x8d\\\\x81;?\\\\x95\\\\xd7\\\\x19?\\\\x9f\\\\xbd\\\\x7f?:\\\\x00f?\\\\x1c\\\\x19\\\\x12>1\\\\xd2\\\\x80=\\\\x81\\\\xb1\\\\x06?\\\\x83\\\\x9bb>5\\\\x8f\\\\xae>-\\\\x9d\\\\x1d>\\\\xf4\\\\x89b?z\\\\xfdT>\\\\xf0n`?,\\\\xba2?\\\\xbe\\\\'\\\\x1d?\\\\xc6\\\\n\\\\xb1>J\\\\xd0k?\\\\xa4\\\\xfcq>\\\\xd4\\\\x02\\\\'??@\\\\xaa>\\\\xc5\\\\xfc\\\\x93>\\\\x8f\\\\x12y>s\\\\xa1\\\\xc7>\\\\xfa\\\\x15J>\\\\x13\\\\x90X>\\\\x9c\\\\x0cp>\\\\xe5\\\\xd0\\\\xf9>p\\\\xb0\\\\xad>Ef\\\\xb3>4\\\\xb1L?^\\\\xa3\\\\x87>_\\\\x8c\\\\x05?:lE<\\\\xfc\\\\xf8w?u\\\\x12\\\\x8a>W5\\\\xf7>\\\\xcb\\\\xb8{=s\\\\xa7\\\\xc9>K\\\\x88P?\\\\x93\\\\\\\\\\\\x93>\\\\xb0\\\\xed\\\\x03?\\\\xdb|-?\\\\xb8U\\\\x1c?\\\\xe1!<? \\\\xc6\\\\x94>n\\\\xcec>m\\\\xa6K>.\\\\xd3Y?\\\\xeeN\\\\x12>\\\\x89\\\\xa8@?\\\\x80\\\\xeaO?*l\\\\x7f?\\\\xa9\\\\xe6\\\\xc0>$F\\\\xcc>\\\\xf3\\\\x8e\\\\xd1>9\\\\x98\\\\xcd=6n\\\\x01?\\\\xd8\\\\x1et?+\\\\xb3I?\\\\x9d\\\\xdc\\\\x11?\\\\xc5v\\\\x10?%\\\\xda:>\\\\xbfC\\\\x17>\\\\xda\\\\x85\\\\xb8=*\\\\xceO?3eS>\\\\xff\\\\x1c1?fs\\\\x1e<\\\\xa12\\\\xce>\\\\xecB\\\\xbf>\\\\xb9$,?\\\\xfa\\\\xe6N>\\\\xdc\\\\xb1H?\\\\xf2\\\\x07\\\\\\\\>\\\\xc6\\\\x9e\\\\xf0>\\\\xce\\\\x18L?K\\\\xc1\\\\x13>x\\\\xd5)?F{q?\\\\xe7f.?S\\\\x02!>39.?\\\\x19c\\\\xaa>}\\\\x9bs>\\\\xcf\\\\x1fx?D\\\\xf3\\\\xd8>!!H?\\\\xa7uD?\\\\x8c\\\\x9e\\\\\\\\?\\\\xb5\\\\x1dY?f\\\\xb0\\\\x04=\\\\x90\\\\xda\\\\xd1>\\\\xd2\\\\x13\\\\r?\\\\x92{\\\\xcb<\\\\xa1\\\\xf4L?\\\\xedh^>\\\\xe6\\\\xe5o?\\\\x86\\\\xe49?oS\\\\x1d=sWs>\\\\xb0c\\\\xad>\\\\x94\\\\x1e\\\\xd5=)\\\\xdf\\\\x12?\\\\xb5A\\\\x1c>\\\\x11\\\\xda{?\\\\xd5+\\\\n?\\\\xd1G\\\\xe4>\\\\xdf^7>\\\\x9e-\\\\xc7=]\\\\xbbp?\\\\x88\\\\x97\\\\xaa=\\\\xfc\\\\xb3y?J\\\\x0c\\\\x0e>\\\\x83\\\\xa0\\\\x89>\\\\xfeEQ?\\\\x93H\\\\xd9>\\\\rZ\\\\x19?\\\\xfc\\\\xe2\\\\xf5=\\\\x93d\\\\x0e?!\\\\xb1\\\\t?\\\\xaat\\\\x19?\\\\xce\\\\xc2\\\\xdf<C\\\\x1c\\\\x9d>s\\\\x8fd?\\\\x17\\\\xd53>\\\\x01\\\\xcd\\\\xd0>\\\\x86\\\\xbfD?\\\\xdbD\\\\x1a?\\\\x17\\\\x81\\\\xac>\\\\xfb.#>\\\\xfe\\\\xfd\\\\x15?\\\\xb8\\\\xa0\\\\x00?TnZ>Q\\\\xd0\\\\x15?\\\\'y\\\\x13?\\\\x04\\\\xca\\\\xe4>^\\\\xc4\\\\xb6=\\\\xa9$]?7\\\\xc1\\\\x9f>S\\\\xbf\\\\xc8>\\\\xa7=k?2\\\\xf7D?%\\\\x03c?O\\\\x99l?\\\\xd4]\\\\xa0=K(\\\\xda>\\\\xa6\\\\x90]?2<\\\\x13?\\\\xb4\\\\x16a?y\\\\x80h?k\\\\xed\\\\xd8>D\\\\xa1\\\\xa7>\\\\xae\\\\xc4\\\\x1f?\\\\xe5|\\\\x05?\\\\xc9g\\\\xe6>\\\\xfc4\\\\n?\\\\xd9\\\\xaf\\\\xf9>\\\\x10@\\\\xf1=% \\\\xe2=\\\\xb5\\\\xae\\\\x1a?\\\\xedv\\\\x8b<\\\\x98\\\\x01~>\\\\x81\\\\xb4\\\\x13?\\\\xa4N\\\\xe6=\\\\xb69-?za\\\\r?\\\\xc6\\\\x00A?\\\\x97\\\\xf2k?\\\\xa7\\\\x06U?\\\\x91\\\\xbfs?\\\\xfd\\\\xa6\\\\xf6=K\\\\xf8=?|\\\\xea\\\\xd3>\\\\xa7\\\\xaa.>\\\\x12\\\\x08F=\\\\xa2\\\\xcd\\\\xf4=\\\\x8doh?\\\\xb8\\\\xe7q?gt7>\\\\x03\\\\x91C?ML\\\\x03?\\\\xe7m/?=B\\\\x06?\\\\xe3\\\\xd2E>P\\\\x1c\\\\x10>uH\\\\\\\\?|\\\\xafH?\\\\xa2\\\\x82|?\\\\xad\\\\x99\\\\x14?6\\\\xb2V>\\\\xe9)\\\\xa8<\\\\x04\\\\xf3\\\\x04?B\\\\xa1$?ne\\\\xc5>\\\\x96\\\\xb2/?\\\\xb0&\\\\xa9=\\\\x8f`\\\\xaa=\\\\xce-\\\\x03=\\\\xbc\\\\x10\\\\xdd>!\\\\x16g>[R\\\\x04?\\\"\\\\xa3F?\\\\x83\\\\xd6B?Z\\\\xefC?\\\\x85yX?%o\\\\xa4>z\\\\x19}?\\\\xf7\\\\xcf\\\\x92=\\\\xf7b\\\\x9b>\\\\x8cG\\\\xcc>\\\\xfeY8?\\\\xd8\\\\x86\\\\xac>\\\\xb3al?\\\\x00\\\\xf5\\\\n?t\\\\xfcx?B\\\\xe7\\\\xe7>W\\\\xb8\\\\xb5=K\\\\xedv?(:I?\\\\x82\\\\x9a\\\\'?\\\\xe3\\\\xef$>fW\\\\xdd>\\\\xf9\\\\x81Q;P\\\\x9a\\\\'?\\\\xfak-?\\\\x1b=+?P\\\\x11E?\\\\xcbG\\\\x07?\\\\x90\\\\xcc\\\\x9f>4W\\\\xfc>E#\\\\xa5>\\\\x08\\\\xf1V?-o\\\\xf7>%W#?\\\\xfd\\\\xda\\\\xa9=*\\\\xab\\\\r>\\\\xb5\\\\x1a\\\\xd1:\\\\xbb\\\\x89Z?1\\\\xa4Q?*\\\\xc7\\\\x05?\\\\xe0u.=S\\\\xbe\\\\x08?\\\\x1c\\\\xe9\\\\xc7> \\\\x95\\\\x9d>\\\\x84\\\\x03+?\\\\x1a9A?\\\\xcb\\\\xb8\\\\xa1>\\\\xfb\\\\xa1\\\\x84=\\\\xac\\\\xadc?\\\\xe5\\\\xca\\\\x81>\\\\xec\\\\x07/?\\\\xc9\\\\xe0p?(TU?\\\\xef\\\\xefP?\\\\xec\\\\xce|>:\\\\xc5\\\\x17?\\\\x1a\\\\x89\\\\x9c>\\\\xab\\\\'F?,\\\\x9eR?\\\\x8d;\\\\xb3>Z {?\\\\xa8\\\\xee\\\\xbb>|\\\\x81m?=\\\\xf4`>\\\\x01\\\\xea\\\\x9e>q68?Xcj?T\\\\x04\\\\x1e?\\\\xb1\\\\x13$?\\\\x88\\\\xea\\\\x9b>\\\\xa8\\\\x83\\\\x08?h\\\\xffd?Vw\\\\n?\\\\x07\\\\x93h?424?_\\\\x1b\\\\xf9>r\\\\xcf]?L\\\\t\\\\xdc>i\\\\xc9q?$B\\\\xce>\\\\xb8\\\\xc6\\\\xb1>\\\\x01\\\\xe5\\\\xa3<\\\\xd6z\\\\x9b>\\\\x15\\\\xecn?\\\\x96\\\\x0c<>7\\\\xa2\\\\x96>\\\\xe1:=?2\\\\x03\\\\xe9>oPr?\\\\x85\\\\x9b\\\\xac>r7\\\\xf3=\\\\xe0^\\\\x0c>\\\\xefk\\\\r>f\\\\xac8?\\\\xbc\\\\xe5\\\\x11?1kS?\\\\xd9\\\\xd3\\\\x1a>M\\\\xfe\\\\\\\\?\\\\xb3J\\\\x14=\\\\x90\\\\xba@?\\\\x98\\\\xfc\\\\x8b>\\\\x01\\\\x8d,?>\\\\x13{?\\\\x0bb4?\\\\x9b\\\\xc7\\\\xd4>b\\\\x16\\\\x99>^9;?T\\\\xec\\\\xd3:^*K?\\\\x1b.\\\\x05?\\\\xa3\\\\xb1\\\\x88>-\\\\xb2\\\\\\\\?\\\\xa9\\\\xd3\\\\xba>y\\\\xda\\\\xb8=\\\\xc2{\\\\'?\\\\x95A0?j\\\\x80S?yB\\\\xe1>:\\\\xa4T=Q\\\\x01y>\\\\xa3-l>-\\\\x08\\\\x19?\\\\xe5\\\\xe6\\\\x08>q\\\\x0e1?\\\\xe7wG?\\\\xcbs$?pH)?\\\\x9b\\\\xb8\\\\xe6:\\\\xad\\\\xc8I>\\\\x8a\\\\x9ad?\\\\xff\\\\xcet?\\\\xaa\\\\x1f\\\\xce>\\\\x92U\\\\xfc>\\\\xe1\\\\xb4V>,T8?\\\\xbbR+>\\\\xf0\\\\xc7a>\\\\xd8\\\\xa1\\\\x90>\\\\x14\\\\xc7b?RFP?6Ps?\\\\xd1G8>\\\\xbcQA>4\\\\x99\\\\xbb>0\\\\xfd\\\\xda=\\\\x89\\\\xdc~?l%R?\\\\xb8\\\\x05\\\\x1e?\\\\x0cS3>\\\\x1c\\\\x8aU?y\\\\xb2P?\\\\x15\\\\x97\\\\xe7>5\\\\x91G?8C\\\\xa5>c\\\\x8e\\\\x0e?\\\\\\\\UP?;4m>6\\\\x024?\\\\x11\\\\xed\\\\x87>}H7?f\\\\x8e&?+\\\\xe4\\\\xc0>\\\\x11\\\\xfdL?\\\\x94\\\\xf1\\\\xef>\\\\xea\\\\xa3\\\\x08?1h\\\\xfa>\\\\xed\\\\xfdJ?*\\\\x13:?\\\\x16:\\\\xe0>\\\\x0c\\\\xd8\\\\x14?\\\\xb9\\\\x1ej?(\\\\x8c\\\\x94=\\\\xce\\\\xe6\\\\xa3>F\\\\x14\\\\x99>r\\\\xf8T?\\\\xa5~\\\\xca=t\\\\xe7e?\\\\xfc\\\\xbd\\\\xc6>+\\\\x8c\\\\x08?\\\\x88\\\\x99b?\\\\xb7\\\\x02|?B\\\\xa8\\\\x87<*\\\\x0f\\\\xea>\\\\xf6\\\\xc8~>\\\\x9b\\\\x92[?\\\\xdcO\\\\xa2<\\\\xa6=Q>\\\\x9a\\\\xfbl?\\\\xa5\\\\x89\\\\xb0>\\\\xf0f\\\\xd5>v1/?S\\\\xbd3?\\\\xb9\\\\xc5D?\\\\xc5\\\\x0f\\\\xff>\\\\x07\\\\x043?\\\\x08\\\\x05\\\\x12?&\\\\xc3G?#\\\\xcb\\\\xb6>$\\\\xf3h>\\\\xf6\\\\xd1\\\\\\\\?z\\\\x85\\\\x03?I-\\\\x1b?I\\\\xf8\\\\xb1>\\\\n*\\\\xb9>\\\\x82\\\\xcd\\\\x1b>\\\\x11A\\\\xd9>VE(?v\\\\xd2\\\\xa4>\\\\xf0\\\\x8f\\\\xc3=a\\\\x89\\\\x7f>\\\\xbcF\\\\x9f>\\\\xe5\\\\x97\\\\x81>&\\\\x1eI?\\\\x1f[\\\\xab=r\\\\xac\\\\x1a;\\\\xd6\\\\x16\\\\x1a?\\\\x06\\\\xfa\\\\'?[\\\\xc1\\\\x13>\\\\x1el(?\\\\x971\\\\xd5>)\\\\xbc\\\\x85>|\\\\xe3;?@KB?\\\\x99\\\\x17\\\\x13=\\\\x1a\\\\x9dP?\\\\x92\\\\nR>c\\\\r}?1+\\\\xfd>\\\\xb61&?\\\\xd7o@?\\\\x0cP5?\\\\xcf\\\\r\\\\x08>\\\\x06Qo?\\\\xa2\\\\xe2Z?\\\\xa5\\\\xccY>X\\\\xda\\\\xdb>]\\\\x8a9?\\\\xbc>u?\\\\xfc\\\\xcc->!\\\\xe27?\\\\xec\\\\xd4Y?\\\\xabj^>v\\\\xa7W?\\\\xeb\\\\xa52?\\\\xbb\\\\x10$?\\\\xa3\\\\xf3;?BOw?\\\\xc6=p=\\\\xadU}?\\\\x14\\\\x9a\\\\x03?\\\\xc8)\\\\x01?\\\\xa7\\\\r\\\\xa1>\\\\xfdKX?\\\\xcf\\\\xab\\\\xcd>\\\\x0ecE>\\\\x88Ox?bx%?\\\\xe8sg?\\\\xa1\\\\xb2d?\\\\xed|\\\\x98>/p\\\\xa1>\\\\x90b\\\\x0c>Rd#?\\\\xde\\\\xda_?\\\\x1a\\\\'\\\\xf7>y\\\\xaf:?%`\\\\xe7>\\\\x99_+?6\\\\x8a\\\\x08?\\\\x19YJ?\\\\xc1\\\\xb1\\\\xd9>PF\\\\xe2=\\\\xd4\\\\x8a\\\\x1b?\\\\x1f\\\\x18\\\\xa5>.\\\\x94<?\\\\xc3T\\\\x1b?VVu>\\\\xd5\\\\xe0\\\\xfd>VC\\\\x17?BE\\\\x0b=\\\\x9a\\\\xf7\\\\xe1>CTK>\\\\x97\\\\x92d?\\\\xec\\\\x1f\\\\x1b?\\\\xe0\\\\xc7J?\\\\x9b\\\\xef\\\\x13?\\\\xdd\\\\x8e\\\\x0b?q\\\\xb1a?\\\\x07Tt?\\\\x91\\\\xa7\\\\xe9=(\\\\xe3\\\\xaa>\\\\x04\\\\x8e[>?\\\\x19\\\\xc8>(\\\\x10\\\\x9f=d[}?\\\\xf2\\\\xba\\\\xa5>N~\\\\xd4;98$?DG\\\\xc8<3\\\\x026??\\\\x99e?'},\\n\",\n       \" Document {'id': 'doc:b', 'payload': None, 'score': '0.253424823284', 'vector': b'1\\\\x16\\\\xe9>\\\\x9cax?z\\\\xc6c?\\\\x92\\\\xa2%?\\\\xcdk\\\\x9c>\\\\xa3|\\\\x8a>#L\\\\xec>\\\\xc9\\\\xca\\\\xf0;\\\\xce\\\\x1d\\\\x1a?\\\\x12\\\\xe4\\\\xab>h\\\\x88\\\\xe4>\\\\x9a\\\\xb4\\\\xe8>\\\\xb7\\\\x03\\\\xdc=\\\\xf2*l?\\\\xe7\\\\xe1,?(;p?\\\\xceL\\\\x13?\\\\xf5\\\\xfd\\\\xdc=\\\\x19\\\\x92\\\\x84=7\\\\xe1\\\\x7f?\\\\xb7\\\\x898?yR@?\\\\xa4\\\\x1cp?S\\\\xde\\\\xb1>5#\\\\xf4>F4\\\\xc4>\\\\x14R\\\\x18><W\\\\x0b>\\\\xb8n:?/%A?NEU?tu\\\\x0e?\\\\x85\\\\xe8\\\\x88>e\\\\x04\\\\x1b?T\\\\xed4?\\\\x08\\\\xea\\\\x03?\\\\x98\\\\x16p?\\\\x0f\\\\xa4\\\\xfa>$\\\\xb5O>\\\\x9b\\\\xb0~?\\\\x94\\\\xab\\\\xbc>\\\\xa0\\\\xe6G?\\\\x1f\\\\x8bz?s\\\\xca-?\\\\x03\\\\x9e\\\\xb3>\\\\xb4|\\\\xd1=\\\\xf0Z_<\\\\x1a\\\\xe3:?\\\\x06q]>\\\\x02\\\\x0b\\\\x9c>\\\\xdcZ\\\\x1a?L\\\\xb0\\\\xcc=E^\\\\x1a?\\\\x05~\\\\x03?m\\\\xea\\\\x15?\\\\xc4r]?\\\\x0c\\\"\\\\x89>\\\\x9c\\\\xb78?\\\\x87\\\\xf3\\\\x8f>+e\\\\xc0>\\\\x16\\\\xe3@?\\\\xa5\\\\x85M=jq\\\\x12>5.`=T\\\\xac&?\\\\xc7\\\\x91\\\\xb8>\\\\x93\\\\x9cs?\\\\xb1\\\\t\\\\xfa=\\\\xb2\\\\xdfd?#\\\\x8cO?zX&?R*\\\\x05?\\\\x90\\\\x9f\\\\xf7=\\\\x83\\\\x9c\\\\x02=AY\\\\x1d?m$w?\\\\xa6\\\\xc4\\\\x80>\\\\xfd\\\\xceH?\\\\x87\\\\xbaK>\\\\xca\\\\t\\\\xf8<{\\\\x1c\\\\xf5=L\\\\xf9a?TB ?\\\\xae\\\\xcd\\\\xb2=\\\\xd0*\\\\xe9=\\\\x92\\\\x88[?[\\\\x06F?\\\\xb4\\\\xffu?Y{\\\\xbc>\\\\xba\\\\x8bU?z,\\\\r>d\\\\x7fE?\\\\xcd\\\\x85\\\\xb4>m\\\\x91\\\\x9a>z\\\\xa5N?7\\\\xd5\\\\x8a>\\\\x8a\\\\xfbA>n\\\\xa2,>\\\\xf9j\\\\x04?\\\\xe8\\\\x84\\\\\\\\>\\\\xb4A=>\\\\xe8v\\\\xde>\\\\x19\\\\xbaX?\\\\x1d\\\\xd3E?\\\\x90\\\\xcf\\\\x9f=\\\\'\\\\x18w?\\\\xbd:\\\\'?1C\\\\x17?\\\\xac\\\\xaeJ?ae\\\\xef>\\\\xc8_v?\\\\xe8\\\\xfcP?/\\\\x96S?E\\\\x16D?2\\\\xf4~=\\\\xcaV\\\\x1f>\\\\xef\\\\xdb\\\\x1a?\\\\x87)\\\\x11?:6\\\\xfd>uLk?\\\\x9ddT?\\\\x02gc?\\\\xc7A\\\"<\\\\x87\\\\x18v?x\\\\xec8?\\\\x84\\\\xad\\\\xf4=\\\\x0cj\\\\xf6>\\\\x04\\\\xa0\\\\xaf=\\\\xa5\\\\x12\\\\xf0>\\\\xda\\\\x98}?kB\\\\xab>\\\\xd2\\\\t\\\\x9c>\\\\xf0\\\\x90\\\\x8c>\\\\xbe,9?\\\\x1b>S?J\\\\x17[?+\\\\xb2k?\\\\xbdO\\\\xf9=\\\\xb8\\\\xdd\\\\xaa>\\\\xbc\\\\xbc\\\\x1a?\\\\x98\\\\xa3&=\\\\xdbo\\\\x1b?*\\\\x1c\\\\x17?\\\\xacP\\\\n?\\\\x15\\\\x99\\\\xfb=D\\\\xd6s?\\\\x97:~?\\\\x83\\\\x97H:\\\\x814\\\\r?\\\\x1d\\\\xbdt?\\\\x98\\\\t\\\\x0e?\\\\x10\\\\xbd\\\\xf6>\\\\xad)\\\\x80=(\\\\x8b!>Th\\\\x1e?\\\\xfaOX?\\\\xd835?\\\\x00\\\\x8c\\\\x03?9\\\\xf1\\\\xa7=\\\\x80\\\\xbd\\\\x12?*\\\\t\\\\x19?F\\\\xa7\\\\xd2>\\\\x0e\\\\x9c\\\\x1f?\\\\x8d\\\\xed\\\\xf9>\\\\x8bD\\\\xea>\\\\xdb\\\\xe4+?\\\\xb3%\\\\x12?h\\\\xd0F=R\\\\xb5\\\\xc7>\\\\xaf\\\\x99\\\\xe8=\\\\xda\\\\xe4\\\\xe6=J\\\\x073?4;\\\\x1c?|e\\\\xf6=\\\\x99\\\\x8e!?\\\\xdeJ\\\\x9d>5\\\\xa2j>\\\\x06\\\\xdf ?\\\\x99\\\\x85\\\\xfb=A\\\\xcce?z\\\\x19M?\\\\x1f\\\\xccA?\\\\xb3\\\\xea)?Ex\\\\xf7>4\\\\x15%=\\\\xe4\\\\xe1(?C\\\\xe0~?;\\\\x9a\\\\x19?\\\\xe2A.?@\\\\x82\\\\x1a>\\\\xf2\\\\x909?\\\\xcb\\\\x18\\\\xfe>\\\\x87\\\\xff\\\\x11?\\\\xd0-H?h\\\\xd9K?Bps?K\\\\x9b\\\\x0b?|%u?\\\\x1b\\\\xcd\\\\n?\\\\xbb\\\\xdb\\\\xb9>]\\\\xd0\\\\\\\\?\\\\xbee\\\\xb5>*\\\\x9f\\\\x9b=\\\\x8f\\\\x1b\\\\x7f?O\\\\xf8N>\\\\x91\\\\xf2b=\\\\x9a\\\\xe5\\\\xcc=5\\\\xf3o?\\\\x86\\\\x83\\\\xbc>9\\\\xaeG>#\\\\xd4\\\\xbe=\\\\xa9m\\\\x85=\\\\xc3\\\\xa7\\\\xf3<\\\\xf6\\\\xb3\\\\xed>\\\\x94\\\\xcc\\\\x98>\\\\xb7m\\\\x12?\\\\x19\\\\xbaq?\\\\xae\\\\x96Z?g\\\\xa2\\\\x11?j9\\\\xc2>/\\\\xc2f=&\\\\xcf??\\\\xdc\\\\xado?\\\\x01d\\\\x1f?B\\\\xb8U?t\\\\\\\\b?\\\\xc0W\\\\x9c>r\\\\xeb}?\\\\xef\\\\xc9\\\\xe6=\\\\xaf{#?\\\\x89BW?S\\\\xffO?\\\\x8e\\\\xaa\\\\x03?\\\\xfb\\\\xf1\\\\xb5>0\\\\x89\\\\x81>\\\\x96\\\\xed\\\\r?\\\\x88W\\\\xf6>\\\\xe5ZG>\\\\x122\\\\x14?\\\\xe8\\\\x0e\\\\x91>\\\\x1a\\\\xc2\\\\xf4>\\\\x15\\\"Q?\\\\x8c\\\\x87\\\\xa0>\\\\\\\\d\\\\xa7>p\\\\x94-?\\\\xe2\\\\xe5\\\\x17?\\\\x8f\\\\xe5\\\\xdd=\\\\xb9\\\\x04#>_\\\\x9fU?\\\\xd8\\\\x0cL>2Jn?1\\\\xa7\\\\\\\\>\\\\xebT\\\\xe4>NT\\\\x9c>B6\\\\x0e?_\\\\x0fS=\\\\xb6\\\\xcdR<\\\\x96\\\\xb2\\\\xc9>\\\\xbc\\\\xf6\\\\xe2>^\\\\x8f\\\\xa2<\\\\x95\\\\x93\\\\xee=\\\\xf3\\\\xc0C>\\\\xfdw\\\\xd1>\\\\xbc\\\\xba\\\\xf5>\\\\x8b\\\\x13Q?\\\\xa5_\\\\xaf>\\\\xa9\\\\xb0W?\\\\x1bWd>!\\\\xa8\\\\xf8>\\\\x9d\\\\x87\\\\x17?\\\\x84.\\\\x01?G<\\\\x10?\\\\xf6^\\\\xc9=\\\\x8a\\\\x87g?\\\\xae\\\\xc61?\\\\xcbn]>\\\\xe9\\\\xf9+>\\\\x1b\\\\x01\\\\x99>\\\\xcd\\\\x02\\\\x86>\\\\xeb\\\\xe0y?\\\\t.4>\\\\xa4\\\\xb2\\\\x01?;4]=}\\\"\\\\xde>\\\"\\\\xf5n>{\\\\xe7\\\\r>\\\\xbb\\\\x03G??8\\\\x18>\\\\x96\\\\xd1$?\\\\xed\\\\xa2\\\\xcc=\\\\x8d\\\\xef\\\\xd1>t\\\\xf1\\\\x08?\\\\x82\\\\x9cO>~I\\\\xf2>\\\\x0b\\\\xd6\\\\x1d?\\\\xff-\\\">\\\\xb8(_?W\\\\x94o?\\\\xcd\\\\x08A>\\\\xac7\\\\x87>\\\\x15\\\\xaf&<\\\\xab\\\\xcd\\\\xd2>\\\\xf6\\\\xb9\\\\xae=@M\\\\x86>\\\\xb2\\\\x89^?=\\\\x10j?x\\\"\\\\xb9>\\\\x03\\\\x05H?&L}?\\\\x05\\\\x803>\\\\x97\\\\x18\\\\xe4>\\\\x1c\\\\xb9\\\\xaa>\\\\x07\\\\xb5\\\\x9d<\\\\x05\\\\xb1~=\\\\xa9L\\\\xbc><)[?\\\\xdc\\\\x8eC?\\\\xa3\\\\xcb\\\\xf8<k_>?_\\\\x12e?3\\\\xce5?\\\\xef.V?h\\\\x92\\\\r?U<\\\\xa6>\\\\xe6,\\\\x83>J\\\\xd3\\\\x8a>\\\\xf4\\\\xe6*?\\\\xf1y]?\\\\xea\\\\xda\\\\xea=|\\\\xff\\\\x96>\\\\xe4\\\\x8ed?\\\\xff\\\\x00\\\\x82>\\\\x13\\\\xb6\\\\x1e?\\\\x04{h?\\\\xa9\\\\xf4\\\\x86>\\\\x8d\\\\xed\\\\xf9>]\\\\xf3|<\\\\x8d!\\\\t?mq\\\\x94>M6]?\\\\xa5cP=\\\\tb~>\\\\xcf\\\\xd6\\\\x83=K\\\\xb4Z?\\\\rPt>+\\\\x1bW?\\\\x93>W?\\\\xc7E ?\\\\x81\\\\xcb-?D)i?\\\\x99\\\\x02\\\\xb0>\\\\xa4X\\\\x18>:\\\\xc5\\\\xc8>~\\\\x85\\\\xef>=\\\\x7fy>h\\\\x8ee?\\\\x02\\\\xd7Y?\\\\x1bd\\\\xf2<\\\\x82\\\\xda\\\\x0c>\\\\xb9f,>\\\\xed\\\\xd9:?\\\\x12\\\\xf66>\\\\x90\\\\xeek?:+l?+e\\\\xbe>7\\\\x18=?j\\\\xda~?\\\\xd0A\\\\xb8>\\\\x8f2\\\\x95>E\\\\x92\\\\xc0=f\\\\xb2\\\\xbb>X\\\\x8e\\\\x99>\\\\xfc\\\\xcaa>\\\\xa7j\\\\xde80\\\\xe6\\\\xde>\\\\x8d:l<[%\\\\x0c=\\\\xaaq\\\\x05?\\\\xe8\\\\xf7\\\\x89>\\\\xc4\\\\x9c\\\\\\\\?h\\\\xcfG?\\\\xfa\\\\x03T?v\\\\x03k?\\\\xc7\\\\x90\\\\x0c;Y\\\\x8fL?\\\\x15S\\\"?\\\\xbc\\\\t\\\\xd9>\\\\xc8\\\\xda\\\\xa4>\\\\xcf\\\\x82+?\\\\xe0\\\\x11\\\\x1c?\\\\'\\\\xe2\\\\xc5>a\\\\x031?\\\\xd4\\\\x08G=\\\\x82\\\\xb5\\\\xd5<\\\\x9e\\\\xa7j?b\\\\tx>&I\\\\xc1;\\\\xfa\\\\xc0c?8$\\\\x0c?t\\\\xea??\\\\x1eoT?\\\\x02*l?\\\\xe0\\\\xee\\\\xa0=3\\\\x0f\\\\x84>\\\\x81\\\\xae\\\\xbc=!E\\\\xed>\\\\x0b\\\\xee\\\\x80=|^\\\\xeb>\\\\xb6\\\\x0b@?\\\\x1a\\\\xb5v>\\\\x86\\\\xc5M?\\\\xb3]k?x\\\\x0e\\\\xe1>\\\\xb0d\\\\xcb>\\\\xe5\\\\x7f\\\\xa5><\\\\x89\\\\x10?{\\\\x96\\\\x96>\\\\x9bqf?\\\\xf6\\\\x9cd>b\\\\xee\\\\xbf>\\\\x8dP\\\\x00?\\\\xbe\\\\xa8;>a\\\\xf0\\\\xd4>?\\\\x89T?\\\\xbf\\\\xff\\\\xb8=\\\\xe5\\\\ry?\\\\x1b\\\\xcb\\\\x02?\\\\xb7S\\\\x19>\\\\x0f\\\\x96&?\\\\xa1\\\\xca\\\\x18?[A)?y\\\\x8cX?\\\\xf5O#>\\\\x85\\\\x8f\\\\x11>}\\\\x14\\\\x81=UN\\\\xb6>\\\\x8fi\\\\x92>\\\\x9d\\\\x84A> \\\\xd5\\\\xad>\\\\xff2\\\\xa2>yG\\\\x18<\\\\xb5m/?cW.>#\\\\xa1e?\\\\xa0\\\\xfa\\\\x1d?\\\\xcfd\\\\x16?E\\\\xaal?M\\\\x87x?\\\\x8b0\\\\xe6>\\\\x10MW?\\\\x01<\\\\xd1>\\\\t\\\\x990>\\\\xa32\\\\t?\\\\xfd\\\\xe3/?\\\\xa9\\\\xe5=>\\\\x7f\\\\xdd:>a,\\\\'>Eo\\\\x06?\\\\xe9\\\\xabe?\\\\xc5\\\\x879?\\\\n\\\\xe5h=\\\\xd4\\\\x0b^?y\\\\xa7G<\\\\x02A\\\\xf5=\\\\xf7Hy?\\\\xe3\\\\x9e ?\\\\x82\\\\xb6>?~\\\\xf8\\\\xf4>\\\\x90\\\\xd8\\\\xaf=\\\\xfcpX?\\\\x1d\\\\xa5\\\\x1e?2\\\\xc11?\\\\xadf\\\\xe1>\\\\xcf\\\\x1c\\\\n?x\\\\x0f\\\\xd8>\\\\x86\\\\x02\\\\xa9=\\\\xb8\\\\xb3\\\\xcf=\\\\x11\\\\x91:?\\\\x92\\\\xf4\\\\x0e?\\\\xbf\\\\x14\\\\x9b>\\\\x93G\\\\x7f>\\\\xaa-\\\\xea>\\\\xf1\\\\x95\\\\xbb>\\\\xa0*\\\\xde>R9\\\\x7f?\\\\x95\\\\xe2N>B\\\\x84\\\\x12?2\\\\xa0u?\\\\xadbD?\\\\xbb\\\\xefO?i]\\\\xf8=b\\\\x05\\\\t?g\\\\xd6\\\\x80>F\\\\xf6[?n\\\\xde\\\\xb0=\\\\xa2\\\\xbdj?I^\\\\xfd>\\\\xb7k\\\\x07>\\\\x1bE\\\\xf8>vB\\\\xb7>O%=?T\\\\xebF?\\\\x90\\\\x87m?Q+\\\\x16?YCc?\\\\xba\\\\x08\\\\xb4>\\\\x0fNT>xe\\\\x0e?\\\\x974P?\\\\xc4\\\\xbdi>\\\\x8bu\\\\x81>U;\\\\x1a?|\\\\xae8?\\\\x1f\\\\xef\\\\x01>4M\\\\xaa>\\\\x7fgr?\\\\xf8\\\\xd6\\\\xba>z\\\\xa0\\\\xbc>}\\\\xde/>\\\\x90\\\\xbeI?\\\\xa5\\\\xc2e?\\\\x9e\\\\x0cf?\\\\x95:J? \\\\xb2\\\\xf3>\\\\x00\\\\xf6p?\\\\xdaZE?\\\\xce\\\\x18\\\\x1d>\\\\xe8\\\\xdc`>\\\\x9f\\\\x03\\\\xa0>\\\\xe1\\\\xf9\\\\x1d?E_\\\\xcd=\\\\xdc\\\\x1e9?\\\\xb3?a?\\\\xcfnf?\\\\xc1$\\\\x01>5S\\\\x11?f\\\\xf38?0z;?\\\\xfd\\\\x8cb?C\\\\xe1\\\\xe6>\\\\\\\\\\\\xd3S?l_\\\\xa5>k\\\\xe8\\\\x1f?\\\\xa7\\\\xe3\\\\xea>\\\\xb5$p>\\\\xa9\\\\x90\\\\x05?z\\\\xf4P>&\\\\xb0\\\\x0b?M\\\\x8b\\\\x08?\\\\xdd\\\\x94\\\\xcf>MA\\\\x0b?\\\\xea\\\\x12\\\\x19>l\\\\xf2\\\\x1a>\\\\x9e\\\\xecs?\\\\x1a=\\\\x83>\\\\xd9\\\\xc5\\\\t?n.2>\\\\t\\\\xb73?\\\\xbb\\\\x9f??\\\\x15=a?s\\\\xd5N?\\\\xb7\\\\xc9b>`\\\\x1dP?\\\\xa2\\\\xd0\\\\x80>\\\\x04\\\\xf4\\\\xd0>\\\\xf6,K?~\\\\xb2z>\\\\xcc\\\\xa5s?$\\\\xd0f=\\\\x1f\\\\x86\\\\x1d?\\\\x17\\\\xc8v>\\\\x11\\\\xd8@?,@0?\\\\xc7\\\\xa9\\\\'>\\\\xc0/\\\\x1b?!\\\\xbb\\\\x85>I\\\\x9a\\\\xb5=7\\\\x8dx?z\\\\xa7w?w\\\\xb4\\\\x1e>6\\\\xb8\\\\x7f>\\\\x8a\\\\x86\\\\xca>K|`?\\\\xd5\\\\xc7\\\\x19?\\\\x99E ?0@%?\\\\xf3\\\\x0f\\\\x10?\\\\x9a\\\\x01\\\\xa2>\\\\xc5\\\\xbe]?\\\\x13LR?V\\\\x0eH?\\\\x7f9\\\\x13>\\\\x10\\\\x10\\\\xce>\\\\xba\\\\xd6\\\\x00><\\\\x03\\\\x87>\\\\x1d/\\\\xaf=Q\\\\x06x?6y\\\\x03<\\\\x9c\\\\xa88?\\\\x06\\\\\\\\\\\\xa0>\\\\xf24\\\\xe8>\\\\xe57V>\\\\xe4\\\\x803?\\\\x0fu5?\\\\xb8\\\\x02\\\\xd2>:P!=\\\\x08\\\\x98\\\\x88>&\\\\x1dW?\\\\xf9f\\\\xcc=BU&=;\\\\xb5\\\\xd6>\\\\xa1\\\\xa5\\\\xb3>wj`?\\\\xf9 g?\\\\x02A+?\\\\x12\\\\xe4H?\\\\x99\\\\xb4\\\\xf6=\\\\x15\\\\x02\\\\xed>\\\\x9eKZ>\\\\x14vX>\\\\xc7\\\\xe2\\\\'?_\\\\xd16?e\\\\x166?y\\\\xc2R>\\\\xc3\\\\x93n>\\\\xc8\\\\x98\\\\xc9<\\\\x9d*\\\\x01?\\\\xf1*\\\\x8d<\\\\x9e\\\\xe2\\\\t?\\\\xbb\\\\xcdS?C\\\\xdc\\\\x0c?\\\\\\\\\\\\xa6h?\\\\x9a\\\\x13\\\\x8d>5\\\\x023?$+\\\\xcf=\\\\xd5H\\\\x03=w\\\\xba\\\\xba>\\\\x1f\\\\x86P>0\\\\x06\\\\x0c?*\\\\xac\\\\x03?\\\\x8e\\\\xeb_?\\\\x14~\\\\x14?\\\\xa1h=?*<\\\\x94>\\\\xe3\\\\'\\\\xa5>&\\\\xfb2>\\\\xf7\\\\xbc\\\\x9a>2\\\\xc2\\\\xf6=a7\\\\xad=<3\\\\x03?\\\\x89\\\\x16u?f\\\\xa2v?\\\\xfc{\\\\x05?\\\\xe9\\\\xbf\\\\x95>\\\\xc8\\\\xc9\\\\x0e?\\\\x81\\\\xf9D?\\\\xbd\\\\xc8\\\\x18?\\\\xe7Y\\\\xb9=\\\\xa8\\\\xac\\\\xa8>\\\\x109c>\\\\xd4\\\\x04\\\\x18?7\\\\x859?\\\\xa2e\\\\x92>\\\\xcf\\\\x9d\\\\x11>\\\\x95\\\\xe6\\\\x94>\\\\x17\\\\x02\\\\xf1>\\\\x85\\\\xd3Y?\\\\xa0E\\\\xc6>\\\\x80n\\\\xe2>/\\\\x03\\\\x10?\\\\x16-\\\\x8b>\\\\x95h,?r\\\\x1a\\\\x11>2L\\\\xe3=\\\\xa0\\\\xf8E>\\\\x8b\\\\xc3\\\\xfd>\\\\xebN\\\\xc2>\\\\'=\\\\xd2>\\\\xc6\\\\xadM?\\\\x04\\\\xe8\\\\xf6>\\\\xf2\\\\x89\\\\xc5>*JG?\\\\xdb\\\\x1db?o\\\\x9bg?\\\"\\\\xf7o=ly&?2\\\\xf6f?2%\\\\xd8>\\\\xaf70?\\\\xfdV\\\\x82>\\\\x1a\\\\x10v?\\\\xa4\\\\xe9\\\\xd4=e\\\\x8cK?\\\\xf2=\\\\xe5=\\\\x8d\\\\xa9u?C\\\\x1c\\\\xff>Maq?I\\\\x97\\\\x10?t\\\\xbf\\\\x04?\\\\xe3Z\\\\x00?\\\\xa5\\\\xec\\\\xf7=\\\\x0ew\\\\xbb>3jP?\\\\x1eS\\\\xec>\\\\xdb\\\\xe1??\\\\xde\\\\xb5\\\\x15?\\\\xb1\\\\xc0H>\\\\x0f\\\\xf7,?F\\\\x83\\\\x8e=\\\\xd5\\\\x08_?\\\\x1a\\\\x0fo?i\\\\xe3p?\\\\xe0\\\\x1a\\\\x11?_\\\\x0cI?rF\\\"?P\\\\x82&?\\\\x94\\\\x8fY?\\\\x0f\\\\xb5c?\\\\xc5$\\\\xa4>3-}>\\\\x05\\\\xdcV>\\\\xeb.\\\\xe1>\\\\xa5\\\\xf8\\\\'?\\\\xbd\\\\xaaN>\\\\x1b\\\\xc3\\\\xce=\\\\xad\\\\xc2\\\\x0f?R\\\\x80k?\\\\xb0\\\\x95\\\\xf2>\\\\xa8\\\\xe7\\\\xb8=,3\\\\xb0>\\\\xc0i4>9\\\\xd1\\\\x1d?\\\\xc5\\\\x8d\\\\x11?\\\\x17\\\\xeb\\\\x13?\\\\xed\\\\xecE>\\\\x88\\\\xb6Q?Ou\\\\xea>\\\\x84<\\\\xed><\\\\xcf\\\\x02>\\\\xec\\\\n\\\\x90>\\\\xb1\\\\xe4\\\\x15>9\\\\xf8U?\\\\x90\\\\x91\\\\xb8>\\\\x94N\\\\xd6>\\\\xbc\\\\xf2\\\\x0f>\\\\xaa\\\\xae\\\\xb8>\\\\x83 \\\\xe3>fH\\\\r?\\\\x8a\\\\x19\\\\xae>c\\\\xe3\\\\x8c>x#w?\\\\x07*\\\\x14?\\\\xba$\\\\x89=\\\\x0c\\\\xe7T?\\\\xbb\\\\n\\\\x14?*\\\\xa5\\\\xd5>@\\\\x15\\\\xd4=\\\\x1d}1?\\\\xaf\\\\xbb\\\\x1a>\\\\x83\\\\x18\\\\xa8>0\\\\r\\\\xeb>\\\\xabt\\\\xf3>\\\\x1b\\\\x9f\\\\x03?}\\\\xd7k?2+c<\\\\x04\\\\xf2\\\\xe9=~B=?\\\\xc4an?`\\\\xd9\\\\xb3>\\\\x82\\\\x92\\\\x7f?\\\\xba}\\\\xa9>\\\\xe75j=\\\\xe8\\\\x9f/?\\\\xd4Z\\\\x19>\\\\t\\\\xaa}?>\\\\x97\\\\xa6=o\\\\x1b+>\\\\\\\\\\\\xca\\\\xf9>\\\\xe3Xl?C\\\\xa3T??*\\\\xf2>\\\\xe1\\\\xd7d?\\\\x90M\\\\xb2>2{U?\\\\xba\\\\xd0y?\\\\xe47M?M\\\\xca7>3\\\\x84\\\\x1e?\\\\xb0D\\\\xfb>\\\\x1d \\\\xc9>~I\\\\x16?\\\\\\\\O\\\\xf3=:\\\\xbf#?DT\\\\xdb>\\\\xa9\\\\x901>e\\\\xd9\\\\x16>\\\\xae\\\\x13\\\\xa3<\\\\\\\\\\\\xc5\\\\xfc>\\\\xde\\\\xabk?\\\\xd7\\\\xcc\\\\xdb<\\\\xdf\\\\xb6;?\\\\x99\\\\x13)>\\\\xc7q`<,\\\\xa5\\\\xe3>\\\\xb6\\\\xfd\\\\x02?\\\\x15\\\\x11\\\\x06?ia/?8\\\\xf1D>U\\\\xddf?\\\\n\\\\x9f(?O\\\\xf0d?\\\\xa114?\\\\x85\\\\xca[>P\\\\x89\\\\\\\\?\\\\x7f\\\\x1df?r\\\\xdd|?\\\\xb8\\\\xcfV?\\\\x96AS>gs\\\\x0b>}\\\\x89U?\\\\x89\\\\x18+?\\\\xa0\\\\xf4\\\\xb1>\\\\xf0\\\\xf3n?\\\\x8aQ\\\\x11?p\\\\xbdI>\\\\xed\\\\x10%?\\\\xdc/\\\\x08?j@\\\\xcb>C\\\\x04\\\\x8b>\\\\x8b9\\\\x11<\\\\x8a\\\\xc2 ?\\\\xb8,7>\\\\xd7$\\\\xeb>\\\\x8f\\\\xc0w?\\\\xfc\\\\x0fb>cJi?\\\\x00\\\\x14\\\\x0f?\\\\xeb\\\\xb9:?\\\\x18!`?.\\\\x18\\\\'?\\\\\\\\\\\\x98Y?\\\\xc1\\\\xe0\\\\xa9>~\\\\xae2?\\\\x8a\\\\xe0\\\\xa0<\\\\xec\\\\xf0&?\\\\xf71J?\\\\x92u\\\\xef>S\\\\xfem?\\\\xd6\\\\xea}=\\\\xfa@\\\\x99>8\\\\xffo>\\\\x83\\\\xddq?\\\\xf3m\\\\\\\\>%\\\\xfe\\\\xbf>\\\\xac\\\\xdc\\\\x1c?\\\\xba\\\\x80Y?\\\\x01\\\\x08W?`\\\\xf5i?\\\\xb0\\\\xda\\\\xdf>\\\\x08@>?\\\\xc8\\\\xb6\\\\x8a>\\\\x98_\\\\x0e=\\\\x88r\\\\x9e=\\\\x94h]>\\\\xa5\\\\xba*?\\\\x0c\\\\x10!?.\\\\xbe\\\\x9a>\\\\x87R*>-2\\\\x8b>\\\\x89\\\\x08I?*\\\\xf5&?\\\\n40?\\\\xe1\\\\xa6o?\\\\xafW\\\\xf1=i\\\\xb6\\\\x83>\\\\x10\\\\xe0\\\\x18?\\\\xb48B?\\\\t\\\\xaf/?\\\\xdf3o?)jY>i\\\\xce\\\\x97>X f?x\\\\x84\\\\xbc>d\\\\x7f\\\\x19>\\\\x00(\\\\xe4>\\\\xc7p[?J\\\\xce$>\\\\xe9\\\\xbeq?M$O?\\\\xdd\\\\x0b5=v\\\\xfaS>d\\\\xa5t>bj\\\\xec>\\\\xab\\\\xees?v)7?\\\\x9d\\\\x03\\\\xd7>\\\\xbc\\\\xa9\\\\x98=\\\\xc7x\\\\';\\\\xa4\\\\xb2\\\\xdf>\\\\xfe:\\\\x06?\\\\xde\\\\'\\\\xeb>\\\\xf5-\\\\xa7<\\\\x9c\\\\xc9f?`\\\\xf9@>\\\\x1e\\\\xffh?\\\\xe1\\\\xd7\\\\xbb>\\\\x84D\\\\xbd>\\\\xf4(\\\\x7f>D\\\\x15U?T\\\\x9e\\\\x80=>\\\\xbbO?\\\\xa8R\\\\xfc=)#n?\\\\x91\\\\xd8\\\\t?k9Q>$\\\\x9a\\\\xad>$\\\\xd3b?\\\\xee\\\\xb6.=\\\\xc0\\\\xf8\\\\r>\\\\x87\\\\x8f\\\\xa1=\\\\x95Y]?P\\\\x0f\\\\x1a?\\\\x14\\\\t\\\\xf0>n\\\\xa1\\\\x16>@\\\\xff\\\\xbb>\\\\xa44\\\\x03?<\\\\x87\\\\x8d=e4\\\\xd1>\\\\xbb\\\\x1fD?\\\\x84-\\\\xb9>\\\\x11\\\\xc6\\\\xab>\\\\xce\\\\x99|?\\\\x82\\\\xde9?C!\\\\xb1=nXh?|\\\\\\\\\\\\x12=\\\\x8a\\\\xea8?Q\\\\x80i?\\\\xf6\\\\xf3k?\\\\x8c#n?\\\\x07\\\\xb6\\\"?b\\\\xe5.?\\\\xc8\\\\xa0y?&{i=\\\\x1c\\\\xeb\\\\xa6>\\\\x14\\\\xc3\\\\xb6=6rW?\\\\xb3\\\\xd2\\\\xbc>t\\\\'k>F\\\\xca\\\\xa7>e\\\\xce\\\\x05?JL\\\\xa2>\\\\x13\\\\x02\\\\t?\\\\xbcF\\\\xc6>\\\\xaf=u?\\\\r\\\\x16p?\\\\x0e\\\\x002=\\\\x9b\\\\x89\\\\xfa=\\\\xcdY\\\\xb0>+\\\\xf4c>\\\\xe2Wl?\\\\x90m\\\\xa8>\\\\x14)\\\\x18?m\\\\xff\\\\xc7>\\\\x8f\\\\xda\\\\x1d?\\\\x19\\\\xa0\\\\x1d?Q\\\\xfbv?\\\\xcb[\\\\xae>\\\\x80\\\\xc3\\\\x9c>\\\\x1eP\\\\x17?G.\\\\x9c>d\\\\xcdD?7\\\\x1eC?\\\\xb9hK?E\\\\xdf\\\\'>\\\\xccPQ?\\\\x8b\\\\x1a\\\\xcb>\\\\x81^T>\\\\xe7\\\\x9b\\\\x97>:\\\\xa2\\\\xbe>\\\\x84e3?\\\\x00>\\\\xe1=\\\\xc0u\\\\xd0=\\\\xc3\\\\xb6\\\\xca=y\\\\xbaL?\\\\x1eU\\\\xb7>\\\\x1f`\\\\xcc>\\\\x16\\\\xf1K?\\\\xb4\\\\xed*?\\\\xa5\\\\x83\\\\x16>_\\\\xdd\\\\xfe=\\\\x0c\\\\xa3\\\\x01?1y\\\\xdd>\\\\x7f\\\\xaa<>\\\\x97[\\\\x14?*\\\\x7f[?n\\\\xf3:?3g\\\\xf6>\\\\x96\\\\xa1x>%n\\\\x96=\\\\xfa?\\\\x15?\\\\x08\\\\xf3\\\\xac>\\\\xe8\\\\xefh?B5\\\\x7f>\\\\xfd\\\\x9cu?L\\\\x0ew>.\\\\x1f\\\\x8c>\\\\x11\\\\xae\\\\xf0>\\\\xe1\\\\x0e\\\\x01?\\\\xca\\\\xac\\\\x17?8\\\\xb4b?\\\\xd3\\\\xa9\\\\xbf=\\\\x81\\\\x17i?\\\\x18\\\\xd4\\\\xd3>X\\\\x99\\\\r>\\\\x1af\\\\x1e?\\\\xc4\\\\xaaa>K\\\\xd8\\\\xb9>B\\\\xb3\\\\x0c?\\\\x9d\\\\xbdB?\\\\xb0PN?\\\\xad\\\\x1ah>\\\\xb9\\\\xfaL?\\\\xf1@*?\\\\x9d+V?1\\\\xf9\\\\xe4>\\\\x17\\\\xb1\\\\xc2>\\\\x9e\\\\xa3\\\\x85;\\\\xae4\\\\xdc>\\\\xdfw\\\\x1a?\\\\xee\\\\xabQ?\\\\x0f\\\\x1ab?\\\\x05\\\\x1f??^\\\\xedz?n\\\\xff\\\\xde>\\\\x18W\\\\xbd=ZT\\\\x92>\\\\xe1\\\\xba7?\\\\x86\\\\xd5\\\\xf6>g\\\\xee\\\\xa0>\\\\xa6R\\\\xbd=\\\\x1e\\\\x04\\\\x84>\\\\x07\\\\x9e\\\\xe6=\\\\xc3\\\\x03\\\"?\\\\xe2y\\\\x94>\\\\xf0u??~\\\\xafA>\\\\xfax\\\\x19?\\\\xc2=\\\\x01?/\\\\x812?\\\\xad\\\\xd2\\\\xd8<\\\\'\\\\x80\\\\xb1>\\\\x9c$\\\\xff<\\\\xe6\\\\x0b\\\\xca>S\\\\xa6\\\\xda>\\\\xe8_u?\\\\xa4O\\\\xd8>7t\\\\x1b=\\\\xcbA\\\\xd3>\\\\xa6\\\\xa9\\\\xee>\\\\xe7\\\\x82\\\\xfb>\\\\xad\\\\x86x?$\\\\xbcc?\\\\x83\\\\x9bI?\\\\xcf\\\\xe48?\\\\xb5\\\\x0fm?\\\\x7f\\\\xf75>$\\\\xa3G?\\\\'\\\\xee\\\\x0b>\\\\x17\\\\xc5\\\\x12>\\\\xbb\\\\x83Y?\\\\xfc\\\\x01 ?-\\\\x9en?U\\\\xe5$>\\\\x10\\\\t\\\\r?v\\\\x05Q?/*\\\\x1c>\\\\x06\\\\xa5t>KNR?\\\\x06\\\\xb8\\\\x16:`\\\"D?\\\\x9c\\\\x94\\\\x04?\\\\x1e\\\\xa2#?\\\\xfc\\\\x8b\\\\xe3>\\\\x91\\\\x1fN?\\\\x1c\\\\xd0\\\\x17?\\\\xd3{\\\\xe8>\\\\xe6,4?\\\\xe9\\\\x8fP?\\\\xf1r\\\\x83>\\\\x80\\\\x85\\\\x80>\\\\xa7u<?\\\\xac$\\\\xe1>\\\\xb2y\\\\x94>\\\\x88F\\\\x1f?)H\\\\xc7>\\\\x89]\\\\xf9>\\\\xf5\\\\x03\\\\x82>R\\\\x8c\\\\xd0>3\\\\xaf>?\\\\xc4&b?\\\\xab\\\\xfbW?\\\\xf8VL=J\\\\x81V?.\\\\xe1x>\\\\x7f\\\\x1ei?\\\\xb1\\\\x16k?\\\\xe5\\\\xe24> 5z=\\\\xfd\\\\xed\\\\x0f>\\\\nN\\\\xc9>\\\\xb4\\\\x84\\\\xfc>@\\\\x13k?\\\\xea\\\\x0b[=\\\\xf1.l?\\\\xdc\\\\xf9\\\"?\\\\nr/?\\\\xc1)=?B\\\\x14,?YD6?WK\\\\x0c?\\\\x05F\\\\xfa<c\\\\xe2\\\\xb2=\\\\x8f\\\\x0e\\\\x12>\\\\x9cW\\\\t?\\\\x8a\\\\x0c\\\\x1f?i=\\\\x15?nq\\\\x02;\\\\x9d\\\\xefj?^eA>0\\\\xc2=?O\\\\xca\\\\xf2=\\\\'\\\\xb9\\\\xf1=\\\\x96Y\\\\x1f>W\\\\x838?x\\\\x1a\\\\xe5>\\\\x02r\\\\x1f?\\\\xca\\\\x14\\\\x1a?\\\\x0e\\\\rU>\\\\nG\\\\xb1>\\\\xcdKP<\\\\x18\\\\x83\\\\x93=\\\\xdf\\\\x84\\\\x88>p\\\\x02\\\\xa6>\\\\xad\\\\xc3\\\\xe9<\\\\xe8\\\\x07_?\\\\x92U\\\\t?\\\\x1bs\\\\x00?\\\\xc0\\\\xb1\\\\xc6>\\\\xef\\\\xfc\\\\xca=k\\\"8>\\\\xb2,\\\\x00>\\\\xa6\\\\x1d7>\\\\x17\\\\xc1\\\\x05>l.N?\\\\xd3i\\\\xb3>\\\\xdd\\\\xc0\\\\x0b?\\\\xfdmn=H\\\\xf0\\\\xd3;ZN/?H\\\\x15\\\\xb4>L*n?Nq(?E+]?$\\\\x92\\\\x14?x\\\\x9b\\\\x1b?^`\\\\xbb=\\\\x0e\\\\xc8r?\\\\xb47<>\\\\xcdv\\\\x1d?\\\\xc3\\\\xda\\\\xe0>6P\\\\xd2>A\\\\x99\\\\xc4>0\\\\x85X?\\\\x86\\\\xe8%?t*\\\\xb1>\\\\xbf\\\\xb0\\\\xaa>N\\\\xb9\\\\xfd>\\\\x10d\\\\x92>\\\\xf7X\\\\x0e?\\\\x04\\\\x9c\\\\xb1>HM\\\\x16?\\\\xf8\\\\x1c\\\\x82>\\\\x01\\\\xb2\\\\xab=\\\\xa2\\\\xce\\\\x15>\\\\x9e\\\\xcdl?bb1?wz\\\\xf6<Y\\\\xd5d?x@\\\\x1d=\\\\xf9\\\\xed\\\\x10?\\\\xe2\\\\xbf\\\\xd6>\\\\xcc\\\\xfa\\\\xa4>]\\\\x86B?\\\\xb8\\\\x9aV>\\\\xe7\\\\xe86?\\\\x10\\\\x9f\\\\xea==P,?\\\\xc2T\\\\xc0>0p\\\\x1d>?}\\\\xd6>\\\\xd4\\\\x08\\\\xfd=\\\\xe5}\\\\x92>\\\\x98\\\\x95\\\\xd0>\\\\xf1\\\\x8f\\\\x0e>\\\\x07OV?\\\\xd8F\\\\xc9>\\\\xf0\\\\x95\\\\x10?\\\\xdc\\\\xf9i?\\\\x95\\\\xc8\\\\xb6<\\\\xa0%\\\\\\\\>\\\\x15\\\\x99??\\\\x1c\\\\x8a\\\\xfe>*<->\\\\xc0\\\\xf9\\\\xba=\\\\x17\\\\xa1\\\\xd0> O\\\\xff><\\\\xfd^?\\\\x1c\\\\xb9\\\\xbf>\\\\xbed\\\\x8f>\\\\x8c\\\\x14\\\\x7f>\\\\x12j\\\\xbc>30x?\\\\x8b~\\\\x85>\\\\xdez\\\\x17?m\\\\xd1_?\\\\xc0\\\\n\\\\xde>\\\\xecV\\\\x84=\\\\x0fj\\\\x84>\\\\xb8)\\\\xef<=n\\\\xe9>~\\\\x8e\\\\xf0>\\\\xee\\\\xc3z?\\\\x9a\\\\xa5-?\\\\x1d\\\\xea7?\\\\x14\\\\xec\\\\xc3>\\\\x1a|W>\\\\x00h\\\\x97>\\\\xc9\\\\xe0\\\\x89>t\\\\xdeH?\\\\xf4PN?\\\\x94X\\\\x13?\\\\xbd\\\\x10\\\\xa5>\\\\x7f\\\\xab6?\\\\xce\\\\xab\\\\x07?\\\\xceQr?/t\\\\x1d?H\\\\r\\\\xf2=\\\\x0bX\\\\xae<\\\\x11\\\\xb3\\\\x0f?\\\\xff\\\\xe7N?\\\\xf5\\\\xcf%?L\\\\xe8.?\\\\xf7A\\\\x19?\\\\x05\\\\x18%>/#\\\\x91=\\\\xd3A\\\\t?\\\\xbb\\\\xce\\\\x06?\\\\x1bS\\\\r>\\\\x13\\\\xd8.?(+\\\\x15?\\\\xf9\\\\x1e\\\\x01>\\\\xdf\\\\xf1\\\\x96>\\\\xe7\\\\x953?Y\\\\x1d>=\\\\xd9\\\\xf4\\\\x91>\\\\x16wA?\\\\x84V8?O\\\\x8b<=\\\\rd\\\\xbe>\\\\xf1\\\\xc2\\\\xcd>-\\\\x10:?\\\\x02>\\\\x96>VI\\\\xbc>n\\\\x9b\\\\x10?\\\\xbf\\\\x97\\\\x14?A\\\\x85\\\\xce>a\\\\xae\\\\x9e>U5%?d\\\\xda\\\\x01?\\\\xd5N\\\\xa2>\\\\xcd\\\\xe0\\\\xf6>:\\\\x9f\\\\x1d>V\\\"9?\\\\xa9\\\\\\\\\\\\x04?u2t:+$\\\\xfe>\\\\x18\\\\x00??-\\\\t\\\\xcd>\\\\xdd\\\\xe3W>\\\\xe1\\\\x8c*?\\\\xd0\\\\xa2\\\\x11>U\\\\xa0x>\\\\xf6Hk>\\\\xddwP=\\\\x85cg?\\\\x9d\\\\x18u?z\\\\xceb>4\\\\x11\\\\xd6=\\\\n \\\\xe9>W\\\\xff\\\\xcc>\\\\xb8\\\\x0b\\\\x95>\\\\xab\\\\xcd\\\\x0b?\\\\xcc\\\\xf5q?dI=?\\\\xb9\\\\xa5x?\\\\x92\\\\x02\\\\x1c?\\\\xabm\\\\xb4>N\\\\xa7%?A5\\\\xcc>;;u>>~\\\\xed=\\\\x8fp&?\\\\xa3K\\\\x8c=%r\\\\t?\\\\xb6ns?\\\\xa1\\\\x89H?\\\\x01=\\\\xc0>\\\\xce\\\\x1e\\\\xfd=\\\\x08\\\\xfc\\\\x91>\\\\x97D\\\\xa6>\\\\x01\\\\x9ae?w\\\\xe4\\\\x9f>\\\\xd8\\\\x14\\\\xb7<i#q?\\\\xd1\\\\xbd\\\\x05?\\\\x9a\\\\x00\\\\x13>d\\\\xac\\\\t?\\\\xd5i\\\\x8a>\\\\xdf\\\\xb4\\\\n?S\\\\xa0\\\\x1f?Pu1>J\\\\rd?\\\\x02\\\\xc8M>\\\\x97\\\\xb9E?\\\\x0f8c?\\\\xcc\\\\x14&?\\\\xd5\\\\xa6\\\\x95=\\\\r\\\\xdd\\\\x80>I\\\\x81G?\\\\x8eC~?h\\\\x07L?A?J?\\\\x15j\\\\x0f?\\\\xcat\\\\xf1>\\\\x8a.\\\\xcb>\\\\xee\\\\xacy?,Q\\\\xdf=\\\\xa4>\\\\xc2>-\\\\n\\\\x11?-z\\\\xf8=oV1>\\\\'\\\\xf9B?\\\\x86W\\\\xa2>\\\\x9f\\\\xd8a<\\\\xfd1\\\\x07?&\\\\xd1\\\\x16?\\\\x0c\\\\x17\\\\xe0>\\\\xbe\\\\tP>T@\\\\xeb=\\\\xfe:\\\\x97>\\\\x05o\\\\x83;\\\\x9d-\\\"?Ks\\\\xef>\\\\xde\\\\x8e\\\\x13?\\\\xc9\\\\xe7\\\\x98>[\\\\xdb=>\\\\xafs!?9\\\\xe7\\\\xae=\\\\\\\\\\\\xce\\\\t?F\\\\xec\\\\x93>\\\\xd4\\\\xe05>\\\\x9f\\\\xa5:>\\\\xefe\\\\xa3>Z\\\\x8e\\\\xfa>\\\\x12\\\\xc7\\\\xda<\\\\x91\\\\xbd\\\\xfb>E\\\\r\\\\xbd>37T?\\\\xf6\\\\xdc%?\\\\xc28\\\\x01=DB\\\\xc7>K\\\\x022?\\\\xb5\\\\xd3\\\\x8a>\\\\xb1\\\\xd3H?\\\\x1f\\\\xe2q?\\\\x1b\\\\x91k?\\\\xa5\\\\xb0$?\\\\xee\\\\xa6\\\\xa4>(\\\\xae\\\\xe6<\\\\']\\\\x18?\\\\xf5\\\\xce\\\\x95>\\\\xdc\\\\x06\\\\x1f?\\\\x06\\\\xa9\\\\xce=\\\\xe0u\\\\xbb>e\\\\xc1j>:\\\\xf4D?\\\\n\\\\xc6\\\\\\\\?\\\\x83\\\\xb7Q?\\\\xebT\\\\x15?%g0>?\\\\xa7s>C(@?\\\\x86GQ<`go?_\\\\x8dp?!B\\\\x8f>\\\\xd1\\\\x16\\\\xea>\\\\x03\\\\xed\\\\xbe>ni\\\\x13>ezA>\\\\xdf<\\\\x02<\\\\xda\\\\xe1x>\\\\x91\\\\xd6\\\\x81>\\\\x1c.k?\\\\xe6\\\\x8b\\\\xa7>\\\\xea<P?v\\\\xec<?\\\\xd8\\\\x12q?\\\\xe9\\\\x00\\\\xda=\\\\x1c\\\\xf8\\\\xce>f\\\\xa7\\\\x1f?}\\\\xf7a>\\\\xff\\\\x96q?\\\\xd2\\\\xf8\\\\xed>f\\\\xd0<?\\\\xcd\\\\x06\\\\xe8>B\\\\xb6\\\\xd9>0W6?\\\\xc2\\\\t\\\\x19?x\\\\x909?\\\\xc7\\\\x06\\\\xd6>Xa\\\\xae>\\\\n\\\\x12\\\\x06?_\\\\xacq?\\\\x9d\\\\x85L<8\\\\x8f\\\\xdf>\\\\x87\\\\xfcE?\\\\x89\\\\xbbT?\\\\x0fb{?<b\\\\x82=}\\\\xe8\\\\x04?\\\\xafA\\\\xab>a\\\\xf6\\\\x86;{\\\\xa6T?\\\\xca\\\\xb2\\\\xeb>\\\\xc8\\\\xa0\\\\xca>\\\\x08\\\\xa5q?\\\\xe4`\\\\x85>\\\\x7f}\\\\xb6=k\\\\xe6W?\\\\xd0\\\\xaf3>,\\\\x1e)=\\\\x02\\\\x0f7?\\\\xb8\\\\xd4r?\\\\xfa\\\\x0bD?\\\\xc5\\\\x8b@?\\\\x1dFQ?\\\\xdc\\\\xdd_?!\\\\xb0\\\\x06<\\\\x04v\\\\xbc>\\\\x12kN?\\\\xa7*\\\\xd7>\\\\x8ab\\\\xa2>q]N?\\\\x8b\\\\']=9\\\\xefF?\\\\xcd\\\\xc6\\\\t?\\\\xf3OO?\\\\xc48e>*vC>\\\\x80\\\\x9a\\\\xc3=\\\\x8d\\\\xa9J?L@j?\\\\x985\\\\xb3>\\\\xa0\\\\xd5\\\\xc9>\\\\x7f\\\\x04\\\\xc0>\\\\x94\\\\xeb\\\\x0f?F\\\\xa9\\\\x91>h\\\\xf3\\\\xed>\\\\x08\\\\xbe\\\\x1d>-\\\\xc79>\\\\x0b[R?\\\\xcc\\\\x17\\\\x06?\\\\xf5@\\\\xe3>5\\\\xc1_?\\\\xceF\\\\xb0>\\\\xf8\\\\x8d\\\\xb9>\\\\x10{3?\\\\xf5\\\\xce\\\\xee>\\\\x89\\\\xceu?:\\\\x14f?\\\\xb7\\\\xc6\\\\xf2=y\\\\xc5\\\\xa1>\\\\x13\\\\xd8t?6d\\\\xbc>X\\\\xd2\\\\xa4>\\\\xdb\\\\x1ez??\\\\x15\\\\\\\\?\\\\x9a2\\\\x1e?\\\\x8b\\\\x9eS>j\\\\x92\\\\xe0>\\\\xf3\\\\xcb\\\\xf3=\\\\x12\\\\xdb\\\\t?\\\\xb8\\\\xd8_>\\\\x8a\\\\x11A?\\\\x93\\\\xc3\\\\xdf=\\\\xb8\\\\x9bq?\\\\xe0\\\\x9a,?\\\\xd3\\\\xe6^>\\\\xb6\\\\xecQ?\\\\xba\\\\xa0\\\\x85>\\\\x95\\\\xcb\\\\x90=\\\\xc6\\\\x8b\\\\xba>\\\\xdd\\\\xafq?vj-?\\\\xdc\\\\xd0\\\\xe7=\\\\xa0\\\\x1f\\\\xca>v\\\\xce\\\\xb9=/\\\\xa9;?Y_\\\\xcc>\\\\x99\\\\x8e0?\\\\xf55.?+9\\\\\\\\>y|\\\\xba=\\\\xa4\\\\xe9\\\\x14?.%\\\\xb3>\\\\xde\\\\xd9\\\\x1e?w,\\\\x88>\\\\xad\\\\x86\\\\x94=\\\\xa0\\\\xd8)?\\\\x9f\\\\x1d\\\\xb2>\\\\xdbx\\\\xa9>U4\\\\x00?#\\\\xbcX?I\\\\xc3\\\\x8e='}]\"\n      ]\n     },\n     \"execution_count\": 5,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"query = (\\n\",\n    \"    Query(\\\"*=>[KNN 2 @vector $vec as score]\\\")\\n\",\n    \"     .sort_by(\\\"score\\\")\\n\",\n    \"     .return_fields(\\\"id\\\", \\\"score\\\")\\n\",\n    \"     .return_field(\\\"vector\\\", decode_field=False) # return the vector field as bytes\\n\",\n    \"     .paging(0, 2)\\n\",\n    \"     .dialect(2)\\n\",\n    \")\\n\",\n    \"\\n\",\n    \"query_params = {\\n\",\n    \"    \\\"vec\\\": np.random.rand(VECTOR_DIMENSIONS).astype(np.float32).tobytes()\\n\",\n    \"}\\n\",\n    \"r.ft(INDEX_NAME).search(query, query_params).docs\"\n   ]\n  },\n  {\n   \"attachments\": {},\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"### Range Queries\\n\",\n    \"Range queries provide a way to filter results by the distance between a vector field in Redis and a query vector based on some pre-defined threshold (radius).\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 6,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"[Document {'id': 'doc:a', 'payload': None, 'score': '0.243115246296'},\\n\",\n       \" Document {'id': 'doc:c', 'payload': None, 'score': '0.24981123209'},\\n\",\n       \" Document {'id': 'doc:b', 'payload': None, 'score': '0.251443207264'}]\"\n      ]\n     },\n     \"execution_count\": 6,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"query = (\\n\",\n    \"    Query(\\\"@vector:[VECTOR_RANGE $radius $vec]=>{$YIELD_DISTANCE_AS: score}\\\")\\n\",\n    \"     .sort_by(\\\"score\\\")\\n\",\n    \"     .return_fields(\\\"id\\\", \\\"score\\\")\\n\",\n    \"     .paging(0, 3)\\n\",\n    \"     .dialect(2)\\n\",\n    \")\\n\",\n    \"\\n\",\n    \"# Find all vectors within 0.8 of the query vector\\n\",\n    \"query_params = {\\n\",\n    \"    \\\"radius\\\": 0.8,\\n\",\n    \"    \\\"vec\\\": np.random.rand(VECTOR_DIMENSIONS).astype(np.float32).tobytes()\\n\",\n    \"}\\n\",\n    \"r.ft(INDEX_NAME).search(query, query_params).docs\"\n   ]\n  },\n  {\n   \"attachments\": {},\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"See additional Range Query examples in [this Jupyter notebook](https://github.com/RediSearch/RediSearch/blob/master/docs/docs/vecsim-range_queries_examples.ipynb).\"\n   ]\n  },\n  {\n   \"attachments\": {},\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"### Hybrid Queries\\n\",\n    \"Hybrid queries contain both traditional filters (numeric, tags, text) and VSS in one single Redis command.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 7,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"[Document {'id': 'doc:b', 'payload': None, 'score': '0.24422544241', 'tag': 'foo'},\\n\",\n       \" Document {'id': 'doc:a', 'payload': None, 'score': '0.259926855564', 'tag': 'foo'}]\"\n      ]\n     },\n     \"execution_count\": 7,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"query = (\\n\",\n    \"    Query(\\\"(@tag:{ foo })=>[KNN 2 @vector $vec as score]\\\")\\n\",\n    \"     .sort_by(\\\"score\\\")\\n\",\n    \"     .return_fields(\\\"id\\\", \\\"tag\\\", \\\"score\\\")\\n\",\n    \"     .paging(0, 2)\\n\",\n    \"     .dialect(2)\\n\",\n    \")\\n\",\n    \"\\n\",\n    \"query_params = {\\n\",\n    \"    \\\"vec\\\": np.random.rand(VECTOR_DIMENSIONS).astype(np.float32).tobytes()\\n\",\n    \"}\\n\",\n    \"r.ft(INDEX_NAME).search(query, query_params).docs\"\n   ]\n  },\n  {\n   \"attachments\": {},\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"See additional Hybrid Query examples in [this Jupyter notebook](https://github.com/RediSearch/RediSearch/blob/master/docs/docs/vecsim-hybrid_queries_examples.ipynb).\"\n   ]\n  },\n  {\n   \"attachments\": {},\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Vector Creation and Storage Examples\\n\",\n    \"The above examples use dummy data as vectors. However, in reality, most use cases leverage production-grade AI models for creating embeddings. Below we will take some sample text data, pass it to the OpenAI and Cohere API's respectively, and then write them to Redis.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 8,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"texts = [\\n\",\n    \"    \\\"Today is a really great day!\\\",\\n\",\n    \"    \\\"The dog next door barks really loudly.\\\",\\n\",\n    \"    \\\"My cat escaped and got out before I could close the door.\\\",\\n\",\n    \"    \\\"It's supposed to rain and thunder tomorrow.\\\"\\n\",\n    \"]\"\n   ]\n  },\n  {\n   \"attachments\": {},\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"### OpenAI Embeddings\\n\",\n    \"Before working with OpenAI Embeddings, we clean up our existing search index and create a new one.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 9,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"# delete index\\n\",\n    \"r.ft(INDEX_NAME).dropindex(delete_documents=True)\\n\",\n    \"\\n\",\n    \"# make a new one\\n\",\n    \"create_index(vector_dimensions=VECTOR_DIMENSIONS)\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"%pip install openai\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 10,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"import openai\\n\",\n    \"\\n\",\n    \"# set your OpenAI API key - get one at https://platform.openai.com\\n\",\n    \"openai.api_key = \\\"YOUR OPENAI API KEY\\\"\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 11,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"# Create Embeddings with OpenAI text-embedding-ada-002\\n\",\n    \"# https://openai.com/blog/new-and-improved-embedding-model\\n\",\n    \"response = openai.Embedding.create(input=texts, engine=\\\"text-embedding-ada-002\\\")\\n\",\n    \"embeddings = np.array([r[\\\"embedding\\\"] for r in response[\\\"data\\\"]], dtype=np.float32)\\n\",\n    \"\\n\",\n    \"# Write to Redis\\n\",\n    \"pipe = r.pipeline()\\n\",\n    \"for i, embedding in enumerate(embeddings):\\n\",\n    \"    pipe.hset(f\\\"doc:{i}\\\", mapping = {\\n\",\n    \"        \\\"vector\\\": embedding.tobytes(),\\n\",\n    \"        \\\"content\\\": texts[i],\\n\",\n    \"        \\\"tag\\\": \\\"openai\\\"\\n\",\n    \"    })\\n\",\n    \"res = pipe.execute()\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 12,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"array([[ 0.00509819,  0.0010873 , -0.00228475, ..., -0.00457579,\\n\",\n       \"         0.01329307, -0.03167175],\\n\",\n       \"       [-0.00357223, -0.00550784, -0.01314328, ..., -0.02915693,\\n\",\n       \"         0.01470436, -0.01367203],\\n\",\n       \"       [-0.01284631,  0.0034875 , -0.01719686, ..., -0.01537451,\\n\",\n       \"         0.01953256, -0.05048691],\\n\",\n       \"       [-0.01145045, -0.00785481,  0.00206323, ..., -0.02070181,\\n\",\n       \"        -0.01629098, -0.00300795]], dtype=float32)\"\n      ]\n     },\n     \"execution_count\": 12,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"embeddings\"\n   ]\n  },\n  {\n   \"attachments\": {},\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"### Search with OpenAI Embeddings\\n\",\n    \"\\n\",\n    \"Now that we've created embeddings with OpenAI, we can also perform a search to find relevant documents to some input text.\\n\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 13,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"array([ 0.00062901, -0.0070723 , -0.00148926, ..., -0.01904645,\\n\",\n       \"       -0.00436092, -0.01117944], dtype=float32)\"\n      ]\n     },\n     \"execution_count\": 13,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"text = \\\"animals\\\"\\n\",\n    \"\\n\",\n    \"# create query embedding\\n\",\n    \"response = openai.Embedding.create(input=[text], engine=\\\"text-embedding-ada-002\\\")\\n\",\n    \"query_embedding = np.array([r[\\\"embedding\\\"] for r in response[\\\"data\\\"]], dtype=np.float32)[0]\\n\",\n    \"\\n\",\n    \"query_embedding\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 14,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"[Document {'id': 'doc:1', 'payload': None, 'score': '0.214349985123', 'content': 'The dog next door barks really loudly.', 'tag': 'openai'},\\n\",\n       \" Document {'id': 'doc:2', 'payload': None, 'score': '0.237052619457', 'content': 'My cat escaped and got out before I could close the door.', 'tag': 'openai'}]\"\n      ]\n     },\n     \"execution_count\": 14,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"# query for similar documents that have the openai tag\\n\",\n    \"query = (\\n\",\n    \"    Query(\\\"(@tag:{ openai })=>[KNN 2 @vector $vec as score]\\\")\\n\",\n    \"     .sort_by(\\\"score\\\")\\n\",\n    \"     .return_fields(\\\"content\\\", \\\"tag\\\", \\\"score\\\")\\n\",\n    \"     .paging(0, 2)\\n\",\n    \"     .dialect(2)\\n\",\n    \")\\n\",\n    \"\\n\",\n    \"query_params = {\\\"vec\\\": query_embedding.tobytes()}\\n\",\n    \"r.ft(INDEX_NAME).search(query, query_params).docs\\n\",\n    \"\\n\",\n    \"# the two pieces of content related to animals are returned\"\n   ]\n  },\n  {\n   \"attachments\": {},\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"### Cohere Embeddings\\n\",\n    \"Before working with Cohere Embeddings, we clean up our existing search index and create a new one.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 15,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"# delete index\\n\",\n    \"r.ft(INDEX_NAME).dropindex(delete_documents=True)\\n\",\n    \"\\n\",\n    \"# make a new one for cohere embeddings (1024 dimensions)\\n\",\n    \"VECTOR_DIMENSIONS = 1024\\n\",\n    \"create_index(vector_dimensions=VECTOR_DIMENSIONS)\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"%pip install cohere\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 16,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"import cohere\\n\",\n    \"\\n\",\n    \"co = cohere.Client(\\\"YOUR COHERE API KEY\\\")\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 17,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"# Create Embeddings with Cohere\\n\",\n    \"# https://docs.cohere.ai/docs/embeddings\\n\",\n    \"response = co.embed(texts=texts, model=\\\"small\\\")\\n\",\n    \"embeddings = np.array(response.embeddings, dtype=np.float32)\\n\",\n    \"\\n\",\n    \"# Write to Redis\\n\",\n    \"for i, embedding in enumerate(embeddings):\\n\",\n    \"    r.hset(f\\\"doc:{i}\\\", mapping = {\\n\",\n    \"        \\\"vector\\\": embedding.tobytes(),\\n\",\n    \"        \\\"content\\\": texts[i],\\n\",\n    \"        \\\"tag\\\": \\\"cohere\\\"\\n\",\n    \"    })\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 18,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"array([[-0.3034668 , -0.71533203, -0.2836914 , ...,  0.81152344,\\n\",\n       \"         1.0253906 , -0.8095703 ],\\n\",\n       \"       [-0.02560425, -1.4912109 ,  0.24267578, ..., -0.89746094,\\n\",\n       \"         0.15625   , -3.203125  ],\\n\",\n       \"       [ 0.10125732,  0.7246094 , -0.29516602, ..., -1.9638672 ,\\n\",\n       \"         1.6630859 , -0.23291016],\\n\",\n       \"       [-2.09375   ,  0.8588867 , -0.23352051, ..., -0.01541138,\\n\",\n       \"         0.17053223, -3.4042969 ]], dtype=float32)\"\n      ]\n     },\n     \"execution_count\": 18,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"embeddings\"\n   ]\n  },\n  {\n   \"attachments\": {},\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"### Search with Cohere Embeddings\\n\",\n    \"\\n\",\n    \"Now that we've created embeddings with Cohere, we can also perform a search to find relevant documents to some input text.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 19,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"array([-0.49682617,  1.7070312 ,  0.3466797 , ...,  0.58984375,\\n\",\n       \"        0.1060791 , -2.9023438 ], dtype=float32)\"\n      ]\n     },\n     \"execution_count\": 19,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"text = \\\"animals\\\"\\n\",\n    \"\\n\",\n    \"# create query embedding\\n\",\n    \"response = co.embed(texts=[text], model=\\\"small\\\")\\n\",\n    \"query_embedding = np.array(response.embeddings[0], dtype=np.float32)\\n\",\n    \"\\n\",\n    \"query_embedding\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 20,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"[Document {'id': 'doc:1', 'payload': None, 'score': '0.658673524857', 'content': 'The dog next door barks really loudly.', 'tag': 'cohere'},\\n\",\n       \" Document {'id': 'doc:2', 'payload': None, 'score': '0.662699103355', 'content': 'My cat escaped and got out before I could close the door.', 'tag': 'cohere'}]\"\n      ]\n     },\n     \"execution_count\": 20,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"# query for similar documents that have the cohere tag\\n\",\n    \"query = (\\n\",\n    \"    Query(\\\"(@tag:{ cohere })=>[KNN 2 @vector $vec as score]\\\")\\n\",\n    \"     .sort_by(\\\"score\\\")\\n\",\n    \"     .return_fields(\\\"content\\\", \\\"tag\\\", \\\"score\\\")\\n\",\n    \"     .paging(0, 2)\\n\",\n    \"     .dialect(2)\\n\",\n    \")\\n\",\n    \"\\n\",\n    \"query_params = {\\\"vec\\\": query_embedding.tobytes()}\\n\",\n    \"r.ft(INDEX_NAME).search(query, query_params).docs\\n\",\n    \"\\n\",\n    \"# the two pieces of content related to animals are returned\"\n   ]\n  },\n  {\n   \"attachments\": {},\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"Find more example apps, tutorials, and projects using Redis Vector Similarity Search check out the [Redis AI resources repo](https://github.com/redis-developer/redis-ai-resources/tree/main).\"\n   ]\n  }\n ],\n \"metadata\": {\n  \"interpreter\": {\n   \"hash\": \"d45c99ba0feda92868abafa8257cbb4709c97f1a0b5dc62bbeebdf89d4fad7fe\"\n  },\n  \"kernelspec\": {\n   \"display_name\": \"Python 3.8.12 64-bit ('venv': venv)\",\n   \"language\": \"python\",\n   \"name\": \"python3\"\n  },\n  \"language_info\": {\n   \"codemirror_mode\": {\n    \"name\": \"ipython\",\n    \"version\": 3\n   },\n   \"file_extension\": \".py\",\n   \"mimetype\": \"text/x-python\",\n   \"name\": \"python\",\n   \"nbconvert_exporter\": \"python\",\n   \"pygments_lexer\": \"ipython3\",\n   \"version\": \"3.9.12\"\n  },\n  \"orig_nbformat\": 4\n },\n \"nbformat\": 4,\n \"nbformat_minor\": 2\n}\n"
  },
  {
    "path": "docs/examples/set_and_get_examples.ipynb",
    "content": "{\n \"cells\": [\n  {\n   \"cell_type\": \"markdown\",\n   \"id\": \"a3b456e8\",\n   \"metadata\": {},\n   \"source\": [\n    \"# Basic ```set``` and ```get``` operations\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"id\": \"a59abd54\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Start off by connecting to the redis server\\n\",\n    \"\\n\",\n    \"To understand what ```decode_responses=True``` does, refer back to [this document](connection_examples.ipynb)\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 1,\n   \"id\": \"97aa8747\",\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"True\"\n      ]\n     },\n     \"execution_count\": 1,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"import redis \\n\",\n    \"\\n\",\n    \"r = redis.Redis(decode_responses=True)\\n\",\n    \"r.ping()\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"id\": \"218e137f\",\n   \"metadata\": {},\n   \"source\": [\n    \"The most basic usage of ```set``` and ```get```\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 2,\n   \"id\": \"12992c68\",\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"True\"\n      ]\n     },\n     \"execution_count\": 2,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"r.set(\\\"full_name\\\", \\\"john doe\\\")\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 3,\n   \"id\": \"bc9f3888\",\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"1\"\n      ]\n     },\n     \"execution_count\": 3,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"r.exists(\\\"full_name\\\")\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 4,\n   \"id\": \"dc64aec8\",\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"'john doe'\"\n      ]\n     },\n     \"execution_count\": 4,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"r.get(\\\"full_name\\\")\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"id\": \"353d334f\",\n   \"metadata\": {},\n   \"source\": [\n    \"We can override the existing value by calling ```set``` method for the same key\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 5,\n   \"id\": \"c61389ce\",\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"True\"\n      ]\n     },\n     \"execution_count\": 5,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"r.set(\\\"full_name\\\", \\\"overridee!\\\")\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 6,\n   \"id\": \"5e34a520\",\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"'overridee!'\"\n      ]\n     },\n     \"execution_count\": 6,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"r.get(\\\"full_name\\\")\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"id\": \"4ae3747b\",\n   \"metadata\": {},\n   \"source\": [\n    \"It is also possible to pass an expiration value to the key by using ```setex``` method\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 7,\n   \"id\": \"9b87449b\",\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"True\"\n      ]\n     },\n     \"execution_count\": 7,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"r.setex(\\\"important_key\\\", 100, \\\"important_value\\\")\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 8,\n   \"id\": \"a11fe79d\",\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"100\"\n      ]\n     },\n     \"execution_count\": 8,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"r.ttl(\\\"important_key\\\")\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"id\": \"87c16991\",\n   \"metadata\": {},\n   \"source\": [\n    \"A dictionary can be inserted like this\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 9,\n   \"id\": \"3cfa5713\",\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"True\"\n      ]\n     },\n     \"execution_count\": 9,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"dict_data = {\\n\",\n    \"    \\\"employee_name\\\": \\\"Adam Adams\\\",\\n\",\n    \"    \\\"employee_age\\\": 30,\\n\",\n    \"    \\\"position\\\": \\\"Software Engineer\\\",\\n\",\n    \"}\\n\",\n    \"\\n\",\n    \"r.mset(dict_data)\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"id\": \"6d32dbee\",\n   \"metadata\": {},\n   \"source\": [\n    \"To get multiple keys' values, we can use mget. If a non-existing key is also passed, Redis return None for that key\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 10,\n   \"id\": \"45ce1231\",\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"['Adam Adams', '30', 'Software Engineer', None]\"\n      ]\n     },\n     \"execution_count\": 10,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"r.mget(\\\"employee_name\\\", \\\"employee_age\\\", \\\"position\\\", \\\"non_existing\\\")\"\n   ]\n  }\n ],\n \"metadata\": {\n  \"kernelspec\": {\n   \"display_name\": \"Python 3 (ipykernel)\",\n   \"language\": \"python\",\n   \"name\": \"python3\"\n  },\n  \"language_info\": {\n   \"codemirror_mode\": {\n    \"name\": \"ipython\",\n    \"version\": 3\n   },\n   \"file_extension\": \".py\",\n   \"mimetype\": \"text/x-python\",\n   \"name\": \"python\",\n   \"nbconvert_exporter\": \"python\",\n   \"pygments_lexer\": \"ipython3\",\n   \"version\": \"3.9.5\"\n  }\n },\n \"nbformat\": 4,\n \"nbformat_minor\": 5\n}\n"
  },
  {
    "path": "docs/examples/ssl_connection_examples.ipynb",
    "content": "{\n \"cells\": [\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"# SSL Connection Examples\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Connecting to a Redis instance via SSL\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"True\"\n      ]\n     },\n     \"execution_count\": null,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"import redis\\n\",\n    \"\\n\",\n    \"r = redis.Redis(\\n\",\n    \"    host='localhost',\\n\",\n    \"    port=6666,\\n\",\n    \"    ssl=True,\\n\",\n    \"    ssl_cert_reqs=\\\"none\\\",\\n\",\n    \")\\n\",\n    \"r.ping()\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Connecting to a Redis instance via a URL string\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"True\"\n      ]\n     },\n     \"execution_count\": null,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"import redis\\n\",\n    \"\\n\",\n    \"r = redis.from_url(\\\"rediss://localhost:6666?ssl_cert_reqs=none&decode_responses=True&health_check_interval=2\\\")\\n\",\n    \"r.ping()\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Connecting to a Redis instance using a ConnectionPool\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"True\"\n      ]\n     },\n     \"execution_count\": null,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"import redis\\n\",\n    \"\\n\",\n    \"redis_pool = redis.ConnectionPool(\\n\",\n    \"    host=\\\"localhost\\\",\\n\",\n    \"    port=6666,\\n\",\n    \"    connection_class=redis.SSLConnection,\\n\",\n    \"    ssl_cert_reqs=\\\"none\\\",\\n\",\n    \")\\n\",\n    \"\\n\",\n    \"r = redis.StrictRedis(connection_pool=redis_pool)\\n\",\n    \"r.ping()\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Connecting to a Redis instance via SSL, while specifying a minimum TLS version\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"True\"\n      ]\n     },\n     \"execution_count\": null,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"import redis\\n\",\n    \"import ssl\\n\",\n    \"\\n\",\n    \"r = redis.Redis(\\n\",\n    \"    host=\\\"localhost\\\",\\n\",\n    \"    port=6666,\\n\",\n    \"    ssl=True,\\n\",\n    \"    ssl_min_version=ssl.TLSVersion.TLSv1_3,\\n\",\n    \"    ssl_cert_reqs=\\\"none\\\",\\n\",\n    \")\\n\",\n    \"r.ping()\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Connecting to a Redis instance via SSL, while specifying a self-signed SSL CA certificate\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"True\"\n      ]\n     },\n     \"execution_count\": null,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"import os\\n\",\n    \"import redis\\n\",\n    \"\\n\",\n    \"pki_dir = os.path.join(\\\"..\\\", \\\"..\\\", \\\"dockers\\\", \\\"stunnel\\\", \\\"keys\\\")\\n\",\n    \"\\n\",\n    \"r = redis.Redis(\\n\",\n    \"    host=\\\"localhost\\\",\\n\",\n    \"    port=6666,\\n\",\n    \"    ssl=True,\\n\",\n    \"    ssl_certfile=os.path.join(pki_dir, \\\"client-cert.pem\\\"),\\n\",\n    \"    ssl_keyfile=os.path.join(pki_dir, \\\"client-key.pem\\\"),\\n\",\n    \"    ssl_cert_reqs=\\\"required\\\",\\n\",\n    \"    ssl_ca_certs=os.path.join(pki_dir, \\\"ca-cert.pem\\\"),\\n\",\n    \")\\n\",\n    \"r.ping()\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Connecting to a Redis instance via SSL, and validate the OCSP status of the certificate\\n\",\n    \"\\n\",\n    \"The redis package is designed to be small, meaning extra libraries must be installed, in order to support OCSP stapling. As a result, first install redis via:\\n\",\n    \"\\n\",\n    \"`pip install redis[ocsp]`\\n\",\n    \"\\n\",\n    \"This will install cryptography, requests, and PyOpenSSL, none of which are generally required to use Redis.\\n\",\n    \"\\n\",\n    \"In the next example, we will connect to a Redis instance via SSL, and validate the OCSP status of the certificate. However, the certificate we are using does not have an AIA extension, which means that the OCSP validation cannot be performed.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"OCSP validation failed as expected.\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"import os\\n\",\n    \"import redis\\n\",\n    \"\\n\",\n    \"pki_dir = os.path.join(\\\"..\\\", \\\"..\\\", \\\"dockers\\\", \\\"stunnel\\\", \\\"keys\\\")\\n\",\n    \"\\n\",\n    \"r = redis.Redis(\\n\",\n    \"    host=\\\"localhost\\\",\\n\",\n    \"    port=6666,\\n\",\n    \"    ssl=True,\\n\",\n    \"    ssl_certfile=os.path.join(pki_dir, \\\"client-cert.pem\\\"),\\n\",\n    \"    ssl_keyfile=os.path.join(pki_dir, \\\"client-key.pem\\\"),\\n\",\n    \"    ssl_cert_reqs=\\\"required\\\",\\n\",\n    \"    ssl_ca_certs=os.path.join(pki_dir, \\\"ca-cert.pem\\\"),\\n\",\n    \"    ssl_validate_ocsp=True,\\n\",\n    \")\\n\",\n    \"\\n\",\n    \"try:\\n\",\n    \"    r.ping()\\n\",\n    \"except redis.ConnectionError as e:\\n\",\n    \"    assert e.args[0] == \\\"No AIA information present in ssl certificate\\\"\\n\",\n    \"    print(\\\"OCSP validation failed as expected.\\\")\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Connect to a Redis instance via SSL, and validate OCSP-stapled certificates\\n\",\n    \"\\n\",\n    \"It is also possible to validate an OCSP stapled response. Again, for this example the server does not send an OCSP stapled response, so the validation will fail.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": null,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"OCSP validation failed as expected.\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"import os\\n\",\n    \"import redis\\n\",\n    \"\\n\",\n    \"pki_dir = os.path.join(\\\"..\\\", \\\"..\\\", \\\"dockers\\\", \\\"stunnel\\\", \\\"keys\\\")\\n\",\n    \"ca_cert = os.path.join(pki_dir, \\\"ca-cert.pem\\\")\\n\",\n    \"\\n\",\n    \"# It is possible to specify an expected certificate, or leave it out.\\n\",\n    \"expected_certificate = open(ca_cert, 'rb').read()\\n\",\n    \"\\n\",\n    \"# If needed, a custom SSL context for OCSP can be specified via ssl_ocsp_context\\n\",\n    \"\\n\",\n    \"r = redis.Redis(\\n\",\n    \"    host=\\\"localhost\\\",\\n\",\n    \"    port=6666,\\n\",\n    \"    ssl=True,\\n\",\n    \"    ssl_certfile=os.path.join(pki_dir, \\\"client-cert.pem\\\"),\\n\",\n    \"    ssl_keyfile=os.path.join(pki_dir, \\\"client-key.pem\\\"),\\n\",\n    \"    ssl_cert_reqs=\\\"required\\\",\\n\",\n    \"    ssl_ca_certs=ca_cert,\\n\",\n    \"    ssl_validate_ocsp_stapled=True,\\n\",\n    \"    ssl_ocsp_expected_cert=expected_certificate,\\n\",\n    \")\\n\",\n    \"\\n\",\n    \"try:\\n\",\n    \"    r.ping()\\n\",\n    \"except redis.ConnectionError as e:\\n\",\n    \"    assert e.args[0] == \\\"no ocsp response present\\\"\\n\",\n    \"    print(\\\"OCSP validation failed as expected.\\\")\"\n   ]\n  }\n ],\n \"metadata\": {\n  \"interpreter\": {\n   \"hash\": \"d45c99ba0feda92868abafa8257cbb4709c97f1a0b5dc62bbeebdf89d4fad7fe\"\n  },\n  \"kernelspec\": {\n   \"display_name\": \"Python 3 (ipykernel)\",\n   \"language\": \"python\",\n   \"name\": \"python3\"\n  },\n  \"language_info\": {\n   \"codemirror_mode\": {\n    \"name\": \"ipython\",\n    \"version\": 3\n   },\n   \"file_extension\": \".py\",\n   \"mimetype\": \"text/x-python\",\n   \"name\": \"python\",\n   \"nbconvert_exporter\": \"python\",\n   \"pygments_lexer\": \"ipython3\"\n  }\n },\n \"nbformat\": 4,\n \"nbformat_minor\": 4\n}\n"
  },
  {
    "path": "docs/examples/timeseries_examples.ipynb",
    "content": "{\n \"cells\": [\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"# Timeseries\\n\",\n    \"\\n\",\n    \"`redis-py` supports [RedisTimeSeries](https://github.com/RedisTimeSeries/RedisTimeSeries/) which is a time-series-database module for Redis.\\n\",\n    \"\\n\",\n    \"This example shows how to handle timeseries data with `redis-py`.\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Health check\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 1,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"True\"\n      ]\n     },\n     \"execution_count\": 1,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"import redis \\n\",\n    \"\\n\",\n    \"r = redis.Redis(decode_responses=True)\\n\",\n    \"ts = r.ts()\\n\",\n    \"\\n\",\n    \"r.ping()\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Simple example\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"### Create a timeseries\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 2,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"True\"\n      ]\n     },\n     \"execution_count\": 2,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"ts.create(\\\"ts_key\\\")\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"### Add samples to the timeseries\\n\",\n    \"\\n\",\n    \"We can either set the timestamp with an UNIX timestamp in milliseconds or use * to set the timestamp based en server's clock.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 3,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"1657272304448\"\n      ]\n     },\n     \"execution_count\": 3,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"ts.add(\\\"ts_key\\\", 1657265437756, 1)\\n\",\n    \"ts.add(\\\"ts_key\\\", \\\"1657265437757\\\", 2)\\n\",\n    \"ts.add(\\\"ts_key\\\", \\\"*\\\", 3)\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"### Get the last sample\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 4,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"(1657272304448, 3.0)\"\n      ]\n     },\n     \"execution_count\": 4,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"ts.get(\\\"ts_key\\\")\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"### Get samples between two timestamps\\n\",\n    \"\\n\",\n    \"The minimum and maximum possible timestamps can be expressed with respectfully - and +.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 5,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"[(1657265437756, 1.0), (1657265437757, 2.0), (1657272304448, 3.0)]\"\n      ]\n     },\n     \"execution_count\": 5,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"ts.range(\\\"ts_key\\\", \\\"-\\\", \\\"+\\\")\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 6,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"[(1657265437756, 1.0), (1657265437757, 2.0)]\"\n      ]\n     },\n     \"execution_count\": 6,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"ts.range(\\\"ts_key\\\", 1657265437756, 1657265437757)\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"### Delete samples between two timestamps\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 7,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"Before deletion:  [(1657265437756, 1.0), (1657265437757, 2.0), (1657272304448, 3.0)]\\n\",\n      \"After deletion:   [(1657272304448, 3.0)]\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"print(\\\"Before deletion: \\\", ts.range(\\\"ts_key\\\", \\\"-\\\", \\\"+\\\"))\\n\",\n    \"ts.delete(\\\"ts_key\\\", 1657265437756, 1657265437757)\\n\",\n    \"print(\\\"After deletion:  \\\", ts.range(\\\"ts_key\\\", \\\"-\\\", \\\"+\\\"))\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Multiple timeseries with labels\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 8,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"True\"\n      ]\n     },\n     \"execution_count\": 8,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"ts.create(\\\"ts_key1\\\")\\n\",\n    \"ts.create(\\\"ts_key2\\\", labels={\\\"label1\\\": 1, \\\"label2\\\": 2})\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"### Add samples to multiple timeseries\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 9,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"[1657272306147, 1657272306147]\"\n      ]\n     },\n     \"execution_count\": 9,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"ts.madd([(\\\"ts_key1\\\", \\\"*\\\", 1), (\\\"ts_key2\\\", \\\"*\\\", 2)])\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"### Add samples with labels\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 10,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"1657272306457\"\n      ]\n     },\n     \"execution_count\": 10,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"ts.add(\\\"ts_key2\\\", \\\"*\\\", 2,  labels={\\\"label1\\\": 1, \\\"label2\\\": 2})\\n\",\n    \"ts.add(\\\"ts_key2\\\", \\\"*\\\", 2,  labels={\\\"label1\\\": 3, \\\"label2\\\": 4})\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"### Get the last sample matching specific label\\n\",\n    \"\\n\",\n    \"Get the last sample that matches \\\"label1=1\\\", see [Redis documentation](https://redis.io/commands/ts.mget/) to see the posible filter values.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 11,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"[{'ts_key2': [{}, 1657272306457, 2.0]}]\"\n      ]\n     },\n     \"execution_count\": 11,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"ts.mget([\\\"label1=1\\\"])\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"Get also the label-value pairs of the sample:\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 12,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"[{'ts_key2': [{'label1': '1', 'label2': '2'}, 1657272306457, 2.0]}]\"\n      ]\n     },\n     \"execution_count\": 12,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"ts.mget([\\\"label1=1\\\"], with_labels=True)\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Retention period\\n\",\n    \"\\n\",\n    \"You can specify a retention period when creating timeseries objects or when adding a sample timeseries object. Once the retention period has elapsed, the sample is removed from the timeseries.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 13,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"True\"\n      ]\n     },\n     \"execution_count\": 13,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"retention_time = 1000\\n\",\n    \"ts.create(\\\"ts_key_ret\\\", retention_msecs=retention_time)\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 14,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"Base timeseries:                      [(1657272307670, 1.0)]\\n\",\n      \"Timeseries after 1000 milliseconds:   [(1657272307670, 1.0)]\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"import time\\n\",\n    \"# this will be deleted in 1000 milliseconds\\n\",\n    \"ts.add(\\\"ts_key_ret\\\", \\\"*\\\", 1, retention_msecs=retention_time)\\n\",\n    \"print(\\\"Base timeseries:                     \\\", ts.range(\\\"ts_key_ret\\\", \\\"-\\\", \\\"+\\\"))\\n\",\n    \"# sleeping for 1000 milliseconds (1 second)\\n\",\n    \"time.sleep(1)\\n\",\n    \"print(\\\"Timeseries after 1000 milliseconds:  \\\", ts.range(\\\"ts_key_ret\\\", \\\"-\\\", \\\"+\\\"))\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"The two lists are the same, this is because the oldest values are deleted when a new sample is added.\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 15,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"1657272308849\"\n      ]\n     },\n     \"execution_count\": 15,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"ts.add(\\\"ts_key_ret\\\", \\\"*\\\", 10)\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 16,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"[(1657272308849, 10.0)]\"\n      ]\n     },\n     \"execution_count\": 16,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"ts.range(\\\"ts_key_ret\\\", \\\"-\\\", \\\"+\\\")\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"Here the first sample has been deleted.\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Specify duplicate policies\\n\",\n    \"\\n\",\n    \"By default, the policy for duplicates timestamp keys is set to \\\"BLOCK\\\", we cannot create two samples with the same timestamp:\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 17,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"name\": \"stdout\",\n     \"output_type\": \"stream\",\n     \"text\": [\n      \"TSDB: Error at upsert, update is not supported when DUPLICATE_POLICY is set to BLOCK mode\\n\"\n     ]\n    }\n   ],\n   \"source\": [\n    \"ts.add(\\\"ts_key\\\", 123456789, 1)\\n\",\n    \"try:\\n\",\n    \"    ts.add(\\\"ts_key\\\", 123456789, 2)\\n\",\n    \"except Exception as err:\\n\",\n    \"    print(err)\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"You can change this default behaviour using `duplicate_policy` parameter, for instance:\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 18,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"[(123456789, 2.0), (1657272304448, 3.0)]\"\n      ]\n     },\n     \"execution_count\": 18,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"# using policy \\\"LAST\\\", we keep the last added sample\\n\",\n    \"ts.add(\\\"ts_key\\\", 123456789, 2, duplicate_policy=\\\"LAST\\\")\\n\",\n    \"ts.range(\\\"ts_key\\\", \\\"-\\\", \\\"+\\\")\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"For more informations about duplicate policies, see [Redis documentation](https://redis.io/commands/ts.add/).\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"## Using Redis TSDB to keep track of a value\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 19,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"1657272310241\"\n      ]\n     },\n     \"execution_count\": 19,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"ts.add(\\\"ts_key_incr\\\", \\\"*\\\", 0)\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"metadata\": {},\n   \"source\": [\n    \"Increment the value:\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 20,\n   \"metadata\": {},\n   \"outputs\": [],\n   \"source\": [\n    \"for _ in range(10):\\n\",\n    \"    ts.incrby(\\\"ts_key_incr\\\", 1)\\n\",\n    \"    # sleeping a bit so the timestamp are not duplicates\\n\",\n    \"    time.sleep(0.01)\"\n   ]\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 21,\n   \"metadata\": {},\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": [\n       \"[(1657272310241, 0.0),\\n\",\n       \" (1657272310533, 1.0),\\n\",\n       \" (1657272310545, 2.0),\\n\",\n       \" (1657272310556, 3.0),\\n\",\n       \" (1657272310567, 4.0),\\n\",\n       \" (1657272310578, 5.0),\\n\",\n       \" (1657272310589, 6.0),\\n\",\n       \" (1657272310600, 7.0),\\n\",\n       \" (1657272310611, 8.0),\\n\",\n       \" (1657272310622, 9.0),\\n\",\n       \" (1657272310632, 10.0)]\"\n      ]\n     },\n     \"execution_count\": 21,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"ts.range(\\\"ts_key_incr\\\", \\\"-\\\", \\\"+\\\")\"\n   ]\n  },\n  {\n   \"cell_type\": \"markdown\",\n   \"source\": [\n    \"## How to execute multi-key commands on Open Source Redis Cluster\"\n   ],\n   \"metadata\": {\n    \"collapsed\": false\n   }\n  },\n  {\n   \"cell_type\": \"code\",\n   \"execution_count\": 4,\n   \"outputs\": [\n    {\n     \"data\": {\n      \"text/plain\": \"[{'ts_key1': [{}, 1670927124746, 2.0]}, {'ts_key2': [{}, 1670927124748, 10.0]}]\"\n     },\n     \"execution_count\": 4,\n     \"metadata\": {},\n     \"output_type\": \"execute_result\"\n    }\n   ],\n   \"source\": [\n    \"import redis\\n\",\n    \"\\n\",\n    \"r = redis.RedisCluster(host=\\\"localhost\\\", port=46379)\\n\",\n    \"\\n\",\n    \"# This command should be executed on all cluster nodes after creation and any re-sharding\\n\",\n    \"# Please note that this command is internal and will be deprecated in the future\\n\",\n    \"r.execute_command(\\\"timeseries.REFRESHCLUSTER\\\", target_nodes=\\\"primaries\\\")\\n\",\n    \"\\n\",\n    \"# Now multi-key commands can be executed\\n\",\n    \"ts = r.ts()\\n\",\n    \"ts.add(\\\"ts_key1\\\", \\\"*\\\", 2,  labels={\\\"label1\\\": 1, \\\"label2\\\": 2})\\n\",\n    \"ts.add(\\\"ts_key2\\\", \\\"*\\\", 10,  labels={\\\"label1\\\": 1, \\\"label2\\\": 2})\\n\",\n    \"ts.mget([\\\"label1=1\\\"])\"\n   ],\n   \"metadata\": {\n    \"collapsed\": false\n   }\n  }\n ],\n \"metadata\": {\n  \"kernelspec\": {\n   \"display_name\": \"Python 3.9.2 64-bit\",\n   \"language\": \"python\",\n   \"name\": \"python3\"\n  },\n  \"language_info\": {\n   \"codemirror_mode\": {\n    \"name\": \"ipython\",\n    \"version\": 3\n   },\n   \"file_extension\": \".py\",\n   \"mimetype\": \"text/x-python\",\n   \"name\": \"python\",\n   \"nbconvert_exporter\": \"python\",\n   \"pygments_lexer\": \"ipython3\",\n   \"version\": \"3.9.2\"\n  },\n  \"orig_nbformat\": 4,\n  \"vscode\": {\n   \"interpreter\": {\n    \"hash\": \"916dbcbb3f70747c44a77c7bcd40155683ae19c65e1c03b4aa3499c5328201f1\"\n   }\n  }\n },\n \"nbformat\": 4,\n \"nbformat_minor\": 2\n}\n"
  },
  {
    "path": "docs/examples.rst",
    "content": "Examples\n########\n\n.. toctree::\n   :maxdepth: 3\n   :glob:\n\n   examples/connection_examples\n   examples/ssl_connection_examples\n   examples/asyncio_examples\n   examples/search_json_examples\n   examples/set_and_get_examples\n   examples/search_vector_similarity_examples\n   examples/pipeline_examples\n   examples/timeseries_examples\n   examples/redis-stream-example\n   examples/opentelemetry_api_examples\n"
  },
  {
    "path": "docs/exceptions.rst",
    "content": ".. _exceptions-label:\n\nExceptions\n##########\n\n.. automodule:: redis.exceptions\n    :members:"
  },
  {
    "path": "docs/genindex.rst",
    "content": "Module Index\n============"
  },
  {
    "path": "docs/geographic_failover.rst",
    "content": "Client-side geographic failover (Active-Active)\n=====================================\n\nThe multi-database client allows your application to connect to multiple Redis databases, which are typically replicas of each other.\nIt is designed to work with Redis Software and Redis Cloud Active-Active setups.\nThe client continuously monitors database health, detects failures, and automatically fails over to the next healthy database using a configurable strategy.\nWhen the original database becomes healthy again, the client can automatically switch back to it.\n\nKey concepts\n------------\n\n- Database and weight:\n  Each database has a weight indicating its priority. The failover strategy chooses the highest-weight\n  healthy database as the active one.\n\n- Circuit breaker:\n  Each database is guarded by a circuit breaker with states CLOSED (healthy), OPEN (unhealthy),\n  and HALF_OPEN (probing). Health checks toggle these states to avoid hammering a downed database.\n\n- Health checks:\n  A set of checks determines whether a database is healthy in proactive manner.\n  By default, an \"PING\" check runs against the database (all cluster nodes must\n  pass for a cluster). You can provide your own set of health checks or add an\n  additional health check on top of the default one. A Redis Enterprise specific\n  \"lag-aware\" health check is also available.\n\n- Failure detector:\n  A detector observes command failures over a moving window (reactive monitoring).\n  You can specify an exact number of failures and failures rate to have more\n  fine-grain tuned configuration of triggering fail over based on organic traffic.\n  You can provide your own set of custom failure detectors or add an additional\n  detector on top of the default one.\n\n- Failover strategy:\n  The default strategy is based on statically configured weights. It prefers the highest weighted healthy database.\n\n- Command retry:\n  Command execution supports retry with backoff. Low-level client retries are disabled and a global retry\n  setting is applied at the multi-database layer.\n\n- Auto fallback:\n  If configured with a positive interval, the client periodically attempts to fall back to a higher-weighted\n  healthy database.\n\n- Events:\n  The client emits events like \"active database changed\" and \"commands failed\". In addition it resubscribes to Pub/Sub channels automatically.\n\nSynchronous usage\n-----------------\n\nMinimal example\n^^^^^^^^^^^^^^^\n\n.. code-block:: python\n\n    from redis.multidb.client import MultiDBClient\n    from redis.multidb.config import MultiDbConfig, DatabaseConfig\n\n    # Two databases. The first has higher weight -> preferred when healthy.\n    cfg = MultiDbConfig(\n        databases_config=[\n            DatabaseConfig(from_url=\"redis://db-primary:6379/0\", weight=1.0),\n            DatabaseConfig(from_url=\"redis://db-secondary:6379/0\", weight=0.5),\n        ]\n    )\n\n    client = MultiDBClient(cfg)\n\n    # First call triggers initialization and health checks.\n    client.set(\"key\", \"value\")\n    print(client.get(\"key\"))\n\n    # Pipeline\n    with client.pipeline() as pipe:\n        pipe.set(\"a\", 1)\n        pipe.incrby(\"a\", 2)\n        values = pipe.execute()\n        print(values)\n\n    # Transaction\n    def txn(pipe):\n        current = pipe.get(\"balance\")\n        current = int(current or 0)\n        pipe.multi()  # mark transaction\n        pipe.set(\"balance\", current + 100)\n\n    client.transaction(txn)\n\n    # Pub/Sub usage - will automatically re-subscribe on database switch\n    pubsub = client.pubsub()\n    pubsub.subscribe(\"events\")\n\n    # In your loop:\n    message = pubsub.get_message(timeout=1.0)\n    if message:\n        print(message)\n\nAsyncio usage\n-------------\n\nThe asyncio API mirrors the synchronous one and provides async/await semantics.\n\n.. code-block:: python\n\n    import asyncio\n    from redis.asyncio.multidb.client import MultiDBClient\n    from redis.asyncio.multidb.config import MultiDbConfig, DatabaseConfig\n\n    async def main():\n        cfg = MultiDbConfig(\n            databases_config=[\n                DatabaseConfig(from_url=\"redis://db-primary:6379/0\", weight=1.0),\n                DatabaseConfig(from_url=\"redis://db-secondary:6379/0\", weight=0.5),\n            ]\n        )\n\n        # Context-manager approach for graceful client termination when exits.\n        # client = MultiDBClient(cfg) could be used instead\n        async with MultiDBClient(cfg) as client:\n            await client.set(\"key\", \"value\")\n            print(await client.get(\"key\"))\n\n        # Pipeline\n        async with client.pipeline() as pipe:\n            pipe.set(\"a\", 1)\n            pipe.incrby(\"a\", 2)\n            values = await pipe.execute()\n            print(values)\n\n        # Transaction\n        async def txn(pipe):\n            current = await pipe.get(\"balance\")\n            current = int(current or 0)\n            await pipe.multi()\n            await pipe.set(\"balance\", current + 100)\n\n        await client.transaction(txn)\n\n        # Pub/Sub\n        pubsub = client.pubsub()\n        await pubsub.subscribe(\"events\")\n        message = await pubsub.get_message(timeout=1.0)\n        if message:\n            print(message)\n\n    asyncio.run(main())\n\n\nMultiDBClient\n^^^^^^^^^^^^^\n\nThe client exposes the same API as the `Redis` or `RedisCluster` client, making it fully interchangeable and ensuring a simple migration of your application code. Additionally, it supports runtime reconfiguration, allowing you to add features such as health checks, failure detectors, or even new databases without restarting.\n\nConfiguration\n-------------\n\nMultiDbConfig\n^^^^^^^^^^^^^\n\n.. code-block:: python\n\n    from redis.multidb.config import (\n        MultiDbConfig, DatabaseConfig,\n        DEFAULT_HEALTH_CHECK_INTERVAL, DEFAULT_GRACE_PERIOD\n    )\n    from redis.retry import Retry\n    from redis.backoff import ExponentialWithJitterBackoff\n\n    cfg = MultiDbConfig(\n        databases_config=[\n            # Construct via URL\n            DatabaseConfig(\n                from_url=\"redis://db-a:6379/0\",\n                weight=1.0,\n                # Optional: use a custom circuit breaker grace period\n                grace_period=DEFAULT_GRACE_PERIOD,\n                # Optional: Redis Enterprise cluster FQDN for REST health checks\n                health_check_url=\"https://cluster.example.com\",\n                # Optional: Underlying Redis client related configuration\n                client_kwargs={\"socket_timeout\": 5}\n            ),\n            # Or construct via ConnectionPool\n            # DatabaseConfig(from_pool=my_pool, weight=1.0),\n        ],\n\n        # Global command retry policy (applied at multi-db layer)\n        command_retry=Retry(\n            retries=3,\n            backoff=ExponentialWithJitterBackoff(base=1, cap=10),\n        ),\n\n        # Health checks\n        health_check_interval: float = DEFAULT_HEALTH_CHECK_INTERVAL # seconds\n        health_check_probes: int = DEFAULT_HEALTH_CHECK_PROBES\n        health_check_delay: float = DEFAULT_HEALTH_CHECK_DELAY # seconds\n        health_check_timeout: float = DEFAULT_HEALTH_CHECK_TIMEOUT # seconds\n        health_check_policy: HealthCheckPolicies = DEFAULT_HEALTH_CHECK_POLICY,\n\n        # Failure detector\n        min_num_failures: int = DEFAULT_MIN_NUM_FAILURES\n        failure_rate_threshold: float = DEFAULT_FAILURE_RATE_THRESHOLD\n        failures_detection_window: float = DEFAULT_FAILURES_DETECTION_WINDOW # seconds\n\n        # Failover behavior\n        failover_attempts: int = DEFAULT_FAILOVER_ATTEMPTS\n        failover_delay: float = DEFAULT_FAILOVER_DELAY # seconds\n    )\n\nNotes:\n\n- Low-level client retries are disabled automatically per database. The multi-database layer handles retries.\n- For clusters, health checks validate all nodes.\n\nDatabaseConfig\n^^^^^^^^^^^^^^\n\nEach database needs a `DatabaseConfig` that specifies how to connect.\n\nMethod 1: Using client_kwargs (most flexible)\n~~~~~~~~~~~~~~~~~~~~~\nThere's an underlying instance of `Redis` or `RedisCluster` client for each database,\nso you can pass all the arguments related to them via `client_kwargs` argument:\n\n.. code:: python\n    database_config = DatabaseConfig(\n        weight=1.0,\n        client_kwargs={\n            'host': 'localhost',\n            'port': 6379,\n            'username': \"username\",\n            'password': \"password\",\n        }\n    )\n\nMethod 2: Using Redis URL\n~~~~~~~~~~~~~~~~~~~~~~~~~\n\n.. code:: python\n    database_config1 = DatabaseConfig(\n        weight=1.0,\n        from_url=\"redis://host1:port1\",\n        client_kwargs={\n            'username': \"username\",\n            'password': \"password\",\n        }\n    )\n\nMethod 3: Using Custom Connection Pool\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n.. code:: python\n  database_config2 = DatabaseConfig(\n      weight=0.9,\n      from_pool=connection_pool,\n  )\n\n**Important**: Don't pass `Retry` objects in `client_kwargs`. `MultiDBClient`\nhandles all retries at the top level through the `command_retry` configuration.\n\nHealth Monitoring\n-----------------\nThe `MultiDBClient` uses two complementary mechanisms to ensure database availability:\n- Health Checks (Proactive Monitoring)\n- Failure Detection (Reactive Monitoring)\n\n\nHealth Checks (Proactive Monitoring)\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\nThese checks run continuously in the background at configured intervals to proactively\ndetect database issues. They run in the background with a given interval and\nconfiguration defined in the `MultiDBConfig` class.\n\nTo avoid false positives, you can configure amount of health check probes and also\ndefine one of the health check policies to evaluate probes result.\n\n**HealthCheckPolicies.HEALTHY_ALL** - (default) All probes should be successful.\n**HealthCheckPolicies.HEALTHY_MAJORITY** - Majority of probes should be successful.\n**HealthCheckPolicies.HEALTHY_ANY** - Any of probes should be successful.\n\nPingHealthCheck (default)\n^^^^^^^^^^^^^^^^^^^^^^^^^\n\nThe default health check sends the [PING](https://redis.io/docs/latest/commands/ping/) command\nto the database (and to all nodes for clusters).\n\nLag-Aware Healthcheck (Redis Enterprise Only)\n^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\nThis is a special type of healthcheck available for Redis (Enterprise) Software\nthat utilizes a REST API endpoint to obtain information about the synchronization\nlag between a given database and all other databases in an Active-Active setup.\n\nTo use this healthcheck, first you need to adjust your `DatabaseConfig`\nto expose `health_check_url` used by your deployment. By default, your\nCluster FQDN should be used as URL, unless you have some kind of\nreverse proxy behind an actual REST API endpoint.\n\n.. code-block:: python\n\n    from redis.multidb.client import MultiDBClient\n    from redis.multidb.config import MultiDbConfig, DatabaseConfig\n    from redis.multidb.healthcheck import PingHealthCheck, LagAwareHealthCheck\n    from redis.retry import Retry\n    from redis.backoff import ExponentialWithJitterBackoff\n\n    cfg = MultiDbConfig(\n        databases_config=[\n            DatabaseConfig(\n                from_url=\"redis://db-primary:6379/0\",\n                weight=1.0,\n                health_check_url=\"https://cluster.example.com\",  # optional for LagAware\n            ),\n            DatabaseConfig(\n                from_url=\"redis://db-secondary:6379/0\",\n                weight=0.5,\n                health_check_url=\"https://cluster.example.com\",\n            ),\n        ],\n        # Add custom health check to replace the default\n        health_checks=[\n            # Redis Enterprise REST-based lag-aware check\n            LagAwareHealthCheck(\n                # Customize REST port, lag tolerance, TLS, etc.\n                rest_api_port=9443,\n                lag_aware_tolerance=100,  # ms\n                verify_tls=True,\n                # auth_basic=(\"user\", \"pass\"),\n                # ca_file=\"/path/ca.pem\",\n                # client_cert_file=\"/path/cert.pem\",\n                # client_key_file=\"/path/key.pem\",\n            ),\n        ],\n    )\n\n    client = MultiDBClient(cfg)\n\n\n**Custom Health Checks**\n~~~~~~~~~~~~~~~~~~~~~\nYou can add custom health checks for specific requirements. Please notice that all health checks are executed within\nasyncio event loop, so please ensure that `check_health` method is async:\n\n.. code-block:: python\n\n    from redis.asyncio.multidb.healthcheck import AbstractHealthCheck, AsyncRedisClientT\n\n    class EchoHealthCheck(AbstractHealthCheck):\n        \"\"\"\n        Health check based on ECHO command.\n        \"\"\"\n\n        async def check_health(self, database, hc_client: AsyncRedisClientT) -> bool:\n            await connection.send_command(\"ECHO\", \"healthcheck\")\n            response = await connection.read_response()\n            return response in (b\"healthcheck\", \"healthcheck\")\n\nFailure Detection (Reactive Monitoring)\n-----------------\n\nThe failure detector monitors command failures and marks a database as unhealthy when its failure rate exceeds a defined threshold within a sliding time window.\nUnder real traffic conditions, this reactive detection mechanism likely triggers earlier than proactive health checks.\nYou can extend the set of failure detectors by implementing your own and configuring it through the `MultiDBConfig` class.\n\nBy default the failure detector is configured for 1000 failures and a 10% failure rate\nthreshold within a 2 seconds sliding window. This could be adjusted regarding\nyour application specifics and traffic pattern.\n\n.. code-block:: python\n\n    from redis.multidb.config import MultiDbConfig, DatabaseConfig\n    from redis.multidb.client import MultiDBClient\n\n    cfg = MultiDbConfig(\n        databases_config=[\n            DatabaseConfig(from_url=\"redis://db-a:6379/0\", weight=1.0),\n            DatabaseConfig(from_url=\"redis://db-b:6379/0\", weight=0.5),\n        ],\n        # Default detector also created from config values\n    )\n\n    client = MultiDBClient(cfg)\n\n    # Add an additional detector, optionally limited to specific exception types:\n    client.add_failure_detector(\n        CustomFailureDetector()\n    )\n\nFailover and automatic fallback\n--------------------------\n\nWeight-based failover chooses the highest-weighted database with a CLOSED circuit. If no database is\nhealthy it returns `TemporaryUnavailableException`. This exception indicates that the application should\nretry sending requests for a configurable period of time (the configuration (`failover_attempts` * `failover_delay`)\ndefaults to 120 seconds). If none of the databases became available, then a `NoValidDatabaseException` is thrown.\n\nTo enable periodic fallback to a higher-priority healthy database, set `auto_fallback_interval` (seconds):\n\n.. code-block:: python\n\n    from redis.multidb.config import MultiDbConfig, DatabaseConfig\n\n    cfg = MultiDbConfig(\n        databases_config=[\n            DatabaseConfig(from_url=\"redis://db-primary:6379/0\", weight=1.0),\n            DatabaseConfig(from_url=\"redis://db-secondary:6379/0\", weight=0.5),\n        ],\n        # Try to fallback to higher-weight healthy database every 30 seconds\n        auto_fallback_interval=30.0,\n    )\n    client = MultiDBClient(cfg)\n\n\nCustom failover callbacks\n-------------------------\n\nYou may want to activate custom actions when failover happens. For example, you may want to collect some metrics,\nlogs or externally persist a connection state.\n\nYou can register your own event listener for the `ActiveDatabaseChanged` event (which is emitted when a failover happens) using\nthe `EventDispatcher`.\n\n.. code-block:: python\n\n    class LogFailoverEventListener(EventListenerInterface):\n        def __init__(self, logger: Logger):\n            self.logger = logger\n\n        def listen(self, event: ActiveDatabaseChanged):\n            self.logger.warning(\n                f\"Failover happened. Active database switched from {event.old_database} to {event.new_database}\"\n            )\n\n    event_dispatcher = EventDispatcher()\n    listener = LogFailoverEventListener(logging.getLogger(__name__))\n\n    # Register custom listener\n    event_dispatcher.register_listeners(\n        {\n            ActiveDatabaseChanged: [listener],\n        }\n    )\n\n    config = MultiDbConfig(\n        client_class=client_class,\n        databases_config=db_configs,\n        command_retry=command_retry,\n        min_num_failures=min_num_failures,\n        health_check_probes=3,\n        health_check_interval=health_check_interval,\n        event_dispatcher=event_dispatcher,\n        health_check_probes_delay=health_check_delay,\n    )\n\n    client = MultiDBClient(config)\n\n\nManaging databases at runtime\n-----------------------------\n\nYou can manually add/remove databases, update weights, and promote a database if it’s healthy.\n\n.. code-block:: python\n\n    from redis.multidb.client import MultiDBClient\n    from redis.multidb.config import MultiDbConfig, DatabaseConfig\n    from redis.multidb.database import Database\n    from redis.multidb.circuit import PBCircuitBreakerAdapter\n    import pybreaker\n    from redis import Redis\n\n    cfg = MultiDbConfig(\n        databases_config=[DatabaseConfig(from_url=\"redis://db-a:6379/0\", weight=1.0)]\n    )\n    client = MultiDBClient(cfg)\n\n    # Add a database programmatically\n    other = Database(\n        client=Redis.from_url(\"redis://db-b:6379/0\"),\n        circuit=PBCircuitBreakerAdapter(pybreaker.CircuitBreaker(reset_timeout=5.0)),\n        weight=0.5,\n        health_check_url=None,\n    )\n    client.add_database(other)\n\n    # Update weight; if it becomes the highest and healthy, it may become active\n    client.update_database_weight(other, 0.9)\n\n    # Promote a specific healthy database to active\n    client.set_active_database(other)\n\n    # Remove a database\n    client.remove_database(other)\n\nPub/Sub and re-subscription\n--------------------------\n\nThe MultiDBClient offers Pub/Sub functionality with automatic re-subscription\nto channels during failover events. For optimal failover handling,\nboth publishers and subscribers should use MultiDBClient instances.\n\n1. **Subscriber failover**: Automatically reconnects to an alternative database\nand re-subscribes to the same channels\n2. **Publisher failover**: Seamlessly switches to an alternative database and\ncontinues publishing to the same channels\n**Note**: Message loss may occur if failover events happen in reverse order\n(publisher fails before subscriber).\n\n.. code-block:: python\n\n    pubsub = client.pubsub()\n    pubsub.subscribe(\"news\", \"alerts\")\n    # If failover happens here, subscriptions are re-established on the new active DB.\n    msg = pubsub.get_message(timeout=1.0)\n    if msg:\n        print(msg)\n\nPipelines and transactions\n--------------------------\n\nPipelines and transactions are executed against the active database at execution time. The client ensures\nthe active database is healthy and up-to-date before running the stack.\n\n.. code-block:: python\n\n    with client.pipeline() as pipe:\n        pipe.set(\"x\", 1)\n        pipe.incr(\"x\")\n        results = pipe.execute()\n\n    def txn(pipe):\n        pipe.multi()\n        pipe.set(\"y\", \"42\")\n\n    client.transaction(txn)\n\nBest practices\n--------------\n\n- Assign the highest weight to your primary database and lower weights to replicas or disaster recovery sites.\n- Keep `health_check_interval` short enough to promptly detect failures but avoid excessive load.\n- Tune `command_retry` and failover attempts to your SLA and workload profile.\n- Use `auto_fallback_interval` if you want the client to fail over back to your primary automatically.\n- Handle `TemporaryUnavailableException` to be able to recover before giving up. In the meantime, you\ncan switch the data source (e.g. cache). `NoValidDatabaseException` indicates that there are no healthy\ndatabases to operate.\n\nTroubleshooting\n---------------\n\n- NoValidDatabaseException:\n  Indicates no healthy database is available. Check circuit breaker states and health checks.\n\n- TemporaryUnavailableException\n  Indicates that currently there are no healthy databases, but you can still send requests until\n  `NoValidDatabaseException` is thrown. Probe interval is configured with `failure_attemtps`\n\n- Health checks always failing:\n  Verify connectivity and, for clusters, that all nodes are reachable. For `LagAwareHealthCheck`,\n  ensure `health_check_url` points to your Redis Enterprise endpoint and authentication/TLS options\n  are configured properly.\n\n- Pub/Sub not receiving messages after failover:\n  Ensure you are using the client’s Pub/Sub helper. The client re-subscribes automatically on switch.\n"
  },
  {
    "path": "docs/index.rst",
    "content": ".. redis-py documentation master file, created by\n   sphinx-quickstart on Thu Jul 28 13:55:57 2011.\n   You can adapt this file completely to your liking, but it should at least\n   contain the root `toctree` directive.\n\nredis-py - Python Client for Redis\n====================================\n\nGetting Started\n****************\n\n`redis-py <https://pypi.org/project/redis>`_ requires a running Redis server, and Python 3.7+. See the `Redis\nquickstart <https://redis.io/topics/quickstart>`_ for Redis installation instructions.\n\nredis-py can be installed using pip via ``pip install redis``.\n\n\nQuickly connecting to redis\n***************************\n\nThere are two quick ways to connect to Redis.\n\n**Assuming you run Redis on localhost:6379 (the default)**\n\n.. code-block:: python\n\n   import redis\n   r = redis.Redis()\n   r.ping()\n\n**Running redis on foo.bar.com, port 12345**\n\n.. code-block:: python\n\n   import redis\n   r = redis.Redis(host='foo.bar.com', port=12345)\n   r.ping()\n\n**Another example with foo.bar.com, port 12345**\n\n.. code-block:: python\n\n   import redis\n   r = redis.from_url('redis://foo.bar.com:12345')\n   r.ping()\n\nAfter that, you probably want to `run redis commands <commands.html>`_.\n\n.. toctree::\n   :hidden:\n\n   genindex\n\nRedis Command Functions\n***********************\n.. toctree::\n   :maxdepth: 2\n\n   commands\n   redismodules\n\nModule Documentation\n********************\n.. toctree::\n   :maxdepth: 1\n\n   connections\n   clustering\n   multi_database\n   exceptions\n   backoff\n   lock\n   retry\n   lua_scripting\n   opentelemetry\n   resp3_features\n   advanced_features\n   examples\n\nContributing\n*************\n\n- `How to contribute <https://github.com/redis/redis-py/blob/master/CONTRIBUTING.md>`_\n- `Issue Tracker <https://github.com/redis/redis-py/issues>`_\n- `Source Code <https://github.com/redis/redis-py/>`_\n- `Release History <https://github.com/redis/redis-py/releases/>`_\n\nLicense\n*******\n\nThis project is licensed under the `MIT license <https://github.com/redis/redis-py/blob/master/LICENSE>`_.\n"
  },
  {
    "path": "docs/lock.rst",
    "content": "Lock\n#########\n\n.. automodule:: redis.lock\n    :members: "
  },
  {
    "path": "docs/lua_scripting.rst",
    "content": "Lua Scripting\n===\n\n`Lua Scripting <#lua-scripting-in-default-connections>`__ \\|\n`Pipelines <#pipelines>`__ \\| `Cluster mode <#cluster-mode>`__\n\n--------------\n\nLua Scripting in default connections\n------------------------------------\n\nredis-py supports the EVAL, EVALSHA, and SCRIPT commands. However, there\nare a number of edge cases that make these commands tedious to use in\nreal world scenarios. Therefore, redis-py exposes a Script object that\nmakes scripting much easier to use. (RedisClusters have limited support\nfor scripting.)\n\nTo create a Script instance, use the register_script function on a\nclient instance passing the Lua code as the first argument.\nregister_script returns a Script instance that you can use throughout\nyour code.\n\nThe following trivial Lua script accepts two parameters: the name of a\nkey and a multiplier value. The script fetches the value stored in the\nkey, multiplies it with the multiplier value and returns the result.\n\n.. code:: python\n\n   >>> r = redis.Redis()\n   >>> lua = \"\"\"\n   ... local value = redis.call('GET', KEYS[1])\n   ... value = tonumber(value)\n   ... return value * ARGV[1]\"\"\"\n   >>> multiply = r.register_script(lua)\n\nmultiply is now a Script instance that is invoked by calling it like a\nfunction. Script instances accept the following optional arguments:\n\n-  **keys**: A list of key names that the script will access. This\n   becomes the KEYS list in Lua.\n-  **args**: A list of argument values. This becomes the ARGV list in\n   Lua.\n-  **client**: A redis-py Client or Pipeline instance that will invoke\n   the script. If client isn't specified, the client that initially\n   created the Script instance (the one that register_script was invoked\n   from) will be used.\n\nContinuing the example from above:\n\n.. code:: python\n\n   >>> r.set('foo', 2)\n   >>> multiply(keys=['foo'], args=[5])\n   10\n\nThe value of key 'foo' is set to 2. When multiply is invoked, the 'foo'\nkey is passed to the script along with the multiplier value of 5. Lua\nexecutes the script and returns the result, 10.\n\nScript instances can be executed using a different client instance, even\none that points to a completely different Redis server.\n\n.. code:: python\n\n   >>> r2 = redis.Redis('redis2.example.com')\n   >>> r2.set('foo', 3)\n   >>> multiply(keys=['foo'], args=[5], client=r2)\n   15\n\nThe Script object ensures that the Lua script is loaded into Redis's\nscript cache. In the event of a NOSCRIPT error, it will load the script\nand retry executing it.\n\nPipelines\n---------\n\nScript objects can also be used in pipelines. The pipeline instance\nshould be passed as the client argument when calling the script. Care is\ntaken to ensure that the script is registered in Redis's script cache\njust prior to pipeline execution.\n\n.. code:: python\n\n   >>> pipe = r.pipeline()\n   >>> pipe.set('foo', 5)\n   >>> multiply(keys=['foo'], args=[5], client=pipe)\n   >>> pipe.execute()\n   [True, 25]\n\nCluster Mode\n------------\n\nCluster mode has limited support for lua scripting.\n\nThe following commands are supported, with caveats:\n\n- ``EVAL`` and ``EVALSHA``: The command is sent to the relevant node,\n  depending on the keys (i.e., in ``EVAL \"<script>\" num_keys key_1 ...\n  key_n ...``). The keys *must* all be on the same node. If the script\n  requires 0 keys, *the command is sent to a random (primary) node*.\n- ``SCRIPT EXISTS``: The command is sent to all primaries. The result\n  is a list of booleans corresponding to the input SHA hashes. Each\n  boolean is an AND of “does the script exist on each node?”. In other\n  words, each boolean is True iff the script exists on all nodes.\n- ``SCRIPT FLUSH``: The command is sent to all primaries. The result\n  is a bool AND over all nodes’ responses.\n- ``SCRIPT LOAD``: The command is sent to all primaries. The result\n  is the SHA1 digest.\n\nThe following commands are not supported:\n\n- ``EVAL_RO``\n- ``EVALSHA_RO``\n\nUsing scripting within pipelines in cluster mode is **not supported**.\n"
  },
  {
    "path": "docs/opentelemetry.rst",
    "content": "Integrating OpenTelemetry\n=========================\n\nWhat is OpenTelemetry?\n----------------------\n\n`OpenTelemetry <https://opentelemetry.io>`__ is an open-source observability framework for traces, metrics, and logs. It is a merger of OpenCensus and OpenTracing projects hosted by Cloud Native Computing Foundation.\n\nOpenTelemetry allows developers to collect and export telemetry data in a vendor agnostic way. With OpenTelemetry, you can instrument your application once and then add or change vendors without changing the instrumentation, for example, here is a list of `popular DataDog competitors <https://uptrace.dev/get/compare/datadog-competitors.html>`_ that support OpenTelemetry.\n\nWhat is tracing?\n----------------\n\n`OpenTelemetry tracing <https://uptrace.dev/opentelemetry/distributed-tracing.html>`_ allows you to see how a request progresses through different services and systems, timings of each operation, any logs and errors as they occur.\n\nIn a distributed environment, tracing also helps you understand relationships and interactions between microservices. Distributed tracing gives an insight into how a particular microservice is performing and how that service affects other microservices.\n\n.. image:: images/opentelemetry/distributed-tracing.png\n  :alt: Trace\n\nUsing tracing, you can break down requests into spans. **Span** is an operation (unit of work) your app performs handling a request, for example, a database query or a network call.\n\n**Trace** is a tree of spans that shows the path that a request makes through an app. Root span is the first span in a trace.\n\n.. image:: images/opentelemetry/tree-of-spans.png\n  :alt: Trace\n\nTo learn more about tracing, see `Distributed Tracing using OpenTelemetry <https://uptrace.dev/opentelemetry/distributed-tracing.html>`_.\n\nNative OpenTelemetry Integration (Recommended)\n----------------------------------------------\n\nredis-py includes built-in support for OpenTelemetry metrics collection. This native integration is the **recommended approach** as it provides comprehensive metrics without requiring external instrumentation packages.\n\nInstallation\n^^^^^^^^^^^^\n\nTo use native OpenTelemetry support, install the required dependencies:\n\n.. code-block:: shell\n\n   pip install redis[otel]\n\nBasic Setup\n^^^^^^^^^^^\n\nInitialize OpenTelemetry observability once at application startup. All Redis clients will automatically collect metrics:\n\n.. code-block:: python\n\n   from opentelemetry import metrics\n   from opentelemetry.sdk.metrics import MeterProvider\n   from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader\n   from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter\n\n   # 1. Set up OpenTelemetry MeterProvider\n   exporter = OTLPMetricExporter(endpoint=\"http://localhost:4318/v1/metrics\")\n   reader = PeriodicExportingMetricReader(exporter=exporter, export_interval_millis=10000)\n   provider = MeterProvider(metric_readers=[reader])\n   metrics.set_meter_provider(provider)\n\n   # 2. Initialize redis-py observability\n   from redis.observability import get_observability_instance, OTelConfig\n\n   otel = get_observability_instance()\n   otel.init(OTelConfig())\n\n   # 3. Use Redis as usual - metrics are collected automatically\n   import redis\n   r = redis.Redis(host='localhost', port=6379)\n   r.set('key', 'value')  # Metrics collected automatically\n   r.get('key')\n\n   # 4. Shutdown observability at application exit\n   otel.shutdown()\n\nConfiguration Options\n^^^^^^^^^^^^^^^^^^^^^\n\nThe ``OTelConfig`` class provides fine-grained control over metrics collection:\n\n.. code-block:: python\n\n   from redis.observability import OTelConfig, MetricGroup\n\n   config = OTelConfig(\n       # Metric groups to enable (default: CONNECTION_BASIC | RESILIENCY)\n       metric_groups=[\n           MetricGroup.CONNECTION_BASIC,    # Connection creation time, relaxed timeout\n           MetricGroup.CONNECTION_ADVANCED, # Connection wait time, timeouts, closed connections\n           MetricGroup.COMMAND,             # Command execution duration\n           MetricGroup.RESILIENCY,          # Error counts, maintenance notifications\n           MetricGroup.PUBSUB,              # PubSub message counts\n           MetricGroup.STREAMING,           # Stream message lag\n           MetricGroup.CSC,                 # Client Side Caching metrics\n       ],\n\n       # Filter which commands to track\n       include_commands=['GET', 'SET', 'HGET'],  # Only track these commands\n       # OR\n       exclude_commands=['DEBUG', 'SLOWLOG'],    # Track all except these\n\n       # Privacy controls\n       hide_pubsub_channel_names=True,  # Hide channel names in PubSub metrics\n       hide_stream_names=True,          # Hide stream names in streaming metrics\n   )\n\n   otel = get_observability_instance()\n   otel.init(config)\n\nAvailable Metric Groups\n^^^^^^^^^^^^^^^^^^^^^^^\n\n+------------------------+----------------------------------------------------------+\n| Metric Group           | Description                                              |\n+========================+==========================================================+\n| ``CONNECTION_BASIC``   | Connection creation time, relaxed timeout, handoff       |\n+------------------------+----------------------------------------------------------+\n| ``CONNECTION_ADVANCED``| Connection wait time, timeouts, closed connections       |\n+------------------------+----------------------------------------------------------+\n| ``COMMAND``            | Command execution duration                               |\n+------------------------+----------------------------------------------------------+\n| ``RESILIENCY``         | Error counts, maintenance notifications                  |\n+------------------------+----------------------------------------------------------+\n| ``PUBSUB``             | PubSub message counts (publish/receive)                  |\n+------------------------+----------------------------------------------------------+\n| ``STREAMING``          | Stream message lag (XREAD/XREADGROUP)                    |\n+------------------------+----------------------------------------------------------+\n| ``CSC``                | Client Side Caching (requests, evictions, bytes saved)   |\n+------------------------+----------------------------------------------------------+\n\nAvailable Metrics\n^^^^^^^^^^^^^^^^^\n\nThe following metrics are collected based on enabled metric groups:\n\n**Connection Metrics:**\n\n- ``db.client.connection.create_time`` - Time to create a new connection (histogram)\n- ``db.client.connection.timeouts`` - Number of connection timeouts (counter)\n- ``db.client.connection.wait_time`` - Time to obtain a connection from pool (histogram)\n- ``db.client.connection.count`` - Current number of connections (observable gauge)\n- ``redis.client.connection.closed`` - Total closed connections (counter)\n- ``redis.client.connection.relaxed_timeout`` - Relaxed timeout events (up/down counter)\n- ``redis.client.connection.handoff`` - Connection handoff events (counter)\n\n**Command Metrics:**\n\n- ``db.client.operation.duration`` - Command execution duration (histogram)\n\n**Resiliency Metrics:**\n\n- ``redis.client.errors`` - Error counts with error type (counter)\n- ``redis.client.maintenance.notifications`` - Server maintenance notifications (counter)\n\n**PubSub Metrics:**\n\n- ``redis.client.pubsub.messages`` - Published and received messages (counter)\n\n**Streaming Metrics:**\n\n- ``redis.client.stream.lag`` - End-to-end message lag (histogram)\n\n**Client Side Caching (CSC) Metrics:**\n\n- ``redis.client.csc.requests`` - Cache requests with hit/miss result (counter)\n- ``redis.client.csc.evictions`` - Cache evictions (counter)\n- ``redis.client.csc.network_saved`` - Bytes saved by caching (counter)\n- ``redis.client.csc.items`` - Current cache size (observable gauge)\n\nCustom Histogram Buckets\n^^^^^^^^^^^^^^^^^^^^^^^^\n\nYou can customize histogram bucket boundaries for better granularity:\n\n.. code-block:: python\n\n   config = OTelConfig(\n       buckets_operation_duration=[0.0001, 0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1],\n       buckets_connection_create_time=[0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5],\n       buckets_connection_wait_time=[0.0001, 0.0005, 0.001, 0.005, 0.01, 0.05, 0.1],\n       buckets_stream_processing_duration=[0.001, 0.01, 0.1, 1, 10],\n   )\n\nContext Manager Usage\n^^^^^^^^^^^^^^^^^^^^^\n\nFor automatic cleanup, use the context manager pattern:\n\n.. code-block:: python\n\n   from redis.observability import get_observability_instance, OTelConfig\n\n   otel = get_observability_instance()\n\n   with otel.get_provider_manager():\n       # Redis operations here\n       r = redis.Redis()\n       r.set('key', 'value')\n   # Metrics are automatically flushed on exit\n\nError Handling\n^^^^^^^^^^^^^^\n\nThe native integration is designed to be non-intrusive. All metric recording functions are wrapped with try-except blocks, ensuring that any errors during metric collection will not affect your Redis operations.\n\nExternal OpenTelemetry Instrumentation (Alternative)\n----------------------------------------------------\n\nAs an alternative to the native integration, you can use the external ``opentelemetry-instrumentation-redis`` package. This approach uses monkey-patching to instrument redis-py.\n\nInstrumentations are plugins for popular frameworks and libraries that use OpenTelemetry API to record important operations, for example, HTTP requests, DB queries, logs, errors, and more.\n\nTo install OpenTelemetry `instrumentation <https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/redis/redis.html>`_ for redis-py:\n\n.. code-block:: shell\n\n   pip install opentelemetry-instrumentation-redis\n\nYou can then use it to instrument code like this:\n\n.. code-block:: python\n\n   from opentelemetry.instrumentation.redis import RedisInstrumentor\n\n   RedisInstrumentor().instrument()\n\nOnce the code is patched, you can use redis-py as usual:\n\n.. code-block:: python\n\n   # Sync client\n   client = redis.Redis()\n   client.get(\"my-key\")\n\n   # Async client\n   client = redis.asyncio.Redis()\n   await client.get(\"my-key\")\n\nOpenTelemetry API\n-----------------\n\n`OpenTelemetry API <https://uptrace.dev/opentelemetry/>`__ is a programming interface that you can use to instrument code and collect telemetry data such as traces, metrics, and logs.\n\nYou can use OpenTelemetry API to measure important operations:\n\n.. code-block:: python\n\n   from opentelemetry import trace\n\n   tracer = trace.get_tracer(\"app_or_package_name\", \"1.0.0\")\n\n   # Create a span with name \"operation-name\" and kind=\"server\".\n   with tracer.start_as_current_span(\"operation-name\", kind=trace.SpanKind.CLIENT) as span:\n       do_some_work()\n\nRecord contextual information using attributes:\n\n.. code-block:: python\n\n   if span.is_recording():\n       span.set_attribute(\"http.method\", \"GET\")\n       span.set_attribute(\"http.route\", \"/projects/:id\")\n\nAnd monitor exceptions:\n\n.. code-block:: python\n\n   except ValueError as exc:\n       # Record the exception and update the span status.\n       span.record_exception(exc)\n       span.set_status(trace.Status(trace.StatusCode.ERROR, str(exc)))\n\nSee `OpenTelemetry Python Tracing API <https://uptrace.dev/opentelemetry/python-tracing.html>`_ for details.\n\nUptrace\n-------\n\nUptrace is an `open source APM <https://uptrace.dev/get/open-source-apm.html>`_ that supports distributed tracing, metrics, and logs. You can use it to monitor applications and set up automatic alerts to receive notifications via email, Slack, Telegram, and more.\n\nYou can use Uptrace to monitor redis-py using this `GitHub example <https://github.com/redis/redis-py/tree/master/docs/examples/opentelemetry>`_ as a starting point.\n\n.. image:: images/opentelemetry/redis-py-trace.png\n  :alt: Redis-py trace\n\nYou can `install Uptrace <https://uptrace.dev/get/install.html>`_ by downloading a DEB/RPM package or a pre-compiled binary.\n\nMonitoring Redis Server performance\n-----------------------------------\n\nIn addition to monitoring redis-py client, you can also monitor Redis Server performance using OpenTelemetry Collector Agent.\n\nOpenTelemetry Collector is a proxy/middleman between your application and a `distributed tracing tool <https://uptrace.dev/blog/distributed-tracing-tools.html>`_ such as Uptrace or Jaeger. Collector receives telemetry data, processes it, and then exports the data to APM tools that can store it permanently.\n\nFor example, you can use the `OpenTelemetry Redis receiver <https://uptrace.dev/get/monitor/opentelemetry-redis.html>` provided by Otel Collector to monitor Redis performance:\n\n.. image:: images/opentelemetry/redis-metrics.png\n  :alt: Redis metrics\n\nSee introduction to `OpenTelemetry Collector <https://uptrace.dev/opentelemetry/collector.html>`_ for details.\n\nAlerting and notifications\n--------------------------\n\nUptrace also allows you to monitor `OpenTelemetry metrics <https://uptrace.dev/opentelemetry/metrics.html>`_ using alerting rules. For example, the following monitor uses the group by node expression to create an alert whenever an individual Redis shard is down:\n\n.. code-block:: yaml\n\n   monitors:\n     - name: Redis shard is down\n       metrics:\n         - redis_up as $redis_up\n       query:\n         - group by cluster # monitor each cluster,\n         - group by bdb # each database,\n         - group by node # and each shard\n         - $redis_up\n       min_allowed_value: 1\n       # shard should be down for 5 minutes to trigger an alert\n       for_duration: 5m\n\nYou can also create queries with more complex expressions. For example, the following rule creates an alert when the keyspace hit rate is lower than 75%:\n\n.. code-block:: yaml\n\n   monitors:\n     - name: Redis read hit rate < 75%\n       metrics:\n         - redis_keyspace_read_hits as $hits\n         - redis_keyspace_read_misses as $misses\n       query:\n         - group by cluster\n         - group by bdb\n         - group by node\n         - $hits / ($hits + $misses) as hit_rate\n       min_allowed_value: 0.75\n       for_duration: 5m\n\nSee `Alerting and Notifications <https://uptrace.dev/get/alerting.html>`_ for details.\n\nWhat's next?\n------------\n\nNext, you can learn how to configure `uptrace-python <https://uptrace.dev/get/opentelemetry-python.html>`_ to export spans, metrics, and logs to Uptrace.\n\nYou may also be interested in the following guides:\n\n- `OpenTelemetry Django <https://uptrace.dev/get/instrument/opentelemetry-django.html>`_\n- `OpenTelemetry Flask <https://uptrace.dev/get/instrument/instrument/opentelemetry-flask.html>`_\n- `OpenTelemetry FastAPI <https://uptrace.dev/get/instrument/opentelemetry-fastapi.html>`_\n- `OpenTelemetry SQLAlchemy <https://uptrace.dev/get/instrument/opentelemetry-sqlalchemy.html>`_\n"
  },
  {
    "path": "docs/redismodules.rst",
    "content": "Redis Modules Commands\n######################\n\nAccessing redis module commands requires the installation of the supported `Redis module <https://docs.redis.com/latest/modules/>`_. For a quick start with redis modules, try the `Redismod docker <https://hub.docker.com/r/redislabs/redismod>`_.\n\n\nRedisBloom Commands\n*******************\n\nThese are the commands for interacting with the `RedisBloom module <https://redisbloom.io>`_. Below is a brief example, as well as documentation on the commands themselves.\n\n**Create and add to a bloom filter**\n\n.. code-block:: python\n\n    import redis\n    r = redis.Redis()\n    r.bf().create(\"bloom\", 0.01, 1000)\n    r.bf().add(\"bloom\", \"foo\")\n\n**Create and add to a cuckoo filter**\n\n.. code-block:: python\n\n    import redis\n    r = redis.Redis()\n    r.cf().create(\"cuckoo\", 1000)\n    r.cf().add(\"cuckoo\", \"filter\")\n\n**Create Count-Min Sketch and get information**\n\n.. code-block:: python\n\n    import redis\n    r = redis.Redis()\n    r.cms().initbydim(\"dim\", 1000, 5)\n    r.cms().incrby(\"dim\", [\"foo\"], [5])\n    r.cms().info(\"dim\")\n\n**Create a topk list, and access the results**\n\n.. code-block:: python\n\n    import redis\n    r = redis.Redis()\n    r.topk().reserve(\"mytopk\", 3, 50, 4, 0.9)\n    r.topk().info(\"mytopk\")\n\n.. automodule:: redis.commands.bf.commands\n    :members: BFCommands, CFCommands, CMSCommands, TOPKCommands\n\n------\n\nRedisJSON Commands\n******************\n\nThese are the commands for interacting with the `RedisJSON module <https://redisjson.io>`_. Below is a brief example, as well as documentation on the commands themselves.\n\n**Create a json object**\n\n.. code-block:: python\n\n    import redis\n    r = redis.Redis()\n    r.json().set(\"mykey\", \".\", {\"hello\": \"world\", \"i am\": [\"a\", \"json\", \"object!\"]})\n\nExamples of how to combine search and json can be found `here <examples/search_json_examples.html>`_.\n\n.. automodule:: redis.commands.json.commands\n    :members: JSONCommands\n\n-----\n\nRediSearch Commands\n*******************\n\nThese are the commands for interacting with the `RediSearch module <https://redisearch.io>`_. Below is a brief example, as well as documentation on the commands themselves. In the example\nbelow, an index named *my_index* is being created. When an index name is not specified, an index named *idx* is created.\n\n**Create a search index, and display its information**\n\n.. code-block:: python\n\n    import redis\n    from redis.commands.search.field import TextField\n\n    r = redis.Redis()\n    index_name = \"my_index\"\n    schema = (\n        TextField(\"play\", weight=5.0),\n        TextField(\"ball\"),\n    )\n    r.ft(index_name).create_index(schema)\n    print(r.ft(index_name).info())\n\n\n.. automodule:: redis.commands.search.commands\n    :members: SearchCommands\n\n-----\n\nRedisTimeSeries Commands\n************************\n\nThese are the commands for interacting with the `RedisTimeSeries module <https://redistimeseries.io>`_. Below is a brief example, as well as documentation on the commands themselves.\n\n\n**Create a timeseries object with 5 second retention**\n\n.. code-block:: python\n\n    import redis\n    r = redis.Redis()\n    r.ts().create(2, retention_msecs=5000)\n\n.. automodule:: redis.commands.timeseries.commands\n    :members: TimeSeriesCommands\n\n\n"
  },
  {
    "path": "docs/requirements.txt",
    "content": "sphinx>=5.0,<7.0\ndocutils<0.18\nnbsphinx\nsphinx_gallery\nipython\nsphinx-autodoc-typehints\nfuro\npandoc\n"
  },
  {
    "path": "docs/resp3_features.rst",
    "content": "RESP 3 Features\n===============\n\nAs of version 5.0, redis-py supports the `RESP 3 standard <https://github.com/redis/redis-specifications/blob/master/protocol/RESP3.md>`_. Practically, this means that client using RESP 3 will be faster and more performant as fewer type translations occur in the client. It also means new response types like doubles, true simple strings, maps, and booleans are available.\n\nConnecting\n-----------\n\nEnabling RESP3 is no different than other connections in redis-py. In all cases, the connection type must be extending by setting `protocol=3`. The following are some base examples illustrating how to enable a RESP 3 connection.\n\nConnect with a standard connection, but specifying resp 3:\n\n.. code:: python\n\n    >>> import redis\n    >>> r = redis.Redis(host='localhost', port=6379, protocol=3)\n    >>> r.ping()\n\nOr using the URL scheme:\n\n.. code:: python\n\n    >>> import redis\n    >>> r = redis.from_url(\"redis://localhost:6379?protocol=3\")\n    >>> r.ping()\n\nConnect with async, specifying resp 3:\n\n.. code:: python\n\n    >>> import redis.asyncio as redis\n    >>> r = redis.Redis(host='localhost', port=6379, protocol=3)\n    >>> await r.ping()\n\nThe URL scheme with the async client\n\n.. code:: python\n\n    >>> import redis.asyncio as Redis\n    >>> r = redis.from_url(\"redis://localhost:6379?protocol=3\")\n    >>> await r.ping()\n\nConnecting to an OSS Redis Cluster with RESP 3\n\n.. code:: python\n\n    >>> from redis.cluster import RedisCluster, ClusterNode\n    >>> r = RedisCluster(startup_nodes=[ClusterNode('localhost', 6379), ClusterNode('localhost', 6380)], protocol=3)\n    >>> r.ping()\n\nPush notifications\n------------------\n\nPush notifications are a way that redis sends out of band data. The RESP 3 protocol includes a `push type <https://github.com/redis/redis-specifications/blob/master/protocol/RESP3.md#push-type>`_ that allows our client to intercept these out of band messages. By default, clients will log simple messages, but redis-py includes the ability to bring your own function processor.\n\nThis means that should you want to perform something, on a given push notification, you specify a function during the connection, as per this examples:\n\n.. code:: python\n\n    >> from redis import Redis\n    >>\n    >> def our_func(message):\n    >>    if message.find(\"This special thing happened\"):\n    >>        raise IOError(\"This was the message: \\n\" + message)\n    >>\n    >> r = Redis(protocol=3)\n    >> p = r.pubsub(push_handler_func=our_func)\n\nIn the example above, upon receipt of a push notification, rather than log the message, in the case where specific text occurs, an IOError is raised. This example, highlights how one could start implementing a customized message handler.\n\nClient-side caching\n-------------------\n\nClient-side caching is a technique used to create high performance services.\nIt utilizes the memory on application servers, typically separate from the database nodes, to cache a subset of the data directly on the application side.\nFor more information please check `official Redis documentation <https://redis.io/docs/latest/develop/use/client-side-caching/>`_.\nPlease notice that this feature only available with RESP3 protocol enabled in sync client only. Supported in standalone, Cluster and Sentinel clients.\n\nBasic usage:\n\nEnable caching with default configuration:\n\n.. code:: python\n\n    >>> import redis\n    >>> from redis.cache import CacheConfig\n    >>> r = redis.Redis(host='localhost', port=6379, protocol=3, cache_config=CacheConfig())\n\nThe same interface applies to Redis Cluster and Sentinel.\n\nEnable caching with custom cache implementation:\n\n.. code:: python\n\n    >>> import redis\n    >>> from foo.bar import CacheImpl\n    >>> r = redis.Redis(host='localhost', port=6379, protocol=3, cache=CacheImpl())\n\nCacheImpl should implement a `CacheInterface` specified in `redis.cache` package.\n\nMore comprehensive documentation soon will be available at `official Redis documentation <https://redis.io/docs/latest/>`_.\n"
  },
  {
    "path": "docs/retry.rst",
    "content": "Retry Helpers\n#############\n\n.. automodule:: redis.retry\n    :members:\n\n\nRetry in Redis Standalone\n**************************\n\n>>> from redis.backoff import ExponentialBackoff\n>>> from redis.retry import Retry\n>>> from redis.client import Redis\n>>> from redis.exceptions import (\n>>>    BusyLoadingError,\n>>>    RedisError,\n>>> )\n>>>\n>>> # Run 3 retries with exponential backoff strategy\n>>> retry = Retry(ExponentialBackoff(), 3)\n>>> # Redis client with retries on custom errors in addition to the errors\n>>> # that are already retried by default\n>>> r = Redis(host='localhost', port=6379, retry=retry, retry_on_error=[BusyLoadingError, RedisError])\n\nAs you can see from the example above, Redis client supports 2 parameters to configure the retry behaviour:\n\n* ``retry``: :class:`~.Retry` instance with a :ref:`backoff-label` strategy and the max number of retries\n    * The :class:`~.Retry` instance has default set of :ref:`exceptions-label` to retry on,\n      which can be overridden by passing a tuple with :ref:`exceptions-label` to the ``supported_errors`` parameter.\n* ``retry_on_error``: list of additional :ref:`exceptions-label` to retry on\n\n\nIf no ``retry`` is provided, a default one is created with  :class:`~.ExponentialWithJitterBackoff` as backoff strategy\nand 3 retries.\n\n\nRetry in Redis Cluster\n**************************\n\n>>> from redis.backoff import ExponentialBackoff\n>>> from redis.retry import Retry\n>>> from redis.cluster import RedisCluster\n>>>\n>>> # Run 3 retries with exponential backoff strategy\n>>> retry = Retry(ExponentialBackoff(), 3)\n>>> # Redis Cluster client with retries\n>>> rc = RedisCluster(host='localhost', port=6379, retry=retry)\n\nRetry behaviour in Redis Cluster is a little bit different from Standalone:\n\n* ``retry``: :class:`~.Retry` instance with a :ref:`backoff-label` strategy and the max number of retries, default value is ``Retry(ExponentialWithJitterBackoff(base=1, cap=10), cluster_error_retry_attempts)``\n* ``cluster_error_retry_attempts``: number of times to retry before raising an error when :class:`~.TimeoutError`, :class:`~.ConnectionError`, :class:`~.ClusterDownError` or :class:`~.SlotNotCoveredError` are encountered, default value is ``3``\n    * This argument is deprecated - it is used to initialize the number of retries for the retry object,\n      only in the case when the ``retry`` object is not provided.\n      When the ``retry`` argument is provided, the ``cluster_error_retry_attempts`` argument is ignored!\n\n* The retry object is not yet fully utilized in the cluster client.\n  The retry object is used only to determine the number of retries for the cluster level calls.\n\nLet's consider the following example:\n\n>>> from redis.backoff import ExponentialBackoff\n>>> from redis.retry import Retry\n>>> from redis.cluster import RedisCluster\n>>>\n>>> rc = RedisCluster(host='localhost', port=6379, retry=Retry(ExponentialBackoff(), 6))\n>>> rc.set('foo', 'bar')\n\n#. the client library calculates the hash slot for key 'foo'.\n#. given the hash slot, it then determines which node to connect to, in order to execute the command.\n#. during the connection, a :class:`~.ConnectionError` is raised.\n#. because we set ``retry=Retry(ExponentialBackoff(), 6)``, the cluster client starts a cluster update, removes the failed node from the startup nodes, and re-initializes the cluster.\n#. the cluster client retries the command until it either succeeds or the max number of retries is reached."
  },
  {
    "path": "doctests/README.md",
    "content": "# Command examples for redis.io\n\n## How to add an example\n\nCreate regular python file in the current folder with meaningful name. It makes sense prefix example files with\ncommand category (e.g. string, set, list, hash, etc) to make navigation in the folder easier. Files ending in *.py*\nare automatically run by the test suite.\n\n### Special markup\n\nSee https://github.com/redis-stack/redis-stack-website#readme for more details.\n\n## How to test examples\n\nExamples are standalone python scripts, committed to the *doctests* directory. These scripts assume that the\n```doctests/requirements.txt``` and ```dev_requirements.txt``` from this repository have been installed, as per below.\n\n```bash\npip install -r dev_requirements.txt\npip uninstall -y redis  # uninstall Redis package installed via redis-entraid\npip install -r doctests/requirements.txt\n```\n\nNote - the CI process, runs linters against the examples. Assuming\nthe requirements above have been installed you can run ```ruff check yourfile.py``` and ```ruff format yourfile.py```\nlocally to validate the linting, prior to CI.\n\nJust include necessary assertions in the example file and run\n```bash\nsh doctests/run_examples.sh\n```\nto test all examples in the current folder.\n"
  },
  {
    "path": "doctests/cmds_cnxmgmt.py",
    "content": "# EXAMPLE: cmds_cnxmgmt\n# HIDE_START\nimport redis\n\nr = redis.Redis(decode_responses=True)\n# HIDE_END\n\n# STEP_START auth1\n# REMOVE_START\nr.config_set(\"requirepass\", \"temp_pass\")\n# REMOVE_END\nres1 = r.auth(password=\"temp_pass\")\nprint(res1) # >>> True\n\nres2 = r.auth(password=\"temp_pass\", username=\"default\")\nprint(res2) # >>> True\n\n# REMOVE_START\nassert res1 == True\nassert res2 == True\nr.config_set(\"requirepass\", \"\")\n# REMOVE_END\n# STEP_END\n\n# STEP_START auth2\n# REMOVE_START\nr.acl_setuser(\"test-user\", enabled=True, passwords=[\"+strong_password\"], commands=[\"+acl\"])\n# REMOVE_END\nres = r.auth(username=\"test-user\", password=\"strong_password\")\nprint(res) # >>> True\n\n# REMOVE_START\nassert res == True\nr.acl_deluser(\"test-user\")\n# REMOVE_END\n# STEP_END\n"
  },
  {
    "path": "doctests/cmds_generic.py",
    "content": "# EXAMPLE: cmds_generic\n# HIDE_START\nimport redis\n\nr = redis.Redis(decode_responses=True)\n# HIDE_END\n\n# STEP_START del\nres = r.set(\"key1\", \"Hello\")\nprint(res)\n# >>> True\n\nres = r.set(\"key2\", \"World\")\nprint(res)\n# >>> True\n\nres = r.delete(\"key1\", \"key2\", \"key3\")\nprint(res)\n# >>> 2\n# REMOVE_START\nassert res == 2\n# REMOVE_END\n# STEP_END\n\n# STEP_START expire\nres = r.set(\"mykey\", \"Hello\")\nprint(res)\n# >>> True\n\nres = r.expire(\"mykey\", 10)\nprint(res)\n# >>> True\n\nres = r.ttl(\"mykey\")\nprint(res)\n# >>> 10\n# REMOVE_START\nassert res == 10\n# REMOVE_END\n\nres = r.set(\"mykey\", \"Hello World\")\nprint(res)\n# >>> True\n\nres = r.ttl(\"mykey\")\nprint(res)\n# >>> -1\n# REMOVE_START\nassert res == -1\n# REMOVE_END\n\nres = r.expire(\"mykey\", 10, xx=True)\nprint(res)\n# >>> False\n# REMOVE_START\nassert res == False\n# REMOVE_END\n\nres = r.ttl(\"mykey\")\nprint(res)\n# >>> -1\n# REMOVE_START\nassert res == -1\n# REMOVE_END\n\nres = r.expire(\"mykey\", 10, nx=True)\nprint(res)\n# >>> True\n# REMOVE_START\nassert res == True\n# REMOVE_END\n\nres = r.ttl(\"mykey\")\nprint(res)\n# >>> 10\n# REMOVE_START\nassert res == 10\nr.delete(\"mykey\")\n# REMOVE_END\n# STEP_END\n\n# STEP_START ttl\nres = r.set(\"mykey\", \"Hello\")\nprint(res)\n# >>> True\n\nres = r.expire(\"mykey\", 10)\nprint(res)\n# >>> True\n\nres = r.ttl(\"mykey\")\nprint(res)\n# >>> 10\n# REMOVE_START\nassert res == 10\nr.delete(\"mykey\")\n# REMOVE_END\n# STEP_END\n\n# STEP_START scan1\nres = r.sadd(\"myset\", *set([1, 2, 3, \"foo\", \"foobar\", \"feelsgood\"]))\nprint(res)\n# >>> 6\n\nres = list(r.sscan_iter(\"myset\", match=\"f*\"))\nprint(res)\n# >>> ['foobar', 'foo', 'feelsgood']\n# REMOVE_START\nassert sorted(res) == sorted([\"foo\", \"foobar\", \"feelsgood\"])\nr.delete(\"myset\")\n# REMOVE_END\n# STEP_END\n\n# STEP_START scan2\n# REMOVE_START\nfor i in range(1, 1001):\n    r.set(f\"key:{i}\", i)\n# REMOVE_END\n\ncursor, key = r.scan(cursor=0, match='*11*')\nprint(cursor, key)\n\ncursor, key = r.scan(cursor, match='*11*')\nprint(cursor, key)\n\ncursor, key = r.scan(cursor, match='*11*')\nprint(cursor, key)\n\ncursor, key = r.scan(cursor, match='*11*')\nprint(cursor, key)\n\ncursor, keys = r.scan(cursor, match='*11*', count=1000)\nprint(cursor, keys)\n\n# REMOVE_START\nassert len(keys) == 18\ncursor = '0'\nprefix = \"key:*\"\nwhile cursor != 0:\n    cursor, keys = r.scan(cursor=cursor, match=prefix, count=1000)\n    if keys:\n        r.delete(*keys)\n# REMOVE_END\n# STEP_END\n\n# STEP_START scan3\nres = r.geoadd(\"geokey\", (0, 0, \"value\"))\nprint(res)\n# >>> 1\n\nres = r.zadd(\"zkey\", {\"value\": 1000})\nprint(res)\n# >>> 1\n\nres = r.type(\"geokey\")\nprint(res)\n# >>> zset\n# REMOVE_START\nassert res == \"zset\"\n# REMOVE_END\n\nres = r.type(\"zkey\")\nprint(res)\n# >>> zset\n# REMOVE_START\nassert res == \"zset\"\n# REMOVE_END\n\ncursor, keys = r.scan(cursor=0, _type=\"zset\")\nprint(keys)\n# >>> ['zkey', 'geokey']\n# REMOVE_START\nassert sorted(keys) == sorted([\"zkey\", \"geokey\"])\nr.delete(\"geokey\", \"zkey\")\n# REMOVE_END\n# STEP_END\n\n# STEP_START scan4\nres = r.hset(\"myhash\", mapping={\"a\": 1, \"b\": 2})\nprint(res)\n# >>> 2\n\ncursor, keys = r.hscan(\"myhash\", 0)\nprint(keys)\n# >>> {'a': '1', 'b': '2'}\n# REMOVE_START\nassert keys == {'a': '1', 'b': '2'}\n# REMOVE_END\n\ncursor, keys = r.hscan(\"myhash\", 0, no_values=True)\nprint(keys)\n# >>> ['a', 'b']\n# REMOVE_START\nassert keys == ['a', 'b']\n# REMOVE_END\n\n# REMOVE_START\nr.delete(\"myhash\")\n# REMOVE_END\n# STEP_END\n"
  },
  {
    "path": "doctests/cmds_hash.py",
    "content": "# EXAMPLE: cmds_hash\n# HIDE_START\nimport redis\n\nr = redis.Redis(host=\"localhost\", port=6379, db=0, decode_responses=True)\n# HIDE_END\n\n# STEP_START hset\nres1 = r.hset(\"myhash\", \"field1\", \"Hello\")\nprint(res1)\n# >>> 1\n\nres2 = r.hget(\"myhash\", \"field1\")\nprint(res2)\n# >>> Hello\n\nres3 = r.hset(\"myhash\", mapping={\"field2\": \"Hi\", \"field3\": \"World\"})\nprint(res3)\n# >>> 2\n\nres4 = r.hget(\"myhash\", \"field2\")\nprint(res4)\n# >>> Hi\n\nres5 = r.hget(\"myhash\", \"field3\")\nprint(res5)\n# >>> World\n\nres6 = r.hgetall(\"myhash\")\nprint(res6)\n# >>> { \"field1\": \"Hello\", \"field2\": \"Hi\", \"field3\": \"World\" }\n\n# REMOVE_START\nassert res1 == 1\nassert res2 == \"Hello\"\nassert res3 == 2\nassert res4 == \"Hi\"\nassert res5 == \"World\"\nassert res6 == { \"field1\": \"Hello\", \"field2\": \"Hi\", \"field3\": \"World\" }\nr.delete(\"myhash\")\n# REMOVE_END\n# STEP_END\n\n# STEP_START hget\nres7 = r.hset(\"myhash\", \"field1\", \"foo\")\nprint(res7)\n# >>> 1\n\nres8 = r.hget(\"myhash\", \"field1\")\nprint(res8)\n# >>> foo\n\nres9 = r.hget(\"myhash\", \"field2\")\nprint(res9)\n# >>> None\n\n# REMOVE_START\nassert res7 == 1\nassert res8 == \"foo\"\nassert res9 == None\nr.delete(\"myhash\")\n# REMOVE_END\n# STEP_END\n\n# STEP_START hgetall\nres10 = r.hset(\"myhash\", mapping={\"field1\": \"Hello\", \"field2\": \"World\"})\n\nres11 = r.hgetall(\"myhash\")\nprint(res11) # >>> { \"field1\": \"Hello\", \"field2\": \"World\" }\n\n# REMOVE_START\nassert res11 == { \"field1\": \"Hello\", \"field2\": \"World\" }\nr.delete(\"myhash\")\n# REMOVE_END\n# STEP_END\n\n# STEP_START hvals\nres10 = r.hset(\"myhash\", mapping={\"field1\": \"Hello\", \"field2\": \"World\"})\n\nres11 = r.hvals(\"myhash\")\nprint(res11) # >>> [ \"Hello\", \"World\" ]\n\n# REMOVE_START\nassert res11 == [ \"Hello\", \"World\" ]\nr.delete(\"myhash\")\n# REMOVE_END\n# STEP_END"
  },
  {
    "path": "doctests/cmds_list.py",
    "content": "# EXAMPLE: cmds_list\n# HIDE_START\nimport redis\n\nr = redis.Redis(decode_responses=True)\n# HIDE_END\n\n# STEP_START lpush\nres1 = r.lpush(\"mylist\", \"world\")\nprint(res1) # >>> 1\n\nres2 = r.lpush(\"mylist\", \"hello\")\nprint(res2) # >>> 2\n\nres3 = r.lrange(\"mylist\", 0, -1)\nprint(res3)  # >>> [ \"hello\", \"world\" ]\n\n# REMOVE_START\nassert res3 == [ \"hello\", \"world\" ]\nr.delete(\"mylist\")\n# REMOVE_END\n# STEP_END\n\n# STEP_START lrange\nres4 = r.rpush(\"mylist\", \"one\");\nprint(res4) # >>> 1\n\nres5 = r.rpush(\"mylist\", \"two\")\nprint(res5) # >>> 2\n\nres6 = r.rpush(\"mylist\", \"three\")\nprint(res6) # >>> 3\n\nres7 = r.lrange('mylist', 0, 0)\nprint(res7) # >>> [ 'one' ]\n\nres8 = r.lrange('mylist', -3, 2)\nprint(res8) # >>> [ 'one', 'two', 'three' ]\n\nres9 = r.lrange('mylist', -100, 100)\nprint(res9) # >>> [ 'one', 'two', 'three' ]\n\nres10 = r.lrange('mylist', 5, 10)\nprint(res10) # >>> []\n\n# REMOVE_START\nassert res7 == [ 'one' ]\nassert res8 == [ 'one', 'two', 'three' ]\nassert res9 == [ 'one', 'two', 'three' ]\nassert res10 == []\nr.delete('mylist')\n# REMOVE_END\n# STEP_END\n\n# STEP_START llen\nres11 = r.lpush(\"mylist\", \"World\")\nprint(res11) # >>> 1\n\nres12 = r.lpush(\"mylist\", \"Hello\")\nprint(res12) # >>> 2\n\nres13 = r.llen(\"mylist\")\nprint(res13)  # >>> 2\n\n# REMOVE_START\nassert res13 == 2\nr.delete(\"mylist\")\n# REMOVE_END\n# STEP_END\n\n# STEP_START rpush\nres14 = r.rpush(\"mylist\", \"hello\")\nprint(res14) # >>> 1\n\nres15 = r.rpush(\"mylist\", \"world\")\nprint(res15) # >>> 2\n\nres16 = r.lrange(\"mylist\", 0, -1)\nprint(res16)  # >>> [ \"hello\", \"world\" ]\n\n# REMOVE_START\nassert res16 == [ \"hello\", \"world\" ]\nr.delete(\"mylist\")\n# REMOVE_END\n# STEP_END\n\n# STEP_START lpop\nres17 = r.rpush(\"mylist\", *[\"one\", \"two\", \"three\", \"four\", \"five\"])\nprint(res17) # >>> 5\n\nres18 = r.lpop(\"mylist\")\nprint(res18) # >>> \"one\"\n\nres19 = r.lpop(\"mylist\", 2)\nprint(res19) # >>> ['two', 'three']\n\nres17 = r.lrange(\"mylist\", 0, -1)\nprint(res17)  # >>> [ \"four\", \"five\" ]\n\n# REMOVE_START\nassert res17 == [ \"four\", \"five\" ]\nr.delete(\"mylist\")\n# REMOVE_END\n# STEP_END\n\n# STEP_START rpop\nres18 = r.rpush(\"mylist\", *[\"one\", \"two\", \"three\", \"four\", \"five\"])\nprint(res18) # >>> 5\n\nres19 = r.rpop(\"mylist\")\nprint(res19) # >>> \"five\"\n\nres20 = r.rpop(\"mylist\", 2)\nprint(res20) # >>> ['four', 'three']\n\nres21 = r.lrange(\"mylist\", 0, -1)\nprint(res21)  # >>> [ \"one\", \"two\" ]\n\n# REMOVE_START\nassert res21 == [ \"one\", \"two\" ]\nr.delete(\"mylist\")\n# REMOVE_END\n# STEP_END"
  },
  {
    "path": "doctests/cmds_servermgmt.py",
    "content": "# EXAMPLE: cmds_servermgmt\n# HIDE_START\nimport redis\n\nr = redis.Redis(decode_responses=True)\n# HIDE_END\n\n# STEP_START flushall\n# REMOVE_START\nr.set(\"foo\", \"1\")\nr.set(\"bar\", \"2\")\nr.set(\"baz\", \"3\")\n# REMOVE_END\nres1 = r.flushall(asynchronous=False)\nprint(res1) # >>> True\n\nres2 = r.keys()\nprint(res2) # >>> []\n\n# REMOVE_START\nassert res1 == True\nassert res2 == []\n# REMOVE_END\n# STEP_END\n\n# STEP_START info\nres3 = r.info()\nprint(res3)\n# >>> {'redis_version': '7.4.0', 'redis_git_sha1': 'c9d29f6a',...}\n# STEP_END"
  },
  {
    "path": "doctests/cmds_set.py",
    "content": "# EXAMPLE: cmds_set\n# HIDE_START\nimport redis\n\nr = redis.Redis(decode_responses=True)\n# HIDE_END\n\n# STEP_START sadd\nres1 = r.sadd(\"myset\", \"Hello\", \"World\")\nprint(res1)  # >>> 2\n\nres2 = r.sadd(\"myset\", \"World\")\nprint(res2)  # >>> 0\n\nres3 = r.smembers(\"myset\")\nprint(res3)  # >>> {'Hello', 'World'}\n\n# REMOVE_START\nassert res3 == {'Hello', 'World'}\nr.delete('myset')\n# REMOVE_END\n# STEP_END\n\n# STEP_START smembers\nres4 = r.sadd(\"myset\", \"Hello\", \"World\")\nprint(res4)  # >>> 2\n\nres5 = r.smembers(\"myset\")\nprint(res5)  # >>> {'Hello', 'World'}\n\n# REMOVE_START\nassert res5 == {'Hello', 'World'}\nr.delete('myset')\n# REMOVE_END\n# STEP_END"
  },
  {
    "path": "doctests/cmds_sorted_set.py",
    "content": "# EXAMPLE: cmds_sorted_set\n# HIDE_START\nimport redis\n\nr = redis.Redis(host=\"localhost\", port=6379, db=0, decode_responses=True)\n# HIDE_END\n\n# STEP_START zadd\nres = r.zadd(\"myzset\", {\"one\": 1})\nprint(res)\n# >>> 1\n# REMOVE_START\nassert res == 1\n# REMOVE_END\n\nres = r.zadd(\"myzset\", {\"uno\": 1})\nprint(res)\n# >>> 1\n# REMOVE_START\nassert res == 1\n# REMOVE_END\n\nres = r.zadd(\"myzset\", {\"two\": 2, \"three\": 3})\nprint(res)\n# >>> 2\n# REMOVE_START\nassert res == 2\n# REMOVE_END\n\nres = r.zrange(\"myzset\", 0, -1, withscores=True)\n# >>> [('one', 1.0), ('uno', 1.0), ('two', 2.0), ('three', 3.0)]\n# REMOVE_START\nassert res == [('one', 1.0), ('uno', 1.0), ('two', 2.0), ('three', 3.0)]\n# REMOVE_END\n\n# REMOVE_START\nr.delete(\"myzset\")\n# REMOVE_END\n# STEP_END\n\n# STEP_START zrange1\nres = r.zadd(\"myzset\", {\"one\": 1, \"two\":2, \"three\":3})\nprint(res)\n# >>> 3\n\nres = r.zrange(\"myzset\", 0, -1)\nprint(res)\n# >>> ['one', 'two', 'three']\n# REMOVE_START\nassert res == ['one', 'two', 'three']\n# REMOVE_END\n\nres = r.zrange(\"myzset\", 2, 3)\nprint(res)\n# >>> ['three']\n# REMOVE_START\nassert res == ['three']\n# REMOVE_END\n\nres = r.zrange(\"myzset\", -2, -1)\nprint(res)\n# >>> ['two', 'three']\n# REMOVE_START\nassert res == ['two', 'three']\nr.delete(\"myzset\")\n# REMOVE_END\n# STEP_END\n\n# STEP_START zrange2\nres = r.zadd(\"myzset\", {\"one\": 1, \"two\":2, \"three\":3})\nres = r.zrange(\"myzset\", 0, 1, withscores=True)\nprint(res)\n# >>> [('one', 1.0), ('two', 2.0)]\n# REMOVE_START\nassert res == [('one', 1.0), ('two', 2.0)]\nr.delete(\"myzset\")\n# REMOVE_END\n# STEP_END\n\n# STEP_START zrange3\nres = r.zadd(\"myzset\", {\"one\": 1, \"two\":2, \"three\":3})\nres = r.zrange(\"myzset\", 2, 3, byscore=True, offset=1, num=1)\nprint(res)\n# >>> ['three']\n# REMOVE_START\nassert res == ['three']\nr.delete(\"myzset\")\n# REMOVE_END\n# STEP_END\n"
  },
  {
    "path": "doctests/cmds_string.py",
    "content": "# EXAMPLE: cmds_string\n# HIDE_START\nimport redis\n\nr = redis.Redis(host=\"localhost\", port=6379, db=0, decode_responses=True)\n# HIDE_END\n\n# STEP_START incr\nres = r.set(\"mykey\", \"10\")\nprint(res)\n# >>> True\nres = r.incr(\"mykey\")\nprint(res)\n# >>> 11\n# REMOVE_START\nassert res == 11\nr.delete(\"mykey\")\n# REMOVE_END\n# STEP_END\n"
  },
  {
    "path": "doctests/data/query_em.json",
    "content": "[\n  {\n\t  \"pickup_zone\": \"POLYGON((-74.0610 40.7578, -73.9510 40.7578, -73.9510 40.6678, -74.0610 40.6678, -74.0610 40.7578))\",\n\t  \"store_location\": \"-74.0060,40.7128\",\n\t  \"brand\": \"Velorim\",\n\t  \"model\": \"Jigger\",\n\t  \"price\": 270,\n\t  \"description\": \"Small and powerful, the Jigger is the best ride for the smallest of tikes! This is the tiniest kids’ pedal bike on the market available without a coaster brake, the Jigger is the vehicle of choice for the rare tenacious little rider raring to go.\",\n\t  \"condition\": \"new\"\n  },\n  {\n    \"pickup_zone\": \"POLYGON((-118.2887 34.0972, -118.1987 34.0972, -118.1987 33.9872, -118.2887 33.9872, -118.2887 34.0972))\",\n\t  \"store_location\": \"-118.2437,34.0522\",\n\t  \"brand\": \"Bicyk\",\n\t  \"model\": \"Hillcraft\",\n\t  \"price\": 1200,\n\t  \"description\": \"Kids want to ride with as little weight as possible. Especially on an incline! They may be at the age when a 27.5\\\" wheel bike is just too clumsy coming off a 24\\\" bike. The Hillcraft 26 is just the solution they need!\",\n\t  \"condition\": \"used\"\n  },\n  {\n    \"pickup_zone\": \"POLYGON((-87.6848 41.9331, -87.5748 41.9331, -87.5748 41.8231, -87.6848 41.8231, -87.6848 41.9331))\",\n\t  \"store_location\": \"-87.6298,41.8781\",\n  \t\"brand\": \"Nord\",\n  \t\"model\": \"Chook air 5\",\n  \t\"price\": 815,\n  \t\"description\": \"The Chook Air 5  gives kids aged six years and older a durable and uberlight mountain bike for their first experience on tracks and easy cruising through forests and fields. The lower  top tube makes it easy to mount and dismount in any situation, giving your kids greater safety on the trails.\",\n  \t\"condition\": \"used\"\n  },\n  {\n    \"pickup_zone\": \"POLYGON((-80.2433 25.8067, -80.1333 25.8067, -80.1333 25.6967, -80.2433 25.6967, -80.2433 25.8067))\",\n  \t\"store_location\": \"-80.1918,25.7617\",\n  \t\"brand\": \"Eva\",\n  \t\"model\": \"Eva 291\",\n  \t\"price\": 3400,\n  \t\"description\": \"The sister company to Nord, Eva launched in 2005 as the first and only women-dedicated bicycle brand. Designed by women for women, allEva bikes are optimized for the feminine physique using analytics from a body metrics database. If you like 29ers, try the Eva 291. It’s a brand new bike for 2022.. This full-suspension, cross-country ride has been designed for velocity. The 291 has 100mm of front and rear travel, a superlight aluminum frame and fast-rolling 29-inch wheels. Yippee!\",\n  \t\"condition\": \"used\"\n  },\n  {\n    \"pickup_zone\": \"POLYGON((-122.4644 37.8199, -122.3544 37.8199, -122.3544 37.7099, -122.4644 37.7099, -122.4644 37.8199))\",\n  \t\"store_location\": \"-122.4194,37.7749\",\n  \t\"brand\": \"Noka Bikes\",\n  \t\"model\": \"Kahuna\",\n  \t\"price\": 3200,\n  \t\"description\": \"Whether you want to try your hand at XC racing or are looking for a lively trail bike that's just as inspiring on the climbs as it is over rougher ground, the Wilder is one heck of a bike built specifically for short women. Both the frames and components have been tweaked to include a women’s saddle, different bars and unique colourway.\",\n  \t\"condition\": \"used\"\n  },\n  {\n    \"pickup_zone\": \"POLYGON((-0.1778 51.5524, 0.0822 51.5524, 0.0822 51.4024, -0.1778 51.4024, -0.1778 51.5524))\",\n    \"store_location\": \"-0.1278,51.5074\",\n    \"brand\": \"Breakout\",\n    \"model\": \"XBN 2.1 Alloy\",\n    \"price\": 810,\n    \"description\": \"The XBN 2.1 Alloy is our entry-level road bike – but that’s not to say that it’s a basic machine. With an internal weld aluminium frame, a full carbon fork, and the slick-shifting Claris gears from Shimano’s, this is a bike which doesn’t break the bank and delivers craved performance.\",\n    \"condition\": \"new\"\n  },\n  {\n    \"pickup_zone\": \"POLYGON((2.1767 48.9016, 2.5267 48.9016, 2.5267 48.5516, 2.1767 48.5516, 2.1767 48.9016))\",\n    \"store_location\": \"2.3522,48.8566\",\n    \"brand\": \"ScramBikes\",\n    \"model\": \"WattBike\",\n    \"price\": 2300,\n    \"description\": \"The WattBike is the best e-bike for people who still feel young at heart. It has a Bafang 1000W mid-drive system and a 48V 17.5AH Samsung Lithium-Ion battery, allowing you to ride for more than 60 miles on one charge. It’s great for tackling hilly terrain or if you just fancy a more leisurely ride. With three working modes, you can choose between E-bike, assisted bicycle, and normal bike modes.\",\n    \"condition\": \"new\"\n  },\n  {\n    \"pickup_zone\": \"POLYGON((13.3260 52.5700, 13.6550 52.5700, 13.6550 52.2700, 13.3260 52.2700, 13.3260 52.5700))\",\n    \"store_location\": \"13.4050,52.5200\",\n    \"brand\": \"Peaknetic\",\n    \"model\": \"Secto\",\n    \"price\": 430,\n    \"description\": \"If you struggle with stiff fingers or a kinked neck or back after a few minutes on the road, this lightweight, aluminum bike alleviates those issues and allows you to enjoy the ride. From the ergonomic grips to the lumbar-supporting seat position, the Roll Low-Entry offers incredible comfort. The rear-inclined seat tube facilitates stability by allowing you to put a foot on the ground to balance at a stop, and the low step-over frame makes it accessible for all ability and mobility levels. The saddle is very soft, with a wide back to support your hip joints and a cutout in the center to redistribute that pressure. Rim brakes deliver satisfactory braking control, and the wide tires provide a smooth, stable ride on paved roads and gravel. Rack and fender mounts facilitate setting up the Roll Low-Entry as your preferred commuter, and the BMX-like handlebar offers space for mounting a flashlight, bell, or phone holder.\",\n    \"condition\": \"new\"\n  },\n  {\n    \"pickup_zone\": \"POLYGON((1.9450 41.4301, 2.4018 41.4301, 2.4018 41.1987, 1.9450 41.1987, 1.9450 41.4301))\",\n    \"store_location\": \"2.1734, 41.3851\",\n    \"brand\": \"nHill\",\n    \"model\": \"Summit\",\n    \"price\": 1200,\n    \"description\": \"This budget mountain bike from nHill performs well both on bike paths and on the trail. The fork with 100mm of travel absorbs rough terrain. Fat Kenda Booster tires give you grip in corners and on wet trails. The Shimano Tourney drivetrain offered enough gears for finding a comfortable pace to ride uphill, and the Tektro hydraulic disc brakes break smoothly. Whether you want an affordable bike that you can take to work, but also take trail in mountains on the weekends or you’re just after a stable, comfortable ride for the bike path, the Summit gives a good value for money.\",\n    \"condition\": \"new\"\n  },\n  {\n    \"pickup_zone\": \"POLYGON((12.4464 42.1028, 12.5464 42.1028, 12.5464 41.7028, 12.4464 41.7028, 12.4464 42.1028))\",\n    \"store_location\": \"12.4964,41.9028\",\n    \"model\": \"ThrillCycle\",\n    \"brand\": \"BikeShind\",\n    \"price\": 815,\n    \"description\": \"An artsy,  retro-inspired bicycle that’s as functional as it is pretty: The ThrillCycle steel frame offers a smooth ride. A 9-speed drivetrain has enough gears for coasting in the city, but we wouldn’t suggest taking it to the mountains. Fenders protect you from mud, and a rear basket lets you transport groceries, flowers and books. The ThrillCycle comes with a limited lifetime warranty, so this little guy will last you long past graduation.\",\n    \"condition\": \"refurbished\"\n  }\n]\n"
  },
  {
    "path": "doctests/data/query_vector.json",
    "content": "[\n  {\n    \"model\": \"Jigger\",\n    \"brand\": \"Velorim\",\n    \"price\": 270,\n    \"type\": \"Kids bikes\",\n    \"specs\": {\n      \"material\": \"aluminium\",\n      \"weight\": \"10\"\n    },\n    \"description\": \"Small and powerful, the Jigger is the best ride for the smallest of tikes! This is the tiniest kids’ pedal bike on the market available without a coaster brake, the Jigger is the vehicle of choice for the rare tenacious little rider raring to go. We say rare because this smokin’ little bike is not ideal for a nervous first-time rider, but it’s a true giddy up for a true speedster. The Jigger is a 12 inch lightweight kids bicycle and it will meet your little one’s need for speed. It’s a single speed bike that makes learning to pump pedals simple and intuitive. It even has  a handle in the bottom of the saddle so you can easily help your child during training!  The Jigger is among the most lightweight children’s bikes on the planet. It is designed so that 2-3 year-olds fit comfortably in a molded ride position that allows for efficient riding, balanced handling and agility. The Jigger’s frame design and gears work together so your buddingbiker can stand up out of the seat, stop rapidly, rip over trails and pump tracks. The Jigger’s is amazing on dirt or pavement. Your tike will speed down the bike path in no time. The Jigger will ship with a coaster brake. A freewheel kit is provided at no cost. \"\n  },\n  {\n    \"model\": \"Hillcraft\",\n    \"brand\": \"Bicyk\",\n    \"price\": 1200,\n    \"type\": \"Kids Mountain Bikes\",\n    \"specs\": {\n      \"material\": \"carbon\",\n      \"weight\": \"11\"\n    },\n    \"description\": \"Kids want to ride with as little weight as possible. Especially on an incline! They may be at the age when a 27.5\\\" wheel bike is just too clumsy coming off a 24\\\" bike. The Hillcraft 26 is just the solution they need! Imagine 120mm travel. Boost front/rear.  You have NOTHING to tweak because it is easy to assemble right out of the box. The Hillcraft 26 is an efficient trail trekking machine. Up or down does not matter - dominate the trails going both down and up with this amazing bike. The name Monarch comes from Monarch trail in Colorado where we love to ride.  It’s a highly technical, steep and rocky trail but the rip on the waydown is so fulfilling.  Don’t ride the trail on a hardtail! It is so much more fun on the full suspension Hillcraft!  Hit your local trail with the Hillcraft Monarch 26 to get to where you want to go.  \"\n  },\n  {\n    \"model\": \"Chook air 5\",\n    \"brand\": \"Nord\",\n    \"price\": 815,\n    \"type\": \"Kids Mountain Bikes\",\n    \"specs\": {\n      \"material\": \"alloy\",\n      \"weight\": \"9.1\"\n    },\n    \"description\": \"The Chook Air 5  gives kids aged six years and older a durable and uberlight mountain bike for their first experience on tracks and easy cruising through forests and fields. The lower  top tube makes it easy to mount and dismount in any situation, giving your kids greater safety on the trails. The Chook Air 5 is the perfect intro to mountain biking.\"\n  },\n  {\n    \"model\": \"Eva 291\",\n    \"brand\": \"Eva\",\n    \"price\": 3400,\n    \"type\": \"Mountain Bikes\",\n    \"specs\": {\n      \"material\": \"carbon\",\n      \"weight\": \"9.1\"\n    },\n    \"description\": \"The sister company to Nord, Eva launched in 2005 as the first and only women-dedicated bicycle brand. Designed by women for women, allEva bikes are optimized for the feminine physique using analytics from a body metrics database. If you like 29ers, try the Eva 291. It’s a brand new bike for 2022.. This full-suspension, cross-country ride has been designed for velocity. The 291 has 100mm of front and rear travel, a superlight aluminum frame and fast-rolling 29-inch wheels. Yippee!\"\n  },\n  {\n    \"model\": \"Kahuna\",\n    \"brand\": \"Noka Bikes\",\n    \"price\": 3200,\n    \"type\": \"Mountain Bikes\",\n    \"specs\": {\n      \"material\": \"alloy\",\n      \"weight\": \"9.8\"\n    },\n    \"description\": \"Whether you want to try your hand at XC racing or are looking for a lively trail bike that's just as inspiring on the climbs as it is over rougher ground, the Wilder is one heck of a bike built specifically for short women. Both the frames and components have been tweaked to include a women’s saddle, different bars and unique colourway.\"\n  },\n  {\n    \"model\": \"XBN 2.1 Alloy\",\n    \"brand\": \"Breakout\",\n    \"price\": 810,\n    \"type\": \"Road Bikes\",\n    \"specs\": {\n      \"material\": \"alloy\",\n      \"weight\": \"7.2\"\n    },\n    \"description\": \"The XBN 2.1 Alloy is our entry-level road bike – but that’s not to say that it’s a basic machine. With an internal weld aluminium frame, a full carbon fork, and the slick-shifting Claris gears from Shimano’s, this is a bike which doesn’t break the bank and delivers craved performance. The 6061 alloy frame is triple-butted which ensures a lighter weight and smoother ride. And it’s comfortable with dropped seat stays and the carbon fork. The carefully crafted 50-34 tooth chainset and 11-32 tooth cassette give an easy-on-the-legs bottom gear for climbing, and the high-quality Vittoria Zaffiro tires balance grip, rolling friction and puncture protection when coasting down the other side.  \"\n  },\n  {\n    \"model\": \"WattBike\",\n    \"brand\": \"ScramBikes\",\n    \"price\": 2300,\n    \"type\": \"eBikes\",\n    \"specs\": {\n      \"material\": \"alloy\",\n      \"weight\": \"15\"\n    },\n    \"description\": \"The WattBike is the best e-bike for people who still feel young at heart. It has a  Bafang 500 watt geared hub motor that can reach 20 miles per hour on both steep inclines and city streets. The lithium-ion battery, which gets nearly 40 miles per charge, has a lightweight form factor, making it easier for seniors to use. It comes fully assembled (no convoluted instructions!) and includes a sturdy helmet at no cost. The Plush saddle softens over time with use. The included Seatpost, however, is easily adjustable and adds to this bike’s fantastic rating for seniors, as do the hydraulic disc brakes from Tektro.  \"\n  },\n  {\n    \"model\": \"Soothe Electric bike\",\n    \"brand\": \"Peaknetic\",\n    \"price\": 1950,\n    \"type\": \"eBikes\",\n    \"specs\": {\n      \"material\": \"alloy\",\n      \"weight\": \"14.7\"\n    },\n    \"description\": \"The Soothe is an everyday electric bike, from the makers of Exercycle  bikes, that conveys style while you get around the city. The Soothe lives up to its name by keeping your posture upright and relaxed for the ride ahead, keeping those aches and pains from riding at bay. It includes a low-step frame , our memory foam seat, bump-resistant shocks and conveniently placed thumb throttle. \"\n  },\n  {\n    \"model\": \"Secto\",\n    \"brand\": \"Peaknetic\",\n    \"price\": 430,\n    \"type\": \"Commuter bikes\",\n    \"specs\": {\n      \"material\": \"aluminium\",\n      \"weight\": \"10.0\"\n    },\n    \"description\": \"If you struggle with stiff fingers or a kinked neck or back after a few minutes on the road, this lightweight, aluminum bike alleviates those issues and allows you to enjoy the ride. From the ergonomic grips to the lumbar-supporting seat position, the Roll Low-Entry offers incredible comfort. The rear-inclined seat tube facilitates stability by allowing you to put a foot on the ground to balance at a stop, and the low step-over frame makes it accessible for all ability and mobility levels. The saddle is very soft, with a wide back to support your hip joints and a cutout in the center to redistribute that pressure. Rim brakes deliver satisfactory braking control, and the wide tires provide a smooth, stable ride on paved roads and gravel. Rack and fender mounts facilitate setting up the Roll Low-Entry as your preferred commuter, and the BMX-like handlebar offers space for mounting a flashlight, bell, or phone holder.\"\n  },\n  {\n    \"model\": \"Summit\",\n    \"brand\": \"nHill\",\n    \"price\": 1200,\n    \"type\": \"Mountain Bike\",\n    \"specs\": {\n      \"material\": \"alloy\",\n      \"weight\": \"11.3\"\n    },\n    \"description\": \"This budget mountain bike from nHill performs well both on bike paths and on the trail. The fork with 100mm of travel absorbs rough terrain. Fat Kenda Booster tires give you grip in corners and on wet trails. The Shimano Tourney drivetrain offered enough gears for finding a comfortable pace to ride uphill, and the Tektro hydraulic disc brakes break smoothly. Whether you want an affordable bike that you can take to work, but also take trail riding on the weekends or you’re just after a stable, comfortable ride for the bike path, the Summit gives a good value for money.\"\n  },\n  {\n    \"model\": \"ThrillCycle\",\n    \"brand\": \"BikeShind\",\n    \"price\": 815,\n    \"type\": \"Commuter Bikes\",\n    \"specs\": {\n      \"material\": \"alloy\",\n      \"weight\": \"12.7\"\n    },\n    \"description\": \"An artsy,  retro-inspired bicycle that’s as functional as it is pretty: The ThrillCycle steel frame offers a smooth ride. A 9-speed drivetrain has enough gears for coasting in the city, but we wouldn’t suggest taking it to the mountains. Fenders protect you from mud, and a rear basket lets you transport groceries, flowers and books. The ThrillCycle comes with a limited lifetime warranty, so this little guy will last you long past graduation.\"\n  }\n]"
  },
  {
    "path": "doctests/dt_bitfield.py",
    "content": "# EXAMPLE: bitfield_tutorial\n# HIDE_START\n\"\"\"\nCode samples for Bitfield doc pages:\n    https://redis.io/docs/latest/develop/data-types/bitfields/\n\"\"\"\nimport redis\n\nr = redis.Redis(decode_responses=True)\n# HIDE_END\n\n# REMOVE_START\nr.delete(\"bike:1:stats\")\n# REMOVE_END\n\n# STEP_START bf\nbf = r.bitfield(\"bike:1:stats\")\nres1 = bf.set(\"u32\", \"#0\", 1000).execute()\nprint(res1)  # >>> [0]\n\nres2 = bf.incrby(\"u32\", \"#0\", -50).incrby(\"u32\", \"#1\", 1).execute()\nprint(res2)  # >>> [950, 1]\n\nres3 = bf.incrby(\"u32\", \"#0\", 500).incrby(\"u32\", \"#1\", 1).execute()\nprint(res3)  # >>> [1450, 2]\n\nres4 = bf.get(\"u32\", \"#0\").get(\"u32\", \"#1\").execute()\nprint(res4)  # >>> [1450, 2]\n# STEP_END\n\n# REMOVE_START\nassert res1 == [0]\nassert res4 == [1450, 2]\n# REMOVE_END\n"
  },
  {
    "path": "doctests/dt_bitmap.py",
    "content": "# EXAMPLE: bitmap_tutorial\n# HIDE_START\n\"\"\"\nCode samples for Bitmap doc pages:\n    https://redis.io/docs/latest/develop/data-types/bitmaps/\n\"\"\"\nimport redis\n\n# Connect without the usual `decode_responses=True` to\n# see the binary values in the responses more easily.\nr = redis.Redis()\n# HIDE_END\n\n# REMOVE_START\nr.delete(\"pings:2024-01-01-00:00\", \"A\", \"B\", \"C\", \"R\")\n# REMOVE_END\n\n# STEP_START ping\nres1 = r.setbit(\"pings:2024-01-01-00:00\", 123, 1)\nprint(res1)  # >>> 0\n\nres2 = r.getbit(\"pings:2024-01-01-00:00\", 123)\nprint(res2)  # >>> 1\n\nres3 = r.getbit(\"pings:2024-01-01-00:00\", 456)\nprint(res3)  # >>> 0\n# STEP_END\n\n# REMOVE_START\nassert res1 == 0\n# REMOVE_END\n\n# STEP_START bitcount\n# HIDE_START\nr.setbit(\"pings:2024-01-01-00:00\", 123, 1)\n# HIDE_END\nres4 = r.bitcount(\"pings:2024-01-01-00:00\")\nprint(res4)  # >>> 1\n# STEP_END\n# REMOVE_START\nassert res4 == 1\n# REMOVE_END\n\n# STEP_START bitop_setup\nr.setbit(\"A\", 0, 1)\nr.setbit(\"A\", 1, 1)\nr.setbit(\"A\", 3, 1)\nr.setbit(\"A\", 4, 1)\n\nres5 = r.get(\"A\")\nprint(\"{:08b}\".format(int.from_bytes(res5, \"big\")))\n# >>> 11011000\n\nr.setbit(\"B\", 3, 1)\nr.setbit(\"B\", 4, 1)\nr.setbit(\"B\", 7, 1)\n\nres6 = r.get(\"B\")\nprint(\"{:08b}\".format(int.from_bytes(res6, \"big\")))\n# >>> 00011001\n\nr.setbit(\"C\", 1, 1)\nr.setbit(\"C\", 2, 1)\nr.setbit(\"C\", 4, 1)\nr.setbit(\"C\", 5, 1)\n\nres7 = r.get(\"C\")\nprint(\"{:08b}\".format(int.from_bytes(res7, \"big\")))\n# >>> 01101100\n# STEP_END\n# REMOVE_START\nassert int.from_bytes(res5, \"big\") == 0b11011000\nassert int.from_bytes(res6, \"big\") == 0b00011001\nassert int.from_bytes(res7, \"big\") == 0b01101100\n# REMOVE_END\n\n# STEP_START bitop_and\nr.bitop(\"AND\", \"R\", \"A\", \"B\", \"C\")\nres8 = r.get(\"R\")\nprint(\"{:08b}\".format(int.from_bytes(res8, \"big\")))\n# >>> 00001000\n# STEP_END\n# REMOVE_START\nassert int.from_bytes(res8, \"big\") == 0b00001000\n# REMOVE_END\n\n# STEP_START bitop_or\nr.bitop(\"OR\", \"R\", \"A\", \"B\", \"C\")\nres9 = r.get(\"R\")\nprint(\"{:08b}\".format(int.from_bytes(res9, \"big\")))\n# >>> 11111101\n# STEP_END\n# REMOVE_START\nassert int.from_bytes(res9, \"big\") == 0b11111101\n# REMOVE_END\n\n# STEP_START bitop_xor\nr.bitop(\"XOR\", \"R\", \"A\", \"B\")\nres10 = r.get(\"R\")\nprint(\"{:08b}\".format(int.from_bytes(res10, \"big\")))\n# >>> 11000001\n# STEP_END\n# REMOVE_START\nassert int.from_bytes(res10, \"big\") == 0b11000001\n# REMOVE_END\n\n# STEP_START bitop_not\nr.bitop(\"NOT\", \"R\", \"A\")\nres11 = r.get(\"R\")\nprint(\"{:08b}\".format(int.from_bytes(res11, \"big\")))\n# >>> 00100111\n# STEP_END\n# REMOVE_START\nassert int.from_bytes(res11, \"big\") == 0b00100111\n# REMOVE_END\n\n# STEP_START bitop_diff\nr.bitop(\"DIFF\", \"R\", \"A\", \"B\", \"C\")\nres12 = r.get(\"R\")\nprint(\"{:08b}\".format(int.from_bytes(res12, \"big\")))\n# >>> 10000000\n# STEP_END\n# REMOVE_START\nassert int.from_bytes(res12, \"big\") == 0b10000000\n# REMOVE_END\n\n# STEP_START bitop_diff1\nr.bitop(\"DIFF1\", \"R\", \"A\", \"B\", \"C\")\nres13 = r.get(\"R\")\nprint(\"{:08b}\".format(int.from_bytes(res13, \"big\")))\n# >>> 00100101\n# STEP_END\n# REMOVE_START\nassert int.from_bytes(res13, \"big\") == 0b00100101\n# REMOVE_END\n\n# STEP_START bitop_andor\nr.bitop(\"ANDOR\", \"R\", \"A\", \"B\", \"C\")\nres14 = r.get(\"R\")\nprint(\"{:08b}\".format(int.from_bytes(res14, \"big\")))\n# >>> 01011000\n# STEP_END\n# REMOVE_START\nassert int.from_bytes(res14, \"big\") == 0b01011000\n# REMOVE_END\n\n# STEP_START bitop_one\nr.bitop(\"ONE\", \"R\", \"A\", \"B\", \"C\")\nres15 = r.get(\"R\")\nprint(\"{:08b}\".format(int.from_bytes(res15, \"big\")))\n# >>> 10100101\n# STEP_END\n# REMOVE_START\nassert int.from_bytes(res15, \"big\") == 0b10100101\n# REMOVE_END\n"
  },
  {
    "path": "doctests/dt_bloom.py",
    "content": "# EXAMPLE: bf_tutorial\n# HIDE_START\n\"\"\"\nCode samples for Bloom filter doc pages:\n    https://redis.io/docs/latest/develop/data-types/probabilistic/bloom-filter/\n\"\"\"\nimport redis\n\nr = redis.Redis(decode_responses=True)\n# HIDE_END\n\n# STEP_START bloom\nres1 = r.bf().reserve(\"bikes:models\", 0.01, 1000)\nprint(res1)  # >>> True\n\nres2 = r.bf().add(\"bikes:models\", \"Smoky Mountain Striker\")\nprint(res2)  # >>> True\n\nres3 = r.bf().exists(\"bikes:models\", \"Smoky Mountain Striker\")\nprint(res3)  # >>> True\n\nres4 = r.bf().madd(\n    \"bikes:models\",\n    \"Rocky Mountain Racer\",\n    \"Cloudy City Cruiser\",\n    \"Windy City Wippet\",\n)\nprint(res4)  # >>> [True, True, True]\n\nres5 = r.bf().mexists(\n    \"bikes:models\",\n    \"Rocky Mountain Racer\",\n    \"Cloudy City Cruiser\",\n    \"Windy City Wippet\",\n)\nprint(res5)  # >>> [True, True, True]\n# STEP_END\n\n# REMOVE_START\nassert res1 is True\n# REMOVE_END\n"
  },
  {
    "path": "doctests/dt_cms.py",
    "content": "# EXAMPLE: cms_tutorial\n# HIDE_START\n\"\"\"\nCode samples for Count-min sketch doc pages:\n    https://redis.io/docs/latest/develop/data-types/probabilistic/count-min-sketch/\n\"\"\"\nimport redis\n\nr = redis.Redis(decode_responses=True)\n# HIDE_END\n# REMOVE_START\nr.delete(\"bikes:profit\")\n# REMOVE_END\n\n# STEP_START cms\nres1 = r.cms().initbyprob(\"bikes:profit\", 0.001, 0.002)\nprint(res1)  # >>> True\n\nres2 = r.cms().incrby(\"bikes:profit\", [\"Smoky Mountain Striker\"], [100])\nprint(res2)  # >>> [100]\n\nres3 = r.cms().incrby(\n    \"bikes:profit\", [\"Rocky Mountain Racer\", \"Cloudy City Cruiser\"], [200, 150]\n)\nprint(res3)  # >>> [200, 150]\n\nres4 = r.cms().query(\"bikes:profit\", \"Smoky Mountain Striker\")\nprint(res4)  # >>> [100]\n\nres5 = r.cms().info(\"bikes:profit\")\nprint(res5.width, res5.depth, res5.count)  # >>> 2000 9 450\n# STEP_END\n"
  },
  {
    "path": "doctests/dt_cuckoo.py",
    "content": "# EXAMPLE: cuckoo_tutorial\n# HIDE_START\n\"\"\"\nCode samples for Cuckoo filter doc pages:\n    https://redis.io/docs/latest/develop/data-types/probabilistic/cuckoo-filter/\n\"\"\"\nimport redis\n\nr = redis.Redis(decode_responses=True)\n# HIDE_END\n\n# REMOVE_START\nr.delete(\"bikes:models\")\n# REMOVE_END\n\n# STEP_START cuckoo\nres1 = r.cf().reserve(\"bikes:models\", 1000000)\nprint(res1)  # >>> True\n\nres2 = r.cf().add(\"bikes:models\", \"Smoky Mountain Striker\")\nprint(res2)  # >>> 1\n\nres3 = r.cf().exists(\"bikes:models\", \"Smoky Mountain Striker\")\nprint(res3)  # >>> 1\n\nres4 = r.cf().exists(\"bikes:models\", \"Terrible Bike Name\")\nprint(res4)  # >>> 0\n\nres5 = r.cf().delete(\"bikes:models\", \"Smoky Mountain Striker\")\nprint(res5)  # >>> 1\n# STEP_END\n\n# REMOVE_START\nassert res1 is True\nassert res5 == 1\n# REMOVE_END\n"
  },
  {
    "path": "doctests/dt_geo.py",
    "content": "# EXAMPLE: geo_tutorial\n# HIDE_START\n\"\"\"\nCode samples for Geospatial doc pages:\n    https://redis.io/docs/latest/develop/data-types/geospatial/\n\"\"\"\nimport redis\n\nr = redis.Redis(decode_responses=True)\n# HIDE_END\n# REMOVE_START\nr.delete(\"bikes:rentable\")\n# REMOVE_END\n\n# STEP_START geoadd\nres1 = r.geoadd(\"bikes:rentable\", [-122.27652, 37.805186, \"station:1\"])\nprint(res1)  # >>> 1\n\nres2 = r.geoadd(\"bikes:rentable\", [-122.2674626, 37.8062344, \"station:2\"])\nprint(res2)  # >>> 1\n\nres3 = r.geoadd(\"bikes:rentable\", [-122.2469854, 37.8104049, \"station:3\"])\nprint(res3)  # >>> 1\n# STEP_END\n\n# REMOVE_START\nassert res1 == 1\nassert res2 == 1\nassert res3 == 1\n# REMOVE_END\n\n# STEP_START geosearch\nres4 = r.geosearch(\n    \"bikes:rentable\",\n    longitude=-122.27652,\n    latitude=37.805186,\n    radius=5,\n    unit=\"km\",\n)\nprint(res4)  # >>> ['station:1', 'station:2', 'station:3']\n# STEP_END\n\n# REMOVE_START\nassert res4 == [\"station:1\", \"station:2\", \"station:3\"]\n# REMOVE_END\n"
  },
  {
    "path": "doctests/dt_hash.py",
    "content": "# EXAMPLE: hash_tutorial\n# HIDE_START\n\"\"\"\nCode samples for Hash doc pages:\n    https://redis.io/docs/latest/develop/data-types/hashes/\n\"\"\"\nimport redis\n\nr = redis.Redis(decode_responses=True)\n# HIDE_END\n# STEP_START set_get_all\nres1 = r.hset(\n    \"bike:1\",\n    mapping={\n        \"model\": \"Deimos\",\n        \"brand\": \"Ergonom\",\n        \"type\": \"Enduro bikes\",\n        \"price\": 4972,\n    },\n)\nprint(res1)\n# >>> 4\n\nres2 = r.hget(\"bike:1\", \"model\")\nprint(res2)\n# >>> 'Deimos'\n\nres3 = r.hget(\"bike:1\", \"price\")\nprint(res3)\n# >>> '4972'\n\nres4 = r.hgetall(\"bike:1\")\nprint(res4)\n# >>> {'model': 'Deimos', 'brand': 'Ergonom', 'type': 'Enduro bikes', 'price': '4972'}\n\n# STEP_END\n\n# REMOVE_START\nassert res1 == 4\nassert res2 == \"Deimos\"\nassert res3 == \"4972\"\nassert res4 == {\n    \"model\": \"Deimos\",\n    \"brand\": \"Ergonom\",\n    \"type\": \"Enduro bikes\",\n    \"price\": \"4972\",\n}\n# REMOVE_END\n\n# STEP_START hmget\nres5 = r.hmget(\"bike:1\", [\"model\", \"price\"])\nprint(res5)\n# >>> ['Deimos', '4972']\n# STEP_END\n\n# REMOVE_START\nassert res5 == [\"Deimos\", \"4972\"]\n# REMOVE_END\n\n# STEP_START hincrby\nres6 = r.hincrby(\"bike:1\", \"price\", 100)\nprint(res6)\n# >>> 5072\nres7 = r.hincrby(\"bike:1\", \"price\", -100)\nprint(res7)\n# >>> 4972\n# STEP_END\n\n# REMOVE_START\nassert res6 == 5072\nassert res7 == 4972\n# REMOVE_END\n\n\n# STEP_START incrby_get_mget\nres11 = r.hincrby(\"bike:1:stats\", \"rides\", 1)\nprint(res11)\n# >>> 1\nres12 = r.hincrby(\"bike:1:stats\", \"rides\", 1)\nprint(res12)\n# >>> 2\nres13 = r.hincrby(\"bike:1:stats\", \"rides\", 1)\nprint(res13)\n# >>> 3\nres14 = r.hincrby(\"bike:1:stats\", \"crashes\", 1)\nprint(res14)\n# >>> 1\nres15 = r.hincrby(\"bike:1:stats\", \"owners\", 1)\nprint(res15)\n# >>> 1\nres16 = r.hget(\"bike:1:stats\", \"rides\")\nprint(res16)\n# >>> 3\nres17 = r.hmget(\"bike:1:stats\", [\"crashes\", \"owners\"])\nprint(res17)\n# >>> ['1', '1']\n# STEP_END\n\n# REMOVE_START\nassert res11 == 1\nassert res12 == 2\nassert res13 == 3\nassert res14 == 1\nassert res15 == 1\nassert res16 == \"3\"\nassert res17 == [\"1\", \"1\"]\n# REMOVE_END\n"
  },
  {
    "path": "doctests/dt_hll.py",
    "content": "# # EXAMPLE: hll_tutorial\n# HIDE_START\n\"\"\"\nCode samples for HyperLogLog doc pages:\n    https://redis.io/docs/latest/develop/data-types/probabilistic/hyperloglogs/\n\"\"\"\n\nimport redis\n\nr = redis.Redis(decode_responses=True)\n# HIDE_END\n\n# REMOVE_START\nr.delete(\"bikes\", \"commuter_bikes\", \"all_bikes\")\n# REMOVE_END\n\n# STEP_START pfadd\nres1 = r.pfadd(\"bikes\", \"Hyperion\", \"Deimos\", \"Phoebe\", \"Quaoar\")\nprint(res1)  # >>> 1\n\nres2 = r.pfcount(\"bikes\")\nprint(res2)  # >>> 4\n\nres3 = r.pfadd(\"commuter_bikes\", \"Salacia\", \"Mimas\", \"Quaoar\")\nprint(res3)  # >>> 1\n\nres4 = r.pfmerge(\"all_bikes\", \"bikes\", \"commuter_bikes\")\nprint(res4)  # >>> True\n\nres5 = r.pfcount(\"all_bikes\")\nprint(res5)  # >>> 6\n# STEP_END\n\n# REMOVE_START\nassert res4 is True\n# REMOVE_END\n"
  },
  {
    "path": "doctests/dt_json.py",
    "content": "# EXAMPLE: json_tutorial\n# HIDE_START\n\"\"\"\nCode samples for JSON doc pages:\n    https://redis.io/docs/latest/develop/data-types/json/\n\"\"\"\nimport redis\n\nr = redis.Redis(decode_responses=True)\n# HIDE_END\n\n# REMOVE_START\nr.delete(\"bike\")\nr.delete(\"crashes\")\nr.delete(\"newbike\")\nr.delete(\"rider\")\n# REMOVE_END\n\n# STEP_START set_get\nres1 = r.json().set(\"bike\", \"$\", '\"Hyperion\"')\nprint(res1)  # >>> True\n\nres2 = r.json().get(\"bike\", \"$\")\nprint(res2)  # >>> ['\"Hyperion\"']\n\nres3 = r.json().type(\"bike\", \"$\")\nprint(res3)  # >>> ['string']\n# STEP_END\n\n# REMOVE_START\nassert res2 == ['\"Hyperion\"']\n# REMOVE_END\n\n# STEP_START str\nres4 = r.json().strlen(\"bike\", \"$\")\nprint(res4)  # >>> [10]\n\nres5 = r.json().strappend(\"bike\", '\" (Enduro bikes)\"')\nprint(res5)  # >>> 27\n\nres6 = r.json().get(\"bike\", \"$\")\nprint(res6)  # >>> ['\"Hyperion\"\" (Enduro bikes)\"']\n# STEP_END\n\n# REMOVE_START\nassert res6 == ['\"Hyperion\"\" (Enduro bikes)\"']\n# REMOVE_END\n\n# STEP_START num\nres7 = r.json().set(\"crashes\", \"$\", 0)\nprint(res7)  # >>> True\n\nres8 = r.json().numincrby(\"crashes\", \"$\", 1)\nprint(res8)  # >>> [1]\n\nres9 = r.json().numincrby(\"crashes\", \"$\", 1.5)\nprint(res9)  # >>> [2.5]\n\nres10 = r.json().numincrby(\"crashes\", \"$\", -0.75)\nprint(res10)  # >>> [1.75]\n# STEP_END\n\n# REMOVE_START\nassert res10 == [1.75]\n# REMOVE_END\n\n# STEP_START arr\nres11 = r.json().set(\"newbike\", \"$\", [\"Deimos\", {\"crashes\": 0}, None])\nprint(res11)  # >>> True\n\nres12 = r.json().get(\"newbike\", \"$\")\nprint(res12)  # >>> ['[\"Deimos\", { \"crashes\": 0 }, null]']\n\nres13 = r.json().get(\"newbike\", \"$[1].crashes\")\nprint(res13)  # >>> [0]\n\nres14 = r.json().delete(\"newbike\", \"$.[-1]\")\nprint(res14)  # >>> [1]\n\nres15 = r.json().get(\"newbike\", \"$\")\nprint(res15)  # >>> [['Deimos', {'crashes': 0}]]\n# STEP_END\n\n# REMOVE_START\nassert res15 == [[\"Deimos\", {\"crashes\": 0}]]\n# REMOVE_END\n\n# STEP_START arr2\nres16 = r.json().set(\"riders\", \"$\", [])\nprint(res16)  # >>> True\n\nres17 = r.json().arrappend(\"riders\", \"$\", \"Norem\")\nprint(res17)  # >>> [1]\n\nres18 = r.json().get(\"riders\", \"$\")\nprint(res18)  # >>> [['Norem']]\n\nres19 = r.json().arrinsert(\"riders\", \"$\", 1, \"Prickett\", \"Royce\", \"Castilla\")\nprint(res19)  # >>> [4]\n\nres20 = r.json().get(\"riders\", \"$\")\nprint(res20)  # >>> [['Norem', 'Prickett', 'Royce', 'Castilla']]\n\nres21 = r.json().arrtrim(\"riders\", \"$\", 1, 1)\nprint(res21)  # >>> [1]\n\nres22 = r.json().get(\"riders\", \"$\")\nprint(res22)  # >>> [['Prickett']]\n\nres23 = r.json().arrpop(\"riders\", \"$\")\nprint(res23)  # >>> ['\"Prickett\"']\n\nres24 = r.json().arrpop(\"riders\", \"$\")\nprint(res24)  # >>> [None]\n# STEP_END\n\n# REMOVE_START\nassert res24 == [None]\n# REMOVE_END\n\n# STEP_START obj\nres25 = r.json().set(\n    \"bike:1\", \"$\", {\"model\": \"Deimos\", \"brand\": \"Ergonom\", \"price\": 4972}\n)\nprint(res25)  # >>> True\n\nres26 = r.json().objlen(\"bike:1\", \"$\")\nprint(res26)  # >>> [3]\n\nres27 = r.json().objkeys(\"bike:1\", \"$\")\nprint(res27)  # >>> [['model', 'brand', 'price']]\n# STEP_END\n\n# REMOVE_START\nassert res27 == [[\"model\", \"brand\", \"price\"]]\n# REMOVE_END\n\n# STEP_START set_bikes\n# HIDE_START\ninventory_json = {\n    \"inventory\": {\n        \"mountain_bikes\": [\n            {\n                \"id\": \"bike:1\",\n                \"model\": \"Phoebe\",\n                \"description\": \"This is a mid-travel trail slayer that is a fantastic \"\n                \"daily driver or one bike quiver. The Shimano Claris 8-speed groupset \"\n                \"gives plenty of gear range to tackle hills and there\\u2019s room for \"\n                \"mudguards and a rack too.  This is the bike for the rider who wants \"\n                \"trail manners with low fuss ownership.\",\n                \"price\": 1920,\n                \"specs\": {\"material\": \"carbon\", \"weight\": 13.1},\n                \"colors\": [\"black\", \"silver\"],\n            },\n            {\n                \"id\": \"bike:2\",\n                \"model\": \"Quaoar\",\n                \"description\": \"Redesigned for the 2020 model year, this bike \"\n                \"impressed our testers and is the best all-around trail bike we've \"\n                \"ever tested. The Shimano gear system effectively does away with an \"\n                \"external cassette, so is super low maintenance in terms of wear \"\n                \"and tear. All in all it's an impressive package for the price, \"\n                \"making it very competitive.\",\n                \"price\": 2072,\n                \"specs\": {\"material\": \"aluminium\", \"weight\": 7.9},\n                \"colors\": [\"black\", \"white\"],\n            },\n            {\n                \"id\": \"bike:3\",\n                \"model\": \"Weywot\",\n                \"description\": \"This bike gives kids aged six years and older \"\n                \"a durable and uberlight mountain bike for their first experience \"\n                \"on tracks and easy cruising through forests and fields. A set of \"\n                \"powerful Shimano hydraulic disc brakes provide ample stopping \"\n                \"ability. If you're after a budget option, this is one of the best \"\n                \"bikes you could get.\",\n                \"price\": 3264,\n                \"specs\": {\"material\": \"alloy\", \"weight\": 13.8},\n            },\n        ],\n        \"commuter_bikes\": [\n            {\n                \"id\": \"bike:4\",\n                \"model\": \"Salacia\",\n                \"description\": \"This bike is a great option for anyone who just \"\n                \"wants a bike to get about on With a slick-shifting Claris gears \"\n                \"from Shimano\\u2019s, this is a bike which doesn\\u2019t break the \"\n                \"bank and delivers craved performance.  It\\u2019s for the rider \"\n                \"who wants both efficiency and capability.\",\n                \"price\": 1475,\n                \"specs\": {\"material\": \"aluminium\", \"weight\": 16.6},\n                \"colors\": [\"black\", \"silver\"],\n            },\n            {\n                \"id\": \"bike:5\",\n                \"model\": \"Mimas\",\n                \"description\": \"A real joy to ride, this bike got very high \"\n                \"scores in last years Bike of the year report. The carefully \"\n                \"crafted 50-34 tooth chainset and 11-32 tooth cassette give an \"\n                \"easy-on-the-legs bottom gear for climbing, and the high-quality \"\n                \"Vittoria Zaffiro tires give balance and grip.It includes \"\n                \"a low-step frame , our memory foam seat, bump-resistant shocks and \"\n                \"conveniently placed thumb throttle. Put it all together and you \"\n                \"get a bike that helps redefine what can be done for this price.\",\n                \"price\": 3941,\n                \"specs\": {\"material\": \"alloy\", \"weight\": 11.6},\n            },\n        ],\n    }\n}\n# HIDE_END\n\nres1 = r.json().set(\"bikes:inventory\", \"$\", inventory_json)\nprint(res1)  # >>> True\n# STEP_END\n\n# STEP_START get_bikes\nres2 = r.json().get(\"bikes:inventory\", \"$.inventory.*\")\nprint(res2)\n# >>>    [[{'id': 'bike:1', 'model': 'Phoebe',\n# >>>       'description': 'This is a mid-travel trail slayer...\n# STEP_END\n\n# STEP_START get_mtnbikes\nres3 = r.json().get(\"bikes:inventory\", \"$.inventory.mountain_bikes[*].model\")\nprint(res3)  # >>> [['Phoebe', 'Quaoar', 'Weywot']]\n\nres4 = r.json().get(\"bikes:inventory\", '$.inventory[\"mountain_bikes\"][*].model')\nprint(res4)  # >>> [['Phoebe', 'Quaoar', 'Weywot']]\n\nres5 = r.json().get(\"bikes:inventory\", \"$..mountain_bikes[*].model\")\nprint(res5)  # >>> [['Phoebe', 'Quaoar', 'Weywot']]\n# STEP_END\n\n# REMOVE_START\nassert res3 == [\"Phoebe\", \"Quaoar\", \"Weywot\"]\nassert res4 == [\"Phoebe\", \"Quaoar\", \"Weywot\"]\nassert res5 == [\"Phoebe\", \"Quaoar\", \"Weywot\"]\n# REMOVE_END\n\n# STEP_START get_models\nres6 = r.json().get(\"bikes:inventory\", \"$..model\")\nprint(res6)  # >>> [['Phoebe', 'Quaoar', 'Weywot', 'Salacia', 'Mimas']]\n# STEP_END\n\n# REMOVE_START\nassert res6 == [\"Phoebe\", \"Quaoar\", \"Weywot\", \"Salacia\", \"Mimas\"]\n# REMOVE_END\n\n# STEP_START get2mtnbikes\nres7 = r.json().get(\"bikes:inventory\", \"$..mountain_bikes[0:2].model\")\nprint(res7)  # >>> [['Phoebe', 'Quaoar']]\n# STEP_END\n\n# REMOVE_START\nassert res7 == [\"Phoebe\", \"Quaoar\"]\n# REMOVE_END\n\n# STEP_START filter1\nres8 = r.json().get(\n    \"bikes:inventory\",\n    \"$..mountain_bikes[?(@.price < 3000 && @.specs.weight < 10)]\",\n)\nprint(res8)\n# >>> [{'id': 'bike:2', 'model': 'Quaoar',\n#           'description': \"Redesigned for the 2020 model year...\n# STEP_END\n\n# REMOVE_START\nassert res8 == [\n    {\n        \"id\": \"bike:2\",\n        \"model\": \"Quaoar\",\n        \"description\": \"Redesigned for the 2020 model year, this bike impressed \"\n        \"our testers and is the best all-around trail bike we've ever tested. \"\n        \"The Shimano gear system effectively does away with an external cassette, \"\n        \"so is super low maintenance in terms of wear and tear. All in all it's \"\n        \"an impressive package for the price, making it very competitive.\",\n        \"price\": 2072,\n        \"specs\": {\"material\": \"aluminium\", \"weight\": 7.9},\n        \"colors\": [\"black\", \"white\"],\n    }\n]\n# REMOVE_END\n\n# STEP_START filter2\nres9 = r.json().get(\"bikes:inventory\", \"$..[?(@.specs.material == 'alloy')].model\")\nprint(res9)  # >>> ['Weywot', 'Mimas']\n# STEP_END\n\n# REMOVE_START\nassert res9 == [\"Weywot\", \"Mimas\"]\n# REMOVE_END\n\n# STEP_START filter3\nres10 = r.json().get(\"bikes:inventory\", \"$..[?(@.specs.material =~ '(?i)al')].model\")\nprint(res10)  # >>> ['Quaoar', 'Weywot', 'Salacia', 'Mimas']\n# STEP_END\n\n# REMOVE_START\nassert res10 == [\"Quaoar\", \"Weywot\", \"Salacia\", \"Mimas\"]\n# REMOVE_END\n\n# STEP_START filter4\nres11 = r.json().set(\n    \"bikes:inventory\", \"$.inventory.mountain_bikes[0].regex_pat\", \"(?i)al\"\n)\nres12 = r.json().set(\n    \"bikes:inventory\", \"$.inventory.mountain_bikes[1].regex_pat\", \"(?i)al\"\n)\nres13 = r.json().set(\n    \"bikes:inventory\", \"$.inventory.mountain_bikes[2].regex_pat\", \"(?i)al\"\n)\n\nres14 = r.json().get(\n    \"bikes:inventory\",\n    \"$.inventory.mountain_bikes[?(@.specs.material =~ @.regex_pat)].model\",\n)\nprint(res14)  # >>> ['Quaoar', 'Weywot']\n# STEP_END\n\n# REMOVE_START\nassert res14 == [\"Quaoar\", \"Weywot\"]\n# REMOVE_END\n\n# STEP_START update_bikes\nres15 = r.json().get(\"bikes:inventory\", \"$..price\")\nprint(res15)  # >>> [1920, 2072, 3264, 1475, 3941]\n\nres16 = r.json().numincrby(\"bikes:inventory\", \"$..price\", -100)\nprint(res16)  # >>> [1820, 1972, 3164, 1375, 3841]\n\nres17 = r.json().numincrby(\"bikes:inventory\", \"$..price\", 100)\nprint(res17)  # >>> [1920, 2072, 3264, 1475, 3941]\n# STEP_END\n\n# REMOVE_START\nassert res15 == [1920, 2072, 3264, 1475, 3941]\nassert res16 == [1820, 1972, 3164, 1375, 3841]\nassert res17 == [1920, 2072, 3264, 1475, 3941]\n# REMOVE_END\n\n# STEP_START update_filters1\nres18 = r.json().set(\"bikes:inventory\", \"$.inventory.*[?(@.price<2000)].price\", 1500)\nres19 = r.json().get(\"bikes:inventory\", \"$..price\")\nprint(res19)  # >>> [1500, 2072, 3264, 1500, 3941]\n# STEP_END\n\n# REMOVE_START\nassert res19 == [1500, 2072, 3264, 1500, 3941]\n# REMOVE_END\n\n# STEP_START update_filters2\nres20 = r.json().arrappend(\n    \"bikes:inventory\", \"$.inventory.*[?(@.price<2000)].colors\", \"pink\"\n)\nprint(res20)  # >>> [3, 3]\n\nres21 = r.json().get(\"bikes:inventory\", \"$..[*].colors\")\nprint(\n    res21\n)  # >>> [['black', 'silver', 'pink'], ['black', 'white'], ['black', 'silver', 'pink']]\n# STEP_END\n\n# REMOVE_START\nassert res21 == [\n    [\"black\", \"silver\", \"pink\"],\n    [\"black\", \"white\"],\n    [\"black\", \"silver\", \"pink\"],\n]\n# REMOVE_END\n"
  },
  {
    "path": "doctests/dt_list.py",
    "content": "# EXAMPLE: list_tutorial\n# HIDE_START\n\"\"\"\nCode samples for List doc pages:\n    https://redis.io/docs/latest/develop/data-types/lists/\n\"\"\"\n\nimport redis\n\nr = redis.Redis(decode_responses=True)\n# HIDE_END\n# REMOVE_START\nr.delete(\"bikes:repairs\")\nr.delete(\"bikes:finished\")\n# REMOVE_END\n\n# STEP_START queue\nres1 = r.lpush(\"bikes:repairs\", \"bike:1\")\nprint(res1)  # >>> 1\n\nres2 = r.lpush(\"bikes:repairs\", \"bike:2\")\nprint(res2)  # >>> 2\n\nres3 = r.rpop(\"bikes:repairs\")\nprint(res3)  # >>> bike:1\n\nres4 = r.rpop(\"bikes:repairs\")\nprint(res4)  # >>> bike:2\n# STEP_END\n\n# REMOVE_START\nassert res1 == 1\nassert res2 == 2\nassert res3 == \"bike:1\"\nassert res4 == \"bike:2\"\n# REMOVE_END\n\n# STEP_START stack\nres5 = r.lpush(\"bikes:repairs\", \"bike:1\")\nprint(res5)  # >>> 1\n\nres6 = r.lpush(\"bikes:repairs\", \"bike:2\")\nprint(res6)  # >>> 2\n\nres7 = r.lpop(\"bikes:repairs\")\nprint(res7)  # >>> bike:2\n\nres8 = r.lpop(\"bikes:repairs\")\nprint(res8)  # >>> bike:1\n# STEP_END\n\n# REMOVE_START\nassert res5 == 1\nassert res6 == 2\nassert res7 == \"bike:2\"\nassert res8 == \"bike:1\"\n# REMOVE_END\n\n# STEP_START llen\nres9 = r.llen(\"bikes:repairs\")\nprint(res9)  # >>> 0\n# STEP_END\n\n# REMOVE_START\nassert res9 == 0\n# REMOVE_END\n\n# STEP_START lmove_lrange\nres10 = r.lpush(\"bikes:repairs\", \"bike:1\")\nprint(res10)  # >>> 1\n\nres11 = r.lpush(\"bikes:repairs\", \"bike:2\")\nprint(res11)  # >>> 2\n\nres12 = r.lmove(\"bikes:repairs\", \"bikes:finished\", \"LEFT\", \"LEFT\")\nprint(res12)  # >>> 'bike:2'\n\nres13 = r.lrange(\"bikes:repairs\", 0, -1)\nprint(res13)  # >>> ['bike:1']\n\nres14 = r.lrange(\"bikes:finished\", 0, -1)\nprint(res14)  # >>> ['bike:2']\n# STEP_END\n\n# REMOVE_START\nassert res10 == 1\nassert res11 == 2\nassert res12 == \"bike:2\"\nassert res13 == [\"bike:1\"]\nassert res14 == [\"bike:2\"]\nr.delete(\"bikes:repairs\")\n# REMOVE_END\n\n# STEP_START lpush_rpush\nres15 = r.rpush(\"bikes:repairs\", \"bike:1\")\nprint(res15)  # >>> 1\n\nres16 = r.rpush(\"bikes:repairs\", \"bike:2\")\nprint(res16)  # >>> 2\n\nres17 = r.lpush(\"bikes:repairs\", \"bike:important_bike\")\nprint(res17)  # >>> 3\n\nres18 = r.lrange(\"bikes:repairs\", 0, -1)\nprint(res18)  # >>> ['bike:important_bike', 'bike:1', 'bike:2']\n# STEP_END\n\n# REMOVE_START\nassert res15 == 1\nassert res16 == 2\nassert res17 == 3\nassert res18 == [\"bike:important_bike\", \"bike:1\", \"bike:2\"]\nr.delete(\"bikes:repairs\")\n# REMOVE_END\n\n# STEP_START variadic\nres19 = r.rpush(\"bikes:repairs\", \"bike:1\", \"bike:2\", \"bike:3\")\nprint(res19)  # >>> 3\n\nres20 = r.lpush(\"bikes:repairs\", \"bike:important_bike\", \"bike:very_important_bike\")\nprint(res20)  # >>> 5\n\nres21 = r.lrange(\"bikes:repairs\", 0, -1)\nprint(\n    res21\n)  # >>> ['bike:very_important_bike', 'bike:important_bike', 'bike:1', ...\n# STEP_END\n\n# REMOVE_START\nassert res19 == 3\nassert res20 == 5\nassert res21 == [\n    \"bike:very_important_bike\",\n    \"bike:important_bike\",\n    \"bike:1\",\n    \"bike:2\",\n    \"bike:3\",\n]\nr.delete(\"bikes:repairs\")\n# REMOVE_END\n\n# STEP_START lpop_rpop\nres22 = r.rpush(\"bikes:repairs\", \"bike:1\", \"bike:2\", \"bike:3\")\nprint(res22)  # >>> 3\n\nres23 = r.rpop(\"bikes:repairs\")\nprint(res23)  # >>> 'bike:3'\n\nres24 = r.lpop(\"bikes:repairs\")\nprint(res24)  # >>> 'bike:1'\n\nres25 = r.rpop(\"bikes:repairs\")\nprint(res25)  # >>> 'bike:2'\n\nres26 = r.rpop(\"bikes:repairs\")\nprint(res26)  # >>> None\n# STEP_END\n\n# REMOVE_START\nassert res22 == 3\nassert res23 == \"bike:3\"\nassert res24 == \"bike:1\"\nassert res25 == \"bike:2\"\nassert res26 is None\n# REMOVE_END\n\n# STEP_START ltrim\nres27 = r.rpush(\"bikes:repairs\", \"bike:1\", \"bike:2\", \"bike:3\", \"bike:4\", \"bike:5\")\nprint(res27)  # >>> 5\n\nres28 = r.ltrim(\"bikes:repairs\", 0, 2)\nprint(res28)  # >>> True\n\nres29 = r.lrange(\"bikes:repairs\", 0, -1)\nprint(res29)  # >>> ['bike:1', 'bike:2', 'bike:3']\n# STEP_END\n\n# REMOVE_START\nassert res27 == 5\nassert res28 is True\nassert res29 == [\"bike:1\", \"bike:2\", \"bike:3\"]\nr.delete(\"bikes:repairs\")\n# REMOVE_END\n\n# STEP_START ltrim_end_of_list\nres27 = r.rpush(\"bikes:repairs\", \"bike:1\", \"bike:2\", \"bike:3\", \"bike:4\", \"bike:5\")\nprint(res27)  # >>> 5\n\nres28 = r.ltrim(\"bikes:repairs\", -3, -1)\nprint(res28)  # >>> True\n\nres29 = r.lrange(\"bikes:repairs\", 0, -1)\nprint(res29)  # >>> ['bike:3', 'bike:4', 'bike:5']\n# STEP_END\n\n# REMOVE_START\nassert res27 == 5\nassert res28 is True\nassert res29 == [\"bike:3\", \"bike:4\", \"bike:5\"]\nr.delete(\"bikes:repairs\")\n# REMOVE_END\n\n# STEP_START brpop\nres31 = r.rpush(\"bikes:repairs\", \"bike:1\", \"bike:2\")\nprint(res31)  # >>> 2\n\nres32 = r.brpop(\"bikes:repairs\", timeout=1)\nprint(res32)  # >>> ('bikes:repairs', 'bike:2')\n\nres33 = r.brpop(\"bikes:repairs\", timeout=1)\nprint(res33)  # >>> ('bikes:repairs', 'bike:1')\n\nres34 = r.brpop(\"bikes:repairs\", timeout=1)\nprint(res34)  # >>> None\n# STEP_END\n\n# REMOVE_START\nassert res31 == 2\nassert res32 == (\"bikes:repairs\", \"bike:2\")\nassert res33 == (\"bikes:repairs\", \"bike:1\")\nassert res34 is None\nr.delete(\"bikes:repairs\")\nr.delete(\"new_bikes\")\n# REMOVE_END\n\n# STEP_START rule_1\nres35 = r.delete(\"new_bikes\")\nprint(res35)  # >>> 0\n\nres36 = r.lpush(\"new_bikes\", \"bike:1\", \"bike:2\", \"bike:3\")\nprint(res36)  # >>> 3\n# STEP_END\n\n# REMOVE_START\nassert res35 == 0\nassert res36 == 3\nr.delete(\"new_bikes\")\n# REMOVE_END\n\n# STEP_START rule_1.1\nres37 = r.set(\"new_bikes\", \"bike:1\")\nprint(res37)  # >>> True\n\nres38 = r.type(\"new_bikes\")\nprint(res38)  # >>> 'string'\n\ntry:\n    res39 = r.lpush(\"new_bikes\", \"bike:2\", \"bike:3\")\n    # >>> redis.exceptions.ResponseError:\n    # >>> WRONGTYPE Operation against a key holding the wrong kind of value\nexcept redis.exceptions.ResponseError as e:\n    print(e)\n# STEP_END\n\n# REMOVE_START\nassert res37 is True\nassert res38 == \"string\"\nr.delete(\"new_bikes\")\n# REMOVE_END\n\n# STEP_START rule_2\nr.lpush(\"bikes:repairs\", \"bike:1\", \"bike:2\", \"bike:3\")\nprint(res36)  # >>> 3\n\nres40 = r.exists(\"bikes:repairs\")\nprint(res40)  # >>> 1\n\nres41 = r.lpop(\"bikes:repairs\")\nprint(res41)  # >>> 'bike:3'\n\nres42 = r.lpop(\"bikes:repairs\")\nprint(res42)  # >>> 'bike:2'\n\nres43 = r.lpop(\"bikes:repairs\")\nprint(res43)  # >>> 'bike:1'\n\nres44 = r.exists(\"bikes:repairs\")\nprint(res44)  # >>> False\n# STEP_END\n\n# REMOVE_START\nassert res40 == 1\nassert res41 == \"bike:3\"\nassert res42 == \"bike:2\"\nassert res43 == \"bike:1\"\nassert res44 == 0\nr.delete(\"bikes:repairs\")\n# REMOVE_END\n\n# STEP_START rule_3\nres45 = r.delete(\"bikes:repairs\")\nprint(res45)  # >>> 0\n\nres46 = r.llen(\"bikes:repairs\")\nprint(res46)  # >>> 0\n\nres47 = r.lpop(\"bikes:repairs\")\nprint(res47)  # >>> None\n# STEP_END\n\n# REMOVE_START\nassert res45 == 0\nassert res46 == 0\nassert res47 is None\n# REMOVE_END\n\n# STEP_START ltrim.1\nres48 = r.lpush(\"bikes:repairs\", \"bike:1\", \"bike:2\", \"bike:3\", \"bike:4\", \"bike:5\")\nprint(res48)  # >>> 5\n\nres49 = r.ltrim(\"bikes:repairs\", 0, 2)\nprint(res49)  # >>> True\n\nres50 = r.lrange(\"bikes:repairs\", 0, -1)\nprint(res50)  # >>> ['bike:5', 'bike:4', 'bike:3']\n# STEP_END\n\n# REMOVE_START\nassert res48 == 5\nassert res49 is True\nassert res50 == [\"bike:5\", \"bike:4\", \"bike:3\"]\nr.delete(\"bikes:repairs\")\n# REMOVE_END\n"
  },
  {
    "path": "doctests/dt_set.py",
    "content": "# EXAMPLE: sets_tutorial\n# HIDE_START\n\"\"\"\nCode samples for Set doc pages:\n    https://redis.io/docs/latest/develop/data-types/sets/\n\"\"\"\n\nimport redis\n\nr = redis.Redis(decode_responses=True)\n# HIDE_END\n# REMOVE_START\nr.delete(\"bikes:racing:france\")\nr.delete(\"bikes:racing:usa\")\n# REMOVE_END\n\n# STEP_START sadd\nres1 = r.sadd(\"bikes:racing:france\", \"bike:1\")\nprint(res1)  # >>> 1\n\nres2 = r.sadd(\"bikes:racing:france\", \"bike:1\")\nprint(res2)  # >>> 0\n\nres3 = r.sadd(\"bikes:racing:france\", \"bike:2\", \"bike:3\")\nprint(res3)  # >>> 2\n\nres4 = r.sadd(\"bikes:racing:usa\", \"bike:1\", \"bike:4\")\nprint(res4)  # >>> 2\n# STEP_END\n\n# REMOVE_START\nassert res1 == 1\nassert res2 == 0\nassert res3 == 2\nassert res4 == 2\n# REMOVE_END\n\n# STEP_START sismember\n# HIDE_START\nr.sadd(\"bikes:racing:france\", \"bike:1\", \"bike:2\", \"bike:3\")\nr.sadd(\"bikes:racing:usa\", \"bike:1\", \"bike:4\")\n# HIDE_END\nres5 = r.sismember(\"bikes:racing:usa\", \"bike:1\")\nprint(res5)  # >>> 1\n\nres6 = r.sismember(\"bikes:racing:usa\", \"bike:2\")\nprint(res6)  # >>> 0\n# STEP_END\n\n# REMOVE_START\nassert res5 == 1\nassert res6 == 0\n# REMOVE_END\n\n# STEP_START sinter\n# HIDE_START\nr.sadd(\"bikes:racing:france\", \"bike:1\", \"bike:2\", \"bike:3\")\nr.sadd(\"bikes:racing:usa\", \"bike:1\", \"bike:4\")\n# HIDE_END\nres7 = r.sinter(\"bikes:racing:france\", \"bikes:racing:usa\")\nprint(res7)  # >>> {'bike:1'}\n# STEP_END\n\n# REMOVE_START\nassert res7 == {\"bike:1\"}\n# REMOVE_END\n\n# STEP_START scard\n# HIDE_START\nr.sadd(\"bikes:racing:france\", \"bike:1\", \"bike:2\", \"bike:3\")\n# HIDE_END\nres8 = r.scard(\"bikes:racing:france\")\nprint(res8)  # >>> 3\n# STEP_END\n\n# REMOVE_START\nassert res8 == 3\nr.delete(\"bikes:racing:france\")\n# REMOVE_END\n\n# STEP_START sadd_smembers\nres9 = r.sadd(\"bikes:racing:france\", \"bike:1\", \"bike:2\", \"bike:3\")\nprint(res9)  # >>> 3\n\nres10 = r.smembers(\"bikes:racing:france\")\nprint(res10)  # >>> {'bike:1', 'bike:2', 'bike:3'}\n# STEP_END\n\n# REMOVE_START\nassert res9 == 3\nassert res10 == {'bike:1', 'bike:2', 'bike:3'}\n# REMOVE_END\n\n# STEP_START smismember\nres11 = r.sismember(\"bikes:racing:france\", \"bike:1\")\nprint(res11)  # >>> 1\n\nres12 = r.smismember(\"bikes:racing:france\", \"bike:2\", \"bike:3\", \"bike:4\")\nprint(res12)  # >>> [1, 1, 0]\n# STEP_END\n\n# REMOVE_START\nassert res11 == 1\nassert res12 == [1, 1, 0]\n# REMOVE_END\n\n# STEP_START sdiff\nr.sadd(\"bikes:racing:france\", \"bike:1\", \"bike:2\", \"bike:3\")\nr.sadd(\"bikes:racing:usa\", \"bike:1\", \"bike:4\")\n\nres13 = r.sdiff(\"bikes:racing:france\", \"bikes:racing:usa\")\nprint(res13)  # >>> {'bike:2', 'bike:3'}\n# STEP_END\n\n# REMOVE_START\nassert res13 == {'bike:2', 'bike:3'}\nr.delete(\"bikes:racing:france\")\nr.delete(\"bikes:racing:usa\")\n# REMOVE_END\n\n# STEP_START multisets\nr.sadd(\"bikes:racing:france\", \"bike:1\", \"bike:2\", \"bike:3\")\nr.sadd(\"bikes:racing:usa\", \"bike:1\", \"bike:4\")\nr.sadd(\"bikes:racing:italy\", \"bike:1\", \"bike:2\", \"bike:3\", \"bike:4\")\n\nres13 = r.sinter(\"bikes:racing:france\", \"bikes:racing:usa\", \"bikes:racing:italy\")\nprint(res13)  # >>> {'bike:1'}\n\nres14 = r.sunion(\"bikes:racing:france\", \"bikes:racing:usa\", \"bikes:racing:italy\")\nprint(res14)  # >>> {'bike:1', 'bike:2', 'bike:3', 'bike:4'}\n\nres15 = r.sdiff(\"bikes:racing:france\", \"bikes:racing:usa\", \"bikes:racing:italy\")\nprint(res15)  # >>> {}\n\nres16 = r.sdiff(\"bikes:racing:usa\", \"bikes:racing:france\")\nprint(res16)  # >>> {'bike:4'}\n\nres17 = r.sdiff(\"bikes:racing:france\", \"bikes:racing:usa\")\nprint(res17)  # >>> {'bike:2', 'bike:3'}\n# STEP_END\n\n# REMOVE_START\nassert res13 == {'bike:1'}\nassert res14 == {'bike:1', 'bike:2', 'bike:3', 'bike:4'}\nassert res15 == {}\nassert res16 == {'bike:4'}\nassert res17 == {'bike:2', 'bike:3'}\nr.delete(\"bikes:racing:france\")\nr.delete(\"bikes:racing:usa\")\nr.delete(\"bikes:racing:italy\")\n# REMOVE_END\n\n# STEP_START srem\nr.sadd(\"bikes:racing:france\", \"bike:1\", \"bike:2\", \"bike:3\", \"bike:4\", \"bike:5\")\n\nres18 = r.srem(\"bikes:racing:france\", \"bike:1\")\nprint(res18)  # >>> 1\n\nres19 = r.spop(\"bikes:racing:france\")\nprint(res19)  # >>> bike:3\n\nres20 = r.smembers(\"bikes:racing:france\")\nprint(res20)  # >>> {'bike:2', 'bike:4', 'bike:5'}\n\nres21 = r.srandmember(\"bikes:racing:france\")\nprint(res21)  # >>> bike:4\n# STEP_END\n\n# REMOVE_START\nassert res18 == 1\n# none of the other results are deterministic\n# REMOVE_END\n"
  },
  {
    "path": "doctests/dt_ss.py",
    "content": "# EXAMPLE: ss_tutorial\n# HIDE_START\n\"\"\"\nCode samples for Sorted set doc pages:\n    https://redis.io/docs/latest/develop/data-types/sorted-sets/\n\"\"\"\n\nimport redis\n\nr = redis.Redis(decode_responses=True)\n# HIDE_END\n\n# REMOVE_START\nr.delete(\"racer_scores\")\n# REMOVE_END\n\n# STEP_START zadd\nres1 = r.zadd(\"racer_scores\", {\"Norem\": 10})\nprint(res1)  # >>> 1\n\nres2 = r.zadd(\"racer_scores\", {\"Castilla\": 12})\nprint(res2)  # >>> 1\n\nres3 = r.zadd(\n    \"racer_scores\",\n    {\"Sam-Bodden\": 8, \"Royce\": 10, \"Ford\": 6, \"Prickett\": 14, \"Castilla\": 12},\n)\nprint(res3)  # >>> 4\n# STEP_END\n\n# REMOVE_START\nassert r.zcard(\"racer_scores\") == 6\n# REMOVE_END\n\n# STEP_START zrange\nres4 = r.zrange(\"racer_scores\", 0, -1)\nprint(res4)  # >>> ['Ford', 'Sam-Bodden', 'Norem', 'Royce', 'Castilla', 'Prickett']\n\nres5 = r.zrevrange(\"racer_scores\", 0, -1)\nprint(res5)  # >>> ['Prickett', 'Castilla', 'Royce', 'Norem', 'Sam-Bodden', 'Ford']\n# STEP_END\n\n# STEP_START zrange_withscores\nres6 = r.zrange(\"racer_scores\", 0, -1, withscores=True)\nprint(\n    res6\n)\n# >>> [\n#       ('Ford', 6.0), ('Sam-Bodden', 8.0), ('Norem', 10.0), ('Royce', 10.0),\n#       ('Castilla', 12.0), ('Prickett', 14.0)\n# ]\n# STEP_END\n\n# STEP_START zrangebyscore\nres7 = r.zrangebyscore(\"racer_scores\", \"-inf\", 10)\nprint(res7)  # >>> ['Ford', 'Sam-Bodden', 'Norem', 'Royce']\n# STEP_END\n\n# STEP_START zremrangebyscore\nres8 = r.zrem(\"racer_scores\", \"Castilla\")\nprint(res8)  # >>> 1\n\nres9 = r.zremrangebyscore(\"racer_scores\", \"-inf\", 9)\nprint(res9)  # >>> 2\n\nres10 = r.zrange(\"racer_scores\", 0, -1)\nprint(res10)  # >>> ['Norem', 'Royce', 'Prickett']\n# STEP_END\n\n# REMOVE_START\nassert r.zcard(\"racer_scores\") == 3\n# REMOVE_END\n\n# STEP_START zrank\nres11 = r.zrank(\"racer_scores\", \"Norem\")\nprint(res11)  # >>> 0\n\nres12 = r.zrevrank(\"racer_scores\", \"Norem\")\nprint(res12)  # >>> 2\n# STEP_END\n\n# STEP_START zadd_lex\nres13 = r.zadd(\n    \"racer_scores\",\n    {\n        \"Norem\": 0,\n        \"Sam-Bodden\": 0,\n        \"Royce\": 0,\n        \"Ford\": 0,\n        \"Prickett\": 0,\n        \"Castilla\": 0,\n    },\n)\nprint(res13)  # >>> 3\n\nres14 = r.zrange(\"racer_scores\", 0, -1)\nprint(res14)  # >>> ['Castilla', 'Ford', 'Norem', 'Prickett', 'Royce', 'Sam-Bodden']\n\nres15 = r.zrangebylex(\"racer_scores\", \"[A\", \"[L\")\nprint(res15)  # >>> ['Castilla', 'Ford']\n# STEP_END\n\n# STEP_START leaderboard\nres16 = r.zadd(\"racer_scores\", {\"Wood\": 100})\nprint(res16)  # >>> 1\n\nres17 = r.zadd(\"racer_scores\", {\"Henshaw\": 100})\nprint(res17)  # >>> 1\n\nres18 = r.zadd(\"racer_scores\", {\"Henshaw\": 150})\nprint(res18)  # >>> 0\n\nres19 = r.zincrby(\"racer_scores\", 50, \"Wood\")\nprint(res19)  # >>> 150.0\n\nres20 = r.zincrby(\"racer_scores\", 50, \"Henshaw\")\nprint(res20)  # >>> 200.0\n# STEP_END\n"
  },
  {
    "path": "doctests/dt_stream.py",
    "content": "# EXAMPLE: stream_tutorial\n# HIDE_START\n\"\"\"\nCode samples for Stream doc pages:\n    https://redis.io/docs/latest/develop/data-types/streams/\n\"\"\"\n\nimport redis\n\nr = redis.Redis(decode_responses=True)\n# HIDE_END\n# REMOVE_START\nr.delete(\"race:france\", \"race:italy\", \"race:usa\")\n# REMOVE_END\n\n# STEP_START xadd\nres1 = r.xadd(\n    \"race:france\",\n    {\"rider\": \"Castilla\", \"speed\": 30.2, \"position\": 1, \"location_id\": 1},\n)\nprint(res1)  # >>> 1692629576966-0\n\nres2 = r.xadd(\n    \"race:france\",\n    {\"rider\": \"Norem\", \"speed\": 28.8, \"position\": 3, \"location_id\": 1},\n)\nprint(res2)  # >>> 1692629594113-0\n\nres3 = r.xadd(\n    \"race:france\",\n    {\"rider\": \"Prickett\", \"speed\": 29.7, \"position\": 2, \"location_id\": 1},\n)\nprint(res3)  # >>> 1692629613374-0\n# STEP_END\n\n# REMOVE_START\nassert r.xlen(\"race:france\") == 3\n# REMOVE_END\n\n# STEP_START xrange\nres4 = r.xrange(\"race:france\", \"1691765278160-0\", \"+\", 2)\nprint(\n    res4\n)  # >>> [\n#   ('1692629576966-0',\n#       {'rider': 'Castilla', 'speed': '30.2', 'position': '1', 'location_id': '1'}\n#   ),\n#   ('1692629594113-0',\n#       {'rider': 'Norem', 'speed': '28.8', 'position': '3', 'location_id': '1'}\n#   )\n# ]\n# STEP_END\n\n# STEP_START xread_block\nres5 = r.xread(streams={\"race:france\": 0}, count=100, block=300)\nprint(\n    res5\n)\n# >>> [\n#   ['race:france',\n#       [('1692629576966-0',\n#           {'rider': 'Castilla', 'speed': '30.2', 'position': '1', 'location_id': '1'}\n#       ),\n#       ('1692629594113-0',\n#           {'rider': 'Norem', 'speed': '28.8', 'position': '3', 'location_id': '1'}\n#       ),\n#       ('1692629613374-0',\n#           {'rider': 'Prickett', 'speed': '29.7', 'position': '2', 'location_id': '1'}\n#       )]\n# ]\n# ]\n# STEP_END\n\n# STEP_START xadd_2\nres6 = r.xadd(\n    \"race:france\",\n    {\"rider\": \"Castilla\", \"speed\": 29.9, \"position\": 1, \"location_id\": 2},\n)\nprint(res6)  # >>> 1692629676124-0\n# STEP_END\n\n# STEP_START xlen\nres7 = r.xlen(\"race:france\")\nprint(res7)  # >>> 4\n# STEP_END\n\n\n# STEP_START xadd_id\nres8 = r.xadd(\"race:usa\", {\"racer\": \"Castilla\"}, id=\"0-1\")\nprint(res8)  # >>> 0-1\n\nres9 = r.xadd(\"race:usa\", {\"racer\": \"Norem\"}, id=\"0-2\")\nprint(res9)  # >>> 0-2\n# STEP_END\n\n# STEP_START xadd_bad_id\ntry:\n    res10 = r.xadd(\"race:usa\", {\"racer\": \"Prickett\"}, id=\"0-1\")\n    print(res10)  # >>> 0-1\nexcept redis.exceptions.ResponseError as e:\n    print(e)  # >>> WRONGID\n# STEP_END\n\n# STEP_START xadd_7\n# Not yet implemented\n# STEP_END\n\n# STEP_START xrange_all\nres11 = r.xrange(\"race:france\", \"-\", \"+\")\nprint(\n    res11\n)\n# >>> [\n#   ('1692629576966-0',\n#       {'rider': 'Castilla', 'speed': '30.2', 'position': '1', 'location_id': '1'}\n#   ),\n#   ('1692629594113-0',\n#       {'rider': 'Norem', 'speed': '28.8', 'position': '3', 'location_id': '1'}\n#   ),\n#   ('1692629613374-0',\n#       {'rider': 'Prickett', 'speed': '29.7', 'position': '2', 'location_id': '1'}\n#   ),\n#   ('1692629676124-0',\n#       {'rider': 'Castilla', 'speed': '29.9', 'position': '1', 'location_id': '2'}\n#   )\n# ]\n# STEP_END\n\n# STEP_START xrange_time\nres12 = r.xrange(\"race:france\", 1692629576965, 1692629576967)\nprint(\n    res12\n)\n# >>> [\n#       ('1692629576966-0',\n#           {'rider': 'Castilla', 'speed': '30.2', 'position': '1', 'location_id': '1'}\n#       )\n# ]\n# STEP_END\n\n# STEP_START xrange_step_1\nres13 = r.xrange(\"race:france\", \"-\", \"+\", 2)\nprint(\n    res13\n)\n# >>> [\n#   ('1692629576966-0',\n#       {'rider': 'Castilla', 'speed': '30.2', 'position': '1', 'location_id': '1'}\n#   ),\n#   ('1692629594113-0',\n#       {'rider': 'Norem', 'speed': '28.8', 'position': '3', 'location_id': '1'}\n#   )\n# ]\n# STEP_END\n\n# STEP_START xrange_step_2\nres14 = r.xrange(\"race:france\", \"(1692629594113-0\", \"+\", 2)\nprint(\n    res14\n)\n# >>> [\n#   ('1692629613374-0',\n#       {'rider': 'Prickett', 'speed': '29.7', 'position': '2', 'location_id': '1'}\n#   ),\n#   ('1692629676124-0',\n#       {'rider': 'Castilla', 'speed': '29.9', 'position': '1', 'location_id': '2'}\n#   )\n# ]\n# STEP_END\n\n# STEP_START xrange_empty\nres15 = r.xrange(\"race:france\", \"(1692629676124-0\", \"+\", 2)\nprint(res15)  # >>> []\n# STEP_END\n\n# STEP_START xrevrange\nres16 = r.xrevrange(\"race:france\", \"+\", \"-\", 1)\nprint(\n    res16\n)\n# >>> [\n#       ('1692629676124-0',\n#           {'rider': 'Castilla', 'speed': '29.9', 'position': '1', 'location_id': '2'}\n#       )\n# ]\n# STEP_END\n\n# STEP_START xread\nres17 = r.xread(streams={\"race:france\": 0}, count=2)\nprint(\n    res17\n)\n# >>> [\n#       ['race:france', [\n#       ('1692629576966-0',\n#           {'rider': 'Castilla', 'speed': '30.2', 'position': '1', 'location_id': '1'}\n#       ),\n#       ('1692629594113-0',\n#           {'rider': 'Norem', 'speed': '28.8', 'position': '3', 'location_id': '1'}\n#       )\n#       ]\n#       ]\n#   ]\n# STEP_END\n\n# STEP_START xgroup_create\nres18 = r.xgroup_create(\"race:france\", \"france_riders\", \"$\")\nprint(res18)  # >>> True\n# STEP_END\n\n# STEP_START xgroup_create_mkstream\nres19 = r.xgroup_create(\"race:italy\", \"italy_riders\", \"$\", mkstream=True)\nprint(res19)  # >>> True\n# STEP_END\n\n# STEP_START xgroup_read\nr.xadd(\"race:italy\", {\"rider\": \"Castilla\"})\nr.xadd(\"race:italy\", {\"rider\": \"Royce\"})\nr.xadd(\"race:italy\", {\"rider\": \"Sam-Bodden\"})\nr.xadd(\"race:italy\", {\"rider\": \"Prickett\"})\nr.xadd(\"race:italy\", {\"rider\": \"Norem\"})\n\nres20 = r.xreadgroup(\n    streams={\"race:italy\": \">\"},\n    consumername=\"Alice\",\n    groupname=\"italy_riders\",\n    count=1,\n)\nprint(res20)  # >>> [['race:italy', [('1692629925771-0', {'rider': 'Castilla'})]]]\n# STEP_END\n\n# STEP_START xgroup_read_id\nres21 = r.xreadgroup(\n    streams={\"race:italy\": 0},\n    consumername=\"Alice\",\n    groupname=\"italy_riders\",\n    count=1,\n)\nprint(res21)  # >>> [['race:italy', [('1692629925771-0', {'rider': 'Castilla'})]]]\n# STEP_END\n\n# STEP_START xack\nres22 = r.xack(\"race:italy\", \"italy_riders\", \"1692629925771-0\")\nprint(res22)  # >>> 1\n\nres23 = r.xreadgroup(\n    streams={\"race:italy\": 0},\n    consumername=\"Alice\",\n    groupname=\"italy_riders\",\n    count=1,\n)\nprint(res23)  # >>> [['race:italy', []]]\n# STEP_END\n\n# STEP_START xgroup_read_bob\nres24 = r.xreadgroup(\n    streams={\"race:italy\": \">\"},\n    consumername=\"Bob\",\n    groupname=\"italy_riders\",\n    count=2,\n)\nprint(\n    res24\n)\n# >>> [\n#       ['race:italy', [\n#           ('1692629925789-0',\n#               {'rider': 'Royce'}\n#           ),\n#           ('1692629925790-0',\n#               {'rider': 'Sam-Bodden'}\n#           )\n#       ]\n#       ]\n# ]\n# STEP_END\n\n# STEP_START xpending\nres25 = r.xpending(\"race:italy\", \"italy_riders\")\nprint(\n    res25\n)\n# >>> {\n#       'pending': 2, 'min': '1692629925789-0', 'max': '1692629925790-0',\n#       'consumers': [{'name': 'Bob', 'pending': 2}]\n# }\n# STEP_END\n\n# STEP_START xpending_plus_minus\nres26 = r.xpending_range(\"race:italy\", \"italy_riders\", \"-\", \"+\", 10)\nprint(\n    res26\n)\n# >>> [\n#       {\n#           'message_id': '1692629925789-0', 'consumer': 'Bob',\n#           'time_since_delivered': 31084, 'times_delivered': 1\n#       },\n#       {\n#           'message_id': '1692629925790-0', 'consumer': 'Bob',\n#           'time_since_delivered': 31084, 'times_delivered': 1\n#       }\n# ]\n# STEP_END\n\n# STEP_START xrange_pending\nres27 = r.xrange(\"race:italy\", \"1692629925789-0\", \"1692629925789-0\")\nprint(res27)  # >>> [('1692629925789-0', {'rider': 'Royce'})]\n# STEP_END\n\n# STEP_START xclaim\nres28 = r.xclaim(\"race:italy\", \"italy_riders\", \"Alice\", 60000, [\"1692629925789-0\"])\nprint(res28)  # >>> [('1692629925789-0', {'rider': 'Royce'})]\n# STEP_END\n\n# STEP_START xautoclaim\nres29 = r.xautoclaim(\"race:italy\", \"italy_riders\", \"Alice\", 1, \"0-0\", 1)\nprint(res29)  # >>> ['1692629925790-0', [('1692629925789-0', {'rider': 'Royce'})]]\n# STEP_END\n\n# STEP_START xautoclaim_cursor\nres30 = r.xautoclaim(\"race:italy\", \"italy_riders\", \"Alice\", 1, \"(1692629925789-0\", 1)\nprint(res30)  # >>> ['0-0', [('1692629925790-0', {'rider': 'Sam-Bodden'})]]\n# STEP_END\n\n# STEP_START xinfo\nres31 = r.xinfo_stream(\"race:italy\")\nprint(\n    res31\n)\n# >>> {\n#       'length': 5, 'radix-tree-keys': 1, 'radix-tree-nodes': 2,\n#       'last-generated-id': '1692629926436-0', 'groups': 1,\n#       'first-entry': ('1692629925771-0', {'rider': 'Castilla'}),\n#       'last-entry': ('1692629926436-0', {'rider': 'Norem'})\n# }\n# STEP_END\n\n# STEP_START xinfo_groups\nres32 = r.xinfo_groups(\"race:italy\")\nprint(\n    res32\n)\n# >>> [\n#       {\n#           'name': 'italy_riders', 'consumers': 2, 'pending': 2,\n#           'last-delivered-id': '1692629925790-0'\n#       }\n# ]\n# STEP_END\n\n# STEP_START xinfo_consumers\nres33 = r.xinfo_consumers(\"race:italy\", \"italy_riders\")\nprint(\n    res33\n)\n# >>> [\n#       {'name': 'Alice', 'pending': 2, 'idle': 199332},\n#       {'name': 'Bob', 'pending': 0, 'idle': 489170}\n# ]\n# STEP_END\n\n# STEP_START maxlen\nr.xadd(\"race:italy\", {\"rider\": \"Jones\"}, maxlen=2)\nr.xadd(\"race:italy\", {\"rider\": \"Wood\"}, maxlen=2)\nr.xadd(\"race:italy\", {\"rider\": \"Henshaw\"}, maxlen=2)\n\nres34 = r.xlen(\"race:italy\")\nprint(res34)  # >>> 8\n\nres35 = r.xrange(\"race:italy\", \"-\", \"+\")\nprint(\n    res35\n)\n# >>> [\n#       ('1692629925771-0', {'rider': 'Castilla'}),\n#       ('1692629925789-0', {'rider': 'Royce'}),\n#       ('1692629925790-0', {'rider': 'Sam-Bodden'}),\n#       ('1692629925791-0', {'rider': 'Prickett'}),\n#       ('1692629926436-0', {'rider': 'Norem'}),\n#       ('1692630612602-0', {'rider': 'Jones'}),\n#       ('1692630641947-0', {'rider': 'Wood'}),\n#       ('1692630648281-0', {'rider': 'Henshaw'})\n# ]\n\nr.xadd(\"race:italy\", {\"rider\": \"Smith\"}, maxlen=2, approximate=False)\n\nres36 = r.xrange(\"race:italy\", \"-\", \"+\")\nprint(\n    res36\n)\n# >>> [\n#       ('1692630648281-0', {'rider': 'Henshaw'}),\n#       ('1692631018238-0', {'rider': 'Smith'})\n# ]\n# STEP_END\n\n# STEP_START xtrim\nres37 = r.xtrim(\"race:italy\", maxlen=10, approximate=False)\nprint(res37)  # >>> 0\n# STEP_END\n\n# STEP_START xtrim2\nres38 = r.xtrim(\"race:italy\", maxlen=10)\nprint(res38)  # >>> 0\n# STEP_END\n\n# STEP_START xdel\nres39 = r.xrange(\"race:italy\", \"-\", \"+\")\nprint(\n    res39\n)\n# >>> [\n#       ('1692630648281-0', {'rider': 'Henshaw'}),\n#       ('1692631018238-0', {'rider': 'Smith'})\n# ]\n\nres40 = r.xdel(\"race:italy\", \"1692631018238-0\")\nprint(res40)  # >>> 1\n\nres41 = r.xrange(\"race:italy\", \"-\", \"+\")\nprint(res41)  # >>> [('1692630648281-0', {'rider': 'Henshaw'})]\n# STEP_END\n"
  },
  {
    "path": "doctests/dt_string.py",
    "content": "# EXAMPLE: set_tutorial\n# HIDE_START\n\"\"\"\nCode samples for String doc pages:\n    https://redis.io/docs/latest/develop/data-types/strings/\n\"\"\"\n\nimport redis\n\nr = redis.Redis(decode_responses=True)\n# HIDE_END\n\n# STEP_START set_get\nres1 = r.set(\"bike:1\", \"Deimos\")\nprint(res1)  # True\nres2 = r.get(\"bike:1\")\nprint(res2)  # Deimos\n# STEP_END\n\n# REMOVE_START\nassert res1\nassert res2 == \"Deimos\"\n# REMOVE_END\n\n# STEP_START setnx_xx\nres3 = r.set(\"bike:1\", \"bike\", nx=True)\nprint(res3)  # None\nprint(r.get(\"bike:1\"))  # Deimos\nres4 = r.set(\"bike:1\", \"bike\", xx=True)\nprint(res4)  # True\n# STEP_END\n\n# REMOVE_START\nassert res3 is None\nassert res4\n# REMOVE_END\n\n# STEP_START mset\nres5 = r.mset({\"bike:1\": \"Deimos\", \"bike:2\": \"Ares\", \"bike:3\": \"Vanth\"})\nprint(res5)  # True\nres6 = r.mget([\"bike:1\", \"bike:2\", \"bike:3\"])\nprint(res6)  # ['Deimos', 'Ares', 'Vanth']\n# STEP_END\n\n# REMOVE_START\nassert res5\nassert res6 == [\"Deimos\", \"Ares\", \"Vanth\"]\n# REMOVE_END\n\n# STEP_START incr\nr.set(\"total_crashes\", 0)\nres7 = r.incr(\"total_crashes\")\nprint(res7)  # 1\nres8 = r.incrby(\"total_crashes\", 10)\nprint(res8)  # 11\n# STEP_END\n\n# REMOVE_START\nassert res7 == 1\nassert res8 == 11\n# REMOVE_END\n"
  },
  {
    "path": "doctests/dt_tdigest.py",
    "content": "# EXAMPLE: tdigest_tutorial\n# HIDE_START\n\"\"\"\nCode samples for t-digest pages:\n    https://redis.io/docs/latest/develop/data-types/probabilistic/t-digest/\n\"\"\"\n\nimport redis\n\nr = redis.Redis(decode_responses=True)\n# HIDE_END\n\n# REMOVE_START\nr.delete(\"racer_ages\")\nr.delete(\"bikes:sales\")\n# REMOVE_END\n\n# STEP_START tdig_start\nres1 = r.tdigest().create(\"bikes:sales\", 100)\nprint(res1)  # >>> True\n\nres2 = r.tdigest().add(\"bikes:sales\", [21])\nprint(res2)  # >>> OK\n\nres3 = r.tdigest().add(\"bikes:sales\", [150, 95, 75, 34])\nprint(res3)  # >>> OK\n# STEP_END\n\n# REMOVE_START\nassert res1 is True\n# REMOVE_END\n\n# STEP_START tdig_cdf\nres4 = r.tdigest().create(\"racer_ages\")\nprint(res4)  # >>> True\n\nres5 = r.tdigest().add(\n    \"racer_ages\",\n    [\n        45.88,\n        44.2,\n        58.03,\n        19.76,\n        39.84,\n        69.28,\n        50.97,\n        25.41,\n        19.27,\n        85.71,\n        42.63,\n    ],\n)\nprint(res5)  # >>> OK\n\nres6 = r.tdigest().rank(\"racer_ages\", 50)\nprint(res6)  # >>> [7]\n\nres7 = r.tdigest().rank(\"racer_ages\", 50, 40)\nprint(res7)  # >>> [7, 4]\n# STEP_END\n\n# STEP_START tdig_quant\nres8 = r.tdigest().quantile(\"racer_ages\", 0.5)\nprint(res8)  # >>> [44.2]\n\nres9 = r.tdigest().byrank(\"racer_ages\", 4)\nprint(res9)  # >>> [42.63]\n# STEP_END\n\n# STEP_START tdig_min\nres10 = r.tdigest().min(\"racer_ages\")\nprint(res10)  # >>> 19.27\n\nres11 = r.tdigest().max(\"racer_ages\")\nprint(res11)  # >>> 85.71\n# STEP_END\n\n# STEP_START tdig_reset\nres12 = r.tdigest().reset(\"racer_ages\")\nprint(res12)  # >>> OK\n# STEP_END\n"
  },
  {
    "path": "doctests/dt_time_series.py",
    "content": "# EXAMPLE: time_series_tutorial\n# HIDE_START\n\"\"\"\nCode samples for time series page:\n    https://redis.io/docs/latest/develop/data-types/timeseries/\n\"\"\"\n\nimport redis\n\nr = redis.Redis(decode_responses=True)\n# HIDE_END\n\n# REMOVE_START\nr.delete(\n    \"thermometer:1\", \"thermometer:2\", \"thermometer:3\",\n    \"rg:1\", \"rg:2\", \"rg:3\", \"rg:4\",\n    \"sensor3\",\n    \"wind:1\", \"wind:2\", \"wind:3\", \"wind:4\",\n    \"hyg:1\", \"hyg:compacted\"\n)\n# REMOVE_END\n\n# STEP_START create\nres1 = r.ts().create(\"thermometer:1\")\nprint(res1)  # >>> True\n\nres2 = r.type(\"thermometer:1\")\nprint(res2)  # >>> TSDB-TYPE\n\nres3 = r.ts().info(\"thermometer:1\")\nprint(res3)\n# >>> {'rules': [], ... 'total_samples': 0, ...\n# STEP_END\n# REMOVE_START\nassert res1 is True\nassert res2 == \"TSDB-TYPE\"\nassert res3[\"total_samples\"] == 0\n# REMOVE_END\n\n# STEP_START create_retention\nres4 = r.ts().add(\"thermometer:2\", 1, 10.8, retention_msecs=100)\nprint(res4)  # >>> 1\n\nres5 = r.ts().info(\"thermometer:2\")\nprint(res5)\n# >>> {'rules': [], ... 'retention_msecs': 100, ...\n# STEP_END\n# REMOVE_START\nassert res4 == 1\nassert res5[\"retention_msecs\"] == 100\n# REMOVE_END\n\n# STEP_START create_labels\nres6 = r.ts().create(\n    \"thermometer:3\", 1, 10.4,\n    labels={\"location\": \"UK\", \"type\": \"Mercury\"}\n)\nprint(res6)  # >>> 1\n\nres7 = r.ts().info(\"thermometer:3\")\nprint(res7)\n# >>> {'rules': [], ... 'labels': {'location': 'UK', 'type': 'Mercury'}, ...\n# STEP_END\n# REMOVE_START\nassert res6 == 1\nassert res7[\"labels\"] == {\"location\": \"UK\", \"type\": \"Mercury\"}\n# REMOVE_END\n\n# STEP_START madd\nres8 = r.ts().madd([\n    (\"thermometer:1\", 1, 9.2),\n    (\"thermometer:1\", 2, 9.9),\n    (\"thermometer:2\", 2, 10.3)\n])\nprint(res8)  # >>> [1, 2, 2]\n# STEP_END\n# REMOVE_START\nassert res8 == [1, 2, 2]\n# REMOVE_END\n\n# STEP_START get\n# The last recorded temperature for thermometer:2\n# was 10.3 at time 2.\nres9 = r.ts().get(\"thermometer:2\")\nprint(res9)  # >>> (2, 10.3)\n# STEP_END\n# REMOVE_START\nassert res9 == (2, 10.3)\n# REMOVE_END\n\n# STEP_START range\n# Add 5 data points to a time series named \"rg:1\".\nres10 = r.ts().create(\"rg:1\")\nprint(res10)  # >>> True\n\nres11 = r.ts().madd([\n        (\"rg:1\", 0, 18),\n        (\"rg:1\", 1, 14),\n        (\"rg:1\", 2, 22),\n        (\"rg:1\", 3, 18),\n        (\"rg:1\", 4, 24),\n])\nprint(res11)  # >>> [0, 1, 2, 3, 4]\n\n# Retrieve all the data points in ascending order.\nres12 = r.ts().range(\"rg:1\", \"-\", \"+\")\nprint(res12)  # >>> [(0, 18.0), (1, 14.0), (2, 22.0), (3, 18.0), (4, 24.0)]\n\n# Retrieve data points up to time 1 (inclusive).\nres13 = r.ts().range(\"rg:1\", \"-\", 1)\nprint(res13)  # >>> [(0, 18.0), (1, 14.0)]\n\n# Retrieve data points from time 3 onwards.\nres14 = r.ts().range(\"rg:1\", 3, \"+\")\nprint(res14)  # >>> [(3, 18.0), (4, 24.0)]\n\n# Retrieve all the data points in descending order.\nres15 = r.ts().revrange(\"rg:1\", \"-\", \"+\")\nprint(res15)  # >>> [(4, 24.0), (3, 18.0), (2, 22.0), (1, 14.0), (0, 18.0)]\n\n# Retrieve data points up to time 1 (inclusive), but return them\n# in descending order.\nres16 = r.ts().revrange(\"rg:1\", \"-\", 1)\nprint(res16)  # >>> [(1, 14.0), (0, 18.0)]\n# STEP_END\n# REMOVE_START\nassert res10 is True\nassert res11 == [0, 1, 2, 3, 4]\nassert res12 == [(0, 18.0), (1, 14.0), (2, 22.0), (3, 18.0), (4, 24.0)]\nassert res13 == [(0, 18.0), (1, 14.0)]\nassert res14 == [(3, 18.0), (4, 24.0)]\nassert res15 == [(4, 24.0), (3, 18.0), (2, 22.0), (1, 14.0), (0, 18.0)]\nassert res16 == [(1, 14.0), (0, 18.0)]\n# REMOVE_END\n\n# STEP_START range_filter\nres17 = r.ts().range(\"rg:1\", \"-\", \"+\", filter_by_ts=[0, 2, 4])\nprint(res17)  # >>> [(0, 18.0), (2, 22.0), (4, 24.0)]\n\nres18 = r.ts().revrange(\n    \"rg:1\", \"-\", \"+\",\n    filter_by_ts=[0, 2, 4],\n    filter_by_min_value=20,\n    filter_by_max_value=25,\n)\nprint(res18)  # >>> [(4, 24.0), (2, 22.0)]\n\nres19 = r.ts().revrange(\n    \"rg:1\", \"-\", \"+\",\n    filter_by_ts=[0, 2, 4],\n    filter_by_min_value=22,\n    filter_by_max_value=22,\n    count=1,\n)\nprint(res19)  # >>> [(2, 22.0)]\n# STEP_END\n# REMOVE_START\nassert res17 == [(0, 18.0), (2, 22.0), (4, 24.0)]\nassert res18 == [(4, 24.0), (2, 22.0)]\nassert res19 == [(2, 22.0)]\n# REMOVE_END\n\n# STEP_START query_multi\n# Create three new \"rg:\" time series (two in the US\n# and one in the UK, with different units) and add some\n# data points.\nres20 = r.ts().create(\n    \"rg:2\",\n    labels={\"location\": \"us\", \"unit\": \"cm\"},\n)\nprint(res20)  # >>> True\n\nres21 = r.ts().create(\n    \"rg:3\",\n    labels={\"location\": \"us\", \"unit\": \"in\"},\n)\nprint(res21)  # >>> True\n\nres22 = r.ts().create(\n    \"rg:4\",\n    labels={\"location\": \"uk\", \"unit\": \"mm\"},\n)\nprint(res22)  # >>> True\n\nres23 = r.ts().madd([\n        (\"rg:2\", 0, 1.8),\n        (\"rg:3\", 0, 0.9),\n        (\"rg:4\", 0, 25),\n])\nprint(res23)  # >>> [0, 0, 0]\n\nres24 = r.ts().madd([\n        (\"rg:2\", 1, 2.1),\n        (\"rg:3\", 1, 0.77),\n        (\"rg:4\", 1, 18),\n])\nprint(res24)  # >>> [1, 1, 1]\n\nres25 = r.ts().madd([\n        (\"rg:2\", 2, 2.3),\n        (\"rg:3\", 2, 1.1),\n        (\"rg:4\", 2, 21),\n])\nprint(res25)  # >>> [2, 2, 2]\n\nres26 = r.ts().madd([\n        (\"rg:2\", 3, 1.9),\n        (\"rg:3\", 3, 0.81),\n        (\"rg:4\", 3, 19),\n])\nprint(res26)  # >>> [3, 3, 3]\n\nres27 = r.ts().madd([\n        (\"rg:2\", 4, 1.78),\n        (\"rg:3\", 4, 0.74),\n        (\"rg:4\", 4, 23),\n])\nprint(res27)  # >>> [4, 4, 4]\n\n# Retrieve the last data point from each US time series. If\n# you don't specify any labels, an empty array is returned\n# for the labels.\nres28 = r.ts().mget([\"location=us\"])\nprint(res28)  # >>> [{'rg:2': [{}, 4, 1.78]}, {'rg:3': [{}, 4, 0.74]}]\n\n# Retrieve the same data points, but include the `unit`\n# label in the results.\nres29 = r.ts().mget([\"location=us\"], select_labels=[\"unit\"])\nprint(res29)  # >>> [{'unit': 'cm'}, (4, 1.78), {'unit': 'in'}, (4, 0.74)]\n\n# Retrieve data points up to time 2 (inclusive) from all\n# time series that use millimeters as the unit. Include all\n# labels in the results.\nres30 = r.ts().mrange(\n    \"-\", 2, filters=[\"unit=mm\"], with_labels=True\n)\nprint(res30)\n# >>> [{'rg:4': [{'location': 'uk', 'unit': 'mm'}, [(0, 25.4),...\n\n# Retrieve data points from time 1 to time 3 (inclusive) from\n# all time series that use centimeters or millimeters as the unit,\n# but only return the `location` label. Return the results\n# in descending order of timestamp.\nres31 = r.ts().mrevrange(\n    1, 3, filters=[\"unit=(cm,mm)\"], select_labels=[\"location\"]\n)\nprint(res31)\n# >>> [[{'location': 'uk'}, (3, 19.0), (2, 21.0), (1, 18.0)],...\n# STEP_END\n# REMOVE_START\nassert res20 is True\nassert res21 is True\nassert res22 is True\nassert res23 == [0, 0, 0]\nassert res24 == [1, 1, 1]\nassert res25 == [2, 2, 2]\nassert res26 == [3, 3, 3]\nassert res27 == [4, 4, 4]\nassert res28 == [{'rg:2': [{}, 4, 1.78]}, {'rg:3': [{}, 4, 0.74]}]\nassert res29 == [\n    {'rg:2': [{'unit': 'cm'}, 4, 1.78]},\n    {'rg:3': [{'unit': 'in'}, 4, 0.74]}\n]\nassert res30 == [\n    {\n        'rg:4': [\n            {'location': 'uk', 'unit': 'mm'},\n            [(0, 25), (1, 18.0), (2, 21.0)]\n        ]\n    }\n]\nassert res31 == [\n    {'rg:2': [{'location': 'us'}, [(3, 1.9), (2, 2.3), (1, 2.1)]]},\n    {'rg:4': [{'location': 'uk'}, [(3, 19.0), (2, 21.0), (1, 18.0)]]}\n]\n# REMOVE_END\n\n# STEP_START agg\nres32 = r.ts().range(\n    \"rg:2\", \"-\", \"+\",\n    aggregation_type=\"avg\",\n    bucket_size_msec=2\n)\nprint(res32)\n# >>> [(0, 1.9500000000000002), (2, 2.0999999999999996), (4, 1.78)]\n# STEP_END\n# REMOVE_START\nassert res32 == [\n    (0, 1.9500000000000002), (2, 2.0999999999999996),\n    (4, 1.78)\n]\n# REMOVE_END\n\n# STEP_START agg_bucket\nres33 = r.ts().create(\"sensor3\")\nprint(res33)  # >>> True\n\nres34 = r.ts().madd([\n    (\"sensor3\", 10, 1000),\n    (\"sensor3\", 20, 2000),\n    (\"sensor3\", 30, 3000),\n    (\"sensor3\", 40, 4000),\n    (\"sensor3\", 50, 5000),\n    (\"sensor3\", 60, 6000),\n    (\"sensor3\", 70, 7000),\n])\nprint(res34)  # >>> [10, 20, 30, 40, 50, 60, 70]\n\nres35 = r.ts().range(\n    \"sensor3\", 10, 70,\n    aggregation_type=\"min\",\n    bucket_size_msec=25\n)\nprint(res35)\n# >>> [(0, 1000.0), (25, 3000.0), (50, 5000.0)]\n# STEP_END\n# REMOVE_START\nassert res33 is True\nassert res34 == [10, 20, 30, 40, 50, 60, 70]\nassert res35 == [(0, 1000.0), (25, 3000.0), (50, 5000.0)]\n# REMOVE_END\n\n# STEP_START agg_align\nres36 = r.ts().range(\n    \"sensor3\", 10, 70,\n    aggregation_type=\"min\",\n    bucket_size_msec=25,\n    align=\"START\"\n)\nprint(res36)\n# >>> [(10, 1000.0), (35, 4000.0), (60, 6000.0)]\n# STEP_END\n# REMOVE_START\nassert res36 == [(10, 1000.0), (35, 4000.0), (60, 6000.0)]\n# REMOVE_END\n\n# STEP_START agg_multi\nres37 = r.ts().create(\n    \"wind:1\",\n    labels={\"country\": \"uk\"}\n)\nprint(res37)  # >>> True\n\nres38 = r.ts().create(\n    \"wind:2\",\n    labels={\"country\": \"uk\"}\n)\nprint(res38)  # >>> True\n\nres39 = r.ts().create(\n    \"wind:3\",\n    labels={\"country\": \"us\"}\n)\nprint(res39)  # >>> True\n\nres40 = r.ts().create(\n    \"wind:4\",\n    labels={\"country\": \"us\"}\n)\nprint(res40)  # >>> True\n\nres41 = r.ts().madd([\n        (\"wind:1\", 1, 12),\n        (\"wind:2\", 1, 18),\n        (\"wind:3\", 1, 5),\n        (\"wind:4\", 1, 20),\n])\nprint(res41)  # >>> [1, 1, 1, 1]\n\nres42 = r.ts().madd([\n        (\"wind:1\", 2, 14),\n        (\"wind:2\", 2, 21),\n        (\"wind:3\", 2, 4),\n        (\"wind:4\", 2, 25),\n])\nprint(res42)  # >>> [2, 2, 2, 2]\n\nres43 = r.ts().madd([\n        (\"wind:1\", 3, 10),\n        (\"wind:2\", 3, 24),\n        (\"wind:3\", 3, 8),\n        (\"wind:4\", 3, 18),\n])\nprint(res43)  # >>> [3, 3, 3, 3]\n\n# The result pairs contain the timestamp and the maximum sample value\n# for the country at that timestamp.\nres44 = r.ts().mrange(\n    \"-\", \"+\",\n    filters=[\"country=(us,uk)\"],\n    groupby=\"country\",\n    reduce=\"max\"\n)\nprint(res44)\n# >>> [{'country=uk': [{}, [(1, 18.0), (2, 21.0), (3, 24.0)]]}, ...\n\n# The result pairs contain the timestamp and the average sample value\n# for the country at that timestamp.\nres45 = r.ts().mrange(\n    \"-\", \"+\",\n    filters=[\"country=(us,uk)\"],\n    groupby=\"country\",\n    reduce=\"avg\"\n)\nprint(res45)\n# >>> [{'country=uk': [{}, [(1, 15.0), (2, 17.5), (3, 17.0)]]}, ...\n# STEP_END\n# REMOVE_START\nassert res37 is True\nassert res38 is True\nassert res39 is True\nassert res40 is True\nassert res41 == [1, 1, 1, 1]\nassert res42 == [2, 2, 2, 2]\nassert res43 == [3, 3, 3, 3]\nassert res44 == [\n    {'country=uk': [{}, [(1, 18.0), (2, 21.0), (3, 24.0)]]},\n    {'country=us': [{}, [(1, 20.0), (2, 25.0), (3, 18.0)]]}\n]\nassert res45 == [\n    {'country=uk': [{}, [(1, 15.0), (2, 17.5), (3, 17.0)]]},\n    {'country=us': [{}, [(1, 12.5), (2, 14.5), (3, 13.0)]]}\n]\n# REMOVE_END\n\n# STEP_START create_compaction\nres45 = r.ts().create(\"hyg:1\")\nprint(res45)  # >>> True\n\nres46 = r.ts().create(\"hyg:compacted\")\nprint(res46)  # >>> True\n\nres47 = r.ts().createrule(\"hyg:1\", \"hyg:compacted\", \"min\", 3)\nprint(res47)  # >>> True\n\nres48 = r.ts().info(\"hyg:1\")\nprint(res48.rules)\n# >>> [['hyg:compacted', 3, 'MIN', 0]]\n\nres49 = r.ts().info(\"hyg:compacted\")\nprint(res49.source_key)  # >>> 'hyg:1'\n# STEP_END\n# REMOVE_START\nassert res45 is True\nassert res46 is True\nassert res47 is True\nassert res48.rules == [['hyg:compacted', 3, 'MIN', 0]]\nassert res49.source_key == 'hyg:1'\n# REMOVE_END\n\n# STEP_START comp_add\nres50 = r.ts().madd([\n    (\"hyg:1\", 0, 75),\n    (\"hyg:1\", 1, 77),\n    (\"hyg:1\", 2, 78),\n])\nprint(res50)  # >>> [0, 1, 2]\n\nres51 = r.ts().range(\"hyg:compacted\", \"-\", \"+\")\nprint(res51)  # >>> []\n\nres52 = r.ts().add(\"hyg:1\", 3, 79)\nprint(res52)  # >>> 3\n\nres53 = r.ts().range(\"hyg:compacted\", \"-\", \"+\")\nprint(res53)  # >>> [(0, 75.0)]\n# STEP_END\n# REMOVE_START\nassert res50 == [0, 1, 2]\nassert res51 == []\nassert res52 == 3\nassert res53 == [(0, 75.0)]\n# REMOVE_END\n\n# STEP_START del\nres54 = r.ts().info(\"thermometer:1\")\nprint(res54.total_samples)  # >>> 2\nprint(res54.first_timestamp)  # >>> 1\nprint(res54.last_timestamp)  # >>> 2\n\nres55 = r.ts().add(\"thermometer:1\", 3, 9.7)\nprint(res55)  # >>> 3\n\nres56 = r.ts().info(\"thermometer:1\")\nprint(res56.total_samples)  # >>> 3\nprint(res56.first_timestamp)  # >>> 1\nprint(res56.last_timestamp)  # >>> 3\n\nres57 = r.ts().delete(\"thermometer:1\", 1, 2)\nprint(res57)  # >>> 2\n\nres58 = r.ts().info(\"thermometer:1\")\nprint(res58.total_samples)  # >>> 1\nprint(res58.first_timestamp)  # >>> 3\nprint(res58.last_timestamp)  # >>> 3\n\nres59 = r.ts().delete(\"thermometer:1\", 3, 3)\nprint(res59)  # >>> 1\n\nres60 = r.ts().info(\"thermometer:1\")\nprint(res60.total_samples)  # >>> 0\n# STEP_END\n# REMOVE_START\nassert res54.total_samples == 2\nassert res54.first_timestamp == 1\nassert res54.last_timestamp == 2\nassert res55 == 3\nassert res56.total_samples == 3\nassert res56.first_timestamp == 1\nassert res56.last_timestamp == 3\nassert res57 == 2\nassert res58.total_samples == 1\nassert res58.first_timestamp == 3\nassert res58.last_timestamp == 3\nassert res59 == 1\nassert res60.total_samples == 0\n# REMOVE_END\n"
  },
  {
    "path": "doctests/dt_topk.py",
    "content": "# EXAMPLE: topk_tutorial\n# HIDE_START\n\"\"\"\nCode samples for Top-K pages:\n    https://redis.io/docs/latest/develop/data-types/probabilistic/top-k/\n\"\"\"\n\nimport redis\n\nr = redis.Redis(decode_responses=True)\n# HIDE_END\n\n# REMOVE_START\nr.delete(\"bikes:keywords\")\n# REMOVE_END\n\n# STEP_START topk\nres1 = r.topk().reserve(\"bikes:keywords\", 5, 2000, 7, 0.925)\nprint(res1)  # >>> True\n\nres2 = r.topk().add(\n    \"bikes:keywords\",\n    \"store\",\n    \"seat\",\n    \"handlebars\",\n    \"handles\",\n    \"pedals\",\n    \"tires\",\n    \"store\",\n    \"seat\",\n)\nprint(res2)  # >>> [None, None, None, None, None, 'handlebars', None, None]\n\nres3 = r.topk().list(\"bikes:keywords\")\nprint(res3)  # >>> ['store', 'seat', 'pedals', 'tires', 'handles']\n\nres4 = r.topk().query(\"bikes:keywords\", \"store\", \"handlebars\")\nprint(res4)  # >>> [1, 0]\n"
  },
  {
    "path": "doctests/dt_vec_set.py",
    "content": "# EXAMPLE: vecset_tutorial\n# HIDE_START\n\"\"\"\nCode samples for Vector set doc pages:\n    https://redis.io/docs/latest/develop/data-types/vector-sets/\n\"\"\"\n\nimport redis\n\nfrom redis.commands.vectorset.commands import (\n    QuantizationOptions\n)\n\nr = redis.Redis(decode_responses=True)\n# HIDE_END\n\n# REMOVE_START\nr.delete(\n    \"points\", \"quantSetQ8\", \"quantSetNoQ\",\n    \"quantSetBin\", \"setNotReduced\", \"setReduced\"\n)\n# REMOVE_END\n\n# STEP_START vadd\nres1 = r.vset().vadd(\"points\", [1.0, 1.0], \"pt:A\")\nprint(res1)  # >>> 1\n\nres2 = r.vset().vadd(\"points\", [-1.0, -1.0], \"pt:B\")\nprint(res2)  # >>> 1\n\nres3 = r.vset().vadd(\"points\", [-1.0, 1.0], \"pt:C\")\nprint(res3)  # >>> 1\n\nres4 = r.vset().vadd(\"points\", [1.0, -1.0], \"pt:D\")\nprint(res4)  # >>> 1\n\nres5 = r.vset().vadd(\"points\", [1.0, 0], \"pt:E\")\nprint(res5)  # >>> 1\n\nres6 = r.type(\"points\")\nprint(res6)  # >>> vectorset\n# STEP_END\n# REMOVE_START\nassert res1 == 1\nassert res2 == 1\nassert res3 == 1\nassert res4 == 1\nassert res5 == 1\n\nassert res6 == \"vectorset\"\n# REMOVE_END\n\n# STEP_START vcardvdim\nres7 = r.vset().vcard(\"points\")\nprint(res7)  # >>> 5\n\nres8 = r.vset().vdim(\"points\")\nprint(res8)  # >>> 2\n# STEP_END\n# REMOVE_START\nassert res7 == 5\nassert res8 == 2\n# REMOVE_END\n\n# STEP_START vemb\nres9 = r.vset().vemb(\"points\", \"pt:A\")\nprint(res9)  # >>> [0.9999999403953552, 0.9999999403953552]\n\nres10 = r.vset().vemb(\"points\", \"pt:B\")\nprint(res10)  # >>> [-0.9999999403953552, -0.9999999403953552]\n\nres11 = r.vset().vemb(\"points\", \"pt:C\")\nprint(res11)  # >>> [-0.9999999403953552, 0.9999999403953552]\n\nres12 = r.vset().vemb(\"points\", \"pt:D\")\nprint(res12)  # >>> [0.9999999403953552, -0.9999999403953552]\n\nres13 = r.vset().vemb(\"points\", \"pt:E\")\nprint(res13)  # >>> [1, 0]\n# STEP_END\n# REMOVE_START\nassert 1 - res9[0] < 0.001\nassert 1 - res9[1] < 0.001\nassert 1 + res10[0] < 0.001\nassert 1 + res10[1] < 0.001\nassert 1 + res11[0] < 0.001\nassert 1 - res11[1] < 0.001\nassert 1 - res12[0] < 0.001\nassert 1 + res12[1] < 0.001\nassert res13 == [1, 0]\n# REMOVE_END\n\n# STEP_START attr\nres14 = r.vset().vsetattr(\"points\", \"pt:A\", {\n    \"name\": \"Point A\",\n    \"description\": \"First point added\"\n})\nprint(res14)  # >>> 1\n\nres15 = r.vset().vgetattr(\"points\", \"pt:A\")\nprint(res15)\n# >>> {'name': 'Point A', 'description': 'First point added'}\n\nres16 = r.vset().vsetattr(\"points\", \"pt:A\", \"\")\nprint(res16)  # >>> 1\n\nres17 = r.vset().vgetattr(\"points\", \"pt:A\")\nprint(res17)  # >>> None\n# STEP_END\n# REMOVE_START\nassert res14 == 1\nassert res15 == {\"name\": \"Point A\", \"description\": \"First point added\"}\nassert res16 == 1\nassert res17 is None\n# REMOVE_END\n\n# STEP_START vrem\nres18 = r.vset().vadd(\"points\", [0, 0], \"pt:F\")\nprint(res18)  # >>> 1\n\nres19 = r.vset().vcard(\"points\")\nprint(res19)  # >>> 6\n\nres20 = r.vset().vrem(\"points\", \"pt:F\")\nprint(res20)  # >>> 1\n\nres21 = r.vset().vcard(\"points\")\nprint(res21)  # >>> 5\n# STEP_END\n# REMOVE_START\nassert res18 == 1\nassert res19 == 6\nassert res20 == 1\nassert res21 == 5\n# REMOVE_END\n\n# STEP_START vsim_basic\nres22 = r.vset().vsim(\"points\", [0.9, 0.1])\nprint(res22)\n# >>> ['pt:E', 'pt:A', 'pt:D', 'pt:C', 'pt:B']\n# STEP_END\n# REMOVE_START\nassert res22 == [\"pt:E\", \"pt:A\", \"pt:D\", \"pt:C\", \"pt:B\"]\n# REMOVE_END\n\n# STEP_START vsim_options\nres23 = r.vset().vsim(\n    \"points\", \"pt:A\",\n    with_scores=True,\n    count=4\n)\nprint(res23)\n# >>> {'pt:A': 1.0, 'pt:E': 0.8535534143447876, 'pt:D': 0.5, 'pt:C': 0.5}\n# STEP_END\n# REMOVE_START\nassert res23[\"pt:A\"] == 1.0\nassert res23[\"pt:C\"] == 0.5\nassert res23[\"pt:D\"] == 0.5\nassert res23[\"pt:E\"] - 0.85 < 0.005\n# REMOVE_END\n\n# STEP_START vsim_filter\nres24 = r.vset().vsetattr(\"points\", \"pt:A\", {\n    \"size\": \"large\",\n    \"price\": 18.99\n})\nprint(res24)  # >>> 1\n\nres25 = r.vset().vsetattr(\"points\", \"pt:B\", {\n    \"size\": \"large\",\n    \"price\": 35.99\n})\nprint(res25)  # >>> 1\n\nres26 = r.vset().vsetattr(\"points\", \"pt:C\", {\n    \"size\": \"large\",\n    \"price\": 25.99\n})\nprint(res26)  # >>> 1\n\nres27 = r.vset().vsetattr(\"points\", \"pt:D\", {\n    \"size\": \"small\",\n    \"price\": 21.00\n})\nprint(res27)  # >>> 1\n\nres28 = r.vset().vsetattr(\"points\", \"pt:E\", {\n    \"size\": \"small\",\n    \"price\": 17.75\n})\nprint(res28)  # >>> 1\n\n# Return elements in order of distance from point A whose\n# `size` attribute is `large`.\nres29 = r.vset().vsim(\n    \"points\", \"pt:A\",\n    filter='.size == \"large\"'\n)\nprint(res29)  # >>> ['pt:A', 'pt:C', 'pt:B']\n\n# Return elements in order of distance from point A whose size is\n# `large` and whose price is greater than 20.00.\nres30 = r.vset().vsim(\n    \"points\", \"pt:A\",\n    filter='.size == \"large\" && .price > 20.00'\n)\nprint(res30)  # >>> ['pt:C', 'pt:B']\n# STEP_END\n# REMOVE_START\nassert res24 == 1\nassert res25 == 1\nassert res26 == 1\nassert res27 == 1\nassert res28 == 1\n\nassert res30 == ['pt:C', 'pt:B']\n# REMOVE_END\n\n# STEP_START add_quant\n# Import `QuantizationOptions` enum using:\n#\n# from redis.commands.vectorset.commands import (\n#   QuantizationOptions\n# )\nres31 = r.vset().vadd(\n    \"quantSetQ8\", [1.262185, 1.958231],\n    \"quantElement\",\n    quantization=QuantizationOptions.Q8\n)\nprint(res31)  # >>> 1\n\nres32 = r.vset().vemb(\"quantSetQ8\", \"quantElement\")\nprint(f\"Q8: {res32}\")\n# >>> Q8: [1.2643694877624512, 1.958230972290039]\n\nres33 = r.vset().vadd(\n    \"quantSetNoQ\", [1.262185, 1.958231],\n    \"quantElement\",\n    quantization=QuantizationOptions.NOQUANT\n)\nprint(res33)  # >>> 1\n\nres34 = r.vset().vemb(\"quantSetNoQ\", \"quantElement\")\nprint(f\"NOQUANT: {res34}\")\n# >>> NOQUANT: [1.262184977531433, 1.958230972290039]\n\nres35 = r.vset().vadd(\n    \"quantSetBin\", [1.262185, 1.958231],\n    \"quantElement\",\n    quantization=QuantizationOptions.BIN\n)\nprint(res35)  # >>> 1\n\nres36 = r.vset().vemb(\"quantSetBin\", \"quantElement\")\nprint(f\"BIN: {res36}\")\n# >>> BIN: [1, 1]\n# STEP_END\n# REMOVE_START\nassert res31 == 1\n# REMOVE_END\n\n# STEP_START add_reduce\n# Create a list of 300 arbitrary values.\nvalues = [x / 299 for x in range(300)]\n\nres37 = r.vset().vadd(\n    \"setNotReduced\",\n    values,\n    \"element\"\n)\nprint(res37)  # >>> 1\n\nres38 = r.vset().vdim(\"setNotReduced\")\nprint(res38)  # >>> 300\n\nres39 = r.vset().vadd(\n    \"setReduced\",\n    values,\n    \"element\",\n    reduce_dim=100\n)\nprint(res39)  # >>> 1\n\nres40 = r.vset().vdim(\"setReduced\")  # >>> 100\nprint(res40)\n# STEP_END\n"
  },
  {
    "path": "doctests/geo_index.py",
    "content": "# EXAMPLE: geoindex\nimport redis\nfrom redis.commands.json.path import Path\nfrom redis.commands.search.field import TextField, GeoField, GeoShapeField\nfrom redis.commands.search.indexDefinition import IndexDefinition, IndexType\nfrom redis.commands.search.query import Query\n\nr = redis.Redis()\n# REMOVE_START\ntry:\n    r.ft(\"productidx\").dropindex(True)\nexcept redis.exceptions.ResponseError:\n    pass\n\ntry:\n    r.ft(\"geomidx\").dropindex(True)\nexcept redis.exceptions.ResponseError:\n    pass\n\nr.delete(\"product:46885\", \"product:46886\", \"shape:1\", \"shape:2\", \"shape:3\", \"shape:4\")\n# REMOVE_END\n\n# STEP_START create_geo_idx\ngeo_schema = (\n    GeoField(\"$.location\", as_name=\"location\")\n)\n\ngeo_index_create_result = r.ft(\"productidx\").create_index(\n    geo_schema,\n    definition=IndexDefinition(\n        prefix=[\"product:\"], index_type=IndexType.JSON\n    )\n)\nprint(geo_index_create_result)  # >>> True\n# STEP_END\n# REMOVE_START\nassert geo_index_create_result\n# REMOVE_END\n\n# STEP_START add_geo_json\nprd46885 = {\n    \"description\": \"Navy Blue Slippers\",\n    \"price\": 45.99,\n    \"city\": \"Denver\",\n    \"location\": \"-104.991531, 39.742043\"\n}\n\njson_add_result_1 = r.json().set(\"product:46885\", Path.root_path(), prd46885)\nprint(json_add_result_1)  # >>> True\n\nprd46886 = {\n    \"description\": \"Bright Green Socks\",\n    \"price\": 25.50,\n    \"city\": \"Fort Collins\",\n    \"location\": \"-105.0618814,40.5150098\"\n}\n\njson_add_result_2 = r.json().set(\"product:46886\", Path.root_path(), prd46886)\nprint(json_add_result_2)  # >>> True\n# STEP_END\n# REMOVE_START\nassert json_add_result_1\nassert json_add_result_2\n# REMOVE_END\n\n# STEP_START geo_query\ngeo_result = r.ft(\"productidx\").search(\n    \"@location:[-104.800644 38.846127 100 mi]\"\n)\nprint(geo_result)\n# >>> Result{1 total, docs: [Document {'id': 'product:46885'...\n# STEP_END\n# REMOVE_START\nassert len(geo_result.docs) == 1\nassert geo_result.docs[0][\"id\"] == \"product:46885\"\n# REMOVE_END\n\n# STEP_START create_gshape_idx\ngeom_schema = (\n    TextField(\"$.name\", as_name=\"name\"),\n    GeoShapeField(\n        \"$.geom\", as_name=\"geom\", coord_system=GeoShapeField.FLAT\n    )\n)\n\ngeom_index_create_result = r.ft(\"geomidx\").create_index(\n    geom_schema,\n    definition=IndexDefinition(\n        prefix=[\"shape:\"], index_type=IndexType.JSON\n    )\n)\nprint(geom_index_create_result)  # True\n# STEP_END\n# REMOVE_START\nassert geom_index_create_result\n# REMOVE_END\n\n# STEP_START add_gshape_json\nshape1 = {\n    \"name\": \"Green Square\",\n    \"geom\": \"POLYGON ((1 1, 1 3, 3 3, 3 1, 1 1))\"\n}\n\ngm_json_res_1 = r.json().set(\"shape:1\", Path.root_path(), shape1)\nprint(gm_json_res_1)  # >>> True\n\nshape2 = {\n    \"name\": \"Red Rectangle\",\n    \"geom\": \"POLYGON ((2 2.5, 2 3.5, 3.5 3.5, 3.5 2.5, 2 2.5))\"\n}\n\ngm_json_res_2 = r.json().set(\"shape:2\", Path.root_path(), shape2)\nprint(gm_json_res_2)  # >>> True\n\nshape3 = {\n    \"name\": \"Blue Triangle\",\n    \"geom\": \"POLYGON ((3.5 1, 3.75 2, 4 1, 3.5 1))\"\n}\n\ngm_json_res_3 = r.json().set(\"shape:3\", Path.root_path(), shape3)\nprint(gm_json_res_3)  # >>> True\n\nshape4 = {\n    \"name\": \"Purple Point\",\n    \"geom\": \"POINT (2 2)\"\n}\n\ngm_json_res_4 = r.json().set(\"shape:4\", Path.root_path(), shape4)\nprint(gm_json_res_4)  # >>> True\n# STEP_END\n# REMOVE_START\nassert gm_json_res_1\nassert gm_json_res_2\nassert gm_json_res_3\nassert gm_json_res_4\n# REMOVE_END\n\n# STEP_START gshape_query\ngeom_result = r.ft(\"geomidx\").search(\n    Query(\n        \"(-@name:(Green Square) @geom:[WITHIN $qshape])\"\n    ).dialect(4).paging(0, 1),\n    query_params={\n        \"qshape\": \"POLYGON ((1 1, 1 3, 3 3, 3 1, 1 1))\"\n    }\n)\nprint(geom_result)\n# >>> Result{1 total, docs: [Document {'id': 'shape:4'...\n# STEP_END\n# REMOVE_START\nassert len(geom_result.docs) == 1\nassert geom_result.docs[0][\"id\"] == \"shape:4\"\n# REMOVE_END\n"
  },
  {
    "path": "doctests/home_json.py",
    "content": "# EXAMPLE: py_home_json\n# BINDER_ID python-py_home_json\n\"\"\"\nJSON examples from redis-py \"home\" page\"\n https://redis.io/docs/latest/develop/connect/clients/python/redis-py/#example-indexing-and-querying-json-documents\n\"\"\"\n\n# STEP_START import\nimport redis\nfrom redis.commands.json.path import Path\nimport redis.commands.search.aggregation as aggregations\nimport redis.commands.search.reducers as reducers\nfrom redis.commands.search.field import TextField, NumericField, TagField\nfrom redis.commands.search.index_definition import IndexDefinition, IndexType\nfrom redis.commands.search.query import Query\nimport redis.exceptions\n# STEP_END\n\n# STEP_START create_data\nuser1 = {\n    \"name\": \"Paul John\",\n    \"email\": \"paul.john@example.com\",\n    \"age\": 42,\n    \"city\": \"London\"\n}\n\nuser2 = {\n    \"name\": \"Eden Zamir\",\n    \"email\": \"eden.zamir@example.com\",\n    \"age\": 29,\n    \"city\": \"Tel Aviv\"\n}\n\nuser3 = {\n    \"name\": \"Paul Zamir\",\n    \"email\": \"paul.zamir@example.com\",\n    \"age\": 35,\n    \"city\": \"Tel Aviv\"\n}\n# STEP_END\n\n# STEP_START connect\nr = redis.Redis(decode_responses=True)\n# STEP_END\n\n# STEP_START cleanup_json\ntry:\n    r.ft(\"idx:users\").dropindex(True)\nexcept redis.exceptions.ResponseError:\n    pass\n\nr.delete(\"user:1\", \"user:2\", \"user:3\")\n# STEP_END\n\n# STEP_START make_index\nschema = (\n    TextField(\"$.name\", as_name=\"name\"),\n    TagField(\"$.city\", as_name=\"city\"),\n    NumericField(\"$.age\", as_name=\"age\")\n)\n\nindexCreated = r.ft(\"idx:users\").create_index(\n    schema,\n    definition=IndexDefinition(\n        prefix=[\"user:\"], index_type=IndexType.JSON\n    )\n)\n# STEP_END\n# REMOVE_START\nassert indexCreated\n# REMOVE_END\n\n# STEP_START add_data\nuser1Set = r.json().set(\"user:1\", Path.root_path(), user1)\nuser2Set = r.json().set(\"user:2\", Path.root_path(), user2)\nuser3Set = r.json().set(\"user:3\", Path.root_path(), user3)\n# STEP_END\n# REMOVE_START\nassert user1Set\nassert user2Set\nassert user3Set\n# REMOVE_END\n\n# STEP_START query1\nfindPaulResult = r.ft(\"idx:users\").search(\n    Query(\"Paul @age:[30 40]\")\n)\n\nprint(findPaulResult)\n# >>> Result{1 total, docs: [Document {'id': 'user:3', ...\n# STEP_END\n# REMOVE_START\nassert str(findPaulResult) == (\n    \"Result{1 total, docs: [Document {'id': 'user:3', 'payload': None, \"\n    + \"'json': '{\\\"name\\\":\\\"Paul Zamir\\\",\\\"email\\\":\"\n    + \"\\\"paul.zamir@example.com\\\",\\\"age\\\":35,\\\"city\\\":\\\"Tel Aviv\\\"}'}]}\"\n)\n# REMOVE_END\n\n# STEP_START query2\ncitiesResult = r.ft(\"idx:users\").search(\n    Query(\"Paul\").return_field(\"$.city\", as_field=\"city\")\n).docs\n\nprint(citiesResult)\n# >>> [Document {'id': 'user:1', 'payload': None, ...\n# STEP_END\n# REMOVE_START\ncitiesResult.sort(key=lambda doc: doc['id'])\n\nassert str(citiesResult) == (\n    \"[Document {'id': 'user:1', 'payload': None, 'city': 'London'}, \"\n    + \"Document {'id': 'user:3', 'payload': None, 'city': 'Tel Aviv'}]\"\n)\n# REMOVE_END\n\n# STEP_START query3\nreq = aggregations.AggregateRequest(\"*\").group_by(\n    '@city', reducers.count().alias('count')\n)\n\naggResult = r.ft(\"idx:users\").aggregate(req).rows\nprint(aggResult)\n# >>> [['city', 'London', 'count', '1'], ['city', 'Tel Aviv', 'count', '2']]\n# STEP_END\n# REMOVE_START\naggResult.sort(key=lambda row: row[1])\n\nassert str(aggResult) == (\n    \"[['city', 'London', 'count', '1'], ['city', 'Tel Aviv', 'count', '2']]\"\n)\n# REMOVE_END\n\n# STEP_START cleanup_hash\ntry:\n    r.ft(\"hash-idx:users\").dropindex(True)\nexcept redis.exceptions.ResponseError:\n    pass\n\nr.delete(\"huser:1\", \"huser:2\", \"huser:3\")\n# STEP_END\n\n# STEP_START make_hash_index\nhashSchema = (\n    TextField(\"name\"),\n    TagField(\"city\"),\n    NumericField(\"age\")\n)\n\nhashIndexCreated = r.ft(\"hash-idx:users\").create_index(\n    hashSchema,\n    definition=IndexDefinition(\n        prefix=[\"huser:\"], index_type=IndexType.HASH\n    )\n)\n# STEP_END\n# REMOVE_START\nassert hashIndexCreated\n# REMOVE_END\n\n# STEP_START add_hash_data\nhuser1Set = r.hset(\"huser:1\", mapping=user1)\nhuser2Set = r.hset(\"huser:2\", mapping=user2)\nhuser3Set = r.hset(\"huser:3\", mapping=user3)\n# STEP_END\n# REMOVE_START\nassert huser1Set\nassert huser2Set\nassert huser3Set\n# REMOVE_END\n\n# STEP_START query1_hash\nfindPaulHashResult = r.ft(\"hash-idx:users\").search(\n    Query(\"Paul @age:[30 40]\")\n)\n\nprint(findPaulHashResult)\n# >>> Result{1 total, docs: [Document {'id': 'huser:3',\n# >>>   'payload': None, 'name': 'Paul Zamir', ...\n# STEP_END\n# REMOVE_START\nassert str(findPaulHashResult) == (\n    \"Result{1 total, docs: [Document \" +\n    \"{'id': 'huser:3', 'payload': None, 'name': 'Paul Zamir', \" +\n    \"'email': 'paul.zamir@example.com', 'age': '35', 'city': 'Tel Aviv'}]}\"\n)\n# REMOVE_END\n\nr.close()\n"
  },
  {
    "path": "doctests/home_prob_dts.py",
    "content": "# EXAMPLE: home_prob_dts\n\"\"\"\nProbabilistic data type examples:\n https://redis.io/docs/latest/develop/connect/clients/python/redis-py/prob\n\"\"\"\n\n# HIDE_START\nimport redis\nr = redis.Redis(decode_responses=True)\n# HIDE_END\n# REMOVE_START\nr.delete(\n    \"recorded_users\", \"other_users\",\n    \"group:1\", \"group:2\", \"both_groups\",\n    \"items_sold\",\n    \"male_heights\", \"female_heights\", \"all_heights\",\n    \"top_3_songs\"\n)\n# REMOVE_END\n\n# STEP_START bloom\nres1 = r.bf().madd(\"recorded_users\", \"andy\", \"cameron\", \"david\", \"michelle\")\nprint(res1)  # >>> [1, 1, 1, 1]\n\nres2 = r.bf().exists(\"recorded_users\", \"cameron\")\nprint(res2)  # >>> 1\n\nres3 = r.bf().exists(\"recorded_users\", \"kaitlyn\")\nprint(res3)  # >>> 0\n# STEP_END\n# REMOVE_START\nassert res1 == [1, 1, 1, 1]\nassert res2 == 1\nassert res3 == 0\n# REMOVE_END\n\n# STEP_START cuckoo\nres4 = r.cf().add(\"other_users\", \"paolo\")\nprint(res4)  # >>> 1\n\nres5 = r.cf().add(\"other_users\", \"kaitlyn\")\nprint(res5)  # >>> 1\n\nres6 = r.cf().add(\"other_users\", \"rachel\")\nprint(res6)  # >>> 1\n\nres7 = r.cf().mexists(\"other_users\", \"paolo\", \"rachel\", \"andy\")\nprint(res7)  # >>> [1, 1, 0]\n\nres8 = r.cf().delete(\"other_users\", \"paolo\")\nprint(res8)  # >>> 1\n\nres9 = r.cf().exists(\"other_users\", \"paolo\")\nprint(res9)  # >>> 0\n# STEP_END\n# REMOVE_START\nassert res4 == 1\nassert res5 == 1\nassert res6 == 1\nassert res7 == [1, 1, 0]\nassert res8 == 1\nassert res9 == 0\n# REMOVE_END\n\n# STEP_START hyperloglog\nres10 = r.pfadd(\"group:1\", \"andy\", \"cameron\", \"david\")\nprint(res10)  # >>> 1\n\nres11 = r.pfcount(\"group:1\")\nprint(res11)  # >>> 3\n\nres12 = r.pfadd(\"group:2\", \"kaitlyn\", \"michelle\", \"paolo\", \"rachel\")\nprint(res12)  # >>> 1\n\nres13 = r.pfcount(\"group:2\")\nprint(res13)  # >>> 4\n\nres14 = r.pfmerge(\"both_groups\", \"group:1\", \"group:2\")\nprint(res14)  # >>> True\n\nres15 = r.pfcount(\"both_groups\")\nprint(res15)  # >>> 7\n# STEP_END\n# REMOVE_START\nassert res10 == 1\nassert res11 == 3\nassert res12 == 1\nassert res13 == 4\nassert res14\nassert res15 == 7\n# REMOVE_END\n\n# STEP_START cms\n# Specify that you want to keep the counts within 0.01\n# (1%) of the true value with a 0.005 (0.5%) chance\n# of going outside this limit.\nres16 = r.cms().initbyprob(\"items_sold\", 0.01, 0.005)\nprint(res16)  # >>> True\n\n# The parameters for `incrby()` are two lists. The count\n# for each item in the first list is incremented by the\n# value at the same index in the second list.\nres17 = r.cms().incrby(\n    \"items_sold\",\n    [\"bread\", \"tea\", \"coffee\", \"beer\"],  # Items sold\n    [300, 200, 200, 100]\n)\nprint(res17)  # >>> [300, 200, 200, 100]\n\nres18 = r.cms().incrby(\n    \"items_sold\",\n    [\"bread\", \"coffee\"],\n    [100, 150]\n)\nprint(res18)  # >>> [400, 350]\n\nres19 = r.cms().query(\"items_sold\", \"bread\", \"tea\", \"coffee\", \"beer\")\nprint(res19)  # >>> [400, 200, 350, 100]\n# STEP_END\n# REMOVE_START\nassert res16\nassert res17 == [300, 200, 200, 100]\nassert res18 == [400, 350]\nassert res19 == [400, 200, 350, 100]\n# REMOVE_END\n\n# STEP_START tdigest\nres20 = r.tdigest().create(\"male_heights\")\nprint(res20)  # >>> True\n\nres21 = r.tdigest().add(\n    \"male_heights\",\n    [175.5, 181, 160.8, 152, 177, 196, 164]\n)\nprint(res21)  # >>> OK\n\nres22 = r.tdigest().min(\"male_heights\")\nprint(res22)  # >>> 152.0\n\nres23 = r.tdigest().max(\"male_heights\")\nprint(res23)  # >>> 196.0\n\nres24 = r.tdigest().quantile(\"male_heights\", 0.75)\nprint(res24)  # >>> 181\n\n# Note that the CDF value for 181 is not exactly\n# 0.75. Both values are estimates.\nres25 = r.tdigest().cdf(\"male_heights\", 181)\nprint(res25)  # >>> [0.7857142857142857]\n\nres26 = r.tdigest().create(\"female_heights\")\nprint(res26)  # >>> True\n\nres27 = r.tdigest().add(\n    \"female_heights\",\n    [155.5, 161, 168.5, 170, 157.5, 163, 171]\n)\nprint(res27)  # >>> OK\n\nres28 = r.tdigest().quantile(\"female_heights\", 0.75)\nprint(res28)  # >>> [170]\n\nres29 = r.tdigest().merge(\n    \"all_heights\", 2, \"male_heights\", \"female_heights\"\n)\nprint(res29)  # >>> OK\n\nres30 = r.tdigest().quantile(\"all_heights\", 0.75)\nprint(res30)  # >>> [175.5]\n# STEP_END\n# REMOVE_START\nassert res20\nassert res21 == \"OK\"\nassert res22 == 152.0\nassert res23 == 196.0\nassert res24 == [181]\nassert res25 == [0.7857142857142857]\nassert res26\nassert res27 == \"OK\"\nassert res28 == [170]\nassert res29 == \"OK\"\nassert res30 == [175.5]\n# REMOVE_END\n\n# STEP_START topk\n# The `reserve()` method creates the Top-K object with\n# the given key. The parameters are the number of items\n# in the ranking and values for `width`, `depth`, and\n# `decay`, described in the Top-K reference page.\nres31 = r.topk().reserve(\"top_3_songs\", 3, 7, 8, 0.9)\nprint(res31)  # >>> True\n\n# The parameters for `incrby()` are two lists. The count\n# for each item in the first list is incremented by the\n# value at the same index in the second list.\nres32 = r.topk().incrby(\n    \"top_3_songs\",\n    [\n        \"Starfish Trooper\",\n        \"Only one more time\",\n        \"Rock me, Handel\",\n        \"How will anyone know?\",\n        \"Average lover\",\n        \"Road to everywhere\"\n    ],\n    [\n        3000,\n        1850,\n        1325,\n        3890,\n        4098,\n        770\n    ]\n)\nprint(res32)\n# >>> [None, None, None, 'Rock me, Handel', 'Only one more time', None]\n\nres33 = r.topk().list(\"top_3_songs\")\nprint(res33)\n# >>> ['Average lover', 'How will anyone know?', 'Starfish Trooper']\n\nres34 = r.topk().query(\n    \"top_3_songs\", \"Starfish Trooper\", \"Road to everywhere\"\n)\nprint(res34)  # >>> [1, 0]\n# STEP_END\n# REMOVE_START\nassert res31\nassert res32 == [None, None, None, 'Rock me, Handel', 'Only one more time', None]\nassert res33 == ['Average lover', 'How will anyone know?', 'Starfish Trooper']\nassert res34 == [1, 0]\n# REMOVE_END\n"
  },
  {
    "path": "doctests/query_agg.py",
    "content": "# EXAMPLE: query_agg\n# HIDE_START\nimport json\nimport redis\nfrom redis.commands.json.path import Path\nfrom redis.commands.search import Search\nfrom redis.commands.search.aggregation import AggregateRequest\nfrom redis.commands.search.field import NumericField, TagField\nfrom redis.commands.search.index_definition import IndexDefinition, IndexType\nimport redis.commands.search.reducers as reducers\n\nr = redis.Redis(decode_responses=True)\n\n# create index\nschema = (\n    TagField(\"$.condition\", as_name=\"condition\"),\n    NumericField(\"$.price\", as_name=\"price\"),\n)\n\nindex = r.ft(\"idx:bicycle\")\nindex.create_index(\n    schema,\n    definition=IndexDefinition(prefix=[\"bicycle:\"], index_type=IndexType.JSON),\n)\n\n# load data\nwith open(\"data/query_em.json\") as f:\n    bicycles = json.load(f)\n\npipeline = r.pipeline(transaction=False)\nfor bid, bicycle in enumerate(bicycles):\n    pipeline.json().set(f'bicycle:{bid}', Path.root_path(), bicycle)\npipeline.execute()\n# HIDE_END\n\n# STEP_START agg1\nsearch = Search(r, index_name=\"idx:bicycle\")\naggregate_request = AggregateRequest(query='@condition:{new}') \\\n    .load('__key', 'price') \\\n    .apply(discounted='@price - (@price * 0.1)')\nres = search.aggregate(aggregate_request)\nprint(len(res.rows)) # >>> 5\nprint(res.rows) # >>> [['__key', 'bicycle:0', ...\n#[['__key', 'bicycle:0', 'price', '270', 'discounted', '243'],\n# ['__key', 'bicycle:5', 'price', '810', 'discounted', '729'],\n# ['__key', 'bicycle:6', 'price', '2300', 'discounted', '2070'],\n# ['__key', 'bicycle:7', 'price', '430', 'discounted', '387'],\n# ['__key', 'bicycle:8', 'price', '1200', 'discounted', '1080']]\n# REMOVE_START\nassert len(res.rows) == 5\n# REMOVE_END\n# STEP_END\n\n# STEP_START agg2\nsearch = Search(r, index_name=\"idx:bicycle\")\naggregate_request = AggregateRequest(query='*') \\\n    .load('price') \\\n    .apply(price_category='@price<1000') \\\n    .group_by('@condition', reducers.sum('@price_category').alias('num_affordable'))\nres = search.aggregate(aggregate_request)\nprint(len(res.rows)) # >>> 3\nprint(res.rows) # >>>\n#[['condition', 'refurbished', 'num_affordable', '1'],\n# ['condition', 'used', 'num_affordable', '1'],\n# ['condition', 'new', 'num_affordable', '3']]\n# REMOVE_START\nassert len(res.rows) == 3\n# REMOVE_END\n# STEP_END\n\n# STEP_START agg3\nsearch = Search(r, index_name=\"idx:bicycle\")\naggregate_request = AggregateRequest(query='*') \\\n    .apply(type=\"'bicycle'\") \\\n    .group_by('@type', reducers.count().alias('num_total'))\nres = search.aggregate(aggregate_request)\nprint(len(res.rows)) # >>> 1\nprint(res.rows) # >>> [['type', 'bicycle', 'num_total', '10']]\n# REMOVE_START\nassert len(res.rows) == 1\n# REMOVE_END\n# STEP_END\n\n# STEP_START agg4\nsearch = Search(r, index_name=\"idx:bicycle\")\naggregate_request = AggregateRequest(query='*') \\\n    .load('__key') \\\n    .group_by('@condition', reducers.tolist('__key').alias('bicycles'))\nres = search.aggregate(aggregate_request)\nprint(len(res.rows)) # >>> 3\nprint(res.rows) # >>>\n#[['condition', 'refurbished', 'bicycles', ['bicycle:9']],\n# ['condition', 'used', 'bicycles', ['bicycle:1', 'bicycle:2', 'bicycle:3', 'bicycle:4']],\n# ['condition', 'new', 'bicycles', ['bicycle:5', 'bicycle:6', 'bicycle:7', 'bicycle:0', 'bicycle:8']]]\n# REMOVE_START\nassert len(res.rows) == 3\n# REMOVE_END\n# STEP_END\n\n# REMOVE_START\n# destroy index and data\nr.ft(\"idx:bicycle\").dropindex(delete_documents=True)\n# REMOVE_END\n"
  },
  {
    "path": "doctests/query_combined.py",
    "content": "# EXAMPLE: query_combined\n# HIDE_START\nimport json\nimport numpy as np\nimport redis\nimport warnings\nfrom redis.commands.json.path import Path\nfrom redis.commands.search.field import NumericField, TagField, TextField, VectorField\nfrom redis.commands.search.index_definition import IndexDefinition, IndexType\nfrom redis.commands.search.query import Query\nfrom sentence_transformers import  SentenceTransformer\n\n\ndef embed_text(model, text):\n    return np.array(model.encode(text)).astype(np.float32).tobytes()\n\nwarnings.filterwarnings(\"ignore\", category=FutureWarning, message=r\".*clean_up_tokenization_spaces.*\")\nmodel = SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2')\nquery = \"Bike for small kids\"\nquery_vector = embed_text(model, query)\n\nr = redis.Redis(decode_responses=True)\n\n# create index\nschema = (\n    TextField(\"$.description\", no_stem=True, as_name=\"model\"),\n    TagField(\"$.condition\", as_name=\"condition\"),\n    NumericField(\"$.price\", as_name=\"price\"),\n    VectorField(\n        \"$.description_embeddings\",\n        \"FLAT\",\n        {\n            \"TYPE\": \"FLOAT32\",\n            \"DIM\": 384,\n            \"DISTANCE_METRIC\": \"COSINE\",\n        },\n        as_name=\"vector\",\n    ),\n)\n\nindex = r.ft(\"idx:bicycle\")\nindex.create_index(\n    schema,\n    definition=IndexDefinition(prefix=[\"bicycle:\"], index_type=IndexType.JSON),\n)\n\n# load data\nwith open(\"data/query_vector.json\") as f:\n    bicycles = json.load(f)\n\npipeline = r.pipeline(transaction=False)\nfor bid, bicycle in enumerate(bicycles):\n    pipeline.json().set(f'bicycle:{bid}', Path.root_path(), bicycle)\npipeline.execute()\n# HIDE_END\n\n# STEP_START combined1\nq = Query(\"@price:[500 1000] @condition:{new}\")\nres = index.search(q)\nprint(res.total) # >>> 1\n# REMOVE_START\nassert res.total == 1\n# REMOVE_END\n# STEP_END\n\n# STEP_START combined2\nq = Query(\"kids @price:[500 1000] @condition:{used}\")\nres = index.search(q)\nprint(res.total) # >>> 1\n# REMOVE_START\nassert res.total == 1\n# REMOVE_END\n# STEP_END\n\n# STEP_START combined3\nq = Query(\"(kids | small) @condition:{used}\")\nres = index.search(q)\nprint(res.total) # >>> 2\n# REMOVE_START\nassert res.total == 2\n# REMOVE_END\n# STEP_END\n\n# STEP_START combined4\nq = Query(\"@description:(kids | small) @condition:{used}\")\nres = index.search(q)\nprint(res.total) # >>> 0\n# REMOVE_START\nassert res.total == 0\n# REMOVE_END\n# STEP_END\n\n# STEP_START combined5\nq = Query(\"@description:(kids | small) @condition:{new | used}\")\nres = index.search(q)\nprint(res.total) # >>> 0\n# REMOVE_START\nassert res.total == 0\n# REMOVE_END\n# STEP_END\n\n# STEP_START combined6\nq = Query(\"@price:[500 1000] -@condition:{new}\")\nres = index.search(q)\nprint(res.total) # >>> 2\n# REMOVE_START\nassert res.total == 2\n# REMOVE_END\n# STEP_END\n\n# STEP_START combined7\nq = Query(\"(@price:[500 1000] -@condition:{new})=>[KNN 3 @vector $query_vector]\").dialect(2)\n# put query string here\nres = index.search(q,{ 'query_vector': query_vector })\nprint(res.total) # >>> 2\n# REMOVE_START\nassert res.total == 2\n# REMOVE_END\n# STEP_END\n\n# REMOVE_START\n# destroy index and data\nr.ft(\"idx:bicycle\").dropindex(delete_documents=True)\n# REMOVE_END\n"
  },
  {
    "path": "doctests/query_em.py",
    "content": "# EXAMPLE: query_em\n# HIDE_START\nimport json\nimport redis\nfrom redis.commands.json.path import Path\nfrom redis.commands.search.field import TextField, NumericField, TagField\nfrom redis.commands.search.index_definition import IndexDefinition, IndexType\nfrom redis.commands.search.query import NumericFilter, Query\n\nr = redis.Redis(decode_responses=True)\n\n# create index\nschema = (\n    TextField(\"$.description\", as_name=\"description\"),\n    NumericField(\"$.price\", as_name=\"price\"),\n    TagField(\"$.condition\", as_name=\"condition\"),\n)\n\nindex = r.ft(\"idx:bicycle\")\nindex.create_index(\n    schema,\n    definition=IndexDefinition(prefix=[\"bicycle:\"], index_type=IndexType.JSON),\n)\n\n# load data\nwith open(\"data/query_em.json\") as f:\n    bicycles = json.load(f)\n\npipeline = r.pipeline(transaction=False)\nfor bid, bicycle in enumerate(bicycles):\n    pipeline.json().set(f'bicycle:{bid}', Path.root_path(), bicycle)\npipeline.execute()\n# HIDE_END\n\n# STEP_START em1\nres = index.search(Query(\"@price:[270 270]\"))\nprint(res.total)\n# >>> 1\n# REMOVE_START\nassert res.total == 1\n# REMOVE_END\n\ntry:\n    res = index.search(Query(\"@price:[270]\")) # not yet supported in redis-py\n    print(res.total)\n    # >>> 1\n    assert res.total == 1\nexcept Exception:\n    print(\"'@price:[270]' syntax not yet supported.\")\n\ntry:\n    res = index.search(Query(\"@price==270\")) # not yet supported in redis-py\n    print(res.total)\n    # >>> 1\n    assert res.total == 1\nexcept Exception:\n    print(\"'@price==270' syntax not yet supported.\")\n\nquery = Query(\"*\").add_filter(NumericFilter(\"price\", 270, 270))\nres = index.search(query)\nprint(res.total)\n# >>> 1\n# REMOVE_START\nassert res.total == 1\n# REMOVE_END\n# STEP_END\n\n# STEP_START em2\nres = index.search(Query(\"@condition:{new}\"))\nprint(res.total)\n# >>> 5\n# REMOVE_START\nassert res.total == 5\n# REMOVE_END\n# STEP_END\n\n# STEP_START em3\nschema = (\n    TagField(\"$.email\", as_name=\"email\")\n)\n\nidx_email = r.ft(\"idx:email\")\nidx_email.create_index(\n    schema,\n    definition=IndexDefinition(prefix=[\"key:\"], index_type=IndexType.JSON),\n)\nr.json().set('key:1', Path.root_path(), '{\"email\": \"test@redis.com\"}')\n\ntry:\n    res = idx_email.search(Query(\"test@redis.com\").dialect(2))\n    print(res)\nexcept Exception:\n    print(\"'test@redis.com' syntax not yet supported.\")\n# REMOVE_START\nr.ft(\"idx:email\").dropindex(delete_documents=True)\n# REMOVE_END\n# STEP_END\n\n# STEP_START em4\nres = index.search(Query(\"@description:\\\"rough terrain\\\"\"))\nprint(res.total)\n# >>> 1 (Result{1 total, docs: [Document {'id': 'bicycle:8'...)\n# REMOVE_START\nassert res.total == 1\n# REMOVE_END\n# STEP_END\n\n# REMOVE_START\n# destroy index and data\nr.ft(\"idx:bicycle\").dropindex(delete_documents=True)\n# REMOVE_END\n"
  },
  {
    "path": "doctests/query_ft.py",
    "content": "# EXAMPLE: query_ft\n# HIDE_START\nimport json\nimport sys\nimport redis\nfrom redis.commands.json.path import Path\nfrom redis.commands.search.field import TextField, NumericField, TagField\nfrom redis.commands.search.index_definition import IndexDefinition, IndexType\nfrom redis.commands.search.query import NumericFilter, Query\n\nr = redis.Redis(decode_responses=True)\n\n# create index\nschema = (\n    TextField(\"$.brand\", as_name=\"brand\"),\n    TextField(\"$.model\", as_name=\"model\"),\n    TextField(\"$.description\", as_name=\"description\"),\n)\n\nindex = r.ft(\"idx:bicycle\")\nindex.create_index(\n    schema,\n    definition=IndexDefinition(prefix=[\"bicycle:\"], index_type=IndexType.JSON),\n)\n\n# load data\nwith open(\"data/query_em.json\") as f:\n    bicycles = json.load(f)\n\npipeline = r.pipeline(transaction=False)\nfor bid, bicycle in enumerate(bicycles):\n    pipeline.json().set(f'bicycle:{bid}', Path.root_path(), bicycle)\npipeline.execute()\n# HIDE_END\n\n# STEP_START ft1\nres = index.search(Query(\"@description: kids\"))\nprint(res.total)\n# >>> 2\n# REMOVE_START\nassert res.total == 2\n# REMOVE_END\n# STEP_END\n\n# STEP_START ft2\nres = index.search(Query(\"@model: ka*\"))\nprint(res.total)\n# >>> 1\n# REMOVE_START\nassert res.total == 1\n# REMOVE_END\n# STEP_END\n\n# STEP_START ft3\nres = index.search(Query(\"@brand: *bikes\"))\nprint(res.total)\n# >>> 2\n# REMOVE_START\nassert res.total == 2\n# REMOVE_END\n# STEP_END\n\n# STEP_START ft4\nres = index.search(Query(\"%optamized%\"))\nprint(res)\n# >>> Result{1 total, docs: [Document {'id': 'bicycle:3', 'payload': None, 'json': '{\"pickup_zone\":\"POLYGON((-80.2433 25.8067, -80.1333 25.8067, -80.1333 25.6967, -80.2433 25.6967, -80.2433 25.8067))\",\"store_location\":\"-80.1918,25.7617\",\"brand\":\"Eva\",\"model\":\"Eva 291\",\"price\":3400,\"description\":\"The sister company to Nord, Eva launched in 2005 as the first and only women-dedicated bicycle brand. Designed by women for women, allEva bikes are optimized for the feminine physique using analytics from a body metrics database. If you like 29ers, try the Eva 291. It’s a brand new bike for 2022.. This full-suspension, cross-country ride has been designed for velocity. The 291 has 100mm of front and rear travel, a superlight aluminum frame and fast-rolling 29-inch wheels. Yippee!\",\"condition\":\"used\"}'}]}\n# REMOVE_START\nassert res.total == 1\n# REMOVE_END\n# STEP_END\n\n# STEP_START ft5\nres = index.search(Query(\"%%optamised%%\"))\nprint(res)\n# >>> Result{1 total, docs: [Document {'id': 'bicycle:3', 'payload': None, 'json': '{\"pickup_zone\":\"POLYGON((-80.2433 25.8067, -80.1333 25.8067, -80.1333 25.6967, -80.2433 25.6967, -80.2433 25.8067))\",\"store_location\":\"-80.1918,25.7617\",\"brand\":\"Eva\",\"model\":\"Eva 291\",\"price\":3400,\"description\":\"The sister company to Nord, Eva launched in 2005 as the first and only women-dedicated bicycle brand. Designed by women for women, allEva bikes are optimized for the feminine physique using analytics from a body metrics database. If you like 29ers, try the Eva 291. It’s a brand new bike for 2022.. This full-suspension, cross-country ride has been designed for velocity. The 291 has 100mm of front and rear travel, a superlight aluminum frame and fast-rolling 29-inch wheels. Yippee!\",\"condition\":\"used\"}'}]}\n# REMOVE_START\nassert res.total == 1\n# REMOVE_END\n# STEP_END\n\n# REMOVE_START\n# destroy index and data\nr.ft(\"idx:bicycle\").dropindex(delete_documents=True)\n# REMOVE_END\n"
  },
  {
    "path": "doctests/query_geo.py",
    "content": "# EXAMPLE: query_geo\n# HIDE_START\nimport json\nimport sys\nimport redis\nfrom redis.commands.json.path import Path\nfrom redis.commands.search.field import GeoField, GeoShapeField\nfrom redis.commands.search.index_definition import IndexDefinition, IndexType\nfrom redis.commands.search.query import Query\n\nr = redis.Redis(decode_responses=True)\n\n# create index\nschema = (\n    GeoField(\"$.store_location\", as_name=\"store_location\"),\n    GeoShapeField(\"$.pickup_zone\", coord_system=GeoShapeField.FLAT, as_name=\"pickup_zone\")\n)\n\nindex = r.ft(\"idx:bicycle\")\nindex.create_index(\n    schema,\n    definition=IndexDefinition(prefix=[\"bicycle:\"], index_type=IndexType.JSON),\n)\n\n# load data\nwith open(\"data/query_em.json\") as f:\n    bicycles = json.load(f)\n\npipeline = r.pipeline(transaction=False)\nfor bid, bicycle in enumerate(bicycles):\n    pipeline.json().set(f'bicycle:{bid}', Path.root_path(), bicycle)\npipeline.execute()\n# HIDE_END\n\n# STEP_START geo1\nparams_dict = {\"lon\": -0.1778, \"lat\": 51.5524, \"radius\": 20, \"units\": \"mi\"}\nq = Query(\"@store_location:[$lon $lat $radius $units]\").dialect(2)\nres = index.search(q, query_params=params_dict)\nprint(res)\n# >>> Result{1 total, docs: [Document {'id': 'bicycle:5', ...\n# REMOVE_START\nassert res.total == 1\n# REMOVE_END\n# STEP_END\n\n# STEP_START geo2\nparams_dict = {\"bike\": \"POINT(-0.1278 51.5074)\"}\nq = Query(\"@pickup_zone:[CONTAINS $bike]\").dialect(3)\nres = index.search(q, query_params=params_dict)\nprint(res.total) # >>> 1\n# REMOVE_START\nassert res.total == 1\n# REMOVE_END\n# STEP_END\n\n# STEP_START geo3\nparams_dict = {\"europe\": \"POLYGON((-25 35, 40 35, 40 70, -25 70, -25 35))\"}\nq = Query(\"@pickup_zone:[WITHIN $europe]\").dialect(3)\nres = index.search(q, query_params=params_dict)\nprint(res.total) # >>> 5\n# REMOVE_START\nassert res.total == 5\n# REMOVE_END\n# STEP_END\n\n# REMOVE_START\n# destroy index and data\nr.ft(\"idx:bicycle\").dropindex(delete_documents=True)\n# REMOVE_END\n"
  },
  {
    "path": "doctests/query_range.py",
    "content": "# EXAMPLE: query_range\n# HIDE_START\nimport json\nimport sys\nimport redis\nfrom redis.commands.json.path import Path\nfrom redis.commands.search.field import TextField, NumericField, TagField\nfrom redis.commands.search.index_definition import IndexDefinition, IndexType\nfrom redis.commands.search.query import NumericFilter, Query\n\nr = redis.Redis(decode_responses=True)\n\n# create index\nschema = (\n    TextField(\"$.description\", as_name=\"description\"),\n    NumericField(\"$.price\", as_name=\"price\"),\n    TagField(\"$.condition\", as_name=\"condition\"),\n)\n\nindex = r.ft(\"idx:bicycle\")\nindex.create_index(\n    schema,\n    definition=IndexDefinition(prefix=[\"bicycle:\"], index_type=IndexType.JSON),\n)\n\n# load data\nwith open(\"data/query_em.json\") as f:\n    bicycles = json.load(f)\n\npipeline = r.pipeline(transaction=False)\nfor bid, bicycle in enumerate(bicycles):\n    pipeline.json().set(f'bicycle:{bid}', Path.root_path(), bicycle)\npipeline.execute()\n# HIDE_END\n\n# STEP_START range1\nres = index.search(Query(\"@price:[500 1000]\"))\nprint(res.total)\n# >>> 3\n# REMOVE_START\nassert res.total == 3\n# REMOVE_END\n# STEP_END\n\n# STEP_START range2\nquery = Query(\"*\").add_filter(NumericFilter(\"price\", 500, 1000))\nres = index.search(query)\nprint(res.total)\n# >>> 3\n# REMOVE_START\nassert res.total == 3\n# REMOVE_END\n# STEP_END\n\n# STEP_START range3\nquery = Query(\"*\").add_filter(NumericFilter(\"price\", \"(1000\", \"+inf\"))\nres = index.search(query)\nprint(res.total)\n# >>> 5\n# REMOVE_START\nassert res.total == 5\n# REMOVE_END\n# STEP_END\n\n# STEP_START range4\nquery = Query('@price:[-inf 2000]').sort_by('price').paging(0, 5)\nres = index.search(query)\nprint(res.total)\nprint(res)\n# >>> Result{7 total, docs: [Document {'id': 'bicycle:0', ... }, Document {'id': 'bicycle:7', ... }, Document {'id': 'bicycle:5', ... }, ...]\n# REMOVE_START\nassert res.total == 7\n# REMOVE_END\n# STEP_END\n\n# REMOVE_START\n# destroy index and data\nr.ft(\"idx:bicycle\").dropindex(delete_documents=True)\n# REMOVE_END\n"
  },
  {
    "path": "doctests/requirements.txt",
    "content": "numpy\npandas\nrequests\nsentence_transformers\ntabulate\nredis #install latest stable version\n"
  },
  {
    "path": "doctests/run_examples.sh",
    "content": "#!/bin/sh\n\n\nbasepath=`readlink -f $1`\nif [ $? -ne 0 ]; then\nbasepath=`readlink -f $(dirname $0)`\nfi\necho \"No path specified, using ${basepath}\"\n\nset -e\ncd ${basepath}\nfor i in `ls ${basepath}/*.py`; do\n    redis-cli flushdb\n    python $i\ndone\n"
  },
  {
    "path": "doctests/search_quickstart.py",
    "content": "# EXAMPLE: search_quickstart\n# HIDE_START\n\"\"\"\nCode samples for document database quickstart pages:\n    https://redis.io/docs/latest/develop/get-started/document-database/\n\"\"\"\n\nimport redis\nimport redis.commands.search.aggregation as aggregations\nimport redis.commands.search.reducers as reducers\nfrom redis.commands.json.path import Path\nfrom redis.commands.search.field import NumericField, TagField, TextField\nfrom redis.commands.search.index_definition import IndexDefinition, IndexType\nfrom redis.commands.search.query import Query\n\n# HIDE_END\n\n# STEP_START connect\nr = redis.Redis(host=\"localhost\", port=6379, db=0, decode_responses=True)\n# STEP_END\n# REMOVE_START\ntry:\n    r.ft(\"idx:bicycle\").dropindex()\nexcept Exception:\n    pass\n# REMOVE_END\n# STEP_START data_sample\nbicycle = {\n    \"brand\": \"Velorim\",\n    \"model\": \"Jigger\",\n    \"price\": 270,\n    \"description\": (\n        \"Small and powerful, the Jigger is the best ride \"\n        \"for the smallest of tikes! This is the tiniest \"\n        \"kids’ pedal bike on the market available without\"\n        \" a coaster brake, the Jigger is the vehicle of \"\n        \"choice for the rare tenacious little rider \"\n        \"raring to go.\"\n    ),\n    \"condition\": \"new\",\n}\n# STEP_END\n\nbicycles = [\n    bicycle,\n    {\n        \"brand\": \"Bicyk\",\n        \"model\": \"Hillcraft\",\n        \"price\": 1200,\n        \"description\": (\n            \"Kids want to ride with as little weight as possible.\"\n            \" Especially on an incline! They may be at the age \"\n            'when a 27.5\" wheel bike is just too clumsy coming '\n            'off a 24\" bike. The Hillcraft 26 is just the solution'\n            \" they need!\"\n        ),\n        \"condition\": \"used\",\n    },\n    {\n        \"brand\": \"Nord\",\n        \"model\": \"Chook air 5\",\n        \"price\": 815,\n        \"description\": (\n            \"The Chook Air 5  gives kids aged six years and older \"\n            \"a durable and uberlight mountain bike for their first\"\n            \" experience on tracks and easy cruising through forests\"\n            \" and fields. The lower  top tube makes it easy to mount\"\n            \" and dismount in any situation, giving your kids greater\"\n            \" safety on the trails.\"\n        ),\n        \"condition\": \"used\",\n    },\n    {\n        \"brand\": \"Eva\",\n        \"model\": \"Eva 291\",\n        \"price\": 3400,\n        \"description\": (\n            \"The sister company to Nord, Eva launched in 2005 as the\"\n            \" first and only women-dedicated bicycle brand. Designed\"\n            \" by women for women, allEva bikes are optimized for the\"\n            \" feminine physique using analytics from a body metrics\"\n            \" database. If you like 29ers, try the Eva 291. It’s a \"\n            \"brand new bike for 2022.. This full-suspension, \"\n            \"cross-country ride has been designed for velocity. The\"\n            \" 291 has 100mm of front and rear travel, a superlight \"\n            \"aluminum frame and fast-rolling 29-inch wheels. Yippee!\"\n        ),\n        \"condition\": \"used\",\n    },\n    {\n        \"brand\": \"Noka Bikes\",\n        \"model\": \"Kahuna\",\n        \"price\": 3200,\n        \"description\": (\n            \"Whether you want to try your hand at XC racing or are \"\n            \"looking for a lively trail bike that's just as inspiring\"\n            \" on the climbs as it is over rougher ground, the Wilder\"\n            \" is one heck of a bike built specifically for short women.\"\n            \" Both the frames and components have been tweaked to \"\n            \"include a women’s saddle, different bars and unique \"\n            \"colourway.\"\n        ),\n        \"condition\": \"used\",\n    },\n    {\n        \"brand\": \"Breakout\",\n        \"model\": \"XBN 2.1 Alloy\",\n        \"price\": 810,\n        \"description\": (\n            \"The XBN 2.1 Alloy is our entry-level road bike – but that’s\"\n            \" not to say that it’s a basic machine. With an internal \"\n            \"weld aluminium frame, a full carbon fork, and the slick-shifting\"\n            \" Claris gears from Shimano’s, this is a bike which doesn’t\"\n            \" break the bank and delivers craved performance.\"\n        ),\n        \"condition\": \"new\",\n    },\n    {\n        \"brand\": \"ScramBikes\",\n        \"model\": \"WattBike\",\n        \"price\": 2300,\n        \"description\": (\n            \"The WattBike is the best e-bike for people who still feel young\"\n            \" at heart. It has a Bafang 1000W mid-drive system and a 48V\"\n            \" 17.5AH Samsung Lithium-Ion battery, allowing you to ride for\"\n            \" more than 60 miles on one charge. It’s great for tackling hilly\"\n            \" terrain or if you just fancy a more leisurely ride. With three\"\n            \" working modes, you can choose between E-bike, assisted bicycle,\"\n            \" and normal bike modes.\"\n        ),\n        \"condition\": \"new\",\n    },\n    {\n        \"brand\": \"Peaknetic\",\n        \"model\": \"Secto\",\n        \"price\": 430,\n        \"description\": (\n            \"If you struggle with stiff fingers or a kinked neck or back after\"\n            \" a few minutes on the road, this lightweight, aluminum bike\"\n            \" alleviates those issues and allows you to enjoy the ride. From\"\n            \" the ergonomic grips to the lumbar-supporting seat position, the\"\n            \" Roll Low-Entry offers incredible comfort. The rear-inclined seat\"\n            \" tube facilitates stability by allowing you to put a foot on the\"\n            \" ground to balance at a stop, and the low step-over frame makes it\"\n            \" accessible for all ability and mobility levels. The saddle is\"\n            \" very soft, with a wide back to support your hip joints and a\"\n            \" cutout in the center to redistribute that pressure. Rim brakes\"\n            \" deliver satisfactory braking control, and the wide tires provide\"\n            \" a smooth, stable ride on paved roads and gravel. Rack and fender\"\n            \" mounts facilitate setting up the Roll Low-Entry as your preferred\"\n            \" commuter, and the BMX-like handlebar offers space for mounting a\"\n            \" flashlight, bell, or phone holder.\"\n        ),\n        \"condition\": \"new\",\n    },\n    {\n        \"brand\": \"nHill\",\n        \"model\": \"Summit\",\n        \"price\": 1200,\n        \"description\": (\n            \"This budget mountain bike from nHill performs well both on bike\"\n            \" paths and on the trail. The fork with 100mm of travel absorbs\"\n            \" rough terrain. Fat Kenda Booster tires give you grip in corners\"\n            \" and on wet trails. The Shimano Tourney drivetrain offered enough\"\n            \" gears for finding a comfortable pace to ride uphill, and the\"\n            \" Tektro hydraulic disc brakes break smoothly. Whether you want an\"\n            \" affordable bike that you can take to work, but also take trail in\"\n            \" mountains on the weekends or you’re just after a stable,\"\n            \" comfortable ride for the bike path, the Summit gives a good value\"\n            \" for money.\"\n        ),\n        \"condition\": \"new\",\n    },\n    {\n        \"model\": \"ThrillCycle\",\n        \"brand\": \"BikeShind\",\n        \"price\": 815,\n        \"description\": (\n            \"An artsy,  retro-inspired bicycle that’s as functional as it is\"\n            \" pretty: The ThrillCycle steel frame offers a smooth ride. A\"\n            \" 9-speed drivetrain has enough gears for coasting in the city, but\"\n            \" we wouldn’t suggest taking it to the mountains. Fenders protect\"\n            \" you from mud, and a rear basket lets you transport groceries,\"\n            \" flowers and books. The ThrillCycle comes with a limited lifetime\"\n            \" warranty, so this little guy will last you long past graduation.\"\n        ),\n        \"condition\": \"refurbished\",\n    },\n]\n\n# STEP_START create_index\nschema = (\n    TextField(\"$.brand\", as_name=\"brand\"),\n    TextField(\"$.model\", as_name=\"model\"),\n    TextField(\"$.description\", as_name=\"description\"),\n    NumericField(\"$.price\", as_name=\"price\"),\n    TagField(\"$.condition\", as_name=\"condition\"),\n)\n\nindex = r.ft(\"idx:bicycle\")\nindex.create_index(\n    schema,\n    definition=IndexDefinition(prefix=[\"bicycle:\"], index_type=IndexType.JSON),\n)\n# STEP_END\n# STEP_START add_documents\nfor bid, bicycle in enumerate(bicycles):\n    r.json().set(f\"bicycle:{bid}\", Path.root_path(), bicycle)\n# STEP_END\n\n\n# STEP_START wildcard_query\nres = index.search(Query(\"*\"))\nprint(\"Documents found:\", res.total)\n# >>> Documents found: 10\n# STEP_END\n# REMOVE_START\nassert res.total == 10\n# REMOVE_END\n\n# STEP_START query_single_term\nres = index.search(Query(\"@model:Jigger\"))\nprint(res)\n# >>> Result{1 total, docs: [\n# Document {\n#   'id': 'bicycle:0',\n#   'payload': None,\n#   'json': '{\n#       \"brand\":\"Velorim\",\n#       \"model\":\"Jigger\",\n#       \"price\":270,\n#       ...\n#       \"condition\":\"new\"\n#    }'\n# }]}\n# STEP_END\n# REMOVE_START\nassert res.docs[0].id == \"bicycle:0\"\n# REMOVE_END\n\n# STEP_START query_single_term_limit_fields\nres = index.search(Query(\"@model:Jigger\").return_field(\"$.price\", as_field=\"price\"))\nprint(res)\n# >>> [Document {'id': 'bicycle:0', 'payload': None, 'price': '270'}]\n# STEP_END\n# REMOVE_START\nassert res.docs[0].id == \"bicycle:0\"\n# REMOVE_END\n\n# STEP_START query_single_term_and_num_range\nres = index.search(Query(\"basic @price:[500 1000]\"))\nprint(res)\n# >>> Result{1 total, docs: [\n# Document {\n#   'id': 'bicycle:5',\n#   'payload': None,\n#   'json': '{\n#       \"brand\":\"Breakout\",\n#       \"model\":\"XBN 2.1 Alloy\",\n#       \"price\":810,\n#       ...\n#       \"condition\":\"new\"\n#    }'\n# }]}\n# STEP_END\n# REMOVE_START\nassert res.docs[0].id == \"bicycle:5\"\n# REMOVE_END\n\n# STEP_START query_exact_matching\nres = index.search(Query('@brand:\"Noka Bikes\"'))\nprint(res)\n# >>> Result{1 total, docs: [\n# Document {\n#   'id': 'bicycle:4',\n#   'payload': None,\n#   'json': '{\n#       \"brand\":\"Noka Bikes\",\n#       \"model\":\"Kahuna\",\n#       \"price\":3200,\n#       ...\n#       \"condition\":\"used\"\n#    }'\n# }]}\n# STEP_END\n# REMOVE_START\nassert res.docs[0].id == \"bicycle:4\"\n# REMOVE_END\n\n# STEP_START query_fuzzy_matching\nres = index.search(\n    Query(\"@description:%analitics%\").dialect(  # Note the typo in the word \"analytics\"\n        2\n    )\n)\nprint(res)\n# >>> Result{1 total, docs: [\n# Document {\n#   'id': 'bicycle:3',\n#   'payload': None,\n#   'json': '{\n#       \"brand\":\"Eva\",\n#       \"model\":\"Eva 291\",\n#       \"price\":3400,\n#       \"description\":\"...using analytics from a body metrics database...\",\n#       \"condition\":\"used\"\n#    }'\n# }]}\n# STEP_END\n# REMOVE_START\nassert res.docs[0].id == \"bicycle:3\"\n# REMOVE_END\n\n# STEP_START query_fuzzy_matching_level2\nres = index.search(\n    Query(\"@description:%%analitycs%%\").dialect(  # Note 2 typos in the word \"analytics\"\n        2\n    )\n)\nprint(res)\n# >>> Result{1 total, docs: [\n# Document {\n#   'id': 'bicycle:3',\n#   'payload': None,\n#   'json': '{\n#       \"brand\":\"Eva\",\n#       \"model\":\"Eva 291\",\n#       \"price\":3400,\n#       \"description\":\"...using analytics from a body metrics database...\",\n#       \"condition\":\"used\"\n#    }'\n# }]}\n# STEP_END\n# REMOVE_START\nassert res.docs[0].id == \"bicycle:3\"\n# REMOVE_END\n\n# STEP_START query_prefix_matching\nres = index.search(Query(\"@model:hill*\"))\nprint(res)\n# >>> Result{1 total, docs: [\n# Document {\n#   'id': 'bicycle:1',\n#   'payload': None,\n#   'json': '{\n#       \"brand\":\"Bicyk\",\n#       \"model\":\"Hillcraft\",\n#       \"price\":1200,\n#       ...\n#       \"condition\":\"used\"\n#    }'\n# }]}\n# STEP_END\n# REMOVE_START\nassert res.docs[0].id == \"bicycle:1\"\n# REMOVE_END\n\n# STEP_START query_suffix_matching\nres = index.search(Query(\"@model:*bike\"))\nprint(res)\n# >>> Result{1 total, docs: [\n# Document {\n#   'id': 'bicycle:6',\n#   'payload': None,\n#   'json': '{\n#       \"brand\":\"ScramBikes\",\n#       \"model\":\"WattBike\",\n#       \"price\":2300,\n#       ...\n#       \"condition\":\"new\"\n#   }'\n# }]}\n# STEP_END\n# REMOVE_START\nassert res.docs[0].id == \"bicycle:6\"\n# REMOVE_END\n\n# STEP_START query_wildcard_matching\nres = index.search(Query(\"w'H?*craft'\").dialect(2))\nprint(res.docs[0].json)\n# >>> {\n#   \"brand\":\"Bicyk\",\n#   \"model\":\"Hillcraft\",\n#   \"price\":1200,\n#   ...\n#   \"condition\":\"used\"\n# }\n# STEP_END\n# REMOVE_START\nassert res.docs[0].id == \"bicycle:1\"\n# REMOVE_END\n\n\n# STEP_START query_with_default_scorer\nres = index.search(Query(\"mountain\").with_scores())\nfor sr in res.docs:\n    print(f\"{sr.id}: score={sr.score}\")\n# STEP_END\n# REMOVE_START\nassert res.total == 3\n# REMOVE_END\n\n# STEP_START query_with_bm25_scorer\nres = index.search(Query(\"mountain\").with_scores().scorer(\"BM25\"))\nfor sr in res.docs:\n    print(f\"{sr.id}: score={sr.score}\")\n# STEP_END\n# REMOVE_START\nassert res.total == 3\nassert res.docs[0].score == res.docs[1].score\n# REMOVE_END\n\n# STEP_START simple_aggregation\nreq = aggregations.AggregateRequest(\"*\").group_by(\n    \"@condition\", reducers.count().alias(\"count\")\n)\nres = index.aggregate(req).rows\nprint(res)\n# >>> [['condition', 'refurbished', 'count', '1'],\n#      ['condition', 'used', 'count', '4'],\n#      ['condition', 'new', 'count', '5']]\n# STEP_END\n# REMOVE_START\nassert len(res) == 3\n# REMOVE_END\n"
  },
  {
    "path": "doctests/search_vss.py",
    "content": "# EXAMPLE: search_vss\n# HIDE_START\n\"\"\"\nCode samples for vector database quickstart pages:\n    https://redis.io/docs/latest/develop/get-started/vector-database/\n\"\"\"\n# HIDE_END\n\n# STEP_START imports\nimport json\nimport time\n\nimport numpy as np\nimport pandas as pd\nimport requests\nimport redis\nfrom redis.commands.search.field import (\n    NumericField,\n    TagField,\n    TextField,\n    VectorField,\n)\nfrom redis.commands.search.index_definition import IndexDefinition, IndexType\nfrom redis.commands.search.query import Query\nfrom sentence_transformers import SentenceTransformer\n\n# STEP_END\n\n# STEP_START get_data\nURL = (\"https://raw.githubusercontent.com/bsbodden/redis_vss_getting_started\"\n       \"/main/data/bikes.json\"\n       )\nresponse = requests.get(URL, timeout=10)\nbikes = response.json()\n# STEP_END\n# REMOVE_START\nassert bikes[0][\"model\"] == \"Jigger\"\n# REMOVE_END\n\n# STEP_START dump_data\njson.dumps(bikes[0], indent=2)\n# STEP_END\n\n# STEP_START connect\nclient = redis.Redis(host=\"localhost\", port=6379, decode_responses=True)\n# STEP_END\n\n# STEP_START connection_test\nres = client.ping()\n# >>> True\n# STEP_END\n# REMOVE_START\nassert res\n# REMOVE_END\n\n# STEP_START load_data\npipeline = client.pipeline()\nfor i, bike in enumerate(bikes, start=1):\n    redis_key = f\"bikes:{i:03}\"\n    pipeline.json().set(redis_key, \"$\", bike)\nres = pipeline.execute()\n# >>> [True, True, True, True, True, True, True, True, True, True, True]\n# STEP_END\n# REMOVE_START\nassert res == [True, True, True, True, True, True, True, True, True, True, True]\n# REMOVE_END\n\n# STEP_START get\nres = client.json().get(\"bikes:010\", \"$.model\")\n# >>> ['Summit']\n# STEP_END\n# REMOVE_START\nassert res == [\"Summit\"]\n# REMOVE_END\n\n# STEP_START get_keys\nkeys = sorted(client.keys(\"bikes:*\"))\n# >>> ['bikes:001', 'bikes:002', ..., 'bikes:011']\n# STEP_END\n# REMOVE_START\nassert keys[0] == \"bikes:001\"\n# REMOVE_END\n\n# STEP_START generate_embeddings\ndescriptions = client.json().mget(keys, \"$.description\")\ndescriptions = [item for sublist in descriptions for item in sublist]\nembedder = SentenceTransformer(\"msmarco-distilbert-base-v4\")\nembeddings = embedder.encode(descriptions).astype(np.float32).tolist()\nVECTOR_DIMENSION = len(embeddings[0])\n# >>> 768\n# STEP_END\n# REMOVE_START\nassert VECTOR_DIMENSION == 768\n# REMOVE_END\n\n# STEP_START load_embeddings\npipeline = client.pipeline()\nfor key, embedding in zip(keys, embeddings):\n    pipeline.json().set(key, \"$.description_embeddings\", embedding)\npipeline.execute()\n# >>> [True, True, True, True, True, True, True, True, True, True, True]\n# STEP_END\n\n# STEP_START dump_example\nres = client.json().get(\"bikes:010\")\n# >>>\n# {\n#   \"model\": \"Summit\",\n#   \"brand\": \"nHill\",\n#   \"price\": 1200,\n#   \"type\": \"Mountain Bike\",\n#   \"specs\": {\n#     \"material\": \"alloy\",\n#     \"weight\": \"11.3\"\n#   },\n#   \"description\": \"This budget mountain bike from nHill performs well...\"\n#   \"description_embeddings\": [\n#     -0.538114607334137,\n#     -0.49465855956077576,\n#     -0.025176964700222015,\n#     ...\n#   ]\n# }\n# STEP_END\n# REMOVE_START\nassert len(res[\"description_embeddings\"]) == 768\n# REMOVE_END\n\n# STEP_START create_index\nschema = (\n    TextField(\"$.model\", no_stem=True, as_name=\"model\"),\n    TextField(\"$.brand\", no_stem=True, as_name=\"brand\"),\n    NumericField(\"$.price\", as_name=\"price\"),\n    TagField(\"$.type\", as_name=\"type\"),\n    TextField(\"$.description\", as_name=\"description\"),\n    VectorField(\n        \"$.description_embeddings\",\n        \"FLAT\",\n        {\n            \"TYPE\": \"FLOAT32\",\n            \"DIM\": VECTOR_DIMENSION,\n            \"DISTANCE_METRIC\": \"COSINE\",\n        },\n        as_name=\"vector\",\n    ),\n)\ndefinition = IndexDefinition(prefix=[\"bikes:\"], index_type=IndexType.JSON)\nres = client.ft(\"idx:bikes_vss\").create_index(fields=schema, definition=definition)\n# >>> 'OK'\n# STEP_END\n# REMOVE_START\nassert res == \"OK\"\ntime.sleep(2)\n# REMOVE_END\n\n# STEP_START validate_index\ninfo = client.ft(\"idx:bikes_vss\").info()\nnum_docs = info[\"num_docs\"]\nindexing_failures = info[\"hash_indexing_failures\"]\n# print(f\"{num_docs} documents indexed with {indexing_failures} failures\")\n# >>> 11 documents indexed with 0 failures\n# STEP_END\n# REMOVE_START\nassert (num_docs == \"11\") and (indexing_failures == \"0\")\n# REMOVE_END\n\n# STEP_START simple_query_1\nquery = Query(\"@brand:Peaknetic\")\nres = client.ft(\"idx:bikes_vss\").search(query).docs\n# print(res)\n# >>> [\n#       Document {\n#           'id': 'bikes:008',\n#           'payload': None,\n#           'brand': 'Peaknetic',\n#           'model': 'Soothe Electric bike',\n#           'price': '1950', 'description_embeddings': ...\n# STEP_END\n# REMOVE_START\n\nassert all(\n    item in [x.__dict__[\"id\"] for x in res] for item in [\"bikes:008\", \"bikes:009\"]\n)\n# REMOVE_END\n\n# STEP_START simple_query_2\nquery = Query(\"@brand:Peaknetic\").return_fields(\"id\", \"brand\", \"model\", \"price\")\nres = client.ft(\"idx:bikes_vss\").search(query).docs\n# print(res)\n# >>> [\n#       Document {\n#           'id': 'bikes:008',\n#           'payload': None,\n#           'brand': 'Peaknetic',\n#           'model': 'Soothe Electric bike',\n#           'price': '1950'\n#       },\n#       Document {\n#           'id': 'bikes:009',\n#           'payload': None,\n#           'brand': 'Peaknetic',\n#           'model': 'Secto',\n#           'price': '430'\n#       }\n# ]\n# STEP_END\n# REMOVE_START\nassert all(\n    item in [x.__dict__[\"id\"] for x in res] for item in [\"bikes:008\", \"bikes:009\"]\n)\n# REMOVE_END\n\n# STEP_START simple_query_3\nquery = Query(\"@brand:Peaknetic @price:[0 1000]\").return_fields(\n    \"id\", \"brand\", \"model\", \"price\"\n)\nres = client.ft(\"idx:bikes_vss\").search(query).docs\n# print(res)\n# >>> [\n#       Document {\n#           'id': 'bikes:009',\n#           'payload': None,\n#           'brand': 'Peaknetic',\n#           'model': 'Secto',\n#           'price': '430'\n#       }\n# ]\n# STEP_END\n# REMOVE_START\nassert all(item in [x.__dict__[\"id\"] for x in res] for item in [\"bikes:009\"])\n# REMOVE_END\n\n# STEP_START def_bulk_queries\nqueries = [\n    \"Bike for small kids\",\n    \"Best Mountain bikes for kids\",\n    \"Cheap Mountain bike for kids\",\n    \"Female specific mountain bike\",\n    \"Road bike for beginners\",\n    \"Commuter bike for people over 60\",\n    \"Comfortable commuter bike\",\n    \"Good bike for college students\",\n    \"Mountain bike for beginners\",\n    \"Vintage bike\",\n    \"Comfortable city bike\",\n]\n# STEP_END\n\n# STEP_START enc_bulk_queries\nencoded_queries = embedder.encode(queries)\nlen(encoded_queries)\n# >>> 11\n# STEP_END\n# REMOVE_START\nassert len(encoded_queries) == 11\n# REMOVE_END\n\n\n# STEP_START define_bulk_query\ndef create_query_table(query, queries, encoded_queries, extra_params=None):\n    \"\"\"\n    Creates a query table.\n    \"\"\"\n    results_list = []\n    for i, encoded_query in enumerate(encoded_queries):\n        result_docs = (\n            client.ft(\"idx:bikes_vss\")\n            .search(\n                query,\n                {\"query_vector\": np.array(encoded_query, dtype=np.float32).tobytes()}\n                | (extra_params if extra_params else {}),\n            )\n            .docs\n        )\n        for doc in result_docs:\n            vector_score = round(1 - float(doc.vector_score), 2)\n            results_list.append(\n                {\n                    \"query\": queries[i],\n                    \"score\": vector_score,\n                    \"id\": doc.id,\n                    \"brand\": doc.brand,\n                    \"model\": doc.model,\n                    \"description\": doc.description,\n                }\n            )\n\n    # Optional: convert the table to Markdown using Pandas\n    queries_table = pd.DataFrame(results_list)\n    queries_table.sort_values(\n        by=[\"query\", \"score\"], ascending=[True, False], inplace=True\n    )\n    queries_table[\"query\"] = queries_table.groupby(\"query\")[\"query\"].transform(\n        lambda x: [x.iloc[0]] + [\"\"] * (len(x) - 1)\n    )\n    queries_table[\"description\"] = queries_table[\"description\"].apply(\n        lambda x: (x[:497] + \"...\") if len(x) > 500 else x\n    )\n    return queries_table.to_markdown(index=False)\n\n\n# STEP_END\n\n# STEP_START run_knn_query\nquery = (\n    Query(\"(*)=>[KNN 3 @vector $query_vector AS vector_score]\")\n    .sort_by(\"vector_score\")\n    .return_fields(\"vector_score\", \"id\", \"brand\", \"model\", \"description\")\n    .dialect(2)\n)\n\ntable = create_query_table(query, queries, encoded_queries)\nprint(table)\n# >>> | Best Mountain bikes for kids     |    0.54 | bikes:003...\n# STEP_END\n\n# STEP_START run_hybrid_query\nhybrid_query = (\n    Query(\"(@brand:Peaknetic)=>[KNN 3 @vector $query_vector AS vector_score]\")\n    .sort_by(\"vector_score\")\n    .return_fields(\"vector_score\", \"id\", \"brand\", \"model\", \"description\")\n    .dialect(2)\n)\ntable = create_query_table(hybrid_query, queries, encoded_queries)\nprint(table)\n# >>> | Best Mountain bikes for kids     |    0.3  | bikes:008...\n# STEP_END\n\n# STEP_START run_range_query\nrange_query = (\n    Query(\n        \"@vector:[VECTOR_RANGE $range $query_vector]=>\"\n        \"{$YIELD_DISTANCE_AS: vector_score}\"\n    )\n    .sort_by(\"vector_score\")\n    .return_fields(\"vector_score\", \"id\", \"brand\", \"model\", \"description\")\n    .paging(0, 4)\n    .dialect(2)\n)\ntable = create_query_table(\n    range_query, queries[:1],\n    encoded_queries[:1],\n    {\"range\": 0.55}\n)\nprint(table)\n# >>> | Bike for small kids |    0.52 | bikes:001 | Velorim    |...\n# STEP_END\n"
  },
  {
    "path": "doctests/string_set_get.py",
    "content": "# EXAMPLE: set_and_get\n# HIDE_START\n\"\"\"\nCode samples for data structure store quickstart pages:\n    https://redis.io/docs/latest/develop/get-started/data-store/\n\"\"\"\n\nimport redis\n\nr = redis.Redis(host=\"localhost\", port=6379, db=0, decode_responses=True)\n# HIDE_END\n\nres = r.set(\"bike:1\", \"Process 134\")\nprint(res)\n# >>> True\n# REMOVE_START\nassert res\n# REMOVE_END\n\nres = r.get(\"bike:1\")\nprint(res)\n# >>> \"Process 134\"\n# REMOVE_START\nassert res == \"Process 134\"\n# REMOVE_END\n"
  },
  {
    "path": "doctests/trans_pipe.py",
    "content": "# EXAMPLE: pipe_trans_tutorial\n# HIDE_START\n\"\"\"\nCode samples for vector database quickstart pages:\n    https://redis.io/docs/latest/develop/get-started/vector-database/\n\"\"\"\n# HIDE_END\nimport redis\n\n# STEP_START basic_pipe\nr = redis.Redis(decode_responses=True)\n# REMOVE_START\nfor i in range(5):\n    r.delete(f\"seat:{i}\")\n\nr.delete(\"shellpath\")\n# REMOVE_END\n\npipe = r.pipeline()\n\nfor i in range(5):\n    pipe.set(f\"seat:{i}\", f\"#{i}\")\n\nset_5_result = pipe.execute()\nprint(set_5_result)  # >>> [True, True, True, True, True]\n\npipe = r.pipeline()\n\n# \"Chain\" pipeline commands together.\nget_3_result = pipe.get(\"seat:0\").get(\"seat:3\").get(\"seat:4\").execute()\nprint(get_3_result)  # >>> ['#0', '#3', '#4']\n# STEP_END\n# REMOVE_START\nassert set_5_result == [True, True, True, True, True]\nassert get_3_result == ['#0', '#3', '#4']\n# REMOVE_END\n\n# STEP_START trans_watch\nr.set(\"shellpath\", \"/usr/syscmds/\")\n\nwith r.pipeline() as pipe:\n    # Repeat until successful.\n    while True:\n        try:\n            # Watch the key we are about to change.\n            pipe.watch(\"shellpath\")\n\n            # The pipeline executes commands directly (instead of\n            # buffering them) from immediately after the `watch()`\n            # call until we begin the transaction.\n            current_path = pipe.get(\"shellpath\")\n            new_path = current_path + \":/usr/mycmds/\"\n\n            # Start the transaction, which will enable buffering\n            # again for the remaining commands.\n            pipe.multi()\n\n            pipe.set(\"shellpath\", new_path)\n\n            pipe.execute()\n\n            # The transaction succeeded, so break out of the loop.\n            break\n        except redis.WatchError:\n            # The transaction failed, so continue with the next attempt.\n            continue\n\nget_path_result = r.get(\"shellpath\")\nprint(get_path_result)  # >>> '/usr/syscmds/:/usr/mycmds/'\n# STEP_END\n# REMOVE_START\nassert get_path_result == '/usr/syscmds/:/usr/mycmds/'\nr.delete(\"shellpath\")\n# REMOVE_END\n\n# STEP_START watch_conv_method\nr.set(\"shellpath\", \"/usr/syscmds/\")\n\n\ndef watched_sequence(pipe):\n    current_path = pipe.get(\"shellpath\")\n    new_path = current_path + \":/usr/mycmds/\"\n\n    pipe.multi()\n\n    pipe.set(\"shellpath\", new_path)\n\n\ntrans_result = r.transaction(watched_sequence, \"shellpath\")\nprint(trans_result)  # True\n\nget_path_result = r.get(\"shellpath\")\nprint(get_path_result)  # >>> '/usr/syscmds/:/usr/mycmds/'\n# REMOVE_START\nassert trans_result\nassert get_path_result == '/usr/syscmds/:/usr/mycmds/'\n# REMOVE_END\n# STEP_END\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[project]\nname = \"redis\"\ndynamic = [\"version\"]\ndescription = \"Python client for Redis database and key-value store\"\nreadme = \"README.md\"\nlicense = \"MIT\"\nrequires-python = \">=3.10\"\nauthors = [{ name = \"Redis Inc.\", email = \"oss@redis.com\" }]\nkeywords = [\"Redis\", \"database\", \"key-value-store\"]\nclassifiers = [\n    \"Development Status :: 5 - Production/Stable\",\n    \"Environment :: Console\",\n    \"Intended Audience :: Developers\",\n    \"License :: OSI Approved :: MIT License\",\n    \"Operating System :: OS Independent\",\n    \"Programming Language :: Python\",\n    \"Programming Language :: Python :: 3\",\n    \"Programming Language :: Python :: 3 :: Only\",\n    \"Programming Language :: Python :: 3.10\",\n    \"Programming Language :: Python :: 3.11\",\n    \"Programming Language :: Python :: 3.12\",\n    \"Programming Language :: Python :: 3.13\",\n    \"Programming Language :: Python :: 3.14\",\n    \"Programming Language :: Python :: Implementation :: CPython\",\n    \"Programming Language :: Python :: Implementation :: PyPy\",\n]\ndependencies = [\n    'async-timeout>=4.0.3; python_full_version<\"3.11.3\"',\n]\n\n[project.optional-dependencies]\nhiredis = [\n    \"hiredis>=3.2.0\",\n]\n\nxxhash = [\n    'xxhash~=3.6.0',\n]\n\nocsp = [\n    \"cryptography>=36.0.1\",\n    \"pyopenssl>=20.0.1\",\n    \"requests>=2.31.0\",\n]\njwt = [\n    \"PyJWT>=2.9.0\",\n]\ncircuit_breaker = [\n    \"pybreaker>=1.4.0\"\n]\notel = [\n    \"opentelemetry-api>=1.39.1\",\n    \"opentelemetry-sdk>=1.39.1\",\n    \"opentelemetry-exporter-otlp-proto-http>=1.39.1\",\n]\n\n[project.urls]\nChanges = \"https://github.com/redis/redis-py/releases\"\nCode = \"https://github.com/redis/redis-py\"\nDocumentation = \"https://redis.readthedocs.io/en/latest/\"\nHomepage = \"https://github.com/redis/redis-py\"\n\"Issue tracker\" = \"https://github.com/redis/redis-py/issues\"\n\n[tool.hatch.version]\npath = \"redis/__init__.py\"\n\n[tool.hatch.build.targets.sdist]\ninclude = [\"/redis\", \"/tests\", \"dev_requirements.txt\"]\n\n[tool.hatch.build.targets.wheel]\ninclude = [\"/redis\"]\n\n[tool.pytest.ini_options]\naddopts = \"-s\"\nmarkers = [\n    \"redismod: run only the redis module tests\",\n    \"pipeline: pipeline tests\",\n    \"onlycluster: marks tests to be run only with cluster mode redis\",\n    \"onlynoncluster: marks tests to be run only with standalone redis\",\n    \"ssl: marker for only the ssl tests\",\n    \"asyncio: marker for async tests\",\n    \"replica: replica tests\",\n    \"experimental: run only experimental tests\",\n    \"cp_integration: credential provider integration tests\",\n    \"no_mock_connections: skip the autouse mock_health_check_connections fixture\",\n]\nasyncio_default_fixture_loop_scope = \"function\"\nasyncio_mode = \"auto\"\ntimeout = 30\nfilterwarnings = [\n    \"always\",\n    # Ignore a coverage warning when COVERAGE_CORE=sysmon for Pythons < 3.12.\n    \"ignore:sys.monitoring isn't available:coverage.exceptions.CoverageWarning\",\n]\nlog_cli_level = \"INFO\"\nlog_cli_date_format = \"%H:%M:%S:%f\"\nlog_cli = false\nlog_cli_format = \"%(asctime)s %(levelname)s %(threadName)s: %(message)s\"\nlog_level = \"INFO\"\ncapture = \"yes\"\n\n[tool.ruff]\ntarget-version = \"py310\"\nline-length = 88\nexclude = [\n    \"*.egg-info\",\n    \"*.pyc\",\n    \".git\",\n    \".venv*\",\n    \"build\",\n    \"dist\",\n    \"docker\",\n    \"docs/*\",\n    \"doctests/*\",\n    \"tasks.py\",\n    \"venv*\",\n    \"whitelist.py\",\n]\n\n[tool.ruff.lint]\nignore = [\n    \"E501\", # line too long (taken care of with ruff format)\n    \"E741\", # ambiguous variable name\n    \"N818\", # Errors should have Error suffix\n]\n\nselect = [\"E\", \"F\", \"FLY\", \"I\", \"N\", \"W\"]\n\n[tool.ruff.lint.per-file-ignores]\n\"redis/commands/bf/*\" = [\n    # the `bf` module uses star imports, so this is required there.\n    \"F405\", # name may be undefined, or defined from star imports\n]\n\"redis/commands/{bf,timeseries,json,search}/*\" = [\"N\"]\n\"tests/*\" = [\n    \"I\",    # TODO: could be enabled, plenty of changes\n    \"N801\", # class name should use CapWords convention\n    \"N803\", # argument name should be lowercase\n    \"N802\", # function name should be lowercase\n    \"N806\", # variable name should be lowercase\n]\n"
  },
  {
    "path": "redis/__init__.py",
    "content": "from redis import asyncio  # noqa\nfrom redis.backoff import default_backoff\nfrom redis.client import Redis, StrictRedis\nfrom redis.driver_info import DriverInfo\nfrom redis.cluster import RedisCluster\nfrom redis.connection import (\n    BlockingConnectionPool,\n    Connection,\n    ConnectionPool,\n    SSLConnection,\n    UnixDomainSocketConnection,\n)\nfrom redis.credentials import CredentialProvider, UsernamePasswordCredentialProvider\nfrom redis.exceptions import (\n    AuthenticationError,\n    AuthenticationWrongNumberOfArgsError,\n    BusyLoadingError,\n    ChildDeadlockedError,\n    ConnectionError,\n    CrossSlotTransactionError,\n    DataError,\n    InvalidPipelineStack,\n    InvalidResponse,\n    MaxConnectionsError,\n    OutOfMemoryError,\n    PubSubError,\n    ReadOnlyError,\n    RedisClusterException,\n    RedisError,\n    ResponseError,\n    TimeoutError,\n    WatchError,\n)\nfrom redis.sentinel import (\n    Sentinel,\n    SentinelConnectionPool,\n    SentinelManagedConnection,\n    SentinelManagedSSLConnection,\n)\nfrom redis.utils import from_url\n\n\ndef int_or_str(value):\n    try:\n        return int(value)\n    except ValueError:\n        return value\n\n\n__version__ = \"7.1.1\"\n\nVERSION = tuple(map(int_or_str, __version__.split(\".\")))\n\n\n__all__ = [\n    \"AuthenticationError\",\n    \"AuthenticationWrongNumberOfArgsError\",\n    \"BlockingConnectionPool\",\n    \"BusyLoadingError\",\n    \"ChildDeadlockedError\",\n    \"Connection\",\n    \"ConnectionError\",\n    \"ConnectionPool\",\n    \"CredentialProvider\",\n    \"CrossSlotTransactionError\",\n    \"DataError\",\n    \"DriverInfo\",\n    \"from_url\",\n    \"default_backoff\",\n    \"InvalidPipelineStack\",\n    \"InvalidResponse\",\n    \"MaxConnectionsError\",\n    \"OutOfMemoryError\",\n    \"PubSubError\",\n    \"ReadOnlyError\",\n    \"Redis\",\n    \"RedisCluster\",\n    \"RedisClusterException\",\n    \"RedisError\",\n    \"ResponseError\",\n    \"Sentinel\",\n    \"SentinelConnectionPool\",\n    \"SentinelManagedConnection\",\n    \"SentinelManagedSSLConnection\",\n    \"SSLConnection\",\n    \"UsernamePasswordCredentialProvider\",\n    \"StrictRedis\",\n    \"TimeoutError\",\n    \"UnixDomainSocketConnection\",\n    \"WatchError\",\n]\n"
  },
  {
    "path": "redis/_parsers/__init__.py",
    "content": "from .base import (\n    AsyncPushNotificationsParser,\n    BaseParser,\n    PushNotificationsParser,\n    _AsyncRESPBase,\n)\nfrom .commands import AsyncCommandsParser, CommandsParser\nfrom .encoders import Encoder\nfrom .hiredis import _AsyncHiredisParser, _HiredisParser\nfrom .resp2 import _AsyncRESP2Parser, _RESP2Parser\nfrom .resp3 import _AsyncRESP3Parser, _RESP3Parser\n\n__all__ = [\n    \"AsyncCommandsParser\",\n    \"_AsyncHiredisParser\",\n    \"_AsyncRESPBase\",\n    \"_AsyncRESP2Parser\",\n    \"_AsyncRESP3Parser\",\n    \"AsyncPushNotificationsParser\",\n    \"CommandsParser\",\n    \"Encoder\",\n    \"BaseParser\",\n    \"_HiredisParser\",\n    \"_RESP2Parser\",\n    \"_RESP3Parser\",\n    \"PushNotificationsParser\",\n]\n"
  },
  {
    "path": "redis/_parsers/base.py",
    "content": "import logging\nimport sys\nfrom abc import ABC\nfrom asyncio import IncompleteReadError, StreamReader, TimeoutError\nfrom typing import Awaitable, Callable, List, Optional, Protocol, Union\n\nfrom redis.maint_notifications import (\n    MaintenanceNotification,\n    NodeFailedOverNotification,\n    NodeFailingOverNotification,\n    NodeMigratedNotification,\n    NodeMigratingNotification,\n    NodeMovingNotification,\n    OSSNodeMigratedNotification,\n    OSSNodeMigratingNotification,\n)\nfrom redis.utils import safe_str\n\nif sys.version_info.major >= 3 and sys.version_info.minor >= 11:\n    from asyncio import timeout as async_timeout\nelse:\n    from async_timeout import timeout as async_timeout\n\nfrom ..exceptions import (\n    AskError,\n    AuthenticationError,\n    AuthenticationWrongNumberOfArgsError,\n    BusyLoadingError,\n    ClusterCrossSlotError,\n    ClusterDownError,\n    ConnectionError,\n    ExecAbortError,\n    ExternalAuthProviderError,\n    MasterDownError,\n    ModuleError,\n    MovedError,\n    NoPermissionError,\n    NoScriptError,\n    OutOfMemoryError,\n    ReadOnlyError,\n    ResponseError,\n    TryAgainError,\n)\nfrom ..typing import EncodableT\nfrom .encoders import Encoder\nfrom .socket import SERVER_CLOSED_CONNECTION_ERROR, SocketBuffer\n\nMODULE_LOAD_ERROR = \"Error loading the extension. Please check the server logs.\"\nNO_SUCH_MODULE_ERROR = \"Error unloading module: no such module with that name\"\nMODULE_UNLOAD_NOT_POSSIBLE_ERROR = \"Error unloading module: operation not possible.\"\nMODULE_EXPORTS_DATA_TYPES_ERROR = (\n    \"Error unloading module: the module \"\n    \"exports one or more module-side data \"\n    \"types, can't unload\"\n)\n# user send an AUTH cmd to a server without authorization configured\nNO_AUTH_SET_ERROR = {\n    # Redis >= 6.0\n    \"AUTH <password> called without any password \"\n    \"configured for the default user. Are you sure \"\n    \"your configuration is correct?\": AuthenticationError,\n    # Redis < 6.0\n    \"Client sent AUTH, but no password is set\": AuthenticationError,\n}\n\nEXTERNAL_AUTH_PROVIDER_ERROR = {\n    \"problem with LDAP service\": ExternalAuthProviderError,\n}\n\nlogger = logging.getLogger(__name__)\n\n\nclass BaseParser(ABC):\n    EXCEPTION_CLASSES = {\n        \"ERR\": {\n            \"max number of clients reached\": ConnectionError,\n            \"invalid password\": AuthenticationError,\n            # some Redis server versions report invalid command syntax\n            # in lowercase\n            \"wrong number of arguments \"\n            \"for 'auth' command\": AuthenticationWrongNumberOfArgsError,\n            # some Redis server versions report invalid command syntax\n            # in uppercase\n            \"wrong number of arguments \"\n            \"for 'AUTH' command\": AuthenticationWrongNumberOfArgsError,\n            MODULE_LOAD_ERROR: ModuleError,\n            MODULE_EXPORTS_DATA_TYPES_ERROR: ModuleError,\n            NO_SUCH_MODULE_ERROR: ModuleError,\n            MODULE_UNLOAD_NOT_POSSIBLE_ERROR: ModuleError,\n            **NO_AUTH_SET_ERROR,\n            **EXTERNAL_AUTH_PROVIDER_ERROR,\n        },\n        \"OOM\": OutOfMemoryError,\n        \"WRONGPASS\": AuthenticationError,\n        \"EXECABORT\": ExecAbortError,\n        \"LOADING\": BusyLoadingError,\n        \"NOSCRIPT\": NoScriptError,\n        \"READONLY\": ReadOnlyError,\n        \"NOAUTH\": AuthenticationError,\n        \"NOPERM\": NoPermissionError,\n        \"ASK\": AskError,\n        \"TRYAGAIN\": TryAgainError,\n        \"MOVED\": MovedError,\n        \"CLUSTERDOWN\": ClusterDownError,\n        \"CROSSSLOT\": ClusterCrossSlotError,\n        \"MASTERDOWN\": MasterDownError,\n    }\n\n    @classmethod\n    def parse_error(cls, response):\n        \"Parse an error response\"\n        error_code = response.split(\" \")[0]\n        if error_code in cls.EXCEPTION_CLASSES:\n            response = response[len(error_code) + 1 :]\n            exception_class = cls.EXCEPTION_CLASSES[error_code]\n            if isinstance(exception_class, dict):\n                exception_class = exception_class.get(response, ResponseError)\n            return exception_class(response, status_code=error_code)\n        return ResponseError(response)\n\n    def on_disconnect(self):\n        raise NotImplementedError()\n\n    def on_connect(self, connection):\n        raise NotImplementedError()\n\n\nclass _RESPBase(BaseParser):\n    \"\"\"Base class for sync-based resp parsing\"\"\"\n\n    def __init__(self, socket_read_size):\n        self.socket_read_size = socket_read_size\n        self.encoder = None\n        self._sock = None\n        self._buffer = None\n\n    def __del__(self):\n        try:\n            self.on_disconnect()\n        except Exception:\n            pass\n\n    def on_connect(self, connection):\n        \"Called when the socket connects\"\n        self._sock = connection._sock\n        self._buffer = SocketBuffer(\n            self._sock, self.socket_read_size, connection.socket_timeout\n        )\n        self.encoder = connection.encoder\n\n    def on_disconnect(self):\n        \"Called when the socket disconnects\"\n        self._sock = None\n        if self._buffer is not None:\n            self._buffer.close()\n            self._buffer = None\n        self.encoder = None\n\n    def can_read(self, timeout):\n        return self._buffer and self._buffer.can_read(timeout)\n\n\nclass AsyncBaseParser(BaseParser):\n    \"\"\"Base parsing class for the python-backed async parser\"\"\"\n\n    __slots__ = \"_stream\", \"_read_size\"\n\n    def __init__(self, socket_read_size: int):\n        self._stream: Optional[StreamReader] = None\n        self._read_size = socket_read_size\n\n    async def can_read_destructive(self) -> bool:\n        raise NotImplementedError()\n\n    async def read_response(\n        self, disable_decoding: bool = False\n    ) -> Union[EncodableT, ResponseError, None, List[EncodableT]]:\n        raise NotImplementedError()\n\n\nclass MaintenanceNotificationsParser:\n    \"\"\"Protocol defining maintenance push notification parsing functionality\"\"\"\n\n    @staticmethod\n    def parse_oss_maintenance_start_msg(response):\n        # Expected message format is:\n        # SMIGRATING <seq_number> <slot, range1-range2,...>\n        id = response[1]\n        slots = safe_str(response[2])\n        return OSSNodeMigratingNotification(id, slots)\n\n    @staticmethod\n    def parse_oss_maintenance_completed_msg(response):\n        # Expected message format is:\n        # SMIGRATED <seq_number> [[<src_host:port> <dest_host:port> <slot_range>], ...]\n        id = response[1]\n        nodes_to_slots_mapping_data = response[2]\n        # Build the nodes_to_slots_mapping dict structure:\n        # {\n        #     \"src_host:port\": [\n        #         {\"dest_host:port\": \"slot_range\"},\n        #         ...\n        #     ],\n        #     ...\n        # }\n        nodes_to_slots_mapping = {}\n        for src_node, dest_node, slots in nodes_to_slots_mapping_data:\n            src_node_str = safe_str(src_node)\n            dest_node_str = safe_str(dest_node)\n            slots_str = safe_str(slots)\n\n            if src_node_str not in nodes_to_slots_mapping:\n                nodes_to_slots_mapping[src_node_str] = []\n            nodes_to_slots_mapping[src_node_str].append({dest_node_str: slots_str})\n\n        return OSSNodeMigratedNotification(id, nodes_to_slots_mapping)\n\n    @staticmethod\n    def parse_maintenance_start_msg(response, notification_type):\n        # Expected message format is: <notification_type> <seq_number> <time>\n        # Examples:\n        # MIGRATING 1 10\n        # FAILING_OVER 2 20\n        id = response[1]\n        ttl = response[2]\n        return notification_type(id, ttl)\n\n    @staticmethod\n    def parse_maintenance_completed_msg(response, notification_type):\n        # Expected message format is: <notification_type> <seq_number>\n        # Examples:\n        # MIGRATED 1\n        # FAILED_OVER 2\n        id = response[1]\n        return notification_type(id)\n\n    @staticmethod\n    def parse_moving_msg(response):\n        # Expected message format is: MOVING <seq_number> <time> <endpoint>\n        id = response[1]\n        ttl = response[2]\n        if response[3] is None:\n            host, port = None, None\n        else:\n            value = safe_str(response[3])\n            host, port = value.split(\":\")\n            port = int(port) if port is not None else None\n\n        return NodeMovingNotification(id, host, port, ttl)\n\n\n_INVALIDATION_MESSAGE = \"invalidate\"\n_MOVING_MESSAGE = \"MOVING\"\n_MIGRATING_MESSAGE = \"MIGRATING\"\n_MIGRATED_MESSAGE = \"MIGRATED\"\n_FAILING_OVER_MESSAGE = \"FAILING_OVER\"\n_FAILED_OVER_MESSAGE = \"FAILED_OVER\"\n_SMIGRATING_MESSAGE = \"SMIGRATING\"\n_SMIGRATED_MESSAGE = \"SMIGRATED\"\n\n_MAINTENANCE_MESSAGES = (\n    _MIGRATING_MESSAGE,\n    _MIGRATED_MESSAGE,\n    _FAILING_OVER_MESSAGE,\n    _FAILED_OVER_MESSAGE,\n    _SMIGRATING_MESSAGE,\n)\n\nMSG_TYPE_TO_MAINT_NOTIFICATION_PARSER_MAPPING: dict[\n    str, tuple[type[MaintenanceNotification], Callable]\n] = {\n    _MIGRATING_MESSAGE: (\n        NodeMigratingNotification,\n        MaintenanceNotificationsParser.parse_maintenance_start_msg,\n    ),\n    _MIGRATED_MESSAGE: (\n        NodeMigratedNotification,\n        MaintenanceNotificationsParser.parse_maintenance_completed_msg,\n    ),\n    _FAILING_OVER_MESSAGE: (\n        NodeFailingOverNotification,\n        MaintenanceNotificationsParser.parse_maintenance_start_msg,\n    ),\n    _FAILED_OVER_MESSAGE: (\n        NodeFailedOverNotification,\n        MaintenanceNotificationsParser.parse_maintenance_completed_msg,\n    ),\n    _MOVING_MESSAGE: (\n        NodeMovingNotification,\n        MaintenanceNotificationsParser.parse_moving_msg,\n    ),\n    _SMIGRATING_MESSAGE: (\n        OSSNodeMigratingNotification,\n        MaintenanceNotificationsParser.parse_oss_maintenance_start_msg,\n    ),\n    _SMIGRATED_MESSAGE: (\n        OSSNodeMigratedNotification,\n        MaintenanceNotificationsParser.parse_oss_maintenance_completed_msg,\n    ),\n}\n\n\nclass PushNotificationsParser(Protocol):\n    \"\"\"Protocol defining RESP3-specific parsing functionality\"\"\"\n\n    pubsub_push_handler_func: Callable\n    invalidation_push_handler_func: Optional[Callable] = None\n    node_moving_push_handler_func: Optional[Callable] = None\n    maintenance_push_handler_func: Optional[Callable] = None\n    oss_cluster_maint_push_handler_func: Optional[Callable] = None\n\n    def handle_pubsub_push_response(self, response):\n        \"\"\"Handle pubsub push responses\"\"\"\n        raise NotImplementedError()\n\n    def handle_push_response(self, response, **kwargs):\n        msg_type = response[0]\n        if isinstance(msg_type, bytes):\n            msg_type = msg_type.decode()\n\n        if msg_type not in (\n            _INVALIDATION_MESSAGE,\n            *_MAINTENANCE_MESSAGES,\n            _MOVING_MESSAGE,\n            _SMIGRATED_MESSAGE,\n        ):\n            return self.pubsub_push_handler_func(response)\n\n        try:\n            if (\n                msg_type == _INVALIDATION_MESSAGE\n                and self.invalidation_push_handler_func\n            ):\n                return self.invalidation_push_handler_func(response)\n\n            if msg_type == _MOVING_MESSAGE and self.node_moving_push_handler_func:\n                parser_function = MSG_TYPE_TO_MAINT_NOTIFICATION_PARSER_MAPPING[\n                    msg_type\n                ][1]\n\n                notification = parser_function(response)\n                return self.node_moving_push_handler_func(notification)\n\n            if msg_type in _MAINTENANCE_MESSAGES and self.maintenance_push_handler_func:\n                parser_function = MSG_TYPE_TO_MAINT_NOTIFICATION_PARSER_MAPPING[\n                    msg_type\n                ][1]\n                if msg_type == _SMIGRATING_MESSAGE:\n                    notification = parser_function(response)\n                else:\n                    notification_type = MSG_TYPE_TO_MAINT_NOTIFICATION_PARSER_MAPPING[\n                        msg_type\n                    ][0]\n                    notification = parser_function(response, notification_type)\n\n                if notification is not None:\n                    return self.maintenance_push_handler_func(notification)\n            if msg_type == _SMIGRATED_MESSAGE and (\n                self.oss_cluster_maint_push_handler_func\n                or self.maintenance_push_handler_func\n            ):\n                parser_function = MSG_TYPE_TO_MAINT_NOTIFICATION_PARSER_MAPPING[\n                    msg_type\n                ][1]\n                notification = parser_function(response)\n\n                if notification is not None:\n                    if self.maintenance_push_handler_func:\n                        self.maintenance_push_handler_func(notification)\n                    if self.oss_cluster_maint_push_handler_func:\n                        self.oss_cluster_maint_push_handler_func(notification)\n        except Exception as e:\n            logger.error(\n                \"Error handling {} message ({}): {}\".format(msg_type, response, e)\n            )\n\n        return None\n\n    def set_pubsub_push_handler(self, pubsub_push_handler_func):\n        self.pubsub_push_handler_func = pubsub_push_handler_func\n\n    def set_invalidation_push_handler(self, invalidation_push_handler_func):\n        self.invalidation_push_handler_func = invalidation_push_handler_func\n\n    def set_node_moving_push_handler(self, node_moving_push_handler_func):\n        self.node_moving_push_handler_func = node_moving_push_handler_func\n\n    def set_maintenance_push_handler(self, maintenance_push_handler_func):\n        self.maintenance_push_handler_func = maintenance_push_handler_func\n\n    def set_oss_cluster_maint_push_handler(self, oss_cluster_maint_push_handler_func):\n        self.oss_cluster_maint_push_handler_func = oss_cluster_maint_push_handler_func\n\n\nclass AsyncPushNotificationsParser(Protocol):\n    \"\"\"Protocol defining async RESP3-specific parsing functionality\"\"\"\n\n    pubsub_push_handler_func: Callable\n    invalidation_push_handler_func: Optional[Callable] = None\n    node_moving_push_handler_func: Optional[Callable[..., Awaitable[None]]] = None\n    maintenance_push_handler_func: Optional[Callable[..., Awaitable[None]]] = None\n    oss_cluster_maint_push_handler_func: Optional[Callable[..., Awaitable[None]]] = None\n\n    async def handle_pubsub_push_response(self, response):\n        \"\"\"Handle pubsub push responses asynchronously\"\"\"\n        raise NotImplementedError()\n\n    async def handle_push_response(self, response, **kwargs):\n        \"\"\"Handle push responses asynchronously\"\"\"\n\n        msg_type = response[0]\n        if isinstance(msg_type, bytes):\n            msg_type = msg_type.decode()\n\n        if msg_type not in (\n            _INVALIDATION_MESSAGE,\n            *_MAINTENANCE_MESSAGES,\n            _MOVING_MESSAGE,\n            _SMIGRATED_MESSAGE,\n        ):\n            return await self.pubsub_push_handler_func(response)\n\n        try:\n            if (\n                msg_type == _INVALIDATION_MESSAGE\n                and self.invalidation_push_handler_func\n            ):\n                return await self.invalidation_push_handler_func(response)\n\n            if isinstance(msg_type, bytes):\n                msg_type = msg_type.decode()\n\n            if msg_type == _MOVING_MESSAGE and self.node_moving_push_handler_func:\n                parser_function = MSG_TYPE_TO_MAINT_NOTIFICATION_PARSER_MAPPING[\n                    msg_type\n                ][1]\n                notification = parser_function(response)\n                return await self.node_moving_push_handler_func(notification)\n\n            if msg_type in _MAINTENANCE_MESSAGES and self.maintenance_push_handler_func:\n                parser_function = MSG_TYPE_TO_MAINT_NOTIFICATION_PARSER_MAPPING[\n                    msg_type\n                ][1]\n                if msg_type == _SMIGRATING_MESSAGE:\n                    notification = parser_function(response)\n                else:\n                    notification_type = MSG_TYPE_TO_MAINT_NOTIFICATION_PARSER_MAPPING[\n                        msg_type\n                    ][0]\n                    notification = parser_function(response, notification_type)\n\n                if notification is not None:\n                    return await self.maintenance_push_handler_func(notification)\n            if (\n                msg_type == _SMIGRATED_MESSAGE\n                and self.oss_cluster_maint_push_handler_func\n            ):\n                parser_function = MSG_TYPE_TO_MAINT_NOTIFICATION_PARSER_MAPPING[\n                    msg_type\n                ][1]\n                notification = parser_function(response)\n                if notification is not None:\n                    return await self.oss_cluster_maint_push_handler_func(notification)\n        except Exception as e:\n            logger.error(\n                \"Error handling {} message ({}): {}\".format(msg_type, response, e)\n            )\n\n        return None\n\n    def set_pubsub_push_handler(self, pubsub_push_handler_func):\n        \"\"\"Set the pubsub push handler function\"\"\"\n        self.pubsub_push_handler_func = pubsub_push_handler_func\n\n    def set_invalidation_push_handler(self, invalidation_push_handler_func):\n        \"\"\"Set the invalidation push handler function\"\"\"\n        self.invalidation_push_handler_func = invalidation_push_handler_func\n\n    def set_node_moving_push_handler(self, node_moving_push_handler_func):\n        self.node_moving_push_handler_func = node_moving_push_handler_func\n\n    def set_maintenance_push_handler(self, maintenance_push_handler_func):\n        self.maintenance_push_handler_func = maintenance_push_handler_func\n\n    def set_oss_cluster_maint_push_handler(self, oss_cluster_maint_push_handler_func):\n        self.oss_cluster_maint_push_handler_func = oss_cluster_maint_push_handler_func\n\n\nclass _AsyncRESPBase(AsyncBaseParser):\n    \"\"\"Base class for async resp parsing\"\"\"\n\n    __slots__ = AsyncBaseParser.__slots__ + (\"encoder\", \"_buffer\", \"_pos\", \"_chunks\")\n\n    def __init__(self, socket_read_size: int):\n        super().__init__(socket_read_size)\n        self.encoder: Optional[Encoder] = None\n        self._buffer = b\"\"\n        self._chunks = []\n        self._pos = 0\n\n    def _clear(self):\n        self._buffer = b\"\"\n        self._chunks.clear()\n\n    def on_connect(self, connection):\n        \"\"\"Called when the stream connects\"\"\"\n        self._stream = connection._reader\n        if self._stream is None:\n            raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR)\n        self.encoder = connection.encoder\n        self._clear()\n        self._connected = True\n\n    def on_disconnect(self):\n        \"\"\"Called when the stream disconnects\"\"\"\n        self._connected = False\n\n    async def can_read_destructive(self) -> bool:\n        if not self._connected:\n            raise OSError(\"Buffer is closed.\")\n        if self._buffer:\n            return True\n        try:\n            async with async_timeout(0):\n                return self._stream.at_eof()\n        except TimeoutError:\n            return False\n\n    async def _read(self, length: int) -> bytes:\n        \"\"\"\n        Read `length` bytes of data.  These are assumed to be followed\n        by a '\\r\\n' terminator which is subsequently discarded.\n        \"\"\"\n        want = length + 2\n        end = self._pos + want\n        if len(self._buffer) >= end:\n            result = self._buffer[self._pos : end - 2]\n        else:\n            tail = self._buffer[self._pos :]\n            try:\n                data = await self._stream.readexactly(want - len(tail))\n            except IncompleteReadError as error:\n                raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR) from error\n            result = (tail + data)[:-2]\n            self._chunks.append(data)\n        self._pos += want\n        return result\n\n    async def _readline(self) -> bytes:\n        \"\"\"\n        read an unknown number of bytes up to the next '\\r\\n'\n        line separator, which is discarded.\n        \"\"\"\n        found = self._buffer.find(b\"\\r\\n\", self._pos)\n        if found >= 0:\n            result = self._buffer[self._pos : found]\n        else:\n            tail = self._buffer[self._pos :]\n            data = await self._stream.readline()\n            if not data.endswith(b\"\\r\\n\"):\n                raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR)\n            result = (tail + data)[:-2]\n            self._chunks.append(data)\n        self._pos += len(result) + 2\n        return result\n"
  },
  {
    "path": "redis/_parsers/commands.py",
    "content": "from enum import Enum\nfrom typing import TYPE_CHECKING, Any, Awaitable, Dict, Optional, Tuple, Union\n\nfrom redis.exceptions import IncorrectPolicyType, RedisError, ResponseError\nfrom redis.utils import str_if_bytes\n\nif TYPE_CHECKING:\n    from redis.asyncio.cluster import ClusterNode\n\n\nclass RequestPolicy(Enum):\n    ALL_NODES = \"all_nodes\"\n    ALL_SHARDS = \"all_shards\"\n    ALL_REPLICAS = \"all_replicas\"\n    MULTI_SHARD = \"multi_shard\"\n    SPECIAL = \"special\"\n    DEFAULT_KEYLESS = \"default_keyless\"\n    DEFAULT_KEYED = \"default_keyed\"\n    DEFAULT_NODE = \"default_node\"\n\n\nclass ResponsePolicy(Enum):\n    ONE_SUCCEEDED = \"one_succeeded\"\n    ALL_SUCCEEDED = \"all_succeeded\"\n    AGG_LOGICAL_AND = \"agg_logical_and\"\n    AGG_LOGICAL_OR = \"agg_logical_or\"\n    AGG_MIN = \"agg_min\"\n    AGG_MAX = \"agg_max\"\n    AGG_SUM = \"agg_sum\"\n    SPECIAL = \"special\"\n    DEFAULT_KEYLESS = \"default_keyless\"\n    DEFAULT_KEYED = \"default_keyed\"\n\n\nclass CommandPolicies:\n    def __init__(\n        self,\n        request_policy: RequestPolicy = RequestPolicy.DEFAULT_KEYLESS,\n        response_policy: ResponsePolicy = ResponsePolicy.DEFAULT_KEYLESS,\n    ):\n        self.request_policy = request_policy\n        self.response_policy = response_policy\n\n\nPolicyRecords = dict[str, dict[str, CommandPolicies]]\n\n\nclass AbstractCommandsParser:\n    def _get_pubsub_keys(self, *args):\n        \"\"\"\n        Get the keys from pubsub command.\n        Although PubSub commands have predetermined key locations, they are not\n        supported in the 'COMMAND's output, so the key positions are hardcoded\n        in this method\n        \"\"\"\n        if len(args) < 2:\n            # The command has no keys in it\n            return None\n        args = [str_if_bytes(arg) for arg in args]\n        command = args[0].upper()\n        keys = None\n        if command == \"PUBSUB\":\n            # the second argument is a part of the command name, e.g.\n            # ['PUBSUB', 'NUMSUB', 'foo'].\n            pubsub_type = args[1].upper()\n            if pubsub_type in [\"CHANNELS\", \"NUMSUB\", \"SHARDCHANNELS\", \"SHARDNUMSUB\"]:\n                keys = args[2:]\n        elif command in [\"SUBSCRIBE\", \"PSUBSCRIBE\", \"UNSUBSCRIBE\", \"PUNSUBSCRIBE\"]:\n            # format example:\n            # SUBSCRIBE channel [channel ...]\n            keys = list(args[1:])\n        elif command in [\"PUBLISH\", \"SPUBLISH\"]:\n            # format example:\n            # PUBLISH channel message\n            keys = [args[1]]\n        return keys\n\n    def parse_subcommand(self, command, **options):\n        cmd_dict = {}\n        cmd_name = str_if_bytes(command[0])\n        cmd_dict[\"name\"] = cmd_name\n        cmd_dict[\"arity\"] = int(command[1])\n        cmd_dict[\"flags\"] = [str_if_bytes(flag) for flag in command[2]]\n        cmd_dict[\"first_key_pos\"] = command[3]\n        cmd_dict[\"last_key_pos\"] = command[4]\n        cmd_dict[\"step_count\"] = command[5]\n        if len(command) > 7:\n            cmd_dict[\"tips\"] = command[7]\n            cmd_dict[\"key_specifications\"] = command[8]\n            cmd_dict[\"subcommands\"] = command[9]\n        return cmd_dict\n\n\nclass CommandsParser(AbstractCommandsParser):\n    \"\"\"\n    Parses Redis commands to get command keys.\n    COMMAND output is used to determine key locations.\n    Commands that do not have a predefined key location are flagged with\n    'movablekeys', and these commands' keys are determined by the command\n    'COMMAND GETKEYS'.\n    \"\"\"\n\n    def __init__(self, redis_connection):\n        self.commands = {}\n        self.redis_connection = redis_connection\n        self.initialize(self.redis_connection)\n\n    def initialize(self, r):\n        commands = r.command()\n        uppercase_commands = []\n        for cmd in commands:\n            if any(x.isupper() for x in cmd):\n                uppercase_commands.append(cmd)\n        for cmd in uppercase_commands:\n            commands[cmd.lower()] = commands.pop(cmd)\n        self.commands = commands\n\n    # As soon as this PR is merged into Redis, we should reimplement\n    # our logic to use COMMAND INFO changes to determine the key positions\n    # https://github.com/redis/redis/pull/8324\n    def get_keys(self, redis_conn, *args):\n        \"\"\"\n        Get the keys from the passed command.\n\n        NOTE: Due to a bug in redis<7.0, this function does not work properly\n        for EVAL or EVALSHA when the `numkeys` arg is 0.\n         - issue: https://github.com/redis/redis/issues/9493\n         - fix: https://github.com/redis/redis/pull/9733\n\n        So, don't use this function with EVAL or EVALSHA.\n        \"\"\"\n        if len(args) < 2:\n            # The command has no keys in it\n            return None\n\n        cmd_name = args[0].lower()\n        if cmd_name not in self.commands:\n            # try to split the command name and to take only the main command,\n            # e.g. 'memory' for 'memory usage'\n            cmd_name_split = cmd_name.split()\n            cmd_name = cmd_name_split[0]\n            if cmd_name in self.commands:\n                # save the splitted command to args\n                args = cmd_name_split + list(args[1:])\n            else:\n                # We'll try to reinitialize the commands cache, if the engine\n                # version has changed, the commands may not be current\n                self.initialize(redis_conn)\n                if cmd_name not in self.commands:\n                    raise RedisError(\n                        f\"{cmd_name.upper()} command doesn't exist in Redis commands\"\n                    )\n\n        command = self.commands.get(cmd_name)\n        if \"movablekeys\" in command[\"flags\"]:\n            keys = self._get_moveable_keys(redis_conn, *args)\n        elif \"pubsub\" in command[\"flags\"] or command[\"name\"] == \"pubsub\":\n            keys = self._get_pubsub_keys(*args)\n        else:\n            if (\n                command[\"step_count\"] == 0\n                and command[\"first_key_pos\"] == 0\n                and command[\"last_key_pos\"] == 0\n            ):\n                is_subcmd = False\n                if \"subcommands\" in command:\n                    subcmd_name = f\"{cmd_name}|{args[1].lower()}\"\n                    for subcmd in command[\"subcommands\"]:\n                        if str_if_bytes(subcmd[0]) == subcmd_name:\n                            command = self.parse_subcommand(subcmd)\n\n                            if command[\"first_key_pos\"] > 0:\n                                is_subcmd = True\n\n                # The command doesn't have keys in it\n                if not is_subcmd:\n                    return None\n            last_key_pos = command[\"last_key_pos\"]\n            if last_key_pos < 0:\n                last_key_pos = len(args) - abs(last_key_pos)\n            keys_pos = list(\n                range(command[\"first_key_pos\"], last_key_pos + 1, command[\"step_count\"])\n            )\n            keys = [args[pos] for pos in keys_pos]\n\n        return keys\n\n    def _get_moveable_keys(self, redis_conn, *args):\n        \"\"\"\n        NOTE: Due to a bug in redis<7.0, this function does not work properly\n        for EVAL or EVALSHA when the `numkeys` arg is 0.\n         - issue: https://github.com/redis/redis/issues/9493\n         - fix: https://github.com/redis/redis/pull/9733\n\n        So, don't use this function with EVAL or EVALSHA.\n        \"\"\"\n        # The command name should be splitted into separate arguments,\n        # e.g. 'MEMORY USAGE' will be splitted into ['MEMORY', 'USAGE']\n        pieces = args[0].split() + list(args[1:])\n        try:\n            keys = redis_conn.execute_command(\"COMMAND GETKEYS\", *pieces)\n        except ResponseError as e:\n            message = e.__str__()\n            if (\n                \"Invalid arguments\" in message\n                or \"The command has no key arguments\" in message\n            ):\n                return None\n            else:\n                raise e\n        return keys\n\n    def _is_keyless_command(\n        self, command_name: str, subcommand_name: Optional[str] = None\n    ) -> bool:\n        \"\"\"\n        Determines whether a given command or subcommand is considered \"keyless\".\n\n        A keyless command does not operate on specific keys, which is determined based\n        on the first key position in the command or subcommand details. If the command\n        or subcommand's first key position is zero or negative, it is treated as keyless.\n\n        Parameters:\n            command_name: str\n                The name of the command to check.\n            subcommand_name: Optional[str], default=None\n                The name of the subcommand to check, if applicable. If not provided,\n                the check is performed only on the command.\n\n        Returns:\n            bool\n                True if the specified command or subcommand is considered keyless,\n                False otherwise.\n\n        Raises:\n            ValueError\n                If the specified subcommand is not found within the command or the\n                specified command does not exist in the available commands.\n        \"\"\"\n        if subcommand_name:\n            for subcommand in self.commands.get(command_name)[\"subcommands\"]:\n                if str_if_bytes(subcommand[0]) == subcommand_name:\n                    parsed_subcmd = self.parse_subcommand(subcommand)\n                    return parsed_subcmd[\"first_key_pos\"] <= 0\n            raise ValueError(\n                f\"Subcommand {subcommand_name} not found in command {command_name}\"\n            )\n        else:\n            command_details = self.commands.get(command_name, None)\n            if command_details is not None:\n                return command_details[\"first_key_pos\"] <= 0\n\n            raise ValueError(f\"Command {command_name} not found in commands\")\n\n    def get_command_policies(self) -> PolicyRecords:\n        \"\"\"\n        Retrieve and process the command policies for all commands and subcommands.\n\n        This method traverses through commands and subcommands, extracting policy details\n        from associated data structures and constructing a dictionary of commands with their\n        associated policies. It supports nested data structures and handles both main commands\n        and their subcommands.\n\n        Returns:\n            PolicyRecords: A collection of commands and subcommands associated with their\n            respective policies.\n\n        Raises:\n            IncorrectPolicyType: If an invalid policy type is encountered during policy extraction.\n        \"\"\"\n        command_with_policies = {}\n\n        def extract_policies(data, module_name, command_name):\n            \"\"\"\n            Recursively extract policies from nested data structures.\n\n            Args:\n                data: The data structure to search (can be list, dict, str, bytes, etc.)\n                command_name: The command name to associate with found policies\n            \"\"\"\n            if isinstance(data, (str, bytes)):\n                # Decode bytes to string if needed\n                policy = str_if_bytes(data.decode())\n\n                # Check if this is a policy string\n                if policy.startswith(\"request_policy\") or policy.startswith(\n                    \"response_policy\"\n                ):\n                    if policy.startswith(\"request_policy\"):\n                        policy_type = policy.split(\":\")[1]\n\n                        try:\n                            command_with_policies[module_name][\n                                command_name\n                            ].request_policy = RequestPolicy(policy_type)\n                        except ValueError:\n                            raise IncorrectPolicyType(\n                                f\"Incorrect request policy type: {policy_type}\"\n                            )\n\n                    if policy.startswith(\"response_policy\"):\n                        policy_type = policy.split(\":\")[1]\n\n                        try:\n                            command_with_policies[module_name][\n                                command_name\n                            ].response_policy = ResponsePolicy(policy_type)\n                        except ValueError:\n                            raise IncorrectPolicyType(\n                                f\"Incorrect response policy type: {policy_type}\"\n                            )\n\n            elif isinstance(data, list):\n                # For lists, recursively process each element\n                for item in data:\n                    extract_policies(item, module_name, command_name)\n\n            elif isinstance(data, dict):\n                # For dictionaries, recursively process each value\n                for value in data.values():\n                    extract_policies(value, module_name, command_name)\n\n        for command, details in self.commands.items():\n            # Check whether the command has keys\n            is_keyless = self._is_keyless_command(command)\n\n            if is_keyless:\n                default_request_policy = RequestPolicy.DEFAULT_KEYLESS\n                default_response_policy = ResponsePolicy.DEFAULT_KEYLESS\n            else:\n                default_request_policy = RequestPolicy.DEFAULT_KEYED\n                default_response_policy = ResponsePolicy.DEFAULT_KEYED\n\n            # Check if it's a core or module command\n            split_name = command.split(\".\")\n\n            if len(split_name) > 1:\n                module_name = split_name[0]\n                command_name = split_name[1]\n            else:\n                module_name = \"core\"\n                command_name = split_name[0]\n\n            # Create a CommandPolicies object with default policies on the new command.\n            if command_with_policies.get(module_name, None) is None:\n                command_with_policies[module_name] = {\n                    command_name: CommandPolicies(\n                        request_policy=default_request_policy,\n                        response_policy=default_response_policy,\n                    )\n                }\n            else:\n                command_with_policies[module_name][command_name] = CommandPolicies(\n                    request_policy=default_request_policy,\n                    response_policy=default_response_policy,\n                )\n\n            tips = details.get(\"tips\")\n            subcommands = details.get(\"subcommands\")\n\n            # Process tips for the main command\n            if tips:\n                extract_policies(tips, module_name, command_name)\n\n            # Process subcommands\n            if subcommands:\n                for subcommand_details in subcommands:\n                    # Get the subcommand name (first element)\n                    subcmd_name = subcommand_details[0]\n                    if isinstance(subcmd_name, bytes):\n                        subcmd_name = subcmd_name.decode()\n\n                    # Check whether the subcommand has keys\n                    is_keyless = self._is_keyless_command(command, subcmd_name)\n\n                    if is_keyless:\n                        default_request_policy = RequestPolicy.DEFAULT_KEYLESS\n                        default_response_policy = ResponsePolicy.DEFAULT_KEYLESS\n                    else:\n                        default_request_policy = RequestPolicy.DEFAULT_KEYED\n                        default_response_policy = ResponsePolicy.DEFAULT_KEYED\n\n                    subcmd_name = subcmd_name.replace(\"|\", \" \")\n\n                    # Create a CommandPolicies object with default policies on the new command.\n                    command_with_policies[module_name][subcmd_name] = CommandPolicies(\n                        request_policy=default_request_policy,\n                        response_policy=default_response_policy,\n                    )\n\n                    # Recursively extract policies from the rest of the subcommand details\n                    for subcommand_detail in subcommand_details[1:]:\n                        extract_policies(subcommand_detail, module_name, subcmd_name)\n\n        return command_with_policies\n\n\nclass AsyncCommandsParser(AbstractCommandsParser):\n    \"\"\"\n    Parses Redis commands to get command keys.\n\n    COMMAND output is used to determine key locations.\n    Commands that do not have a predefined key location are flagged with 'movablekeys',\n    and these commands' keys are determined by the command 'COMMAND GETKEYS'.\n\n    NOTE: Due to a bug in redis<7.0, this does not work properly\n    for EVAL or EVALSHA when the `numkeys` arg is 0.\n     - issue: https://github.com/redis/redis/issues/9493\n     - fix: https://github.com/redis/redis/pull/9733\n\n    So, don't use this with EVAL or EVALSHA.\n    \"\"\"\n\n    __slots__ = (\"commands\", \"node\")\n\n    def __init__(self) -> None:\n        self.commands: Dict[str, Union[int, Dict[str, Any]]] = {}\n\n    async def initialize(self, node: Optional[\"ClusterNode\"] = None) -> None:\n        if node:\n            self.node = node\n\n        commands = await self.node.execute_command(\"COMMAND\")\n        self.commands = {cmd.lower(): command for cmd, command in commands.items()}\n\n    # As soon as this PR is merged into Redis, we should reimplement\n    # our logic to use COMMAND INFO changes to determine the key positions\n    # https://github.com/redis/redis/pull/8324\n    async def get_keys(self, *args: Any) -> Optional[Tuple[str, ...]]:\n        \"\"\"\n        Get the keys from the passed command.\n\n        NOTE: Due to a bug in redis<7.0, this function does not work properly\n        for EVAL or EVALSHA when the `numkeys` arg is 0.\n         - issue: https://github.com/redis/redis/issues/9493\n         - fix: https://github.com/redis/redis/pull/9733\n\n        So, don't use this function with EVAL or EVALSHA.\n        \"\"\"\n        if len(args) < 2:\n            # The command has no keys in it\n            return None\n\n        cmd_name = args[0].lower()\n        if cmd_name not in self.commands:\n            # try to split the command name and to take only the main command,\n            # e.g. 'memory' for 'memory usage'\n            cmd_name_split = cmd_name.split()\n            cmd_name = cmd_name_split[0]\n            if cmd_name in self.commands:\n                # save the splitted command to args\n                args = cmd_name_split + list(args[1:])\n            else:\n                # We'll try to reinitialize the commands cache, if the engine\n                # version has changed, the commands may not be current\n                await self.initialize()\n                if cmd_name not in self.commands:\n                    raise RedisError(\n                        f\"{cmd_name.upper()} command doesn't exist in Redis commands\"\n                    )\n\n        command = self.commands.get(cmd_name)\n        if \"movablekeys\" in command[\"flags\"]:\n            keys = await self._get_moveable_keys(*args)\n        elif \"pubsub\" in command[\"flags\"] or command[\"name\"] == \"pubsub\":\n            keys = self._get_pubsub_keys(*args)\n        else:\n            if (\n                command[\"step_count\"] == 0\n                and command[\"first_key_pos\"] == 0\n                and command[\"last_key_pos\"] == 0\n            ):\n                is_subcmd = False\n                if \"subcommands\" in command:\n                    subcmd_name = f\"{cmd_name}|{args[1].lower()}\"\n                    for subcmd in command[\"subcommands\"]:\n                        if str_if_bytes(subcmd[0]) == subcmd_name:\n                            command = self.parse_subcommand(subcmd)\n\n                            if command[\"first_key_pos\"] > 0:\n                                is_subcmd = True\n\n                # The command doesn't have keys in it\n                if not is_subcmd:\n                    return None\n            last_key_pos = command[\"last_key_pos\"]\n            if last_key_pos < 0:\n                last_key_pos = len(args) - abs(last_key_pos)\n            keys_pos = list(\n                range(command[\"first_key_pos\"], last_key_pos + 1, command[\"step_count\"])\n            )\n            keys = [args[pos] for pos in keys_pos]\n\n        return keys\n\n    async def _get_moveable_keys(self, *args: Any) -> Optional[Tuple[str, ...]]:\n        try:\n            keys = await self.node.execute_command(\"COMMAND GETKEYS\", *args)\n        except ResponseError as e:\n            message = e.__str__()\n            if (\n                \"Invalid arguments\" in message\n                or \"The command has no key arguments\" in message\n            ):\n                return None\n            else:\n                raise e\n        return keys\n\n    async def _is_keyless_command(\n        self, command_name: str, subcommand_name: Optional[str] = None\n    ) -> bool:\n        \"\"\"\n        Determines whether a given command or subcommand is considered \"keyless\".\n\n        A keyless command does not operate on specific keys, which is determined based\n        on the first key position in the command or subcommand details. If the command\n        or subcommand's first key position is zero or negative, it is treated as keyless.\n\n        Parameters:\n            command_name: str\n                The name of the command to check.\n            subcommand_name: Optional[str], default=None\n                The name of the subcommand to check, if applicable. If not provided,\n                the check is performed only on the command.\n\n        Returns:\n            bool\n                True if the specified command or subcommand is considered keyless,\n                False otherwise.\n\n        Raises:\n            ValueError\n                If the specified subcommand is not found within the command or the\n                specified command does not exist in the available commands.\n        \"\"\"\n        if subcommand_name:\n            for subcommand in self.commands.get(command_name)[\"subcommands\"]:\n                if str_if_bytes(subcommand[0]) == subcommand_name:\n                    parsed_subcmd = self.parse_subcommand(subcommand)\n                    return parsed_subcmd[\"first_key_pos\"] <= 0\n            raise ValueError(\n                f\"Subcommand {subcommand_name} not found in command {command_name}\"\n            )\n        else:\n            command_details = self.commands.get(command_name, None)\n            if command_details is not None:\n                return command_details[\"first_key_pos\"] <= 0\n\n            raise ValueError(f\"Command {command_name} not found in commands\")\n\n    async def get_command_policies(self) -> Awaitable[PolicyRecords]:\n        \"\"\"\n        Retrieve and process the command policies for all commands and subcommands.\n\n        This method traverses through commands and subcommands, extracting policy details\n        from associated data structures and constructing a dictionary of commands with their\n        associated policies. It supports nested data structures and handles both main commands\n        and their subcommands.\n\n        Returns:\n            PolicyRecords: A collection of commands and subcommands associated with their\n            respective policies.\n\n        Raises:\n            IncorrectPolicyType: If an invalid policy type is encountered during policy extraction.\n        \"\"\"\n        command_with_policies = {}\n\n        def extract_policies(data, module_name, command_name):\n            \"\"\"\n            Recursively extract policies from nested data structures.\n\n            Args:\n                data: The data structure to search (can be list, dict, str, bytes, etc.)\n                command_name: The command name to associate with found policies\n            \"\"\"\n            if isinstance(data, (str, bytes)):\n                # Decode bytes to string if needed\n                policy = str_if_bytes(data.decode())\n\n                # Check if this is a policy string\n                if policy.startswith(\"request_policy\") or policy.startswith(\n                    \"response_policy\"\n                ):\n                    if policy.startswith(\"request_policy\"):\n                        policy_type = policy.split(\":\")[1]\n\n                        try:\n                            command_with_policies[module_name][\n                                command_name\n                            ].request_policy = RequestPolicy(policy_type)\n                        except ValueError:\n                            raise IncorrectPolicyType(\n                                f\"Incorrect request policy type: {policy_type}\"\n                            )\n\n                    if policy.startswith(\"response_policy\"):\n                        policy_type = policy.split(\":\")[1]\n\n                        try:\n                            command_with_policies[module_name][\n                                command_name\n                            ].response_policy = ResponsePolicy(policy_type)\n                        except ValueError:\n                            raise IncorrectPolicyType(\n                                f\"Incorrect response policy type: {policy_type}\"\n                            )\n\n            elif isinstance(data, list):\n                # For lists, recursively process each element\n                for item in data:\n                    extract_policies(item, module_name, command_name)\n\n            elif isinstance(data, dict):\n                # For dictionaries, recursively process each value\n                for value in data.values():\n                    extract_policies(value, module_name, command_name)\n\n        for command, details in self.commands.items():\n            # Check whether the command has keys\n            is_keyless = await self._is_keyless_command(command)\n\n            if is_keyless:\n                default_request_policy = RequestPolicy.DEFAULT_KEYLESS\n                default_response_policy = ResponsePolicy.DEFAULT_KEYLESS\n            else:\n                default_request_policy = RequestPolicy.DEFAULT_KEYED\n                default_response_policy = ResponsePolicy.DEFAULT_KEYED\n\n            # Check if it's a core or module command\n            split_name = command.split(\".\")\n\n            if len(split_name) > 1:\n                module_name = split_name[0]\n                command_name = split_name[1]\n            else:\n                module_name = \"core\"\n                command_name = split_name[0]\n\n            # Create a CommandPolicies object with default policies on the new command.\n            if command_with_policies.get(module_name, None) is None:\n                command_with_policies[module_name] = {\n                    command_name: CommandPolicies(\n                        request_policy=default_request_policy,\n                        response_policy=default_response_policy,\n                    )\n                }\n            else:\n                command_with_policies[module_name][command_name] = CommandPolicies(\n                    request_policy=default_request_policy,\n                    response_policy=default_response_policy,\n                )\n\n            tips = details.get(\"tips\")\n            subcommands = details.get(\"subcommands\")\n\n            # Process tips for the main command\n            if tips:\n                extract_policies(tips, module_name, command_name)\n\n            # Process subcommands\n            if subcommands:\n                for subcommand_details in subcommands:\n                    # Get the subcommand name (first element)\n                    subcmd_name = subcommand_details[0]\n                    if isinstance(subcmd_name, bytes):\n                        subcmd_name = subcmd_name.decode()\n\n                    # Check whether the subcommand has keys\n                    is_keyless = await self._is_keyless_command(command, subcmd_name)\n\n                    if is_keyless:\n                        default_request_policy = RequestPolicy.DEFAULT_KEYLESS\n                        default_response_policy = ResponsePolicy.DEFAULT_KEYLESS\n                    else:\n                        default_request_policy = RequestPolicy.DEFAULT_KEYED\n                        default_response_policy = ResponsePolicy.DEFAULT_KEYED\n\n                    subcmd_name = subcmd_name.replace(\"|\", \" \")\n\n                    # Create a CommandPolicies object with default policies on the new command.\n                    command_with_policies[module_name][subcmd_name] = CommandPolicies(\n                        request_policy=default_request_policy,\n                        response_policy=default_response_policy,\n                    )\n\n                    # Recursively extract policies from the rest of the subcommand details\n                    for subcommand_detail in subcommand_details[1:]:\n                        extract_policies(subcommand_detail, module_name, subcmd_name)\n\n        return command_with_policies\n"
  },
  {
    "path": "redis/_parsers/encoders.py",
    "content": "from ..exceptions import DataError\n\n\nclass Encoder:\n    \"Encode strings to bytes-like and decode bytes-like to strings\"\n\n    __slots__ = \"encoding\", \"encoding_errors\", \"decode_responses\"\n\n    def __init__(self, encoding, encoding_errors, decode_responses):\n        self.encoding = encoding\n        self.encoding_errors = encoding_errors\n        self.decode_responses = decode_responses\n\n    def encode(self, value):\n        \"Return a bytestring or bytes-like representation of the value\"\n        if isinstance(value, (bytes, memoryview)):\n            return value\n        elif isinstance(value, bool):\n            # special case bool since it is a subclass of int\n            raise DataError(\n                \"Invalid input of type: 'bool'. Convert to a \"\n                \"bytes, string, int or float first.\"\n            )\n        elif isinstance(value, (int, float)):\n            value = repr(value).encode()\n        elif not isinstance(value, str):\n            # a value we don't know how to deal with. throw an error\n            typename = type(value).__name__\n            raise DataError(\n                f\"Invalid input of type: '{typename}'. \"\n                f\"Convert to a bytes, string, int or float first.\"\n            )\n        if isinstance(value, str):\n            value = value.encode(self.encoding, self.encoding_errors)\n        return value\n\n    def decode(self, value, force=False):\n        \"Return a unicode string from the bytes-like representation\"\n        if self.decode_responses or force:\n            if isinstance(value, memoryview):\n                value = value.tobytes()\n            if isinstance(value, bytes):\n                value = value.decode(self.encoding, self.encoding_errors)\n        return value\n"
  },
  {
    "path": "redis/_parsers/helpers.py",
    "content": "import datetime\n\nfrom redis.utils import str_if_bytes\n\n\ndef timestamp_to_datetime(response):\n    \"Converts a unix timestamp to a Python datetime object\"\n    if not response:\n        return None\n    try:\n        response = int(response)\n    except ValueError:\n        return None\n    return datetime.datetime.fromtimestamp(response)\n\n\ndef parse_debug_object(response):\n    \"Parse the results of Redis's DEBUG OBJECT command into a Python dict\"\n    # The 'type' of the object is the first item in the response, but isn't\n    # prefixed with a name\n    response = str_if_bytes(response)\n    response = \"type:\" + response\n    response = dict(kv.split(\":\") for kv in response.split())\n\n    # parse some expected int values from the string response\n    # note: this cmd isn't spec'd so these may not appear in all redis versions\n    int_fields = (\"refcount\", \"serializedlength\", \"lru\", \"lru_seconds_idle\")\n    for field in int_fields:\n        if field in response:\n            response[field] = int(response[field])\n\n    return response\n\n\ndef parse_info(response):\n    \"\"\"Parse the result of Redis's INFO command into a Python dict\"\"\"\n    info = {}\n    response = str_if_bytes(response)\n\n    def get_value(value):\n        if \",\" not in value and \"=\" not in value:\n            try:\n                if \".\" in value:\n                    return float(value)\n                else:\n                    return int(value)\n            except ValueError:\n                return value\n        elif \"=\" not in value:\n            return [get_value(v) for v in value.split(\",\") if v]\n        else:\n            sub_dict = {}\n            for item in value.split(\",\"):\n                if not item:\n                    continue\n                if \"=\" in item:\n                    k, v = item.rsplit(\"=\", 1)\n                    sub_dict[k] = get_value(v)\n                else:\n                    sub_dict[item] = True\n            return sub_dict\n\n    for line in response.splitlines():\n        if line and not line.startswith(\"#\"):\n            if line.find(\":\") != -1:\n                # Split, the info fields keys and values.\n                # Note that the value may contain ':'. but the 'host:'\n                # pseudo-command is the only case where the key contains ':'\n                key, value = line.split(\":\", 1)\n                if key == \"cmdstat_host\":\n                    key, value = line.rsplit(\":\", 1)\n\n                if key == \"module\":\n                    # Hardcode a list for key 'modules' since there could be\n                    # multiple lines that started with 'module'\n                    info.setdefault(\"modules\", []).append(get_value(value))\n                else:\n                    info[key] = get_value(value)\n            else:\n                # if the line isn't splittable, append it to the \"__raw__\" key\n                info.setdefault(\"__raw__\", []).append(line)\n\n    return info\n\n\ndef parse_memory_stats(response, **kwargs):\n    \"\"\"Parse the results of MEMORY STATS\"\"\"\n    stats = pairs_to_dict(response, decode_keys=True, decode_string_values=True)\n    for key, value in stats.items():\n        if key.startswith(\"db.\") and isinstance(value, list):\n            stats[key] = pairs_to_dict(\n                value, decode_keys=True, decode_string_values=True\n            )\n    return stats\n\n\nSENTINEL_STATE_TYPES = {\n    \"can-failover-its-master\": int,\n    \"config-epoch\": int,\n    \"down-after-milliseconds\": int,\n    \"failover-timeout\": int,\n    \"info-refresh\": int,\n    \"last-hello-message\": int,\n    \"last-ok-ping-reply\": int,\n    \"last-ping-reply\": int,\n    \"last-ping-sent\": int,\n    \"master-link-down-time\": int,\n    \"master-port\": int,\n    \"num-other-sentinels\": int,\n    \"num-slaves\": int,\n    \"o-down-time\": int,\n    \"pending-commands\": int,\n    \"parallel-syncs\": int,\n    \"port\": int,\n    \"quorum\": int,\n    \"role-reported-time\": int,\n    \"s-down-time\": int,\n    \"slave-priority\": int,\n    \"slave-repl-offset\": int,\n    \"voted-leader-epoch\": int,\n}\n\n\ndef parse_sentinel_state(item):\n    result = pairs_to_dict_typed(item, SENTINEL_STATE_TYPES)\n    flags = set(result[\"flags\"].split(\",\"))\n    for name, flag in (\n        (\"is_master\", \"master\"),\n        (\"is_slave\", \"slave\"),\n        (\"is_sdown\", \"s_down\"),\n        (\"is_odown\", \"o_down\"),\n        (\"is_sentinel\", \"sentinel\"),\n        (\"is_disconnected\", \"disconnected\"),\n        (\"is_master_down\", \"master_down\"),\n    ):\n        result[name] = flag in flags\n    return result\n\n\ndef parse_sentinel_master(response, **options):\n    return parse_sentinel_state(map(str_if_bytes, response))\n\n\ndef parse_sentinel_state_resp3(response, **options):\n    result = {}\n    for key in response:\n        try:\n            value = SENTINEL_STATE_TYPES[key](str_if_bytes(response[key]))\n            result[str_if_bytes(key)] = value\n        except Exception:\n            result[str_if_bytes(key)] = response[str_if_bytes(key)]\n    flags = set(result[\"flags\"].split(\",\"))\n    result[\"flags\"] = flags\n    return result\n\n\ndef parse_sentinel_masters(response, **options):\n    result = {}\n    for item in response:\n        state = parse_sentinel_state(map(str_if_bytes, item))\n        result[state[\"name\"]] = state\n    return result\n\n\ndef parse_sentinel_masters_resp3(response, **options):\n    return [parse_sentinel_state_resp3(master) for master in response]\n\n\ndef parse_sentinel_slaves_and_sentinels(response, **options):\n    return [parse_sentinel_state(map(str_if_bytes, item)) for item in response]\n\n\ndef parse_sentinel_slaves_and_sentinels_resp3(response, **options):\n    return [parse_sentinel_state_resp3(item, **options) for item in response]\n\n\ndef parse_sentinel_get_master(response, **options):\n    return response and (response[0], int(response[1])) or None\n\n\ndef pairs_to_dict(response, decode_keys=False, decode_string_values=False):\n    \"\"\"Create a dict given a list of key/value pairs\"\"\"\n    if response is None:\n        return {}\n    if decode_keys or decode_string_values:\n        # the iter form is faster, but I don't know how to make that work\n        # with a str_if_bytes() map\n        keys = response[::2]\n        if decode_keys:\n            keys = map(str_if_bytes, keys)\n        values = response[1::2]\n        if decode_string_values:\n            values = map(str_if_bytes, values)\n        return dict(zip(keys, values))\n    else:\n        it = iter(response)\n        return dict(zip(it, it))\n\n\ndef pairs_to_dict_typed(response, type_info):\n    it = iter(response)\n    result = {}\n    for key, value in zip(it, it):\n        if key in type_info:\n            try:\n                value = type_info[key](value)\n            except Exception:\n                # if for some reason the value can't be coerced, just use\n                # the string value\n                pass\n        result[key] = value\n    return result\n\n\ndef zset_score_pairs(response, **options):\n    \"\"\"\n    If ``withscores`` is specified in the options, return the response as\n    a list of (value, score) pairs\n    \"\"\"\n    if not response or not options.get(\"withscores\"):\n        return response\n    score_cast_func = options.get(\"score_cast_func\", float)\n    it = iter(response)\n    return list(zip(it, map(score_cast_func, it)))\n\n\ndef zset_score_for_rank(response, **options):\n    \"\"\"\n    If ``withscores`` is specified in the options, return the response as\n    a [value, score] pair\n    \"\"\"\n    if not response or not options.get(\"withscore\"):\n        return response\n    score_cast_func = options.get(\"score_cast_func\", float)\n    return [response[0], score_cast_func(response[1])]\n\n\ndef zset_score_pairs_resp3(response, **options):\n    \"\"\"\n    If ``withscores`` is specified in the options, return the response as\n    a list of [value, score] pairs\n    \"\"\"\n    if not response or not options.get(\"withscores\"):\n        return response\n    score_cast_func = options.get(\"score_cast_func\", float)\n    return [[name, score_cast_func(val)] for name, val in response]\n\n\ndef zset_score_for_rank_resp3(response, **options):\n    \"\"\"\n    If ``withscores`` is specified in the options, return the response as\n    a [value, score] pair\n    \"\"\"\n    if not response or not options.get(\"withscore\"):\n        return response\n    score_cast_func = options.get(\"score_cast_func\", float)\n    return [response[0], score_cast_func(response[1])]\n\n\ndef sort_return_tuples(response, **options):\n    \"\"\"\n    If ``groups`` is specified, return the response as a list of\n    n-element tuples with n being the value found in options['groups']\n    \"\"\"\n    if not response or not options.get(\"groups\"):\n        return response\n    n = options[\"groups\"]\n    return list(zip(*[response[i::n] for i in range(n)]))\n\n\ndef parse_stream_list(response, **options):\n    if response is None:\n        return None\n    data = []\n    for r in response:\n        if r is not None:\n            if \"claim_min_idle_time\" in options:\n                data.append((r[0], pairs_to_dict(r[1]), *r[2:]))\n            else:\n                data.append((r[0], pairs_to_dict(r[1])))\n        else:\n            data.append((None, None))\n    return data\n\n\ndef pairs_to_dict_with_str_keys(response):\n    return pairs_to_dict(response, decode_keys=True)\n\n\ndef parse_list_of_dicts(response):\n    return list(map(pairs_to_dict_with_str_keys, response))\n\n\ndef parse_xclaim(response, **options):\n    if options.get(\"parse_justid\", False):\n        return response\n    return parse_stream_list(response)\n\n\ndef parse_xautoclaim(response, **options):\n    if options.get(\"parse_justid\", False):\n        return response[1]\n    response[1] = parse_stream_list(response[1])\n    return response\n\n\ndef parse_xinfo_stream(response, **options):\n    if isinstance(response, list):\n        data = pairs_to_dict(response, decode_keys=True)\n    else:\n        data = {str_if_bytes(k): v for k, v in response.items()}\n    if not options.get(\"full\", False):\n        first = data.get(\"first-entry\")\n        if first is not None and first[0] is not None:\n            data[\"first-entry\"] = (first[0], pairs_to_dict(first[1]))\n        last = data[\"last-entry\"]\n        if last is not None and last[0] is not None:\n            data[\"last-entry\"] = (last[0], pairs_to_dict(last[1]))\n    else:\n        data[\"entries\"] = {_id: pairs_to_dict(entry) for _id, entry in data[\"entries\"]}\n        if len(data[\"groups\"]) > 0 and isinstance(data[\"groups\"][0], list):\n            data[\"groups\"] = [\n                pairs_to_dict(group, decode_keys=True) for group in data[\"groups\"]\n            ]\n            for g in data[\"groups\"]:\n                if g[\"consumers\"] and g[\"consumers\"][0] is not None:\n                    g[\"consumers\"] = [\n                        pairs_to_dict(c, decode_keys=True) for c in g[\"consumers\"]\n                    ]\n        else:\n            data[\"groups\"] = [\n                {str_if_bytes(k): v for k, v in group.items()}\n                for group in data[\"groups\"]\n            ]\n    return data\n\n\ndef parse_xread(response, **options):\n    if response is None:\n        return []\n    return [[r[0], parse_stream_list(r[1], **options)] for r in response]\n\n\ndef parse_xread_resp3(response, **options):\n    if response is None:\n        return {}\n    return {\n        key: [parse_stream_list(value, **options)] for key, value in response.items()\n    }\n\n\ndef parse_xpending(response, **options):\n    if options.get(\"parse_detail\", False):\n        return parse_xpending_range(response)\n    consumers = [{\"name\": n, \"pending\": int(p)} for n, p in response[3] or []]\n    return {\n        \"pending\": response[0],\n        \"min\": response[1],\n        \"max\": response[2],\n        \"consumers\": consumers,\n    }\n\n\ndef parse_xpending_range(response):\n    k = (\"message_id\", \"consumer\", \"time_since_delivered\", \"times_delivered\")\n    return [dict(zip(k, r)) for r in response]\n\n\ndef float_or_none(response):\n    if response is None:\n        return None\n    return float(response)\n\n\ndef bool_ok(response, **options):\n    return str_if_bytes(response) == \"OK\"\n\n\ndef parse_zadd(response, **options):\n    if response is None:\n        return None\n    if options.get(\"as_score\"):\n        return float(response)\n    return int(response)\n\n\ndef parse_client_list(response, **options):\n    clients = []\n    for c in str_if_bytes(response).splitlines():\n        client_dict = {}\n        tokens = c.split(\" \")\n        last_key = None\n        for token in tokens:\n            if \"=\" in token:\n                # Values might contain '='\n                key, value = token.split(\"=\", 1)\n                client_dict[key] = value\n                last_key = key\n            else:\n                # Values may include spaces. For instance, when running Redis via a Unix socket — such as\n                # \"/tmp/redis sock/redis.sock\" — the addr or laddr field will include a space.\n                client_dict[last_key] += \" \" + token\n\n        if client_dict:\n            clients.append(client_dict)\n    return clients\n\n\ndef parse_config_get(response, **options):\n    response = [str_if_bytes(i) if i is not None else None for i in response]\n    return response and pairs_to_dict(response) or {}\n\n\ndef parse_scan(response, **options):\n    cursor, r = response\n    return int(cursor), r\n\n\ndef parse_hscan(response, **options):\n    cursor, r = response\n    no_values = options.get(\"no_values\", False)\n    if no_values:\n        payload = r or []\n    else:\n        payload = r and pairs_to_dict(r) or {}\n    return int(cursor), payload\n\n\ndef parse_zscan(response, **options):\n    score_cast_func = options.get(\"score_cast_func\", float)\n    cursor, r = response\n    it = iter(r)\n    return int(cursor), list(zip(it, map(score_cast_func, it)))\n\n\ndef parse_zmscore(response, **options):\n    # zmscore: list of scores (double precision floating point number) or nil\n    return [float(score) if score is not None else None for score in response]\n\n\ndef parse_slowlog_get(response, **options):\n    space = \" \" if options.get(\"decode_responses\", False) else b\" \"\n\n    def parse_item(item):\n        result = {\"id\": item[0], \"start_time\": int(item[1]), \"duration\": int(item[2])}\n        # Redis Enterprise injects another entry at index [3], which has\n        # the complexity info (i.e. the value N in case the command has\n        # an O(N) complexity) instead of the command.\n        if isinstance(item[3], list):\n            result[\"command\"] = space.join(item[3])\n\n            # These fields are optional, depends on environment.\n            if len(item) >= 6:\n                result[\"client_address\"] = item[4]\n                result[\"client_name\"] = item[5]\n        else:\n            result[\"complexity\"] = item[3]\n            result[\"command\"] = space.join(item[4])\n\n            # These fields are optional, depends on environment.\n            if len(item) >= 7:\n                result[\"client_address\"] = item[5]\n                result[\"client_name\"] = item[6]\n\n        return result\n\n    return [parse_item(item) for item in response]\n\n\ndef parse_stralgo(response, **options):\n    \"\"\"\n    Parse the response from `STRALGO` command.\n    Without modifiers the returned value is string.\n    When LEN is given the command returns the length of the result\n    (i.e integer).\n    When IDX is given the command returns a dictionary with the LCS\n    length and all the ranges in both the strings, start and end\n    offset for each string, where there are matches.\n    When WITHMATCHLEN is given, each array representing a match will\n    also have the length of the match at the beginning of the array.\n    \"\"\"\n    if options.get(\"len\", False):\n        return int(response)\n    if options.get(\"idx\", False):\n        if options.get(\"withmatchlen\", False):\n            matches = [\n                [(int(match[-1]))] + list(map(tuple, match[:-1]))\n                for match in response[1]\n            ]\n        else:\n            matches = [list(map(tuple, match)) for match in response[1]]\n        return {\n            str_if_bytes(response[0]): matches,\n            str_if_bytes(response[2]): int(response[3]),\n        }\n    return str_if_bytes(response)\n\n\ndef parse_cluster_info(response, **options):\n    response = str_if_bytes(response)\n    return dict(line.split(\":\") for line in response.splitlines() if line)\n\n\ndef _parse_node_line(line):\n    line_items = line.split(\" \")\n    node_id, addr, flags, master_id, ping, pong, epoch, connected = line.split(\" \")[:8]\n    ip = addr.split(\"@\")[0]\n    hostname = addr.split(\"@\")[1].split(\",\")[1] if \"@\" in addr and \",\" in addr else \"\"\n    node_dict = {\n        \"node_id\": node_id,\n        \"hostname\": hostname,\n        \"flags\": flags,\n        \"master_id\": master_id,\n        \"last_ping_sent\": ping,\n        \"last_pong_rcvd\": pong,\n        \"epoch\": epoch,\n        \"slots\": [],\n        \"migrations\": [],\n        \"connected\": True if connected == \"connected\" else False,\n    }\n    if len(line_items) >= 9:\n        slots, migrations = _parse_slots(line_items[8:])\n        node_dict[\"slots\"], node_dict[\"migrations\"] = slots, migrations\n    return ip, node_dict\n\n\ndef _parse_slots(slot_ranges):\n    slots, migrations = [], []\n    for s_range in slot_ranges:\n        if \"->-\" in s_range:\n            slot_id, dst_node_id = s_range[1:-1].split(\"->-\", 1)\n            migrations.append(\n                {\"slot\": slot_id, \"node_id\": dst_node_id, \"state\": \"migrating\"}\n            )\n        elif \"-<-\" in s_range:\n            slot_id, src_node_id = s_range[1:-1].split(\"-<-\", 1)\n            migrations.append(\n                {\"slot\": slot_id, \"node_id\": src_node_id, \"state\": \"importing\"}\n            )\n        else:\n            s_range = [sl for sl in s_range.split(\"-\")]\n            slots.append(s_range)\n\n    return slots, migrations\n\n\ndef parse_cluster_nodes(response, **options):\n    \"\"\"\n    @see: https://redis.io/commands/cluster-nodes  # string / bytes\n    @see: https://redis.io/commands/cluster-replicas # list of string / bytes\n    \"\"\"\n    if isinstance(response, (str, bytes)):\n        response = response.splitlines()\n    return dict(_parse_node_line(str_if_bytes(node)) for node in response)\n\n\ndef parse_geosearch_generic(response, **options):\n    \"\"\"\n    Parse the response of 'GEOSEARCH', GEORADIUS' and 'GEORADIUSBYMEMBER'\n    commands according to 'withdist', 'withhash' and 'withcoord' labels.\n    \"\"\"\n    try:\n        if options[\"store\"] or options[\"store_dist\"]:\n            # `store` and `store_dist` cant be combined\n            # with other command arguments.\n            # relevant to 'GEORADIUS' and 'GEORADIUSBYMEMBER'\n            return response\n    except KeyError:  # it means the command was sent via execute_command\n        return response\n\n    if not isinstance(response, list):\n        response_list = [response]\n    else:\n        response_list = response\n\n    if not options[\"withdist\"] and not options[\"withcoord\"] and not options[\"withhash\"]:\n        # just a bunch of places\n        return response_list\n\n    cast = {\n        \"withdist\": float,\n        \"withcoord\": lambda ll: (float(ll[0]), float(ll[1])),\n        \"withhash\": int,\n    }\n\n    # zip all output results with each casting function to get\n    # the properly native Python value.\n    f = [lambda x: x]\n    f += [cast[o] for o in [\"withdist\", \"withhash\", \"withcoord\"] if options[o]]\n    return [list(map(lambda fv: fv[0](fv[1]), zip(f, r))) for r in response_list]\n\n\ndef parse_command(response, **options):\n    commands = {}\n    for command in response:\n        cmd_dict = {}\n        cmd_name = str_if_bytes(command[0])\n        cmd_dict[\"name\"] = cmd_name\n        cmd_dict[\"arity\"] = int(command[1])\n        cmd_dict[\"flags\"] = [str_if_bytes(flag) for flag in command[2]]\n        cmd_dict[\"first_key_pos\"] = command[3]\n        cmd_dict[\"last_key_pos\"] = command[4]\n        cmd_dict[\"step_count\"] = command[5]\n        if len(command) > 7:\n            cmd_dict[\"tips\"] = command[7]\n            cmd_dict[\"key_specifications\"] = command[8]\n            cmd_dict[\"subcommands\"] = command[9]\n        commands[cmd_name] = cmd_dict\n    return commands\n\n\ndef parse_command_resp3(response, **options):\n    commands = {}\n    for command in response:\n        cmd_dict = {}\n        cmd_name = str_if_bytes(command[0])\n        cmd_dict[\"name\"] = cmd_name\n        cmd_dict[\"arity\"] = command[1]\n        cmd_dict[\"flags\"] = {str_if_bytes(flag) for flag in command[2]}\n        cmd_dict[\"first_key_pos\"] = command[3]\n        cmd_dict[\"last_key_pos\"] = command[4]\n        cmd_dict[\"step_count\"] = command[5]\n        cmd_dict[\"acl_categories\"] = command[6]\n        if len(command) > 7:\n            cmd_dict[\"tips\"] = command[7]\n            cmd_dict[\"key_specifications\"] = command[8]\n            cmd_dict[\"subcommands\"] = command[9]\n\n        commands[cmd_name] = cmd_dict\n    return commands\n\n\ndef parse_pubsub_numsub(response, **options):\n    return list(zip(response[0::2], response[1::2]))\n\n\ndef parse_client_kill(response, **options):\n    if isinstance(response, int):\n        return response\n    return str_if_bytes(response) == \"OK\"\n\n\ndef parse_acl_getuser(response, **options):\n    if response is None:\n        return None\n    if isinstance(response, list):\n        data = pairs_to_dict(response, decode_keys=True)\n    else:\n        data = {str_if_bytes(key): value for key, value in response.items()}\n\n    # convert everything but user-defined data in 'keys' to native strings\n    data[\"flags\"] = list(map(str_if_bytes, data[\"flags\"]))\n    data[\"passwords\"] = list(map(str_if_bytes, data[\"passwords\"]))\n    data[\"commands\"] = str_if_bytes(data[\"commands\"])\n    if isinstance(data[\"keys\"], str) or isinstance(data[\"keys\"], bytes):\n        data[\"keys\"] = list(str_if_bytes(data[\"keys\"]).split(\" \"))\n    if data[\"keys\"] == [\"\"]:\n        data[\"keys\"] = []\n    if \"channels\" in data:\n        if isinstance(data[\"channels\"], str) or isinstance(data[\"channels\"], bytes):\n            data[\"channels\"] = list(str_if_bytes(data[\"channels\"]).split(\" \"))\n        if data[\"channels\"] == [\"\"]:\n            data[\"channels\"] = []\n    if \"selectors\" in data:\n        if data[\"selectors\"] != [] and isinstance(data[\"selectors\"][0], list):\n            data[\"selectors\"] = [\n                list(map(str_if_bytes, selector)) for selector in data[\"selectors\"]\n            ]\n        elif data[\"selectors\"] != []:\n            data[\"selectors\"] = [\n                {str_if_bytes(k): str_if_bytes(v) for k, v in selector.items()}\n                for selector in data[\"selectors\"]\n            ]\n\n    # split 'commands' into separate 'categories' and 'commands' lists\n    commands, categories = [], []\n    for command in data[\"commands\"].split(\" \"):\n        categories.append(command) if \"@\" in command else commands.append(command)\n\n    data[\"commands\"] = commands\n    data[\"categories\"] = categories\n    data[\"enabled\"] = \"on\" in data[\"flags\"]\n    return data\n\n\ndef parse_acl_log(response, **options):\n    if response is None:\n        return None\n    if isinstance(response, list):\n        data = []\n        for log in response:\n            log_data = pairs_to_dict(log, True, True)\n            client_info = log_data.get(\"client-info\", \"\")\n            log_data[\"client-info\"] = parse_client_info(client_info)\n\n            # float() is lossy comparing to the \"double\" in C\n            log_data[\"age-seconds\"] = float(log_data[\"age-seconds\"])\n            data.append(log_data)\n    else:\n        data = bool_ok(response)\n    return data\n\n\ndef parse_client_info(value):\n    \"\"\"\n    Parsing client-info in ACL Log in following format.\n    \"key1=value1 key2=value2 key3=value3\"\n    \"\"\"\n    client_info = {}\n    for info in str_if_bytes(value).strip().split():\n        key, value = info.split(\"=\")\n        client_info[key] = value\n\n    # Those fields are defined as int in networking.c\n    for int_key in {\n        \"id\",\n        \"age\",\n        \"idle\",\n        \"db\",\n        \"sub\",\n        \"psub\",\n        \"multi\",\n        \"qbuf\",\n        \"qbuf-free\",\n        \"obl\",\n        \"argv-mem\",\n        \"oll\",\n        \"omem\",\n        \"tot-mem\",\n    }:\n        if int_key in client_info:\n            client_info[int_key] = int(client_info[int_key])\n    return client_info\n\n\ndef parse_set_result(response, **options):\n    \"\"\"\n    Handle SET result since GET argument is available since Redis 6.2.\n    Parsing SET result into:\n    - BOOL\n    - String when GET argument is used\n    \"\"\"\n    if options.get(\"get\"):\n        # Redis will return a getCommand result.\n        # See `setGenericCommand` in t_string.c\n        return response\n    return response and str_if_bytes(response) == \"OK\"\n\n\ndef string_keys_to_dict(key_string, callback):\n    return dict.fromkeys(key_string.split(), callback)\n\n\n_RedisCallbacks = {\n    **string_keys_to_dict(\n        \"AUTH COPY EXPIRE EXPIREAT HEXISTS HMSET MOVE MSETNX PERSIST PSETEX \"\n        \"PEXPIRE PEXPIREAT RENAMENX SETEX SETNX SMOVE\",\n        bool,\n    ),\n    **string_keys_to_dict(\"HINCRBYFLOAT INCRBYFLOAT\", float),\n    **string_keys_to_dict(\n        \"ASKING FLUSHALL FLUSHDB LSET LTRIM MSET PFMERGE READONLY READWRITE \"\n        \"RENAME SAVE SELECT SHUTDOWN SLAVEOF SWAPDB WATCH UNWATCH\",\n        bool_ok,\n    ),\n    **string_keys_to_dict(\"XREAD XREADGROUP\", parse_xread),\n    **string_keys_to_dict(\n        \"GEORADIUS GEORADIUSBYMEMBER GEOSEARCH\",\n        parse_geosearch_generic,\n    ),\n    **string_keys_to_dict(\"XRANGE XREVRANGE\", parse_stream_list),\n    \"ACL GETUSER\": parse_acl_getuser,\n    \"ACL LOAD\": bool_ok,\n    \"ACL LOG\": parse_acl_log,\n    \"ACL SETUSER\": bool_ok,\n    \"ACL SAVE\": bool_ok,\n    \"CLIENT INFO\": parse_client_info,\n    \"CLIENT KILL\": parse_client_kill,\n    \"CLIENT LIST\": parse_client_list,\n    \"CLIENT PAUSE\": bool_ok,\n    \"CLIENT SETINFO\": bool_ok,\n    \"CLIENT SETNAME\": bool_ok,\n    \"CLIENT UNBLOCK\": bool,\n    \"CLUSTER ADDSLOTS\": bool_ok,\n    \"CLUSTER ADDSLOTSRANGE\": bool_ok,\n    \"CLUSTER DELSLOTS\": bool_ok,\n    \"CLUSTER DELSLOTSRANGE\": bool_ok,\n    \"CLUSTER FAILOVER\": bool_ok,\n    \"CLUSTER FORGET\": bool_ok,\n    \"CLUSTER INFO\": parse_cluster_info,\n    \"CLUSTER MEET\": bool_ok,\n    \"CLUSTER NODES\": parse_cluster_nodes,\n    \"CLUSTER REPLICAS\": parse_cluster_nodes,\n    \"CLUSTER REPLICATE\": bool_ok,\n    \"CLUSTER RESET\": bool_ok,\n    \"CLUSTER SAVECONFIG\": bool_ok,\n    \"CLUSTER SET-CONFIG-EPOCH\": bool_ok,\n    \"CLUSTER SETSLOT\": bool_ok,\n    \"CLUSTER SLAVES\": parse_cluster_nodes,\n    \"COMMAND\": parse_command,\n    \"CONFIG RESETSTAT\": bool_ok,\n    \"CONFIG SET\": bool_ok,\n    \"FUNCTION DELETE\": bool_ok,\n    \"FUNCTION FLUSH\": bool_ok,\n    \"FUNCTION RESTORE\": bool_ok,\n    \"GEODIST\": float_or_none,\n    \"HSCAN\": parse_hscan,\n    \"INFO\": parse_info,\n    \"LASTSAVE\": timestamp_to_datetime,\n    \"MEMORY PURGE\": bool_ok,\n    \"MODULE LOAD\": bool,\n    \"MODULE UNLOAD\": bool,\n    \"PING\": lambda r: str_if_bytes(r) == \"PONG\",\n    \"PUBSUB NUMSUB\": parse_pubsub_numsub,\n    \"PUBSUB SHARDNUMSUB\": parse_pubsub_numsub,\n    \"QUIT\": bool_ok,\n    \"SET\": parse_set_result,\n    \"SCAN\": parse_scan,\n    \"SCRIPT EXISTS\": lambda r: list(map(bool, r)),\n    \"SCRIPT FLUSH\": bool_ok,\n    \"SCRIPT KILL\": bool_ok,\n    \"SCRIPT LOAD\": str_if_bytes,\n    \"SENTINEL CKQUORUM\": bool_ok,\n    \"SENTINEL FAILOVER\": bool_ok,\n    \"SENTINEL FLUSHCONFIG\": bool_ok,\n    \"SENTINEL GET-MASTER-ADDR-BY-NAME\": parse_sentinel_get_master,\n    \"SENTINEL MONITOR\": bool_ok,\n    \"SENTINEL RESET\": bool_ok,\n    \"SENTINEL REMOVE\": bool_ok,\n    \"SENTINEL SET\": bool_ok,\n    \"SLOWLOG GET\": parse_slowlog_get,\n    \"SLOWLOG RESET\": bool_ok,\n    \"SORT\": sort_return_tuples,\n    \"SSCAN\": parse_scan,\n    \"TIME\": lambda x: (int(x[0]), int(x[1])),\n    \"XAUTOCLAIM\": parse_xautoclaim,\n    \"XCLAIM\": parse_xclaim,\n    \"XGROUP CREATE\": bool_ok,\n    \"XGROUP DESTROY\": bool,\n    \"XGROUP SETID\": bool_ok,\n    \"XINFO STREAM\": parse_xinfo_stream,\n    \"XPENDING\": parse_xpending,\n    \"ZSCAN\": parse_zscan,\n}\n\n\n_RedisCallbacksRESP2 = {\n    **string_keys_to_dict(\n        \"SDIFF SINTER SMEMBERS SUNION\", lambda r: r and set(r) or set()\n    ),\n    **string_keys_to_dict(\n        \"ZDIFF ZINTER ZPOPMAX ZPOPMIN ZRANGE ZRANGEBYSCORE ZREVRANGE \"\n        \"ZREVRANGEBYSCORE ZUNION\",\n        zset_score_pairs,\n    ),\n    **string_keys_to_dict(\n        \"ZREVRANK ZRANK\",\n        zset_score_for_rank,\n    ),\n    **string_keys_to_dict(\"ZINCRBY ZSCORE\", float_or_none),\n    **string_keys_to_dict(\"BGREWRITEAOF BGSAVE\", lambda r: True),\n    **string_keys_to_dict(\"BLPOP BRPOP\", lambda r: r and tuple(r) or None),\n    **string_keys_to_dict(\n        \"BZPOPMAX BZPOPMIN\", lambda r: r and (r[0], r[1], float(r[2])) or None\n    ),\n    \"ACL CAT\": lambda r: list(map(str_if_bytes, r)),\n    \"ACL GENPASS\": str_if_bytes,\n    \"ACL HELP\": lambda r: list(map(str_if_bytes, r)),\n    \"ACL LIST\": lambda r: list(map(str_if_bytes, r)),\n    \"ACL USERS\": lambda r: list(map(str_if_bytes, r)),\n    \"ACL WHOAMI\": str_if_bytes,\n    \"CLIENT GETNAME\": str_if_bytes,\n    \"CLIENT TRACKINGINFO\": lambda r: list(map(str_if_bytes, r)),\n    \"CLUSTER GETKEYSINSLOT\": lambda r: list(map(str_if_bytes, r)),\n    \"COMMAND GETKEYS\": lambda r: list(map(str_if_bytes, r)),\n    \"CONFIG GET\": parse_config_get,\n    \"DEBUG OBJECT\": parse_debug_object,\n    \"GEOHASH\": lambda r: list(map(str_if_bytes, r)),\n    \"GEOPOS\": lambda r: list(\n        map(lambda ll: (float(ll[0]), float(ll[1])) if ll is not None else None, r)\n    ),\n    \"HGETALL\": lambda r: r and pairs_to_dict(r) or {},\n    \"HOTKEYS GET\": lambda r: [pairs_to_dict(m) for m in r],\n    \"MEMORY STATS\": parse_memory_stats,\n    \"MODULE LIST\": lambda r: [pairs_to_dict(m) for m in r],\n    \"RESET\": str_if_bytes,\n    \"SENTINEL MASTER\": parse_sentinel_master,\n    \"SENTINEL MASTERS\": parse_sentinel_masters,\n    \"SENTINEL SENTINELS\": parse_sentinel_slaves_and_sentinels,\n    \"SENTINEL SLAVES\": parse_sentinel_slaves_and_sentinels,\n    \"STRALGO\": parse_stralgo,\n    \"XINFO CONSUMERS\": parse_list_of_dicts,\n    \"XINFO GROUPS\": parse_list_of_dicts,\n    \"ZADD\": parse_zadd,\n    \"ZMSCORE\": parse_zmscore,\n}\n\n\n_RedisCallbacksRESP3 = {\n    **string_keys_to_dict(\n        \"SDIFF SINTER SMEMBERS SUNION\", lambda r: r and set(r) or set()\n    ),\n    **string_keys_to_dict(\n        \"ZRANGE ZINTER ZPOPMAX ZPOPMIN HGETALL XREADGROUP\",\n        lambda r, **kwargs: r,\n    ),\n    **string_keys_to_dict(\n        \"ZRANGE ZRANGEBYSCORE ZREVRANGE ZREVRANGEBYSCORE ZUNION\",\n        zset_score_pairs_resp3,\n    ),\n    **string_keys_to_dict(\n        \"ZREVRANK ZRANK\",\n        zset_score_for_rank_resp3,\n    ),\n    **string_keys_to_dict(\"XREAD XREADGROUP\", parse_xread_resp3),\n    \"ACL LOG\": lambda r: (\n        [\n            {str_if_bytes(key): str_if_bytes(value) for key, value in x.items()}\n            for x in r\n        ]\n        if isinstance(r, list)\n        else bool_ok(r)\n    ),\n    \"COMMAND\": parse_command_resp3,\n    \"CONFIG GET\": lambda r: {\n        str_if_bytes(key) if key is not None else None: (\n            str_if_bytes(value) if value is not None else None\n        )\n        for key, value in r.items()\n    },\n    \"MEMORY STATS\": lambda r: {str_if_bytes(key): value for key, value in r.items()},\n    \"SENTINEL MASTER\": parse_sentinel_state_resp3,\n    \"SENTINEL MASTERS\": parse_sentinel_masters_resp3,\n    \"SENTINEL SENTINELS\": parse_sentinel_slaves_and_sentinels_resp3,\n    \"SENTINEL SLAVES\": parse_sentinel_slaves_and_sentinels_resp3,\n    \"STRALGO\": lambda r, **options: (\n        {str_if_bytes(key): str_if_bytes(value) for key, value in r.items()}\n        if isinstance(r, dict)\n        else str_if_bytes(r)\n    ),\n    \"XINFO CONSUMERS\": lambda r: [\n        {str_if_bytes(key): value for key, value in x.items()} for x in r\n    ],\n    \"XINFO GROUPS\": lambda r: [\n        {str_if_bytes(key): value for key, value in d.items()} for d in r\n    ],\n}\n"
  },
  {
    "path": "redis/_parsers/hiredis.py",
    "content": "import asyncio\nimport socket\nimport sys\nfrom logging import getLogger\nfrom typing import Callable, List, Optional, TypedDict, Union\n\nif sys.version_info.major >= 3 and sys.version_info.minor >= 11:\n    from asyncio import timeout as async_timeout\nelse:\n    from async_timeout import timeout as async_timeout\n\nfrom ..exceptions import ConnectionError, InvalidResponse, RedisError\nfrom ..typing import EncodableT\nfrom ..utils import HIREDIS_AVAILABLE\nfrom .base import (\n    AsyncBaseParser,\n    AsyncPushNotificationsParser,\n    BaseParser,\n    PushNotificationsParser,\n)\nfrom .socket import (\n    NONBLOCKING_EXCEPTION_ERROR_NUMBERS,\n    NONBLOCKING_EXCEPTIONS,\n    SENTINEL,\n    SERVER_CLOSED_CONNECTION_ERROR,\n)\n\n# Used to signal that hiredis-py does not have enough data to parse.\n# Using `False` or `None` is not reliable, given that the parser can\n# return `False` or `None` for legitimate reasons from RESP payloads.\nNOT_ENOUGH_DATA = object()\n\n\nclass _HiredisReaderArgs(TypedDict, total=False):\n    protocolError: Callable[[str], Exception]\n    replyError: Callable[[str], Exception]\n    encoding: Optional[str]\n    errors: Optional[str]\n\n\nclass _HiredisParser(BaseParser, PushNotificationsParser):\n    \"Parser class for connections using Hiredis\"\n\n    def __init__(self, socket_read_size):\n        if not HIREDIS_AVAILABLE:\n            raise RedisError(\"Hiredis is not installed\")\n        self.socket_read_size = socket_read_size\n        self._buffer = bytearray(socket_read_size)\n        self.pubsub_push_handler_func = self.handle_pubsub_push_response\n        self.node_moving_push_handler_func = None\n        self.maintenance_push_handler_func = None\n        self.oss_cluster_maint_push_handler_func = None\n        self.invalidation_push_handler_func = None\n        self._hiredis_PushNotificationType = None\n\n    def __del__(self):\n        try:\n            self.on_disconnect()\n        except Exception:\n            pass\n\n    def handle_pubsub_push_response(self, response):\n        logger = getLogger(\"push_response\")\n        logger.debug(\"Push response: \" + str(response))\n        return response\n\n    def on_connect(self, connection, **kwargs):\n        import hiredis\n\n        self._sock = connection._sock\n        self._socket_timeout = connection.socket_timeout\n        kwargs = {\n            \"protocolError\": InvalidResponse,\n            \"replyError\": self.parse_error,\n            \"errors\": connection.encoder.encoding_errors,\n            \"notEnoughData\": NOT_ENOUGH_DATA,\n        }\n\n        if connection.encoder.decode_responses:\n            kwargs[\"encoding\"] = connection.encoder.encoding\n        self._reader = hiredis.Reader(**kwargs)\n        self._next_response = NOT_ENOUGH_DATA\n\n        try:\n            self._hiredis_PushNotificationType = hiredis.PushNotification\n        except AttributeError:\n            # hiredis < 3.2\n            self._hiredis_PushNotificationType = None\n\n    def on_disconnect(self):\n        self._sock = None\n        self._reader = None\n        self._next_response = NOT_ENOUGH_DATA\n\n    def can_read(self, timeout):\n        if not self._reader:\n            raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR)\n\n        if self._next_response is NOT_ENOUGH_DATA:\n            self._next_response = self._reader.gets()\n            if self._next_response is NOT_ENOUGH_DATA:\n                return self.read_from_socket(timeout=timeout, raise_on_timeout=False)\n        return True\n\n    def read_from_socket(self, timeout=SENTINEL, raise_on_timeout=True):\n        sock = self._sock\n        custom_timeout = timeout is not SENTINEL\n        try:\n            if custom_timeout:\n                sock.settimeout(timeout)\n            bufflen = self._sock.recv_into(self._buffer)\n            if bufflen == 0:\n                raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR)\n            self._reader.feed(self._buffer, 0, bufflen)\n            # data was read from the socket and added to the buffer.\n            # return True to indicate that data was read.\n            return True\n        except socket.timeout:\n            if raise_on_timeout:\n                raise TimeoutError(\"Timeout reading from socket\")\n            return False\n        except NONBLOCKING_EXCEPTIONS as ex:\n            # if we're in nonblocking mode and the recv raises a\n            # blocking error, simply return False indicating that\n            # there's no data to be read. otherwise raise the\n            # original exception.\n            allowed = NONBLOCKING_EXCEPTION_ERROR_NUMBERS.get(ex.__class__, -1)\n            if not raise_on_timeout and ex.errno == allowed:\n                return False\n            raise ConnectionError(f\"Error while reading from socket: {ex.args}\")\n        finally:\n            if custom_timeout:\n                sock.settimeout(self._socket_timeout)\n\n    def read_response(\n        self,\n        disable_decoding=False,\n        push_request=False,\n        timeout: Union[float, object] = SENTINEL,\n    ):\n        if not self._reader:\n            raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR)\n\n        # _next_response might be cached from a can_read() call\n        if self._next_response is not NOT_ENOUGH_DATA:\n            response = self._next_response\n            self._next_response = NOT_ENOUGH_DATA\n            if self._hiredis_PushNotificationType is not None and isinstance(\n                response, self._hiredis_PushNotificationType\n            ):\n                response = self.handle_push_response(response)\n\n                # if this is a push request return the push response\n                if push_request:\n                    return response\n\n                return self.read_response(\n                    disable_decoding=disable_decoding,\n                    push_request=push_request,\n                    timeout=timeout,\n                )\n            return response\n\n        if disable_decoding:\n            response = self._reader.gets(False)\n        else:\n            response = self._reader.gets()\n\n        while response is NOT_ENOUGH_DATA:\n            self.read_from_socket(timeout=timeout)\n            if disable_decoding:\n                response = self._reader.gets(False)\n            else:\n                response = self._reader.gets()\n        # if the response is a ConnectionError or the response is a list and\n        # the first item is a ConnectionError, raise it as something bad\n        # happened\n        if isinstance(response, ConnectionError):\n            raise response\n        elif self._hiredis_PushNotificationType is not None and isinstance(\n            response, self._hiredis_PushNotificationType\n        ):\n            response = self.handle_push_response(response)\n            if push_request:\n                return response\n            return self.read_response(\n                disable_decoding=disable_decoding,\n                push_request=push_request,\n            )\n\n        elif (\n            isinstance(response, list)\n            and response\n            and isinstance(response[0], ConnectionError)\n        ):\n            raise response[0]\n        return response\n\n\nclass _AsyncHiredisParser(AsyncBaseParser, AsyncPushNotificationsParser):\n    \"\"\"Async implementation of parser class for connections using Hiredis\"\"\"\n\n    __slots__ = (\"_reader\",)\n\n    def __init__(self, socket_read_size: int):\n        if not HIREDIS_AVAILABLE:\n            raise RedisError(\"Hiredis is not available.\")\n        super().__init__(socket_read_size=socket_read_size)\n        self._reader = None\n        self.pubsub_push_handler_func = self.handle_pubsub_push_response\n        self.invalidation_push_handler_func = None\n        self._hiredis_PushNotificationType = None\n\n    async def handle_pubsub_push_response(self, response):\n        logger = getLogger(\"push_response\")\n        logger.debug(\"Push response: \" + str(response))\n        return response\n\n    def on_connect(self, connection):\n        import hiredis\n\n        self._stream = connection._reader\n        kwargs: _HiredisReaderArgs = {\n            \"protocolError\": InvalidResponse,\n            \"replyError\": self.parse_error,\n            \"notEnoughData\": NOT_ENOUGH_DATA,\n        }\n        if connection.encoder.decode_responses:\n            kwargs[\"encoding\"] = connection.encoder.encoding\n            kwargs[\"errors\"] = connection.encoder.encoding_errors\n\n        self._reader = hiredis.Reader(**kwargs)\n        self._connected = True\n\n        try:\n            self._hiredis_PushNotificationType = getattr(\n                hiredis, \"PushNotification\", None\n            )\n        except AttributeError:\n            # hiredis < 3.2\n            self._hiredis_PushNotificationType = None\n\n    def on_disconnect(self):\n        self._connected = False\n\n    async def can_read_destructive(self):\n        if not self._connected:\n            raise OSError(\"Buffer is closed.\")\n        if self._reader.gets() is not NOT_ENOUGH_DATA:\n            return True\n        try:\n            async with async_timeout(0):\n                return await self.read_from_socket()\n        except asyncio.TimeoutError:\n            return False\n\n    async def read_from_socket(self):\n        buffer = await self._stream.read(self._read_size)\n        if not buffer or not isinstance(buffer, bytes):\n            raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR) from None\n        self._reader.feed(buffer)\n        # data was read from the socket and added to the buffer.\n        # return True to indicate that data was read.\n        return True\n\n    async def read_response(\n        self, disable_decoding: bool = False, push_request: bool = False\n    ) -> Union[EncodableT, List[EncodableT]]:\n        # If `on_disconnect()` has been called, prohibit any more reads\n        # even if they could happen because data might be present.\n        # We still allow reads in progress to finish\n        if not self._connected:\n            raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR) from None\n\n        if disable_decoding:\n            response = self._reader.gets(False)\n        else:\n            response = self._reader.gets()\n\n        while response is NOT_ENOUGH_DATA:\n            await self.read_from_socket()\n            if disable_decoding:\n                response = self._reader.gets(False)\n            else:\n                response = self._reader.gets()\n\n        # if the response is a ConnectionError or the response is a list and\n        # the first item is a ConnectionError, raise it as something bad\n        # happened\n        if isinstance(response, ConnectionError):\n            raise response\n        elif self._hiredis_PushNotificationType is not None and isinstance(\n            response, self._hiredis_PushNotificationType\n        ):\n            response = await self.handle_push_response(response)\n            if not push_request:\n                return await self.read_response(\n                    disable_decoding=disable_decoding, push_request=push_request\n                )\n            else:\n                return response\n        elif (\n            isinstance(response, list)\n            and response\n            and isinstance(response[0], ConnectionError)\n        ):\n            raise response[0]\n        return response\n"
  },
  {
    "path": "redis/_parsers/resp2.py",
    "content": "from typing import Any, Union\n\nfrom ..exceptions import ConnectionError, InvalidResponse, ResponseError\nfrom ..typing import EncodableT\nfrom .base import _AsyncRESPBase, _RESPBase\nfrom .socket import SENTINEL, SERVER_CLOSED_CONNECTION_ERROR\n\n\nclass _RESP2Parser(_RESPBase):\n    \"\"\"RESP2 protocol implementation\"\"\"\n\n    def read_response(\n        self, disable_decoding=False, timeout: Union[float, object] = SENTINEL\n    ):\n        pos = self._buffer.get_pos() if self._buffer else None\n        try:\n            result = self._read_response(\n                disable_decoding=disable_decoding, timeout=timeout\n            )\n        except BaseException:\n            if self._buffer:\n                self._buffer.rewind(pos)\n            raise\n        else:\n            self._buffer.purge()\n            return result\n\n    def _read_response(\n        self, disable_decoding=False, timeout: Union[float, object] = SENTINEL\n    ):\n        raw = self._buffer.readline(timeout=timeout)\n        if not raw:\n            raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR)\n\n        byte, response = raw[:1], raw[1:]\n\n        # server returned an error\n        if byte == b\"-\":\n            response = response.decode(\"utf-8\", errors=\"replace\")\n            error = self.parse_error(response)\n            # if the error is a ConnectionError, raise immediately so the user\n            # is notified\n            if isinstance(error, ConnectionError):\n                raise error\n            # otherwise, we're dealing with a ResponseError that might belong\n            # inside a pipeline response. the connection's read_response()\n            # and/or the pipeline's execute() will raise this error if\n            # necessary, so just return the exception instance here.\n            return error\n        # single value\n        elif byte == b\"+\":\n            pass\n        # int value\n        elif byte == b\":\":\n            return int(response)\n        # bulk response\n        elif byte == b\"$\" and response == b\"-1\":\n            return None\n        elif byte == b\"$\":\n            response = self._buffer.read(int(response), timeout=timeout)\n        # multi-bulk response\n        elif byte == b\"*\" and response == b\"-1\":\n            return None\n        elif byte == b\"*\":\n            response = [\n                self._read_response(disable_decoding=disable_decoding, timeout=timeout)\n                for i in range(int(response))\n            ]\n        else:\n            raise InvalidResponse(f\"Protocol Error: {raw!r}\")\n\n        if disable_decoding is False:\n            response = self.encoder.decode(response)\n        return response\n\n\nclass _AsyncRESP2Parser(_AsyncRESPBase):\n    \"\"\"Async class for the RESP2 protocol\"\"\"\n\n    async def read_response(self, disable_decoding: bool = False):\n        if not self._connected:\n            raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR)\n        if self._chunks:\n            # augment parsing buffer with previously read data\n            self._buffer += b\"\".join(self._chunks)\n            self._chunks.clear()\n        self._pos = 0\n        response = await self._read_response(disable_decoding=disable_decoding)\n        # Successfully parsing a response allows us to clear our parsing buffer\n        self._clear()\n        return response\n\n    async def _read_response(\n        self, disable_decoding: bool = False\n    ) -> Union[EncodableT, ResponseError, None]:\n        raw = await self._readline()\n        response: Any\n        byte, response = raw[:1], raw[1:]\n\n        # server returned an error\n        if byte == b\"-\":\n            response = response.decode(\"utf-8\", errors=\"replace\")\n            error = self.parse_error(response)\n            # if the error is a ConnectionError, raise immediately so the user\n            # is notified\n            if isinstance(error, ConnectionError):\n                self._clear()  # Successful parse\n                raise error\n            # otherwise, we're dealing with a ResponseError that might belong\n            # inside a pipeline response. the connection's read_response()\n            # and/or the pipeline's execute() will raise this error if\n            # necessary, so just return the exception instance here.\n            return error\n        # single value\n        elif byte == b\"+\":\n            pass\n        # int value\n        elif byte == b\":\":\n            return int(response)\n        # bulk response\n        elif byte == b\"$\" and response == b\"-1\":\n            return None\n        elif byte == b\"$\":\n            response = await self._read(int(response))\n        # multi-bulk response\n        elif byte == b\"*\" and response == b\"-1\":\n            return None\n        elif byte == b\"*\":\n            response = [\n                (await self._read_response(disable_decoding))\n                for _ in range(int(response))  # noqa\n            ]\n        else:\n            raise InvalidResponse(f\"Protocol Error: {raw!r}\")\n\n        if disable_decoding is False:\n            response = self.encoder.decode(response)\n        return response\n"
  },
  {
    "path": "redis/_parsers/resp3.py",
    "content": "from logging import getLogger\nfrom typing import Any, Union\n\nfrom ..exceptions import ConnectionError, InvalidResponse, ResponseError\nfrom ..typing import EncodableT\nfrom .base import (\n    AsyncPushNotificationsParser,\n    PushNotificationsParser,\n    _AsyncRESPBase,\n    _RESPBase,\n)\nfrom .socket import SENTINEL, SERVER_CLOSED_CONNECTION_ERROR\n\n\nclass _RESP3Parser(_RESPBase, PushNotificationsParser):\n    \"\"\"RESP3 protocol implementation\"\"\"\n\n    def __init__(self, socket_read_size):\n        super().__init__(socket_read_size)\n        self.pubsub_push_handler_func = self.handle_pubsub_push_response\n        self.node_moving_push_handler_func = None\n        self.maintenance_push_handler_func = None\n        self.oss_cluster_maint_push_handler_func = None\n        self.invalidation_push_handler_func = None\n\n    def handle_pubsub_push_response(self, response):\n        logger = getLogger(\"push_response\")\n        logger.debug(\"Push response: \" + str(response))\n        return response\n\n    def read_response(\n        self,\n        disable_decoding=False,\n        push_request=False,\n        timeout: Union[float, object] = SENTINEL,\n    ):\n        pos = self._buffer.get_pos() if self._buffer is not None else None\n        try:\n            result = self._read_response(\n                disable_decoding=disable_decoding,\n                push_request=push_request,\n                timeout=timeout,\n            )\n        except BaseException:\n            if self._buffer is not None:\n                self._buffer.rewind(pos)\n            raise\n        else:\n            if self._buffer is not None:\n                try:\n                    self._buffer.purge()\n                except AttributeError:\n                    # Buffer may have been set to None by another thread after\n                    # the check above; result is still valid so we don't raise\n                    pass\n            return result\n\n    def _read_response(\n        self,\n        disable_decoding=False,\n        push_request=False,\n        timeout: Union[float, object] = SENTINEL,\n    ):\n        raw = self._buffer.readline(timeout=timeout)\n        if not raw:\n            raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR)\n\n        byte, response = raw[:1], raw[1:]\n\n        # server returned an error\n        if byte in (b\"-\", b\"!\"):\n            if byte == b\"!\":\n                response = self._buffer.read(int(response), timeout=timeout)\n            response = response.decode(\"utf-8\", errors=\"replace\")\n            error = self.parse_error(response)\n            # if the error is a ConnectionError, raise immediately so the user\n            # is notified\n            if isinstance(error, ConnectionError):\n                raise error\n            # otherwise, we're dealing with a ResponseError that might belong\n            # inside a pipeline response. the connection's read_response()\n            # and/or the pipeline's execute() will raise this error if\n            # necessary, so just return the exception instance here.\n            return error\n        # single value\n        elif byte == b\"+\":\n            pass\n        # null value\n        elif byte == b\"_\":\n            return None\n        # int and big int values\n        elif byte in (b\":\", b\"(\"):\n            return int(response)\n        # double value\n        elif byte == b\",\":\n            return float(response)\n        # bool value\n        elif byte == b\"#\":\n            return response == b\"t\"\n        # bulk response\n        elif byte == b\"$\":\n            response = self._buffer.read(int(response), timeout=timeout)\n        # verbatim string response\n        elif byte == b\"=\":\n            response = self._buffer.read(int(response), timeout=timeout)[4:]\n        # array response\n        elif byte == b\"*\":\n            response = [\n                self._read_response(disable_decoding=disable_decoding, timeout=timeout)\n                for _ in range(int(response))\n            ]\n        # set response\n        elif byte == b\"~\":\n            # redis can return unhashable types (like dict) in a set,\n            # so we return sets as list, all the time, for predictability\n            response = [\n                self._read_response(disable_decoding=disable_decoding, timeout=timeout)\n                for _ in range(int(response))\n            ]\n        # map response\n        elif byte == b\"%\":\n            # We cannot use a dict-comprehension to parse stream.\n            # Evaluation order of key:val expression in dict comprehension only\n            # became defined to be left-right in version 3.8\n            resp_dict = {}\n            for _ in range(int(response)):\n                key = self._read_response(\n                    disable_decoding=disable_decoding, timeout=timeout\n                )\n                resp_dict[key] = self._read_response(\n                    disable_decoding=disable_decoding,\n                    push_request=push_request,\n                    timeout=timeout,\n                )\n            response = resp_dict\n        # push response\n        elif byte == b\">\":\n            response = [\n                self._read_response(\n                    disable_decoding=disable_decoding,\n                    push_request=push_request,\n                    timeout=timeout,\n                )\n                for _ in range(int(response))\n            ]\n            response = self.handle_push_response(response)\n\n            # if this is a push request return the push response\n            if push_request:\n                return response\n\n            return self._read_response(\n                disable_decoding=disable_decoding,\n                push_request=push_request,\n            )\n        else:\n            raise InvalidResponse(f\"Protocol Error: {raw!r}\")\n\n        if isinstance(response, bytes) and disable_decoding is False:\n            response = self.encoder.decode(response)\n\n        return response\n\n\nclass _AsyncRESP3Parser(_AsyncRESPBase, AsyncPushNotificationsParser):\n    def __init__(self, socket_read_size):\n        super().__init__(socket_read_size)\n        self.pubsub_push_handler_func = self.handle_pubsub_push_response\n        self.invalidation_push_handler_func = None\n\n    async def handle_pubsub_push_response(self, response):\n        logger = getLogger(\"push_response\")\n        logger.debug(\"Push response: \" + str(response))\n        return response\n\n    async def read_response(\n        self, disable_decoding: bool = False, push_request: bool = False\n    ):\n        if self._chunks:\n            # augment parsing buffer with previously read data\n            self._buffer += b\"\".join(self._chunks)\n            self._chunks.clear()\n        self._pos = 0\n        response = await self._read_response(\n            disable_decoding=disable_decoding, push_request=push_request\n        )\n        # Successfully parsing a response allows us to clear our parsing buffer\n        self._clear()\n        return response\n\n    async def _read_response(\n        self, disable_decoding: bool = False, push_request: bool = False\n    ) -> Union[EncodableT, ResponseError, None]:\n        if not self._stream or not self.encoder:\n            raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR)\n        raw = await self._readline()\n        response: Any\n        byte, response = raw[:1], raw[1:]\n\n        # if byte not in (b\"-\", b\"+\", b\":\", b\"$\", b\"*\"):\n        #     raise InvalidResponse(f\"Protocol Error: {raw!r}\")\n\n        # server returned an error\n        if byte in (b\"-\", b\"!\"):\n            if byte == b\"!\":\n                response = await self._read(int(response))\n            response = response.decode(\"utf-8\", errors=\"replace\")\n            error = self.parse_error(response)\n            # if the error is a ConnectionError, raise immediately so the user\n            # is notified\n            if isinstance(error, ConnectionError):\n                self._clear()  # Successful parse\n                raise error\n            # otherwise, we're dealing with a ResponseError that might belong\n            # inside a pipeline response. the connection's read_response()\n            # and/or the pipeline's execute() will raise this error if\n            # necessary, so just return the exception instance here.\n            return error\n        # single value\n        elif byte == b\"+\":\n            pass\n        # null value\n        elif byte == b\"_\":\n            return None\n        # int and big int values\n        elif byte in (b\":\", b\"(\"):\n            return int(response)\n        # double value\n        elif byte == b\",\":\n            return float(response)\n        # bool value\n        elif byte == b\"#\":\n            return response == b\"t\"\n        # bulk response\n        elif byte == b\"$\":\n            response = await self._read(int(response))\n        # verbatim string response\n        elif byte == b\"=\":\n            response = (await self._read(int(response)))[4:]\n        # array response\n        elif byte == b\"*\":\n            response = [\n                (await self._read_response(disable_decoding=disable_decoding))\n                for _ in range(int(response))\n            ]\n        # set response\n        elif byte == b\"~\":\n            # redis can return unhashable types (like dict) in a set,\n            # so we always convert to a list, to have predictable return types\n            response = [\n                (await self._read_response(disable_decoding=disable_decoding))\n                for _ in range(int(response))\n            ]\n        # map response\n        elif byte == b\"%\":\n            # We cannot use a dict-comprehension to parse stream.\n            # Evaluation order of key:val expression in dict comprehension only\n            # became defined to be left-right in version 3.8\n            resp_dict = {}\n            for _ in range(int(response)):\n                key = await self._read_response(disable_decoding=disable_decoding)\n                resp_dict[key] = await self._read_response(\n                    disable_decoding=disable_decoding, push_request=push_request\n                )\n            response = resp_dict\n        # push response\n        elif byte == b\">\":\n            response = [\n                (\n                    await self._read_response(\n                        disable_decoding=disable_decoding, push_request=push_request\n                    )\n                )\n                for _ in range(int(response))\n            ]\n            response = await self.handle_push_response(response)\n            if not push_request:\n                return await self._read_response(\n                    disable_decoding=disable_decoding, push_request=push_request\n                )\n            else:\n                return response\n        else:\n            raise InvalidResponse(f\"Protocol Error: {raw!r}\")\n\n        if isinstance(response, bytes) and disable_decoding is False:\n            response = self.encoder.decode(response)\n        return response\n"
  },
  {
    "path": "redis/_parsers/socket.py",
    "content": "import errno\nimport io\nimport socket\nfrom io import SEEK_END\nfrom typing import Optional, Union\n\nfrom ..exceptions import ConnectionError, TimeoutError\nfrom ..utils import SSL_AVAILABLE\n\nNONBLOCKING_EXCEPTION_ERROR_NUMBERS = {BlockingIOError: errno.EWOULDBLOCK}\n\nif SSL_AVAILABLE:\n    import ssl\n\n    if hasattr(ssl, \"SSLWantReadError\"):\n        NONBLOCKING_EXCEPTION_ERROR_NUMBERS[ssl.SSLWantReadError] = 2\n        NONBLOCKING_EXCEPTION_ERROR_NUMBERS[ssl.SSLWantWriteError] = 2\n    else:\n        NONBLOCKING_EXCEPTION_ERROR_NUMBERS[ssl.SSLError] = 2\n\nNONBLOCKING_EXCEPTIONS = tuple(NONBLOCKING_EXCEPTION_ERROR_NUMBERS.keys())\n\nSERVER_CLOSED_CONNECTION_ERROR = \"Connection closed by server.\"\nSENTINEL = object()\n\nSYM_CRLF = b\"\\r\\n\"\n\n\nclass SocketBuffer:\n    def __init__(\n        self, socket: socket.socket, socket_read_size: int, socket_timeout: float\n    ):\n        self._sock = socket\n        self.socket_read_size = socket_read_size\n        self.socket_timeout = socket_timeout\n        self._buffer = io.BytesIO()\n\n    def unread_bytes(self) -> int:\n        \"\"\"\n        Remaining unread length of buffer\n        \"\"\"\n        pos = self._buffer.tell()\n        end = self._buffer.seek(0, SEEK_END)\n        self._buffer.seek(pos)\n        return end - pos\n\n    def _read_from_socket(\n        self,\n        length: Optional[int] = None,\n        timeout: Union[float, object] = SENTINEL,\n        raise_on_timeout: Optional[bool] = True,\n    ) -> bool:\n        sock = self._sock\n        socket_read_size = self.socket_read_size\n        marker = 0\n        custom_timeout = timeout is not SENTINEL\n\n        buf = self._buffer\n        current_pos = buf.tell()\n        buf.seek(0, SEEK_END)\n        if custom_timeout:\n            sock.settimeout(timeout)\n        try:\n            while True:\n                data = sock.recv(socket_read_size)\n                # an empty string indicates the server shutdown the socket\n                if isinstance(data, bytes) and len(data) == 0:\n                    raise ConnectionError(SERVER_CLOSED_CONNECTION_ERROR)\n                buf.write(data)\n                data_length = len(data)\n                marker += data_length\n\n                if length is not None and length > marker:\n                    continue\n                return True\n        except socket.timeout:\n            if raise_on_timeout:\n                raise TimeoutError(\"Timeout reading from socket\")\n            return False\n        except NONBLOCKING_EXCEPTIONS as ex:\n            # if we're in nonblocking mode and the recv raises a\n            # blocking error, simply return False indicating that\n            # there's no data to be read. otherwise raise the\n            # original exception.\n            allowed = NONBLOCKING_EXCEPTION_ERROR_NUMBERS.get(ex.__class__, -1)\n            if not raise_on_timeout and ex.errno == allowed:\n                return False\n            raise ConnectionError(f\"Error while reading from socket: {ex.args}\")\n        finally:\n            buf.seek(current_pos)\n            if custom_timeout:\n                sock.settimeout(self.socket_timeout)\n\n    def can_read(self, timeout: float) -> bool:\n        return bool(self.unread_bytes()) or self._read_from_socket(\n            timeout=timeout, raise_on_timeout=False\n        )\n\n    def read(self, length: int, timeout: Union[float, object] = SENTINEL) -> bytes:\n        length = length + 2  # make sure to read the \\r\\n terminator\n        # BufferIO will return less than requested if buffer is short\n        data = self._buffer.read(length)\n        missing = length - len(data)\n        if missing:\n            # fill up the buffer and read the remainder\n            self._read_from_socket(length=missing, timeout=timeout)\n            data += self._buffer.read(missing)\n        return data[:-2]\n\n    def readline(self, timeout: Union[float, object] = SENTINEL) -> bytes:\n        buf = self._buffer\n        data = buf.readline()\n        while not data.endswith(SYM_CRLF):\n            # there's more data in the socket that we need\n            self._read_from_socket(timeout=timeout)\n            data += buf.readline()\n\n        return data[:-2]\n\n    def get_pos(self) -> int:\n        \"\"\"\n        Get current read position\n        \"\"\"\n        return self._buffer.tell()\n\n    def rewind(self, pos: int) -> None:\n        \"\"\"\n        Rewind the buffer to a specific position, to re-start reading\n        \"\"\"\n        self._buffer.seek(pos)\n\n    def purge(self) -> None:\n        \"\"\"\n        After a successful read, purge the read part of buffer\n        \"\"\"\n        unread = self.unread_bytes()\n\n        # Only if we have read all of the buffer do we truncate, to\n        # reduce the amount of memory thrashing.  This heuristic\n        # can be changed or removed later.\n        if unread > 0:\n            return\n\n        if unread > 0:\n            # move unread data to the front\n            view = self._buffer.getbuffer()\n            view[:unread] = view[-unread:]\n        self._buffer.truncate(unread)\n        self._buffer.seek(0)\n\n    def close(self) -> None:\n        try:\n            self._buffer.close()\n        except Exception:\n            # issue #633 suggests the purge/close somehow raised a\n            # BadFileDescriptor error. Perhaps the client ran out of\n            # memory or something else? It's probably OK to ignore\n            # any error being raised from purge/close since we're\n            # removing the reference to the instance below.\n            pass\n        self._buffer = None\n        self._sock = None\n"
  },
  {
    "path": "redis/asyncio/__init__.py",
    "content": "from redis.asyncio.client import Redis, StrictRedis\nfrom redis.asyncio.cluster import RedisCluster\nfrom redis.asyncio.connection import (\n    BlockingConnectionPool,\n    Connection,\n    ConnectionPool,\n    SSLConnection,\n    UnixDomainSocketConnection,\n)\nfrom redis.asyncio.sentinel import (\n    Sentinel,\n    SentinelConnectionPool,\n    SentinelManagedConnection,\n    SentinelManagedSSLConnection,\n)\nfrom redis.asyncio.utils import from_url\nfrom redis.backoff import default_backoff\nfrom redis.exceptions import (\n    AuthenticationError,\n    AuthenticationWrongNumberOfArgsError,\n    BusyLoadingError,\n    ChildDeadlockedError,\n    ConnectionError,\n    DataError,\n    InvalidResponse,\n    OutOfMemoryError,\n    PubSubError,\n    ReadOnlyError,\n    RedisError,\n    ResponseError,\n    TimeoutError,\n    WatchError,\n)\n\n__all__ = [\n    \"AuthenticationError\",\n    \"AuthenticationWrongNumberOfArgsError\",\n    \"BlockingConnectionPool\",\n    \"BusyLoadingError\",\n    \"ChildDeadlockedError\",\n    \"Connection\",\n    \"ConnectionError\",\n    \"ConnectionPool\",\n    \"DataError\",\n    \"from_url\",\n    \"default_backoff\",\n    \"InvalidResponse\",\n    \"PubSubError\",\n    \"OutOfMemoryError\",\n    \"ReadOnlyError\",\n    \"Redis\",\n    \"RedisCluster\",\n    \"RedisError\",\n    \"ResponseError\",\n    \"Sentinel\",\n    \"SentinelConnectionPool\",\n    \"SentinelManagedConnection\",\n    \"SentinelManagedSSLConnection\",\n    \"SSLConnection\",\n    \"StrictRedis\",\n    \"TimeoutError\",\n    \"UnixDomainSocketConnection\",\n    \"WatchError\",\n]\n"
  },
  {
    "path": "redis/asyncio/client.py",
    "content": "import asyncio\nimport copy\nimport inspect\nimport re\nimport time\nimport warnings\nfrom typing import (\n    TYPE_CHECKING,\n    Any,\n    AsyncIterator,\n    Awaitable,\n    Callable,\n    Dict,\n    Iterable,\n    List,\n    Literal,\n    Mapping,\n    MutableMapping,\n    Optional,\n    Protocol,\n    Set,\n    Tuple,\n    Type,\n    TypedDict,\n    TypeVar,\n    Union,\n    cast,\n)\n\nfrom redis._parsers.helpers import (\n    _RedisCallbacks,\n    _RedisCallbacksRESP2,\n    _RedisCallbacksRESP3,\n    bool_ok,\n)\nfrom redis.asyncio.connection import (\n    Connection,\n    ConnectionPool,\n    SSLConnection,\n    UnixDomainSocketConnection,\n)\nfrom redis.asyncio.lock import Lock\nfrom redis.asyncio.observability.recorder import (\n    record_error_count,\n    record_operation_duration,\n    record_pubsub_message,\n)\nfrom redis.asyncio.retry import Retry\nfrom redis.backoff import ExponentialWithJitterBackoff\nfrom redis.client import (\n    EMPTY_RESPONSE,\n    NEVER_DECODE,\n    AbstractRedis,\n    CaseInsensitiveDict,\n)\nfrom redis.commands import (\n    AsyncCoreCommands,\n    AsyncRedisModuleCommands,\n    AsyncSentinelCommands,\n    list_or_args,\n)\nfrom redis.credentials import CredentialProvider\nfrom redis.driver_info import DriverInfo, resolve_driver_info\nfrom redis.event import (\n    AfterPooledConnectionsInstantiationEvent,\n    AfterPubSubConnectionInstantiationEvent,\n    AfterSingleConnectionInstantiationEvent,\n    ClientType,\n    EventDispatcher,\n)\nfrom redis.exceptions import (\n    ConnectionError,\n    ExecAbortError,\n    PubSubError,\n    RedisError,\n    ResponseError,\n    WatchError,\n)\nfrom redis.observability.attributes import PubSubDirection\nfrom redis.typing import ChannelT, EncodableT, KeyT\nfrom redis.utils import (\n    SSL_AVAILABLE,\n    _set_info_logger,\n    deprecated_args,\n    deprecated_function,\n    safe_str,\n    str_if_bytes,\n    truncate_text,\n)\n\nif TYPE_CHECKING and SSL_AVAILABLE:\n    from ssl import TLSVersion, VerifyFlags, VerifyMode\nelse:\n    TLSVersion = None\n    VerifyMode = None\n    VerifyFlags = None\n\nPubSubHandler = Callable[[Dict[str, str]], Awaitable[None]]\n_KeyT = TypeVar(\"_KeyT\", bound=KeyT)\n_ArgT = TypeVar(\"_ArgT\", KeyT, EncodableT)\n_RedisT = TypeVar(\"_RedisT\", bound=\"Redis\")\n_NormalizeKeysT = TypeVar(\"_NormalizeKeysT\", bound=Mapping[ChannelT, object])\nif TYPE_CHECKING:\n    from redis.commands.core import Script\n\n\nclass ResponseCallbackProtocol(Protocol):\n    def __call__(self, response: Any, **kwargs): ...\n\n\nclass AsyncResponseCallbackProtocol(Protocol):\n    async def __call__(self, response: Any, **kwargs): ...\n\n\nResponseCallbackT = Union[ResponseCallbackProtocol, AsyncResponseCallbackProtocol]\n\n\nclass Redis(\n    AbstractRedis, AsyncRedisModuleCommands, AsyncCoreCommands, AsyncSentinelCommands\n):\n    \"\"\"\n    Implementation of the Redis protocol.\n\n    This abstract class provides a Python interface to all Redis commands\n    and an implementation of the Redis protocol.\n\n    Pipelines derive from this, implementing how\n    the commands are sent and received to the Redis server. Based on\n    configuration, an instance will either use a ConnectionPool, or\n    Connection object to talk to redis.\n    \"\"\"\n\n    # Type discrimination marker for @overload self-type pattern\n    _is_async_client: Literal[True] = True\n\n    response_callbacks: MutableMapping[Union[str, bytes], ResponseCallbackT]\n\n    @classmethod\n    def from_url(\n        cls: Type[\"Redis\"],\n        url: str,\n        single_connection_client: bool = False,\n        auto_close_connection_pool: Optional[bool] = None,\n        **kwargs,\n    ) -> \"Redis\":\n        \"\"\"\n        Return a Redis client object configured from the given URL\n\n        For example::\n\n            redis://[[username]:[password]]@localhost:6379/0\n            rediss://[[username]:[password]]@localhost:6379/0\n            unix://[username@]/path/to/socket.sock?db=0[&password=password]\n\n        Three URL schemes are supported:\n\n        - `redis://` creates a TCP socket connection. See more at:\n          <https://www.iana.org/assignments/uri-schemes/prov/redis>\n        - `rediss://` creates a SSL wrapped TCP socket connection. See more at:\n          <https://www.iana.org/assignments/uri-schemes/prov/rediss>\n        - ``unix://``: creates a Unix Domain Socket connection.\n\n        The username, password, hostname, path and all querystring values\n        are passed through urllib.parse.unquote in order to replace any\n        percent-encoded values with their corresponding characters.\n\n        There are several ways to specify a database number. The first value\n        found will be used:\n\n        1. A ``db`` querystring option, e.g. redis://localhost?db=0\n\n        2. If using the redis:// or rediss:// schemes, the path argument\n               of the url, e.g. redis://localhost/0\n\n        3. A ``db`` keyword argument to this function.\n\n        If none of these options are specified, the default db=0 is used.\n\n        All querystring options are cast to their appropriate Python types.\n        Boolean arguments can be specified with string values \"True\"/\"False\"\n        or \"Yes\"/\"No\". Values that cannot be properly cast cause a\n        ``ValueError`` to be raised. Once parsed, the querystring arguments\n        and keyword arguments are passed to the ``ConnectionPool``'s\n        class initializer. In the case of conflicting arguments, querystring\n        arguments always win.\n\n        \"\"\"\n        connection_pool = ConnectionPool.from_url(url, **kwargs)\n        client = cls(\n            connection_pool=connection_pool,\n            single_connection_client=single_connection_client,\n        )\n        if auto_close_connection_pool is not None:\n            warnings.warn(\n                DeprecationWarning(\n                    '\"auto_close_connection_pool\" is deprecated '\n                    \"since version 5.0.1. \"\n                    \"Please create a ConnectionPool explicitly and \"\n                    \"provide to the Redis() constructor instead.\"\n                )\n            )\n        else:\n            auto_close_connection_pool = True\n        client.auto_close_connection_pool = auto_close_connection_pool\n        return client\n\n    @classmethod\n    def from_pool(\n        cls: Type[\"Redis\"],\n        connection_pool: ConnectionPool,\n    ) -> \"Redis\":\n        \"\"\"\n        Return a Redis client from the given connection pool.\n        The Redis client will take ownership of the connection pool and\n        close it when the Redis client is closed.\n        \"\"\"\n        client = cls(\n            connection_pool=connection_pool,\n        )\n        client.auto_close_connection_pool = True\n        return client\n\n    @deprecated_args(\n        args_to_warn=[\"retry_on_timeout\"],\n        reason=\"TimeoutError is included by default.\",\n        version=\"6.0.0\",\n    )\n    @deprecated_args(\n        args_to_warn=[\"lib_name\", \"lib_version\"],\n        reason=\"Use 'driver_info' parameter instead. \"\n        \"lib_name and lib_version will be removed in a future version.\",\n    )\n    def __init__(\n        self,\n        *,\n        host: str = \"localhost\",\n        port: int = 6379,\n        db: Union[str, int] = 0,\n        password: Optional[str] = None,\n        socket_timeout: Optional[float] = None,\n        socket_connect_timeout: Optional[float] = None,\n        socket_keepalive: Optional[bool] = None,\n        socket_keepalive_options: Optional[Mapping[int, Union[int, bytes]]] = None,\n        connection_pool: Optional[ConnectionPool] = None,\n        unix_socket_path: Optional[str] = None,\n        encoding: str = \"utf-8\",\n        encoding_errors: str = \"strict\",\n        decode_responses: bool = False,\n        retry_on_timeout: bool = False,\n        retry: Retry = Retry(\n            backoff=ExponentialWithJitterBackoff(base=1, cap=10), retries=3\n        ),\n        retry_on_error: Optional[list] = None,\n        ssl: bool = False,\n        ssl_keyfile: Optional[str] = None,\n        ssl_certfile: Optional[str] = None,\n        ssl_cert_reqs: Union[str, VerifyMode] = \"required\",\n        ssl_include_verify_flags: Optional[List[VerifyFlags]] = None,\n        ssl_exclude_verify_flags: Optional[List[VerifyFlags]] = None,\n        ssl_ca_certs: Optional[str] = None,\n        ssl_ca_data: Optional[str] = None,\n        ssl_ca_path: Optional[str] = None,\n        ssl_check_hostname: bool = True,\n        ssl_min_version: Optional[TLSVersion] = None,\n        ssl_ciphers: Optional[str] = None,\n        ssl_password: Optional[str] = None,\n        max_connections: Optional[int] = None,\n        single_connection_client: bool = False,\n        health_check_interval: int = 0,\n        client_name: Optional[str] = None,\n        lib_name: Optional[str] = None,\n        lib_version: Optional[str] = None,\n        driver_info: Optional[\"DriverInfo\"] = None,\n        username: Optional[str] = None,\n        auto_close_connection_pool: Optional[bool] = None,\n        redis_connect_func=None,\n        credential_provider: Optional[CredentialProvider] = None,\n        protocol: Optional[int] = 2,\n        event_dispatcher: Optional[EventDispatcher] = None,\n    ):\n        \"\"\"\n        Initialize a new Redis client.\n\n        To specify a retry policy for specific errors, you have two options:\n\n        1. Set the `retry_on_error` to a list of the error/s to retry on, and\n        you can also set `retry` to a valid `Retry` object(in case the default\n        one is not appropriate) - with this approach the retries will be triggered\n        on the default errors specified in the Retry object enriched with the\n        errors specified in `retry_on_error`.\n\n        2. Define a `Retry` object with configured 'supported_errors' and set\n        it to the `retry` parameter - with this approach you completely redefine\n        the errors on which retries will happen.\n\n        `retry_on_timeout` is deprecated - please include the TimeoutError\n        either in the Retry object or in the `retry_on_error` list.\n\n        When 'connection_pool' is provided - the retry configuration of the\n        provided pool will be used.\n        \"\"\"\n        kwargs: Dict[str, Any]\n        if event_dispatcher is None:\n            self._event_dispatcher = EventDispatcher()\n        else:\n            self._event_dispatcher = event_dispatcher\n        # auto_close_connection_pool only has an effect if connection_pool is\n        # None. It is assumed that if connection_pool is not None, the user\n        # wants to manage the connection pool themselves.\n        if auto_close_connection_pool is not None:\n            warnings.warn(\n                DeprecationWarning(\n                    '\"auto_close_connection_pool\" is deprecated '\n                    \"since version 5.0.1. \"\n                    \"Please create a ConnectionPool explicitly and \"\n                    \"provide to the Redis() constructor instead.\"\n                )\n            )\n        else:\n            auto_close_connection_pool = True\n\n        if not connection_pool:\n            # Create internal connection pool, expected to be closed by Redis instance\n            if not retry_on_error:\n                retry_on_error = []\n\n            # Handle driver_info: if provided, use it; otherwise create from lib_name/lib_version\n            computed_driver_info = resolve_driver_info(\n                driver_info, lib_name, lib_version\n            )\n\n            kwargs = {\n                \"db\": db,\n                \"username\": username,\n                \"password\": password,\n                \"credential_provider\": credential_provider,\n                \"socket_timeout\": socket_timeout,\n                \"encoding\": encoding,\n                \"encoding_errors\": encoding_errors,\n                \"decode_responses\": decode_responses,\n                \"retry_on_error\": retry_on_error,\n                \"retry\": copy.deepcopy(retry),\n                \"max_connections\": max_connections,\n                \"health_check_interval\": health_check_interval,\n                \"client_name\": client_name,\n                \"driver_info\": computed_driver_info,\n                \"redis_connect_func\": redis_connect_func,\n                \"protocol\": protocol,\n            }\n            # based on input, setup appropriate connection args\n            if unix_socket_path is not None:\n                kwargs.update(\n                    {\n                        \"path\": unix_socket_path,\n                        \"connection_class\": UnixDomainSocketConnection,\n                    }\n                )\n            else:\n                # TCP specific options\n                kwargs.update(\n                    {\n                        \"host\": host,\n                        \"port\": port,\n                        \"socket_connect_timeout\": socket_connect_timeout,\n                        \"socket_keepalive\": socket_keepalive,\n                        \"socket_keepalive_options\": socket_keepalive_options,\n                    }\n                )\n\n                if ssl:\n                    kwargs.update(\n                        {\n                            \"connection_class\": SSLConnection,\n                            \"ssl_keyfile\": ssl_keyfile,\n                            \"ssl_certfile\": ssl_certfile,\n                            \"ssl_cert_reqs\": ssl_cert_reqs,\n                            \"ssl_include_verify_flags\": ssl_include_verify_flags,\n                            \"ssl_exclude_verify_flags\": ssl_exclude_verify_flags,\n                            \"ssl_ca_certs\": ssl_ca_certs,\n                            \"ssl_ca_data\": ssl_ca_data,\n                            \"ssl_ca_path\": ssl_ca_path,\n                            \"ssl_check_hostname\": ssl_check_hostname,\n                            \"ssl_min_version\": ssl_min_version,\n                            \"ssl_ciphers\": ssl_ciphers,\n                            \"ssl_password\": ssl_password,\n                        }\n                    )\n            # This arg only used if no pool is passed in\n            self.auto_close_connection_pool = auto_close_connection_pool\n            connection_pool = ConnectionPool(**kwargs)\n            self._event_dispatcher.dispatch(\n                AfterPooledConnectionsInstantiationEvent(\n                    [connection_pool], ClientType.ASYNC, credential_provider\n                )\n            )\n        else:\n            # If a pool is passed in, do not close it\n            self.auto_close_connection_pool = False\n            self._event_dispatcher.dispatch(\n                AfterPooledConnectionsInstantiationEvent(\n                    [connection_pool], ClientType.ASYNC, credential_provider\n                )\n            )\n\n        self.connection_pool = connection_pool\n        self.single_connection_client = single_connection_client\n        self.connection: Optional[Connection] = None\n\n        self.response_callbacks = CaseInsensitiveDict(_RedisCallbacks)\n\n        if self.connection_pool.connection_kwargs.get(\"protocol\") in [\"3\", 3]:\n            self.response_callbacks.update(_RedisCallbacksRESP3)\n        else:\n            self.response_callbacks.update(_RedisCallbacksRESP2)\n\n        # If using a single connection client, we need to lock creation-of and use-of\n        # the client in order to avoid race conditions such as using asyncio.gather\n        # on a set of redis commands\n        self._single_conn_lock = asyncio.Lock()\n\n        # When used as an async context manager, we need to increment and decrement\n        # a usage counter so that we can close the connection pool when no one is\n        # using the client.\n        self._usage_counter = 0\n        self._usage_lock = asyncio.Lock()\n\n    def __repr__(self):\n        return (\n            f\"<{self.__class__.__module__}.{self.__class__.__name__}\"\n            f\"({self.connection_pool!r})>\"\n        )\n\n    def __await__(self):\n        return self.initialize().__await__()\n\n    async def initialize(self: _RedisT) -> _RedisT:\n        if self.single_connection_client:\n            async with self._single_conn_lock:\n                if self.connection is None:\n                    self.connection = await self.connection_pool.get_connection()\n\n            self._event_dispatcher.dispatch(\n                AfterSingleConnectionInstantiationEvent(\n                    self.connection, ClientType.ASYNC, self._single_conn_lock\n                )\n            )\n        return self\n\n    def set_response_callback(self, command: str, callback: ResponseCallbackT):\n        \"\"\"Set a custom Response Callback\"\"\"\n        self.response_callbacks[command] = callback\n\n    def get_encoder(self):\n        \"\"\"Get the connection pool's encoder\"\"\"\n        return self.connection_pool.get_encoder()\n\n    def get_connection_kwargs(self):\n        \"\"\"Get the connection's key-word arguments\"\"\"\n        return self.connection_pool.connection_kwargs\n\n    def get_retry(self) -> Optional[Retry]:\n        return self.get_connection_kwargs().get(\"retry\")\n\n    def set_retry(self, retry: Retry) -> None:\n        self.get_connection_kwargs().update({\"retry\": retry})\n        self.connection_pool.set_retry(retry)\n\n    def load_external_module(self, funcname, func):\n        \"\"\"\n        This function can be used to add externally defined redis modules,\n        and their namespaces to the redis client.\n\n        funcname - A string containing the name of the function to create\n        func - The function, being added to this class.\n\n        ex: Assume that one has a custom redis module named foomod that\n        creates command named 'foo.dothing' and 'foo.anotherthing' in redis.\n        To load function functions into this namespace:\n\n        from redis import Redis\n        from foomodule import F\n        r = Redis()\n        r.load_external_module(\"foo\", F)\n        r.foo().dothing('your', 'arguments')\n\n        For a concrete example see the reimport of the redisjson module in\n        tests/test_connection.py::test_loading_external_modules\n        \"\"\"\n        setattr(self, funcname, func)\n\n    def pipeline(\n        self, transaction: bool = True, shard_hint: Optional[str] = None\n    ) -> \"Pipeline\":\n        \"\"\"\n        Return a new pipeline object that can queue multiple commands for\n        later execution. ``transaction`` indicates whether all commands\n        should be executed atomically. Apart from making a group of operations\n        atomic, pipelines are useful for reducing the back-and-forth overhead\n        between the client and server.\n        \"\"\"\n        return Pipeline(\n            self.connection_pool, self.response_callbacks, transaction, shard_hint\n        )\n\n    async def transaction(\n        self,\n        func: Callable[[\"Pipeline\"], Union[Any, Awaitable[Any]]],\n        *watches: KeyT,\n        shard_hint: Optional[str] = None,\n        value_from_callable: bool = False,\n        watch_delay: Optional[float] = None,\n    ):\n        \"\"\"\n        Convenience method for executing the callable `func` as a transaction\n        while watching all keys specified in `watches`. The 'func' callable\n        should expect a single argument which is a Pipeline object.\n        \"\"\"\n        pipe: Pipeline\n        async with self.pipeline(True, shard_hint) as pipe:\n            while True:\n                try:\n                    if watches:\n                        await pipe.watch(*watches)\n                    func_value = func(pipe)\n                    if inspect.isawaitable(func_value):\n                        func_value = await func_value\n                    exec_value = await pipe.execute()\n                    return func_value if value_from_callable else exec_value\n                except WatchError:\n                    if watch_delay is not None and watch_delay > 0:\n                        await asyncio.sleep(watch_delay)\n                    continue\n\n    def lock(\n        self,\n        name: KeyT,\n        timeout: Optional[float] = None,\n        sleep: float = 0.1,\n        blocking: bool = True,\n        blocking_timeout: Optional[float] = None,\n        lock_class: Optional[Type[Lock]] = None,\n        thread_local: bool = True,\n        raise_on_release_error: bool = True,\n    ) -> Lock:\n        \"\"\"\n        Return a new Lock object using key ``name`` that mimics\n        the behavior of threading.Lock.\n\n        If specified, ``timeout`` indicates a maximum life for the lock.\n        By default, it will remain locked until release() is called.\n\n        ``sleep`` indicates the amount of time to sleep per loop iteration\n        when the lock is in blocking mode and another client is currently\n        holding the lock.\n\n        ``blocking`` indicates whether calling ``acquire`` should block until\n        the lock has been acquired or to fail immediately, causing ``acquire``\n        to return False and the lock not being acquired. Defaults to True.\n        Note this value can be overridden by passing a ``blocking``\n        argument to ``acquire``.\n\n        ``blocking_timeout`` indicates the maximum amount of time in seconds to\n        spend trying to acquire the lock. A value of ``None`` indicates\n        continue trying forever. ``blocking_timeout`` can be specified as a\n        float or integer, both representing the number of seconds to wait.\n\n        ``lock_class`` forces the specified lock implementation. Note that as\n        of redis-py 3.0, the only lock class we implement is ``Lock`` (which is\n        a Lua-based lock). So, it's unlikely you'll need this parameter, unless\n        you have created your own custom lock class.\n\n        ``thread_local`` indicates whether the lock token is placed in\n        thread-local storage. By default, the token is placed in thread local\n        storage so that a thread only sees its token, not a token set by\n        another thread. Consider the following timeline:\n\n            time: 0, thread-1 acquires `my-lock`, with a timeout of 5 seconds.\n                     thread-1 sets the token to \"abc\"\n            time: 1, thread-2 blocks trying to acquire `my-lock` using the\n                     Lock instance.\n            time: 5, thread-1 has not yet completed. redis expires the lock\n                     key.\n            time: 5, thread-2 acquired `my-lock` now that it's available.\n                     thread-2 sets the token to \"xyz\"\n            time: 6, thread-1 finishes its work and calls release(). if the\n                     token is *not* stored in thread local storage, then\n                     thread-1 would see the token value as \"xyz\" and would be\n                     able to successfully release the thread-2's lock.\n\n        ``raise_on_release_error`` indicates whether to raise an exception when\n        the lock is no longer owned when exiting the context manager. By default,\n        this is True, meaning an exception will be raised. If False, the warning\n        will be logged and the exception will be suppressed.\n\n        In some use cases it's necessary to disable thread local storage. For\n        example, if you have code where one thread acquires a lock and passes\n        that lock instance to a worker thread to release later. If thread\n        local storage isn't disabled in this case, the worker thread won't see\n        the token set by the thread that acquired the lock. Our assumption\n        is that these cases aren't common and as such default to using\n        thread local storage.\"\"\"\n        if lock_class is None:\n            lock_class = Lock\n        return lock_class(\n            self,\n            name,\n            timeout=timeout,\n            sleep=sleep,\n            blocking=blocking,\n            blocking_timeout=blocking_timeout,\n            thread_local=thread_local,\n            raise_on_release_error=raise_on_release_error,\n        )\n\n    def pubsub(self, **kwargs) -> \"PubSub\":\n        \"\"\"\n        Return a Publish/Subscribe object. With this object, you can\n        subscribe to channels and listen for messages that get published to\n        them.\n        \"\"\"\n        return PubSub(\n            self.connection_pool, event_dispatcher=self._event_dispatcher, **kwargs\n        )\n\n    def monitor(self) -> \"Monitor\":\n        return Monitor(self.connection_pool)\n\n    def client(self) -> \"Redis\":\n        return self.__class__(\n            connection_pool=self.connection_pool, single_connection_client=True\n        )\n\n    async def __aenter__(self: _RedisT) -> _RedisT:\n        \"\"\"\n        Async context manager entry. Increments a usage counter so that the\n        connection pool is only closed (via aclose()) when no context is using\n        the client.\n        \"\"\"\n        await self._increment_usage()\n        try:\n            # Initialize the client (i.e. establish connection, etc.)\n            return await self.initialize()\n        except Exception:\n            # If initialization fails, decrement the counter to keep it in sync\n            await self._decrement_usage()\n            raise\n\n    async def _increment_usage(self) -> int:\n        \"\"\"\n        Helper coroutine to increment the usage counter while holding the lock.\n        Returns the new value of the usage counter.\n        \"\"\"\n        async with self._usage_lock:\n            self._usage_counter += 1\n            return self._usage_counter\n\n    async def _decrement_usage(self) -> int:\n        \"\"\"\n        Helper coroutine to decrement the usage counter while holding the lock.\n        Returns the new value of the usage counter.\n        \"\"\"\n        async with self._usage_lock:\n            self._usage_counter -= 1\n            return self._usage_counter\n\n    async def __aexit__(self, exc_type, exc_value, traceback):\n        \"\"\"\n        Async context manager exit. Decrements a usage counter. If this is the\n        last exit (counter becomes zero), the client closes its connection pool.\n        \"\"\"\n        current_usage = await asyncio.shield(self._decrement_usage())\n        if current_usage == 0:\n            # This was the last active context, so disconnect the pool.\n            await asyncio.shield(self.aclose())\n\n    _DEL_MESSAGE = \"Unclosed Redis client\"\n\n    # passing _warnings and _grl as argument default since they may be gone\n    # by the time __del__ is called at shutdown\n    def __del__(\n        self,\n        _warn: Any = warnings.warn,\n        _grl: Any = asyncio.get_running_loop,\n    ) -> None:\n        if hasattr(self, \"connection\") and (self.connection is not None):\n            _warn(f\"Unclosed client session {self!r}\", ResourceWarning, source=self)\n            try:\n                context = {\"client\": self, \"message\": self._DEL_MESSAGE}\n                _grl().call_exception_handler(context)\n            except RuntimeError:\n                pass\n            self.connection._close()\n\n    async def aclose(self, close_connection_pool: Optional[bool] = None) -> None:\n        \"\"\"\n        Closes Redis client connection\n\n        Args:\n            close_connection_pool:\n                decides whether to close the connection pool used by this Redis client,\n                overriding Redis.auto_close_connection_pool.\n                By default, let Redis.auto_close_connection_pool decide\n                whether to close the connection pool.\n        \"\"\"\n        conn = self.connection\n        if conn:\n            self.connection = None\n            await self.connection_pool.release(conn)\n        if close_connection_pool or (\n            close_connection_pool is None and self.auto_close_connection_pool\n        ):\n            await self.connection_pool.disconnect()\n\n    @deprecated_function(version=\"5.0.1\", reason=\"Use aclose() instead\", name=\"close\")\n    async def close(self, close_connection_pool: Optional[bool] = None) -> None:\n        \"\"\"\n        Alias for aclose(), for backwards compatibility\n        \"\"\"\n        await self.aclose(close_connection_pool)\n\n    async def _send_command_parse_response(self, conn, command_name, *args, **options):\n        \"\"\"\n        Send a command and parse the response\n        \"\"\"\n        await conn.send_command(*args)\n        return await self.parse_response(conn, command_name, **options)\n\n    async def _close_connection(\n        self,\n        conn: Connection,\n        error: Optional[BaseException] = None,\n        failure_count: Optional[int] = None,\n        start_time: Optional[float] = None,\n        command_name: Optional[str] = None,\n    ):\n        \"\"\"\n        Close the connection before retrying.\n\n        The supported exceptions are already checked in the\n        retry object so we don't need to do it here.\n\n        After we disconnect the connection, it will try to reconnect and\n        do a health check as part of the send_command logic(on connection level).\n        \"\"\"\n        if (\n            error\n            and failure_count is not None\n            and failure_count <= conn.retry.get_retries()\n        ):\n            await record_operation_duration(\n                command_name=command_name,\n                duration_seconds=time.monotonic() - start_time,\n                server_address=getattr(conn, \"host\", None),\n                server_port=getattr(conn, \"port\", None),\n                db_namespace=str(conn.db),\n                error=error,\n                retry_attempts=failure_count,\n            )\n\n        await conn.disconnect(error=error, failure_count=failure_count)\n\n    # COMMAND EXECUTION AND PROTOCOL PARSING\n    async def execute_command(self, *args, **options):\n        \"\"\"Execute a command and return a parsed response\"\"\"\n        await self.initialize()\n        pool = self.connection_pool\n        command_name = args[0]\n        conn = self.connection or await pool.get_connection()\n\n        # Start timing for observability\n        start_time = time.monotonic()\n        # Track actual retry attempts for error reporting\n        actual_retry_attempts = 0\n\n        def failure_callback(error, failure_count):\n            nonlocal actual_retry_attempts\n            actual_retry_attempts = failure_count\n            return self._close_connection(\n                conn, error, failure_count, start_time, command_name\n            )\n\n        if self.single_connection_client:\n            await self._single_conn_lock.acquire()\n        try:\n            result = await conn.retry.call_with_retry(\n                lambda: self._send_command_parse_response(\n                    conn, command_name, *args, **options\n                ),\n                failure_callback,\n                with_failure_count=True,\n            )\n\n            await record_operation_duration(\n                command_name=command_name,\n                duration_seconds=time.monotonic() - start_time,\n                server_address=getattr(conn, \"host\", None),\n                server_port=getattr(conn, \"port\", None),\n                db_namespace=str(conn.db),\n            )\n            return result\n        except Exception as e:\n            await record_error_count(\n                server_address=getattr(conn, \"host\", None),\n                server_port=getattr(conn, \"port\", None),\n                network_peer_address=getattr(conn, \"host\", None),\n                network_peer_port=getattr(conn, \"port\", None),\n                error_type=e,\n                retry_attempts=actual_retry_attempts,\n                is_internal=False,\n            )\n            raise\n        finally:\n            if self.single_connection_client:\n                self._single_conn_lock.release()\n            if not self.connection:\n                await pool.release(conn)\n\n    async def parse_response(\n        self, connection: Connection, command_name: Union[str, bytes], **options\n    ):\n        \"\"\"Parses a response from the Redis server\"\"\"\n        try:\n            if NEVER_DECODE in options:\n                response = await connection.read_response(disable_decoding=True)\n                options.pop(NEVER_DECODE)\n            else:\n                response = await connection.read_response()\n        except ResponseError:\n            if EMPTY_RESPONSE in options:\n                return options[EMPTY_RESPONSE]\n            raise\n\n        if EMPTY_RESPONSE in options:\n            options.pop(EMPTY_RESPONSE)\n\n        # Remove keys entry, it needs only for cache.\n        options.pop(\"keys\", None)\n\n        if command_name in self.response_callbacks:\n            # Mypy bug: https://github.com/python/mypy/issues/10977\n            command_name = cast(str, command_name)\n            retval = self.response_callbacks[command_name](response, **options)\n            return await retval if inspect.isawaitable(retval) else retval\n        return response\n\n\nStrictRedis = Redis\n\n\nclass MonitorCommandInfo(TypedDict):\n    time: float\n    db: int\n    client_address: str\n    client_port: str\n    client_type: str\n    command: str\n\n\nclass Monitor:\n    \"\"\"\n    Monitor is useful for handling the MONITOR command to the redis server.\n    next_command() method returns one command from monitor\n    listen() method yields commands from monitor.\n    \"\"\"\n\n    monitor_re = re.compile(r\"\\[(\\d+) (.*?)\\] (.*)\")\n    command_re = re.compile(r'\"(.*?)(?<!\\\\)\"')\n\n    def __init__(self, connection_pool: ConnectionPool):\n        self.connection_pool = connection_pool\n        self.connection: Optional[Connection] = None\n\n    async def connect(self):\n        if self.connection is None:\n            self.connection = await self.connection_pool.get_connection()\n\n    async def __aenter__(self):\n        await self.connect()\n        await self.connection.send_command(\"MONITOR\")\n        # check that monitor returns 'OK', but don't return it to user\n        response = await self.connection.read_response()\n        if not bool_ok(response):\n            raise RedisError(f\"MONITOR failed: {response}\")\n        return self\n\n    async def __aexit__(self, *args):\n        await self.connection.disconnect()\n        await self.connection_pool.release(self.connection)\n\n    async def next_command(self) -> MonitorCommandInfo:\n        \"\"\"Parse the response from a monitor command\"\"\"\n        await self.connect()\n        response = await self.connection.read_response()\n        if isinstance(response, bytes):\n            response = self.connection.encoder.decode(response, force=True)\n        command_time, command_data = response.split(\" \", 1)\n        m = self.monitor_re.match(command_data)\n        db_id, client_info, command = m.groups()\n        command = \" \".join(self.command_re.findall(command))\n        # Redis escapes double quotes because each piece of the command\n        # string is surrounded by double quotes. We don't have that\n        # requirement so remove the escaping and leave the quote.\n        command = command.replace('\\\\\"', '\"')\n\n        if client_info == \"lua\":\n            client_address = \"lua\"\n            client_port = \"\"\n            client_type = \"lua\"\n        elif client_info.startswith(\"unix\"):\n            client_address = \"unix\"\n            client_port = client_info[5:]\n            client_type = \"unix\"\n        else:\n            # use rsplit as ipv6 addresses contain colons\n            client_address, client_port = client_info.rsplit(\":\", 1)\n            client_type = \"tcp\"\n        return {\n            \"time\": float(command_time),\n            \"db\": int(db_id),\n            \"client_address\": client_address,\n            \"client_port\": client_port,\n            \"client_type\": client_type,\n            \"command\": command,\n        }\n\n    async def listen(self) -> AsyncIterator[MonitorCommandInfo]:\n        \"\"\"Listen for commands coming to the server.\"\"\"\n        while True:\n            yield await self.next_command()\n\n\nclass PubSub:\n    \"\"\"\n    PubSub provides publish, subscribe and listen support to Redis channels.\n\n    After subscribing to one or more channels, the listen() method will block\n    until a message arrives on one of the subscribed channels. That message\n    will be returned and it's safe to start listening again.\n    \"\"\"\n\n    PUBLISH_MESSAGE_TYPES = (\"message\", \"pmessage\", \"smessage\")\n    UNSUBSCRIBE_MESSAGE_TYPES = (\"unsubscribe\", \"punsubscribe\", \"sunsubscribe\")\n    HEALTH_CHECK_MESSAGE = \"redis-py-health-check\"\n\n    def __init__(\n        self,\n        connection_pool: ConnectionPool,\n        shard_hint: Optional[str] = None,\n        ignore_subscribe_messages: bool = False,\n        encoder=None,\n        push_handler_func: Optional[Callable] = None,\n        event_dispatcher: Optional[\"EventDispatcher\"] = None,\n    ):\n        if event_dispatcher is None:\n            self._event_dispatcher = EventDispatcher()\n        else:\n            self._event_dispatcher = event_dispatcher\n        self.connection_pool = connection_pool\n        self.shard_hint = shard_hint\n        self.ignore_subscribe_messages = ignore_subscribe_messages\n        self.connection = None\n        # we need to know the encoding options for this connection in order\n        # to lookup channel and pattern names for callback handlers.\n        self.encoder = encoder\n        self.push_handler_func = push_handler_func\n        if self.encoder is None:\n            self.encoder = self.connection_pool.get_encoder()\n        if self.encoder.decode_responses:\n            self.health_check_response = [\n                [\"pong\", self.HEALTH_CHECK_MESSAGE],\n                self.HEALTH_CHECK_MESSAGE,\n            ]\n        else:\n            self.health_check_response = [\n                [b\"pong\", self.encoder.encode(self.HEALTH_CHECK_MESSAGE)],\n                self.encoder.encode(self.HEALTH_CHECK_MESSAGE),\n            ]\n        if self.push_handler_func is None:\n            _set_info_logger()\n        self.channels = {}\n        self.pending_unsubscribe_channels = set()\n        self.patterns = {}\n        self.pending_unsubscribe_patterns = set()\n        self._lock = asyncio.Lock()\n\n    async def __aenter__(self):\n        return self\n\n    async def __aexit__(self, exc_type, exc_value, traceback):\n        await self.aclose()\n\n    def __del__(self):\n        if self.connection:\n            self.connection.deregister_connect_callback(self.on_connect)\n\n    async def aclose(self):\n        # In case a connection property does not yet exist\n        # (due to a crash earlier in the Redis() constructor), return\n        # immediately as there is nothing to clean-up.\n        if not hasattr(self, \"connection\"):\n            return\n        async with self._lock:\n            if self.connection:\n                await self.connection.disconnect()\n                self.connection.deregister_connect_callback(self.on_connect)\n                await self.connection_pool.release(self.connection)\n                self.connection = None\n            self.channels = {}\n            self.pending_unsubscribe_channels = set()\n            self.patterns = {}\n            self.pending_unsubscribe_patterns = set()\n\n    @deprecated_function(version=\"5.0.1\", reason=\"Use aclose() instead\", name=\"close\")\n    async def close(self) -> None:\n        \"\"\"Alias for aclose(), for backwards compatibility\"\"\"\n        await self.aclose()\n\n    @deprecated_function(version=\"5.0.1\", reason=\"Use aclose() instead\", name=\"reset\")\n    async def reset(self) -> None:\n        \"\"\"Alias for aclose(), for backwards compatibility\"\"\"\n        await self.aclose()\n\n    async def on_connect(self, connection: Connection):\n        \"\"\"Re-subscribe to any channels and patterns previously subscribed to\"\"\"\n        # NOTE: for python3, we can't pass bytestrings as keyword arguments\n        # so we need to decode channel/pattern names back to unicode strings\n        # before passing them to [p]subscribe.\n        #\n        # However, channels subscribed without a callback (positional args) may\n        # have binary names that are not valid in the current encoding (e.g.\n        # arbitrary bytes that are not valid UTF-8).  These channels are stored\n        # with a ``None`` handler.  We re-subscribe them as positional args so\n        # that no decoding is required.\n        self.pending_unsubscribe_channels.clear()\n        self.pending_unsubscribe_patterns.clear()\n        if self.channels:\n            channels_with_handlers = {}\n            channels_without_handlers = []\n            for k, v in self.channels.items():\n                if v is not None:\n                    channels_with_handlers[self.encoder.decode(k, force=True)] = v\n                else:\n                    channels_without_handlers.append(k)\n            if channels_with_handlers or channels_without_handlers:\n                await self.subscribe(\n                    *channels_without_handlers, **channels_with_handlers\n                )\n        if self.patterns:\n            patterns_with_handlers = {}\n            patterns_without_handlers = []\n            for k, v in self.patterns.items():\n                if v is not None:\n                    patterns_with_handlers[self.encoder.decode(k, force=True)] = v\n                else:\n                    patterns_without_handlers.append(k)\n            if patterns_with_handlers or patterns_without_handlers:\n                await self.psubscribe(\n                    *patterns_without_handlers, **patterns_with_handlers\n                )\n\n    @property\n    def subscribed(self):\n        \"\"\"Indicates if there are subscriptions to any channels or patterns\"\"\"\n        return bool(self.channels or self.patterns)\n\n    async def execute_command(self, *args: EncodableT):\n        \"\"\"Execute a publish/subscribe command\"\"\"\n\n        # NOTE: don't parse the response in this function -- it could pull a\n        # legitimate message off the stack if the connection is already\n        # subscribed to one or more channels\n\n        await self.connect()\n        connection = self.connection\n        kwargs = {\"check_health\": not self.subscribed}\n        await self._execute(connection, connection.send_command, *args, **kwargs)\n\n    async def connect(self):\n        \"\"\"\n        Ensure that the PubSub is connected\n        \"\"\"\n        if self.connection is None:\n            self.connection = await self.connection_pool.get_connection()\n            # register a callback that re-subscribes to any channels we\n            # were listening to when we were disconnected\n            self.connection.register_connect_callback(self.on_connect)\n        else:\n            await self.connection.connect()\n        if self.push_handler_func is not None:\n            self.connection._parser.set_pubsub_push_handler(self.push_handler_func)\n\n        self._event_dispatcher.dispatch(\n            AfterPubSubConnectionInstantiationEvent(\n                self.connection, self.connection_pool, ClientType.ASYNC, self._lock\n            )\n        )\n\n    async def _reconnect(\n        self,\n        conn,\n        error: Optional[BaseException] = None,\n        failure_count: Optional[int] = None,\n        start_time: Optional[float] = None,\n        command_name: Optional[str] = None,\n    ):\n        \"\"\"\n        The supported exceptions are already checked in the\n        retry object so we don't need to do it here.\n\n        In this error handler we are trying to reconnect to the server.\n        \"\"\"\n        if (\n            error\n            and failure_count is not None\n            and failure_count <= conn.retry.get_retries()\n        ):\n            if command_name:\n                await record_operation_duration(\n                    command_name=command_name,\n                    duration_seconds=time.monotonic() - start_time,\n                    server_address=getattr(conn, \"host\", None),\n                    server_port=getattr(conn, \"port\", None),\n                    db_namespace=str(conn.db),\n                    error=error,\n                    retry_attempts=failure_count,\n                )\n        await conn.disconnect(error=error, failure_count=failure_count)\n        await conn.connect()\n\n    async def _execute(self, conn, command, *args, **kwargs):\n        \"\"\"\n        Connect manually upon disconnection. If the Redis server is down,\n        this will fail and raise a ConnectionError as desired.\n        After reconnection, the ``on_connect`` callback should have been\n        called by the # connection to resubscribe us to any channels and\n        patterns we were previously listening to\n        \"\"\"\n        if not len(args) == 0:\n            command_name = args[0]\n        else:\n            command_name = None\n\n        # Start timing for observability\n        start_time = time.monotonic()\n        # Track actual retry attempts for error reporting\n        actual_retry_attempts = 0\n\n        def failure_callback(error, failure_count):\n            nonlocal actual_retry_attempts\n            actual_retry_attempts = failure_count\n            return self._reconnect(conn, error, failure_count, start_time, command_name)\n\n        try:\n            response = await conn.retry.call_with_retry(\n                lambda: command(*args, **kwargs),\n                failure_callback,\n                with_failure_count=True,\n            )\n\n            if command_name:\n                await record_operation_duration(\n                    command_name=command_name,\n                    duration_seconds=time.monotonic() - start_time,\n                    server_address=getattr(conn, \"host\", None),\n                    server_port=getattr(conn, \"port\", None),\n                    db_namespace=str(conn.db),\n                )\n\n            return response\n        except Exception as e:\n            await record_error_count(\n                server_address=getattr(conn, \"host\", None),\n                server_port=getattr(conn, \"port\", None),\n                network_peer_address=getattr(conn, \"host\", None),\n                network_peer_port=getattr(conn, \"port\", None),\n                error_type=e,\n                retry_attempts=actual_retry_attempts,\n                is_internal=False,\n            )\n            raise\n\n    async def parse_response(self, block: bool = True, timeout: float = 0):\n        \"\"\"\n        Parse the response from a publish/subscribe command.\n\n        Args:\n            block: If True, block indefinitely until a message is available.\n                   If False, return immediately if no message is available.\n                   Default: True\n            timeout: The timeout in seconds for reading a response when block=False.\n                     This parameter is ignored when block=True.\n                     Default: 0 (return immediately if no data available)\n\n        Returns:\n            The parsed response from the server, or None if no message is available\n            within the timeout period (when block=False).\n\n        Important:\n            The block and timeout parameters work together:\n            - When block=True: timeout is IGNORED, method blocks indefinitely\n            - When block=False: timeout is USED, method returns after timeout expires\n\n            Typically, you should use get_message(timeout=X) instead of calling\n            parse_response() directly. The get_message() method automatically sets\n            block=False when a timeout is provided, and block=True when timeout=None.\n\n        Example:\n            # Block indefinitely (timeout is ignored)\n            response = await pubsub.parse_response(block=True, timeout=0.1)\n\n            # Non-blocking with 0.1 second timeout\n            response = await pubsub.parse_response(block=False, timeout=0.1)\n\n            # Non-blocking, return immediately\n            response = await pubsub.parse_response(block=False, timeout=0)\n\n            # Recommended: use get_message() instead\n            msg = await pubsub.get_message(timeout=0.1)  # automatically sets block=False\n            msg = await pubsub.get_message(timeout=None)  # automatically sets block=True\n        \"\"\"\n        conn = self.connection\n        if conn is None:\n            raise RuntimeError(\n                \"pubsub connection not set: \"\n                \"did you forget to call subscribe() or psubscribe()?\"\n            )\n\n        await self.check_health()\n\n        if not conn.is_connected:\n            await conn.connect()\n\n        read_timeout = None if block else timeout\n        response = await self._execute(\n            conn,\n            conn.read_response,\n            timeout=read_timeout,\n            disconnect_on_error=False,\n            push_request=True,\n        )\n\n        if conn.health_check_interval and response in self.health_check_response:\n            # ignore the health check message as user might not expect it\n            return None\n        return response\n\n    async def check_health(self):\n        conn = self.connection\n        if conn is None:\n            raise RuntimeError(\n                \"pubsub connection not set: \"\n                \"did you forget to call subscribe() or psubscribe()?\"\n            )\n\n        if (\n            conn.health_check_interval\n            and asyncio.get_running_loop().time() > conn.next_health_check\n        ):\n            await conn.send_command(\n                \"PING\", self.HEALTH_CHECK_MESSAGE, check_health=False\n            )\n\n    def _normalize_keys(self, data: _NormalizeKeysT) -> _NormalizeKeysT:\n        \"\"\"\n        normalize channel/pattern names to be either bytes or strings\n        based on whether responses are automatically decoded. this saves us\n        from coercing the value for each message coming in.\n        \"\"\"\n        encode = self.encoder.encode\n        decode = self.encoder.decode\n        return {decode(encode(k)): v for k, v in data.items()}  # type: ignore[return-value]  # noqa: E501\n\n    async def psubscribe(self, *args: ChannelT, **kwargs: PubSubHandler):\n        \"\"\"\n        Subscribe to channel patterns. Patterns supplied as keyword arguments\n        expect a pattern name as the key and a callable as the value. A\n        pattern's callable will be invoked automatically when a message is\n        received on that pattern rather than producing a message via\n        ``listen()``.\n        \"\"\"\n        parsed_args = list_or_args((args[0],), args[1:]) if args else args\n        new_patterns: Dict[ChannelT, PubSubHandler] = dict.fromkeys(parsed_args)\n        # Mypy bug: https://github.com/python/mypy/issues/10970\n        new_patterns.update(kwargs)  # type: ignore[arg-type]\n        ret_val = await self.execute_command(\"PSUBSCRIBE\", *new_patterns.keys())\n        # update the patterns dict AFTER we send the command. we don't want to\n        # subscribe twice to these patterns, once for the command and again\n        # for the reconnection.\n        new_patterns = self._normalize_keys(new_patterns)\n        self.patterns.update(new_patterns)\n        self.pending_unsubscribe_patterns.difference_update(new_patterns)\n        return ret_val\n\n    def punsubscribe(self, *args: ChannelT) -> Awaitable:\n        \"\"\"\n        Unsubscribe from the supplied patterns. If empty, unsubscribe from\n        all patterns.\n        \"\"\"\n        patterns: Iterable[ChannelT]\n        if args:\n            parsed_args = list_or_args((args[0],), args[1:])\n            patterns = self._normalize_keys(dict.fromkeys(parsed_args)).keys()\n        else:\n            parsed_args = []\n            patterns = self.patterns\n        self.pending_unsubscribe_patterns.update(patterns)\n        return self.execute_command(\"PUNSUBSCRIBE\", *parsed_args)\n\n    async def subscribe(self, *args: ChannelT, **kwargs: Callable):\n        \"\"\"\n        Subscribe to channels. Channels supplied as keyword arguments expect\n        a channel name as the key and a callable as the value. A channel's\n        callable will be invoked automatically when a message is received on\n        that channel rather than producing a message via ``listen()`` or\n        ``get_message()``.\n        \"\"\"\n        parsed_args = list_or_args((args[0],), args[1:]) if args else ()\n        new_channels = dict.fromkeys(parsed_args)\n        # Mypy bug: https://github.com/python/mypy/issues/10970\n        new_channels.update(kwargs)  # type: ignore[arg-type]\n        ret_val = await self.execute_command(\"SUBSCRIBE\", *new_channels.keys())\n        # update the channels dict AFTER we send the command. we don't want to\n        # subscribe twice to these channels, once for the command and again\n        # for the reconnection.\n        new_channels = self._normalize_keys(new_channels)\n        self.channels.update(new_channels)\n        self.pending_unsubscribe_channels.difference_update(new_channels)\n        return ret_val\n\n    def unsubscribe(self, *args) -> Awaitable:\n        \"\"\"\n        Unsubscribe from the supplied channels. If empty, unsubscribe from\n        all channels\n        \"\"\"\n        if args:\n            parsed_args = list_or_args(args[0], args[1:])\n            channels = self._normalize_keys(dict.fromkeys(parsed_args))\n        else:\n            parsed_args = []\n            channels = self.channels\n        self.pending_unsubscribe_channels.update(channels)\n        return self.execute_command(\"UNSUBSCRIBE\", *parsed_args)\n\n    async def listen(self) -> AsyncIterator:\n        \"\"\"Listen for messages on channels this client has been subscribed to\"\"\"\n        while self.subscribed:\n            response = await self.handle_message(await self.parse_response(block=True))\n            if response is not None:\n                yield response\n\n    async def get_message(\n        self, ignore_subscribe_messages: bool = False, timeout: Optional[float] = 0.0\n    ):\n        \"\"\"\n        Get the next message if one is available, otherwise None.\n\n        If timeout is specified, the system will wait for `timeout` seconds\n        before returning. Timeout should be specified as a floating point\n        number or None to wait indefinitely.\n        \"\"\"\n        response = await self.parse_response(block=(timeout is None), timeout=timeout)\n        if response:\n            return await self.handle_message(response, ignore_subscribe_messages)\n        return None\n\n    def ping(self, message=None) -> Awaitable[bool]:\n        \"\"\"\n        Ping the Redis server to test connectivity.\n\n        Sends a PING command to the Redis server and returns True if the server\n        responds with \"PONG\".\n        \"\"\"\n        args = [\"PING\", message] if message is not None else [\"PING\"]\n        return self.execute_command(*args)\n\n    async def handle_message(self, response, ignore_subscribe_messages=False):\n        \"\"\"\n        Parses a pub/sub message. If the channel or pattern was subscribed to\n        with a message handler, the handler is invoked instead of a parsed\n        message being returned.\n        \"\"\"\n        if response is None:\n            return None\n        if isinstance(response, bytes):\n            response = [b\"pong\", response] if response != b\"PONG\" else [b\"pong\", b\"\"]\n        message_type = str_if_bytes(response[0])\n        if message_type == \"pmessage\":\n            message = {\n                \"type\": message_type,\n                \"pattern\": response[1],\n                \"channel\": response[2],\n                \"data\": response[3],\n            }\n        elif message_type == \"pong\":\n            message = {\n                \"type\": message_type,\n                \"pattern\": None,\n                \"channel\": None,\n                \"data\": response[1],\n            }\n        else:\n            message = {\n                \"type\": message_type,\n                \"pattern\": None,\n                \"channel\": response[1],\n                \"data\": response[2],\n            }\n\n        if message_type in [\"message\", \"pmessage\"]:\n            channel = str_if_bytes(message[\"channel\"])\n            await record_pubsub_message(\n                direction=PubSubDirection.RECEIVE,\n                channel=channel,\n            )\n\n        # if this is an unsubscribe message, remove it from memory\n        if message_type in self.UNSUBSCRIBE_MESSAGE_TYPES:\n            if message_type == \"punsubscribe\":\n                pattern = response[1]\n                if pattern in self.pending_unsubscribe_patterns:\n                    self.pending_unsubscribe_patterns.remove(pattern)\n                    self.patterns.pop(pattern, None)\n            else:\n                channel = response[1]\n                if channel in self.pending_unsubscribe_channels:\n                    self.pending_unsubscribe_channels.remove(channel)\n                    self.channels.pop(channel, None)\n\n        if message_type in self.PUBLISH_MESSAGE_TYPES:\n            # if there's a message handler, invoke it\n            if message_type == \"pmessage\":\n                handler = self.patterns.get(message[\"pattern\"], None)\n            else:\n                handler = self.channels.get(message[\"channel\"], None)\n            if handler:\n                if inspect.iscoroutinefunction(handler):\n                    await handler(message)\n                else:\n                    handler(message)\n                return None\n        elif message_type != \"pong\":\n            # this is a subscribe/unsubscribe message. ignore if we don't\n            # want them\n            if ignore_subscribe_messages or self.ignore_subscribe_messages:\n                return None\n\n        return message\n\n    async def run(\n        self,\n        *,\n        exception_handler: Optional[\"PSWorkerThreadExcHandlerT\"] = None,\n        poll_timeout: float = 1.0,\n        pubsub=None,\n    ) -> None:\n        \"\"\"Process pub/sub messages using registered callbacks.\n\n        This is the equivalent of :py:meth:`redis.PubSub.run_in_thread` in\n        redis-py, but it is a coroutine. To launch it as a separate task, use\n        ``asyncio.create_task``:\n\n            >>> task = asyncio.create_task(pubsub.run())\n\n        To shut it down, use asyncio cancellation:\n\n            >>> task.cancel()\n            >>> await task\n        \"\"\"\n        for channel, handler in self.channels.items():\n            if handler is None:\n                raise PubSubError(f\"Channel: '{channel}' has no handler registered\")\n        for pattern, handler in self.patterns.items():\n            if handler is None:\n                raise PubSubError(f\"Pattern: '{pattern}' has no handler registered\")\n\n        await self.connect()\n        while True:\n            try:\n                if pubsub is None:\n                    await self.get_message(\n                        ignore_subscribe_messages=True, timeout=poll_timeout\n                    )\n                else:\n                    await pubsub.get_message(\n                        ignore_subscribe_messages=True, timeout=poll_timeout\n                    )\n            except asyncio.CancelledError:\n                raise\n            except BaseException as e:\n                if exception_handler is None:\n                    raise\n                res = exception_handler(e, self)\n                if inspect.isawaitable(res):\n                    await res\n            # Ensure that other tasks on the event loop get a chance to run\n            # if we didn't have to block for I/O anywhere.\n            await asyncio.sleep(0)\n\n\nclass PubsubWorkerExceptionHandler(Protocol):\n    def __call__(self, e: BaseException, pubsub: PubSub): ...\n\n\nclass AsyncPubsubWorkerExceptionHandler(Protocol):\n    async def __call__(self, e: BaseException, pubsub: PubSub): ...\n\n\nPSWorkerThreadExcHandlerT = Union[\n    PubsubWorkerExceptionHandler, AsyncPubsubWorkerExceptionHandler\n]\n\n\nCommandT = Tuple[Tuple[Union[str, bytes], ...], Mapping[str, Any]]\nCommandStackT = List[CommandT]\n\n\nclass Pipeline(Redis):  # lgtm [py/init-calls-subclass]\n    \"\"\"\n    Pipelines provide a way to transmit multiple commands to the Redis server\n    in one transmission.  This is convenient for batch processing, such as\n    saving all the values in a list to Redis.\n\n    All commands executed within a pipeline(when running in transactional mode,\n    which is the default behavior) are wrapped with MULTI and EXEC\n    calls. This guarantees all commands executed in the pipeline will be\n    executed atomically.\n\n    Any command raising an exception does *not* halt the execution of\n    subsequent commands in the pipeline. Instead, the exception is caught\n    and its instance is placed into the response list returned by execute().\n    Code iterating over the response list should be able to deal with an\n    instance of an exception as a potential value. In general, these will be\n    ResponseError exceptions, such as those raised when issuing a command\n    on a key of a different datatype.\n    \"\"\"\n\n    UNWATCH_COMMANDS = {\"DISCARD\", \"EXEC\", \"UNWATCH\"}\n\n    def __init__(\n        self,\n        connection_pool: ConnectionPool,\n        response_callbacks: MutableMapping[Union[str, bytes], ResponseCallbackT],\n        transaction: bool,\n        shard_hint: Optional[str],\n    ):\n        self.connection_pool = connection_pool\n        self.connection = None\n        self.response_callbacks = response_callbacks\n        self.is_transaction = transaction\n        self.shard_hint = shard_hint\n        self.watching = False\n        self.command_stack: CommandStackT = []\n        self.scripts: Set[Script] = set()\n        self.explicit_transaction = False\n\n    async def __aenter__(self: _RedisT) -> _RedisT:\n        return self\n\n    async def __aexit__(self, exc_type, exc_value, traceback):\n        await self.reset()\n\n    def __await__(self):\n        return self._async_self().__await__()\n\n    _DEL_MESSAGE = \"Unclosed Pipeline client\"\n\n    def __len__(self):\n        return len(self.command_stack)\n\n    def __bool__(self):\n        \"\"\"Pipeline instances should always evaluate to True\"\"\"\n        return True\n\n    async def _async_self(self):\n        return self\n\n    async def reset(self):\n        self.command_stack = []\n        self.scripts = set()\n        # make sure to reset the connection state in the event that we were\n        # watching something\n        if self.watching and self.connection:\n            try:\n                # call this manually since our unwatch or\n                # immediate_execute_command methods can call reset()\n                await self.connection.send_command(\"UNWATCH\")\n                await self.connection.read_response()\n            except ConnectionError:\n                # disconnect will also remove any previous WATCHes\n                if self.connection:\n                    await self.connection.disconnect()\n        # clean up the other instance attributes\n        self.watching = False\n        self.explicit_transaction = False\n        # we can safely return the connection to the pool here since we're\n        # sure we're no longer WATCHing anything\n        if self.connection:\n            await self.connection_pool.release(self.connection)\n            self.connection = None\n\n    async def aclose(self) -> None:\n        \"\"\"Alias for reset(), a standard method name for cleanup\"\"\"\n        await self.reset()\n\n    def multi(self):\n        \"\"\"\n        Start a transactional block of the pipeline after WATCH commands\n        are issued. End the transactional block with `execute`.\n        \"\"\"\n        if self.explicit_transaction:\n            raise RedisError(\"Cannot issue nested calls to MULTI\")\n        if self.command_stack:\n            raise RedisError(\n                \"Commands without an initial WATCH have already been issued\"\n            )\n        self.explicit_transaction = True\n\n    def execute_command(\n        self, *args, **kwargs\n    ) -> Union[\"Pipeline\", Awaitable[\"Pipeline\"]]:\n        if (self.watching or args[0] == \"WATCH\") and not self.explicit_transaction:\n            return self.immediate_execute_command(*args, **kwargs)\n        return self.pipeline_execute_command(*args, **kwargs)\n\n    async def _disconnect_reset_raise_on_watching(\n        self,\n        conn: Connection,\n        error: Exception,\n        failure_count: Optional[int] = None,\n        start_time: Optional[float] = None,\n        command_name: Optional[str] = None,\n    ) -> None:\n        \"\"\"\n        Close the connection reset watching state and\n        raise an exception if we were watching.\n\n        The supported exceptions are already checked in the\n        retry object so we don't need to do it here.\n\n        After we disconnect the connection, it will try to reconnect and\n        do a health check as part of the send_command logic(on connection level).\n        \"\"\"\n        if (\n            error\n            and failure_count is not None\n            and failure_count <= conn.retry.get_retries()\n        ):\n            await record_operation_duration(\n                command_name=command_name,\n                duration_seconds=time.monotonic() - start_time,\n                server_address=getattr(conn, \"host\", None),\n                server_port=getattr(conn, \"port\", None),\n                db_namespace=str(conn.db),\n                error=error,\n                retry_attempts=failure_count,\n            )\n        await conn.disconnect(error=error, failure_count=failure_count)\n        # if we were already watching a variable, the watch is no longer\n        # valid since this connection has died. raise a WatchError, which\n        # indicates the user should retry this transaction.\n        if self.watching:\n            await self.reset()\n            raise WatchError(\n                f\"A {type(error).__name__} occurred while watching one or more keys\"\n            )\n\n    async def immediate_execute_command(self, *args, **options):\n        \"\"\"\n        Execute a command immediately, but don't auto-retry on the supported\n        errors for retry if we're already WATCHing a variable.\n        Used when issuing WATCH or subsequent commands retrieving their values but before\n        MULTI is called.\n        \"\"\"\n        command_name = args[0]\n        conn = self.connection\n        # if this is the first call, we need a connection\n        if not conn:\n            conn = await self.connection_pool.get_connection()\n            self.connection = conn\n\n        # Start timing for observability\n        start_time = time.monotonic()\n        # Track actual retry attempts for error reporting\n        actual_retry_attempts = 0\n\n        def failure_callback(error, failure_count):\n            nonlocal actual_retry_attempts\n            actual_retry_attempts = failure_count\n            return self._disconnect_reset_raise_on_watching(\n                conn, error, failure_count, start_time, command_name\n            )\n\n        try:\n            response = await conn.retry.call_with_retry(\n                lambda: self._send_command_parse_response(\n                    conn, command_name, *args, **options\n                ),\n                failure_callback,\n                with_failure_count=True,\n            )\n\n            await record_operation_duration(\n                command_name=command_name,\n                duration_seconds=time.monotonic() - start_time,\n                server_address=getattr(conn, \"host\", None),\n                server_port=getattr(conn, \"port\", None),\n                db_namespace=str(conn.db),\n            )\n\n            return response\n        except Exception as e:\n            await record_error_count(\n                server_address=getattr(conn, \"host\", None),\n                server_port=getattr(conn, \"port\", None),\n                network_peer_address=getattr(conn, \"host\", None),\n                network_peer_port=getattr(conn, \"port\", None),\n                error_type=e,\n                retry_attempts=actual_retry_attempts,\n                is_internal=False,\n            )\n            raise\n\n    def pipeline_execute_command(self, *args, **options):\n        \"\"\"\n        Stage a command to be executed when execute() is next called\n\n        Returns the current Pipeline object back so commands can be\n        chained together, such as:\n\n        pipe = pipe.set('foo', 'bar').incr('baz').decr('bang')\n\n        At some other point, you can then run: pipe.execute(),\n        which will execute all commands queued in the pipe.\n        \"\"\"\n        self.command_stack.append((args, options))\n        return self\n\n    async def _execute_transaction(  # noqa: C901\n        self, connection: Connection, commands: CommandStackT, raise_on_error\n    ):\n        pre: CommandT = ((\"MULTI\",), {})\n        post: CommandT = ((\"EXEC\",), {})\n        cmds = (pre, *commands, post)\n        all_cmds = connection.pack_commands(\n            args for args, options in cmds if EMPTY_RESPONSE not in options\n        )\n        await connection.send_packed_command(all_cmds)\n        errors = []\n\n        # parse off the response for MULTI\n        # NOTE: we need to handle ResponseErrors here and continue\n        # so that we read all the additional command messages from\n        # the socket\n        try:\n            await self.parse_response(connection, \"_\")\n        except ResponseError as err:\n            errors.append((0, err))\n\n        # and all the other commands\n        for i, command in enumerate(commands):\n            if EMPTY_RESPONSE in command[1]:\n                errors.append((i, command[1][EMPTY_RESPONSE]))\n            else:\n                try:\n                    await self.parse_response(connection, \"_\")\n                except ResponseError as err:\n                    self.annotate_exception(err, i + 1, command[0])\n                    errors.append((i, err))\n\n        # parse the EXEC.\n        try:\n            response = await self.parse_response(connection, \"_\")\n        except ExecAbortError as err:\n            if errors:\n                raise errors[0][1] from err\n            raise\n\n        # EXEC clears any watched keys\n        self.watching = False\n\n        if response is None:\n            raise WatchError(\"Watched variable changed.\") from None\n\n        # put any parse errors into the response\n        for i, e in errors:\n            response.insert(i, e)\n\n        if len(response) != len(commands):\n            if self.connection:\n                await self.connection.disconnect()\n            raise ResponseError(\n                \"Wrong number of response items from pipeline execution\"\n            ) from None\n\n        # find any errors in the response and raise if necessary\n        if raise_on_error:\n            self.raise_first_error(commands, response)\n\n        # We have to run response callbacks manually\n        data = []\n        for r, cmd in zip(response, commands):\n            if not isinstance(r, Exception):\n                args, options = cmd\n                command_name = args[0]\n\n                # Remove keys entry, it needs only for cache.\n                options.pop(\"keys\", None)\n\n                if command_name in self.response_callbacks:\n                    r = self.response_callbacks[command_name](r, **options)\n                    if inspect.isawaitable(r):\n                        r = await r\n            data.append(r)\n        return data\n\n    async def _execute_pipeline(\n        self, connection: Connection, commands: CommandStackT, raise_on_error: bool\n    ):\n        # build up all commands into a single request to increase network perf\n        all_cmds = connection.pack_commands([args for args, _ in commands])\n        await connection.send_packed_command(all_cmds)\n\n        response = []\n        for args, options in commands:\n            try:\n                response.append(\n                    await self.parse_response(connection, args[0], **options)\n                )\n            except ResponseError as e:\n                response.append(e)\n\n        if raise_on_error:\n            self.raise_first_error(commands, response)\n        return response\n\n    def raise_first_error(self, commands: CommandStackT, response: Iterable[Any]):\n        for i, r in enumerate(response):\n            if isinstance(r, ResponseError):\n                self.annotate_exception(r, i + 1, commands[i][0])\n                raise r\n\n    def annotate_exception(\n        self, exception: Exception, number: int, command: Iterable[object]\n    ) -> None:\n        cmd = \" \".join(map(safe_str, command))\n        msg = (\n            f\"Command # {number} ({truncate_text(cmd)}) \"\n            f\"of pipeline caused error: {exception.args}\"\n        )\n        exception.args = (msg,) + exception.args[1:]\n\n    async def parse_response(\n        self, connection: Connection, command_name: Union[str, bytes], **options\n    ):\n        result = await super().parse_response(connection, command_name, **options)\n        if command_name in self.UNWATCH_COMMANDS:\n            self.watching = False\n        elif command_name == \"WATCH\":\n            self.watching = True\n        return result\n\n    async def load_scripts(self):\n        # make sure all scripts that are about to be run on this pipeline exist\n        scripts = list(self.scripts)\n        immediate = self.immediate_execute_command\n        shas = [s.sha for s in scripts]\n        # we can't use the normal script_* methods because they would just\n        # get buffered in the pipeline.\n        exists = await immediate(\"SCRIPT EXISTS\", *shas)\n        if not all(exists):\n            for s, exist in zip(scripts, exists):\n                if not exist:\n                    s.sha = await immediate(\"SCRIPT LOAD\", s.script)\n\n    async def _disconnect_raise_on_watching(\n        self,\n        conn: Connection,\n        error: Exception,\n        failure_count: Optional[int] = None,\n        start_time: Optional[float] = None,\n        command_name: Optional[str] = None,\n    ):\n        \"\"\"\n        Close the connection, raise an exception if we were watching.\n\n        The supported exceptions are already checked in the\n        retry object so we don't need to do it here.\n\n        After we disconnect the connection, it will try to reconnect and\n        do a health check as part of the send_command logic(on connection level).\n        \"\"\"\n        if (\n            error\n            and failure_count is not None\n            and failure_count <= conn.retry.get_retries()\n        ):\n            await record_operation_duration(\n                command_name=command_name,\n                duration_seconds=time.monotonic() - start_time,\n                server_address=getattr(conn, \"host\", None),\n                server_port=getattr(conn, \"port\", None),\n                db_namespace=str(conn.db),\n                error=error,\n                retry_attempts=failure_count,\n            )\n        await conn.disconnect(error=error, failure_count=failure_count)\n        # if we were watching a variable, the watch is no longer valid\n        # since this connection has died. raise a WatchError, which\n        # indicates the user should retry this transaction.\n        if self.watching:\n            raise WatchError(\n                f\"A {type(error).__name__} occurred while watching one or more keys\"\n            )\n\n    async def execute(self, raise_on_error: bool = True) -> List[Any]:\n        \"\"\"Execute all the commands in the current pipeline\"\"\"\n        stack = self.command_stack\n        if not stack and not self.watching:\n            return []\n        if self.scripts:\n            await self.load_scripts()\n        if self.is_transaction or self.explicit_transaction:\n            execute = self._execute_transaction\n            operation_name = \"MULTI\"\n        else:\n            execute = self._execute_pipeline\n            operation_name = \"PIPELINE\"\n\n        conn = self.connection\n        if not conn:\n            conn = await self.connection_pool.get_connection()\n            # assign to self.connection so reset() releases the connection\n            # back to the pool after we're done\n            self.connection = conn\n        conn = cast(Connection, conn)\n\n        # Start timing for observability\n        start_time = time.monotonic()\n        # Track actual retry attempts for error reporting\n        actual_retry_attempts = 0\n\n        def failure_callback(error, failure_count):\n            nonlocal actual_retry_attempts\n            actual_retry_attempts = failure_count\n            return self._disconnect_raise_on_watching(\n                conn, error, failure_count, start_time, operation_name\n            )\n\n        try:\n            response = await conn.retry.call_with_retry(\n                lambda: execute(conn, stack, raise_on_error),\n                failure_callback,\n                with_failure_count=True,\n            )\n\n            await record_operation_duration(\n                command_name=operation_name,\n                duration_seconds=time.monotonic() - start_time,\n                server_address=getattr(conn, \"host\", None),\n                server_port=getattr(conn, \"port\", None),\n                db_namespace=str(conn.db),\n            )\n            return response\n        except Exception as e:\n            await record_error_count(\n                server_address=getattr(conn, \"host\", None),\n                server_port=getattr(conn, \"port\", None),\n                network_peer_address=getattr(conn, \"host\", None),\n                network_peer_port=getattr(conn, \"port\", None),\n                error_type=e,\n                retry_attempts=actual_retry_attempts,\n                is_internal=False,\n            )\n            raise\n        finally:\n            await self.reset()\n\n    async def discard(self):\n        \"\"\"Flushes all previously queued commands\n        See: https://redis.io/commands/DISCARD\n        \"\"\"\n        await self.execute_command(\"DISCARD\")\n\n    async def watch(self, *names: KeyT):\n        \"\"\"Watches the values at keys ``names``\"\"\"\n        if self.explicit_transaction:\n            raise RedisError(\"Cannot issue a WATCH after a MULTI\")\n        return await self.execute_command(\"WATCH\", *names)\n\n    async def unwatch(self):\n        \"\"\"Unwatches all previously specified keys\"\"\"\n        return self.watching and await self.execute_command(\"UNWATCH\") or True\n"
  },
  {
    "path": "redis/asyncio/cluster.py",
    "content": "import asyncio\nimport collections\nimport random\nimport socket\nimport threading\nimport time\nimport warnings\nfrom abc import ABC, abstractmethod\nfrom copy import copy\nfrom itertools import chain\nfrom typing import (\n    Any,\n    Callable,\n    Coroutine,\n    Deque,\n    Dict,\n    Generator,\n    List,\n    Literal,\n    Mapping,\n    Optional,\n    Set,\n    Tuple,\n    Type,\n    TypeVar,\n    Union,\n)\n\nfrom redis._parsers import AsyncCommandsParser, Encoder\nfrom redis._parsers.commands import CommandPolicies, RequestPolicy, ResponsePolicy\nfrom redis._parsers.helpers import (\n    _RedisCallbacks,\n    _RedisCallbacksRESP2,\n    _RedisCallbacksRESP3,\n)\nfrom redis.asyncio.client import ResponseCallbackT\nfrom redis.asyncio.connection import Connection, SSLConnection, parse_url\nfrom redis.asyncio.lock import Lock\nfrom redis.asyncio.observability.recorder import (\n    record_error_count,\n    record_operation_duration,\n)\nfrom redis.asyncio.retry import Retry\nfrom redis.auth.token import TokenInterface\nfrom redis.backoff import ExponentialWithJitterBackoff, NoBackoff\nfrom redis.client import EMPTY_RESPONSE, NEVER_DECODE, AbstractRedis\nfrom redis.cluster import (\n    PIPELINE_BLOCKED_COMMANDS,\n    PRIMARY,\n    REPLICA,\n    SLOT_ID,\n    AbstractRedisCluster,\n    LoadBalancer,\n    LoadBalancingStrategy,\n    block_pipeline_command,\n    get_node_name,\n    parse_cluster_slots,\n)\nfrom redis.commands import READ_COMMANDS, AsyncRedisClusterCommands\nfrom redis.commands.policies import AsyncPolicyResolver, AsyncStaticPolicyResolver\nfrom redis.crc import REDIS_CLUSTER_HASH_SLOTS, key_slot\nfrom redis.credentials import CredentialProvider\nfrom redis.event import AfterAsyncClusterInstantiationEvent, EventDispatcher\nfrom redis.exceptions import (\n    AskError,\n    BusyLoadingError,\n    ClusterDownError,\n    ClusterError,\n    ConnectionError,\n    CrossSlotTransactionError,\n    DataError,\n    ExecAbortError,\n    InvalidPipelineStack,\n    MaxConnectionsError,\n    MovedError,\n    RedisClusterException,\n    RedisError,\n    ResponseError,\n    SlotNotCoveredError,\n    TimeoutError,\n    TryAgainError,\n    WatchError,\n)\nfrom redis.typing import AnyKeyT, EncodableT, KeyT\nfrom redis.utils import (\n    SSL_AVAILABLE,\n    deprecated_args,\n    deprecated_function,\n    get_lib_version,\n    safe_str,\n    str_if_bytes,\n    truncate_text,\n)\n\nif SSL_AVAILABLE:\n    from ssl import TLSVersion, VerifyFlags, VerifyMode\nelse:\n    TLSVersion = None\n    VerifyMode = None\n    VerifyFlags = None\n\nTargetNodesT = TypeVar(\n    \"TargetNodesT\", str, \"ClusterNode\", List[\"ClusterNode\"], Dict[Any, \"ClusterNode\"]\n)\n\n\nclass RedisCluster(AbstractRedis, AbstractRedisCluster, AsyncRedisClusterCommands):\n    \"\"\"\n    Create a new RedisCluster client.\n\n    Pass one of parameters:\n\n      - `host` & `port`\n      - `startup_nodes`\n\n    | Use ``await`` :meth:`initialize` to find cluster nodes & create connections.\n    | Use ``await`` :meth:`close` to disconnect connections & close client.\n\n    Many commands support the target_nodes kwarg. It can be one of the\n    :attr:`NODE_FLAGS`:\n\n      - :attr:`PRIMARIES`\n      - :attr:`REPLICAS`\n      - :attr:`ALL_NODES`\n      - :attr:`RANDOM`\n      - :attr:`DEFAULT_NODE`\n\n    Note: This client is not thread/process/fork safe.\n\n    :param host:\n        | Can be used to point to a startup node\n    :param port:\n        | Port used if **host** is provided\n    :param startup_nodes:\n        | :class:`~.ClusterNode` to used as a startup node\n    :param require_full_coverage:\n        | When set to ``False``: the client will not require a full coverage of\n          the slots. However, if not all slots are covered, and at least one node\n          has ``cluster-require-full-coverage`` set to ``yes``, the server will throw\n          a :class:`~.ClusterDownError` for some key-based commands.\n        | When set to ``True``: all slots must be covered to construct the cluster\n          client. If not all slots are covered, :class:`~.RedisClusterException` will be\n          thrown.\n        | See:\n          https://redis.io/docs/manual/scaling/#redis-cluster-configuration-parameters\n    :param read_from_replicas:\n        | @deprecated - please use load_balancing_strategy instead\n        | Enable read from replicas in READONLY mode.\n          When set to true, read commands will be assigned between the primary and\n          its replications in a Round-Robin manner.\n          The data read from replicas is eventually consistent with the data in primary nodes.\n    :param load_balancing_strategy:\n        | Enable read from replicas in READONLY mode and defines the load balancing\n          strategy that will be used for cluster node selection.\n          The data read from replicas is eventually consistent with the data in primary nodes.\n    :param dynamic_startup_nodes:\n        | Set the RedisCluster's startup nodes to all the discovered nodes.\n          If true (default value), the cluster's discovered nodes will be used to\n          determine the cluster nodes-slots mapping in the next topology refresh.\n          It will remove the initial passed startup nodes if their endpoints aren't\n          listed in the CLUSTER SLOTS output.\n          If you use dynamic DNS endpoints for startup nodes but CLUSTER SLOTS lists\n          specific IP addresses, it is best to set it to false.\n    :param reinitialize_steps:\n        | Specifies the number of MOVED errors that need to occur before reinitializing\n          the whole cluster topology. If a MOVED error occurs and the cluster does not\n          need to be reinitialized on this current error handling, only the MOVED slot\n          will be patched with the redirected node.\n          To reinitialize the cluster on every MOVED error, set reinitialize_steps to 1.\n          To avoid reinitializing the cluster on moved errors, set reinitialize_steps to\n          0.\n    :param cluster_error_retry_attempts:\n        | @deprecated - Please configure the 'retry' object instead\n          In case 'retry' object is set - this argument is ignored!\n\n          Number of times to retry before raising an error when :class:`~.TimeoutError`,\n          :class:`~.ConnectionError`, :class:`~.SlotNotCoveredError`\n          or :class:`~.ClusterDownError` are encountered\n    :param retry:\n        | A retry object that defines the retry strategy and the number of\n          retries for the cluster client.\n          In current implementation for the cluster client (starting form redis-py version 6.0.0)\n          the retry object is not yet fully utilized, instead it is used just to determine\n          the number of retries for the cluster client.\n          In the future releases the retry object will be used to handle the cluster client retries!\n    :param max_connections:\n        | Maximum number of connections per node. If there are no free connections & the\n          maximum number of connections are already created, a\n          :class:`~.MaxConnectionsError` is raised.\n    :param address_remap:\n        | An optional callable which, when provided with an internal network\n          address of a node, e.g. a `(host, port)` tuple, will return the address\n          where the node is reachable.  This can be used to map the addresses at\n          which the nodes _think_ they are, to addresses at which a client may\n          reach them, such as when they sit behind a proxy.\n\n    | Rest of the arguments will be passed to the\n      :class:`~redis.asyncio.connection.Connection` instances when created\n\n    :raises RedisClusterException:\n        if any arguments are invalid or unknown. Eg:\n\n        - `db` != 0 or None\n        - `path` argument for unix socket connection\n        - none of the `host`/`port` & `startup_nodes` were provided\n\n    \"\"\"\n\n    @classmethod\n    def from_url(cls, url: str, **kwargs: Any) -> \"RedisCluster\":\n        \"\"\"\n        Return a Redis client object configured from the given URL.\n\n        For example::\n\n            redis://[[username]:[password]]@localhost:6379/0\n            rediss://[[username]:[password]]@localhost:6379/0\n\n        Three URL schemes are supported:\n\n        - `redis://` creates a TCP socket connection. See more at:\n          <https://www.iana.org/assignments/uri-schemes/prov/redis>\n        - `rediss://` creates a SSL wrapped TCP socket connection. See more at:\n          <https://www.iana.org/assignments/uri-schemes/prov/rediss>\n\n        The username, password, hostname, path and all querystring values are passed\n        through ``urllib.parse.unquote`` in order to replace any percent-encoded values\n        with their corresponding characters.\n\n        All querystring options are cast to their appropriate Python types. Boolean\n        arguments can be specified with string values \"True\"/\"False\" or \"Yes\"/\"No\".\n        Values that cannot be properly cast cause a ``ValueError`` to be raised. Once\n        parsed, the querystring arguments and keyword arguments are passed to\n        :class:`~redis.asyncio.connection.Connection` when created.\n        In the case of conflicting arguments, querystring arguments are used.\n        \"\"\"\n        kwargs.update(parse_url(url))\n        if kwargs.pop(\"connection_class\", None) is SSLConnection:\n            kwargs[\"ssl\"] = True\n        return cls(**kwargs)\n\n    # Type discrimination marker for @overload self-type pattern\n    _is_async_client: Literal[True] = True\n\n    __slots__ = (\n        \"_initialize\",\n        \"_lock\",\n        \"retry\",\n        \"command_flags\",\n        \"commands_parser\",\n        \"connection_kwargs\",\n        \"encoder\",\n        \"node_flags\",\n        \"nodes_manager\",\n        \"read_from_replicas\",\n        \"reinitialize_counter\",\n        \"reinitialize_steps\",\n        \"response_callbacks\",\n        \"result_callbacks\",\n    )\n\n    @deprecated_args(\n        args_to_warn=[\"read_from_replicas\"],\n        reason=\"Please configure the 'load_balancing_strategy' instead\",\n        version=\"5.3.0\",\n    )\n    @deprecated_args(\n        args_to_warn=[\n            \"cluster_error_retry_attempts\",\n        ],\n        reason=\"Please configure the 'retry' object instead\",\n        version=\"6.0.0\",\n    )\n    def __init__(\n        self,\n        host: Optional[str] = None,\n        port: Union[str, int] = 6379,\n        # Cluster related kwargs\n        startup_nodes: Optional[List[\"ClusterNode\"]] = None,\n        require_full_coverage: bool = True,\n        read_from_replicas: bool = False,\n        load_balancing_strategy: Optional[LoadBalancingStrategy] = None,\n        dynamic_startup_nodes: bool = True,\n        reinitialize_steps: int = 5,\n        cluster_error_retry_attempts: int = 3,\n        max_connections: int = 2**31,\n        retry: Optional[\"Retry\"] = None,\n        retry_on_error: Optional[List[Type[Exception]]] = None,\n        # Client related kwargs\n        db: Union[str, int] = 0,\n        path: Optional[str] = None,\n        credential_provider: Optional[CredentialProvider] = None,\n        username: Optional[str] = None,\n        password: Optional[str] = None,\n        client_name: Optional[str] = None,\n        lib_name: Optional[str] = \"redis-py\",\n        lib_version: Optional[str] = get_lib_version(),\n        # Encoding related kwargs\n        encoding: str = \"utf-8\",\n        encoding_errors: str = \"strict\",\n        decode_responses: bool = False,\n        # Connection related kwargs\n        health_check_interval: float = 0,\n        socket_connect_timeout: Optional[float] = None,\n        socket_keepalive: bool = False,\n        socket_keepalive_options: Optional[Mapping[int, Union[int, bytes]]] = None,\n        socket_timeout: Optional[float] = None,\n        # SSL related kwargs\n        ssl: bool = False,\n        ssl_ca_certs: Optional[str] = None,\n        ssl_ca_data: Optional[str] = None,\n        ssl_cert_reqs: Union[str, VerifyMode] = \"required\",\n        ssl_include_verify_flags: Optional[List[VerifyFlags]] = None,\n        ssl_exclude_verify_flags: Optional[List[VerifyFlags]] = None,\n        ssl_certfile: Optional[str] = None,\n        ssl_check_hostname: bool = True,\n        ssl_keyfile: Optional[str] = None,\n        ssl_min_version: Optional[TLSVersion] = None,\n        ssl_ciphers: Optional[str] = None,\n        protocol: Optional[int] = 2,\n        address_remap: Optional[Callable[[Tuple[str, int]], Tuple[str, int]]] = None,\n        event_dispatcher: Optional[EventDispatcher] = None,\n        policy_resolver: AsyncPolicyResolver = AsyncStaticPolicyResolver(),\n    ) -> None:\n        if db:\n            raise RedisClusterException(\n                \"Argument 'db' must be 0 or None in cluster mode\"\n            )\n\n        if path:\n            raise RedisClusterException(\n                \"Unix domain socket is not supported in cluster mode\"\n            )\n\n        if (not host or not port) and not startup_nodes:\n            raise RedisClusterException(\n                \"RedisCluster requires at least one node to discover the cluster.\\n\"\n                \"Please provide one of the following or use RedisCluster.from_url:\\n\"\n                '   - host and port: RedisCluster(host=\"localhost\", port=6379)\\n'\n                \"   - startup_nodes: RedisCluster(startup_nodes=[\"\n                'ClusterNode(\"localhost\", 6379), ClusterNode(\"localhost\", 6380)])'\n            )\n\n        kwargs: Dict[str, Any] = {\n            \"max_connections\": max_connections,\n            \"connection_class\": Connection,\n            # Client related kwargs\n            \"credential_provider\": credential_provider,\n            \"username\": username,\n            \"password\": password,\n            \"client_name\": client_name,\n            \"lib_name\": lib_name,\n            \"lib_version\": lib_version,\n            # Encoding related kwargs\n            \"encoding\": encoding,\n            \"encoding_errors\": encoding_errors,\n            \"decode_responses\": decode_responses,\n            # Connection related kwargs\n            \"health_check_interval\": health_check_interval,\n            \"socket_connect_timeout\": socket_connect_timeout,\n            \"socket_keepalive\": socket_keepalive,\n            \"socket_keepalive_options\": socket_keepalive_options,\n            \"socket_timeout\": socket_timeout,\n            \"protocol\": protocol,\n        }\n\n        if ssl:\n            # SSL related kwargs\n            kwargs.update(\n                {\n                    \"connection_class\": SSLConnection,\n                    \"ssl_ca_certs\": ssl_ca_certs,\n                    \"ssl_ca_data\": ssl_ca_data,\n                    \"ssl_cert_reqs\": ssl_cert_reqs,\n                    \"ssl_include_verify_flags\": ssl_include_verify_flags,\n                    \"ssl_exclude_verify_flags\": ssl_exclude_verify_flags,\n                    \"ssl_certfile\": ssl_certfile,\n                    \"ssl_check_hostname\": ssl_check_hostname,\n                    \"ssl_keyfile\": ssl_keyfile,\n                    \"ssl_min_version\": ssl_min_version,\n                    \"ssl_ciphers\": ssl_ciphers,\n                }\n            )\n\n        if read_from_replicas or load_balancing_strategy:\n            # Call our on_connect function to configure READONLY mode\n            kwargs[\"redis_connect_func\"] = self.on_connect\n\n        if retry:\n            self.retry = retry\n        else:\n            self.retry = Retry(\n                backoff=ExponentialWithJitterBackoff(base=1, cap=10),\n                retries=cluster_error_retry_attempts,\n            )\n        if retry_on_error:\n            self.retry.update_supported_errors(retry_on_error)\n\n        kwargs[\"response_callbacks\"] = _RedisCallbacks.copy()\n        if kwargs.get(\"protocol\") in [\"3\", 3]:\n            kwargs[\"response_callbacks\"].update(_RedisCallbacksRESP3)\n        else:\n            kwargs[\"response_callbacks\"].update(_RedisCallbacksRESP2)\n        self.connection_kwargs = kwargs\n\n        if startup_nodes:\n            passed_nodes = []\n            for node in startup_nodes:\n                passed_nodes.append(\n                    ClusterNode(node.host, node.port, **self.connection_kwargs)\n                )\n            startup_nodes = passed_nodes\n        else:\n            startup_nodes = []\n        if host and port:\n            startup_nodes.append(ClusterNode(host, port, **self.connection_kwargs))\n\n        if event_dispatcher is None:\n            self._event_dispatcher = EventDispatcher()\n        else:\n            self._event_dispatcher = event_dispatcher\n\n        self.startup_nodes = startup_nodes\n        self.nodes_manager = NodesManager(\n            startup_nodes,\n            require_full_coverage,\n            kwargs,\n            dynamic_startup_nodes=dynamic_startup_nodes,\n            address_remap=address_remap,\n            event_dispatcher=self._event_dispatcher,\n        )\n        self.encoder = Encoder(encoding, encoding_errors, decode_responses)\n        self.read_from_replicas = read_from_replicas\n        self.load_balancing_strategy = load_balancing_strategy\n        self.reinitialize_steps = reinitialize_steps\n        self.reinitialize_counter = 0\n\n        # For backward compatibility, mapping from existing policies to new one\n        self._command_flags_mapping: dict[str, Union[RequestPolicy, ResponsePolicy]] = {\n            self.__class__.RANDOM: RequestPolicy.DEFAULT_KEYLESS,\n            self.__class__.PRIMARIES: RequestPolicy.ALL_SHARDS,\n            self.__class__.ALL_NODES: RequestPolicy.ALL_NODES,\n            self.__class__.REPLICAS: RequestPolicy.ALL_REPLICAS,\n            self.__class__.DEFAULT_NODE: RequestPolicy.DEFAULT_NODE,\n            SLOT_ID: RequestPolicy.DEFAULT_KEYED,\n        }\n\n        self._policies_callback_mapping: dict[\n            Union[RequestPolicy, ResponsePolicy], Callable\n        ] = {\n            RequestPolicy.DEFAULT_KEYLESS: lambda command_name: [\n                self.get_random_primary_or_all_nodes(command_name)\n            ],\n            RequestPolicy.DEFAULT_KEYED: self.get_nodes_from_slot,\n            RequestPolicy.DEFAULT_NODE: lambda: [self.get_default_node()],\n            RequestPolicy.ALL_SHARDS: self.get_primaries,\n            RequestPolicy.ALL_NODES: self.get_nodes,\n            RequestPolicy.ALL_REPLICAS: self.get_replicas,\n            RequestPolicy.SPECIAL: self.get_special_nodes,\n            ResponsePolicy.DEFAULT_KEYLESS: lambda res: res,\n            ResponsePolicy.DEFAULT_KEYED: lambda res: res,\n        }\n\n        self._policy_resolver = policy_resolver\n        self.commands_parser = AsyncCommandsParser()\n        self._aggregate_nodes = None\n        self.node_flags = self.__class__.NODE_FLAGS.copy()\n        self.command_flags = self.__class__.COMMAND_FLAGS.copy()\n        self.response_callbacks = kwargs[\"response_callbacks\"]\n        self.result_callbacks = self.__class__.RESULT_CALLBACKS.copy()\n        self.result_callbacks[\"CLUSTER SLOTS\"] = (\n            lambda cmd, res, **kwargs: parse_cluster_slots(\n                list(res.values())[0], **kwargs\n            )\n        )\n\n        self._initialize = True\n        self._lock: Optional[asyncio.Lock] = None\n\n        # When used as an async context manager, we need to increment and decrement\n        # a usage counter so that we can close the connection pool when no one is\n        # using the client.\n        self._usage_counter = 0\n        self._usage_lock = asyncio.Lock()\n\n    async def initialize(self) -> \"RedisCluster\":\n        \"\"\"Get all nodes from startup nodes & creates connections if not initialized.\"\"\"\n        if self._initialize:\n            if not self._lock:\n                self._lock = asyncio.Lock()\n            async with self._lock:\n                if self._initialize:\n                    try:\n                        await self.nodes_manager.initialize()\n                        await self.commands_parser.initialize(\n                            self.nodes_manager.default_node\n                        )\n                        self._initialize = False\n                    except BaseException:\n                        await self.nodes_manager.aclose()\n                        await self.nodes_manager.aclose(\"startup_nodes\")\n                        raise\n        return self\n\n    async def aclose(self) -> None:\n        \"\"\"Close all connections & client if initialized.\"\"\"\n        if not self._initialize:\n            if not self._lock:\n                self._lock = asyncio.Lock()\n            async with self._lock:\n                if not self._initialize:\n                    self._initialize = True\n                    await self.nodes_manager.aclose()\n                    await self.nodes_manager.aclose(\"startup_nodes\")\n\n    @deprecated_function(version=\"5.0.0\", reason=\"Use aclose() instead\", name=\"close\")\n    async def close(self) -> None:\n        \"\"\"alias for aclose() for backwards compatibility\"\"\"\n        await self.aclose()\n\n    async def __aenter__(self) -> \"RedisCluster\":\n        \"\"\"\n        Async context manager entry. Increments a usage counter so that the\n        connection pool is only closed (via aclose()) when no context is using\n        the client.\n        \"\"\"\n        await self._increment_usage()\n        try:\n            # Initialize the client (i.e. establish connection, etc.)\n            return await self.initialize()\n        except Exception:\n            # If initialization fails, decrement the counter to keep it in sync\n            await self._decrement_usage()\n            raise\n\n    async def _increment_usage(self) -> int:\n        \"\"\"\n        Helper coroutine to increment the usage counter while holding the lock.\n        Returns the new value of the usage counter.\n        \"\"\"\n        async with self._usage_lock:\n            self._usage_counter += 1\n            return self._usage_counter\n\n    async def _decrement_usage(self) -> int:\n        \"\"\"\n        Helper coroutine to decrement the usage counter while holding the lock.\n        Returns the new value of the usage counter.\n        \"\"\"\n        async with self._usage_lock:\n            self._usage_counter -= 1\n            return self._usage_counter\n\n    async def __aexit__(self, exc_type, exc_value, traceback):\n        \"\"\"\n        Async context manager exit. Decrements a usage counter. If this is the\n        last exit (counter becomes zero), the client closes its connection pool.\n        \"\"\"\n        current_usage = await asyncio.shield(self._decrement_usage())\n        if current_usage == 0:\n            # This was the last active context, so disconnect the pool.\n            await asyncio.shield(self.aclose())\n\n    def __await__(self) -> Generator[Any, None, \"RedisCluster\"]:\n        return self.initialize().__await__()\n\n    _DEL_MESSAGE = \"Unclosed RedisCluster client\"\n\n    def __del__(\n        self,\n        _warn: Any = warnings.warn,\n        _grl: Any = asyncio.get_running_loop,\n    ) -> None:\n        if hasattr(self, \"_initialize\") and not self._initialize:\n            _warn(f\"{self._DEL_MESSAGE} {self!r}\", ResourceWarning, source=self)\n            try:\n                context = {\"client\": self, \"message\": self._DEL_MESSAGE}\n                _grl().call_exception_handler(context)\n            except RuntimeError:\n                pass\n\n    async def on_connect(self, connection: Connection) -> None:\n        await connection.on_connect()\n\n        # Sending READONLY command to server to configure connection as\n        # readonly. Since each cluster node may change its server type due\n        # to a failover, we should establish a READONLY connection\n        # regardless of the server type. If this is a primary connection,\n        # READONLY would not affect executing write commands.\n        await connection.send_command(\"READONLY\")\n        if str_if_bytes(await connection.read_response()) != \"OK\":\n            raise ConnectionError(\"READONLY command failed\")\n\n    def get_nodes(self) -> List[\"ClusterNode\"]:\n        \"\"\"Get all nodes of the cluster.\"\"\"\n        return list(self.nodes_manager.nodes_cache.values())\n\n    def get_primaries(self) -> List[\"ClusterNode\"]:\n        \"\"\"Get the primary nodes of the cluster.\"\"\"\n        return self.nodes_manager.get_nodes_by_server_type(PRIMARY)\n\n    def get_replicas(self) -> List[\"ClusterNode\"]:\n        \"\"\"Get the replica nodes of the cluster.\"\"\"\n        return self.nodes_manager.get_nodes_by_server_type(REPLICA)\n\n    def get_random_node(self) -> \"ClusterNode\":\n        \"\"\"Get a random node of the cluster.\"\"\"\n        return random.choice(list(self.nodes_manager.nodes_cache.values()))\n\n    def get_default_node(self) -> \"ClusterNode\":\n        \"\"\"Get the default node of the client.\"\"\"\n        return self.nodes_manager.default_node\n\n    def set_default_node(self, node: \"ClusterNode\") -> None:\n        \"\"\"\n        Set the default node of the client.\n\n        :raises DataError: if None is passed or node does not exist in cluster.\n        \"\"\"\n        if not node or not self.get_node(node_name=node.name):\n            raise DataError(\"The requested node does not exist in the cluster.\")\n\n        self.nodes_manager.default_node = node\n\n    def get_node(\n        self,\n        host: Optional[str] = None,\n        port: Optional[int] = None,\n        node_name: Optional[str] = None,\n    ) -> Optional[\"ClusterNode\"]:\n        \"\"\"Get node by (host, port) or node_name.\"\"\"\n        return self.nodes_manager.get_node(host, port, node_name)\n\n    def get_node_from_key(\n        self, key: str, replica: bool = False\n    ) -> Optional[\"ClusterNode\"]:\n        \"\"\"\n        Get the cluster node corresponding to the provided key.\n\n        :param key:\n        :param replica:\n            | Indicates if a replica should be returned\n            |\n              None will returned if no replica holds this key\n\n        :raises SlotNotCoveredError: if the key is not covered by any slot.\n        \"\"\"\n        slot = self.keyslot(key)\n        slot_cache = self.nodes_manager.slots_cache.get(slot)\n        if not slot_cache:\n            raise SlotNotCoveredError(f'Slot \"{slot}\" is not covered by the cluster.')\n\n        if replica:\n            if len(self.nodes_manager.slots_cache[slot]) < 2:\n                return None\n            node_idx = 1\n        else:\n            node_idx = 0\n\n        return slot_cache[node_idx]\n\n    def get_random_primary_or_all_nodes(self, command_name):\n        \"\"\"\n        Returns random primary or all nodes depends on READONLY mode.\n        \"\"\"\n        if self.read_from_replicas and command_name in READ_COMMANDS:\n            return self.get_random_node()\n\n        return self.get_random_primary_node()\n\n    def get_random_primary_node(self) -> \"ClusterNode\":\n        \"\"\"\n        Returns a random primary node\n        \"\"\"\n        return random.choice(self.get_primaries())\n\n    async def get_nodes_from_slot(self, command: str, *args):\n        \"\"\"\n        Returns a list of nodes that hold the specified keys' slots.\n        \"\"\"\n        # get the node that holds the key's slot\n        return [\n            self.nodes_manager.get_node_from_slot(\n                await self._determine_slot(command, *args),\n                self.read_from_replicas and command in READ_COMMANDS,\n                self.load_balancing_strategy if command in READ_COMMANDS else None,\n            )\n        ]\n\n    def get_special_nodes(self) -> Optional[list[\"ClusterNode\"]]:\n        \"\"\"\n        Returns a list of nodes for commands with a special policy.\n        \"\"\"\n        if not self._aggregate_nodes:\n            raise RedisClusterException(\n                \"Cannot execute FT.CURSOR commands without FT.AGGREGATE\"\n            )\n\n        return self._aggregate_nodes\n\n    def keyslot(self, key: EncodableT) -> int:\n        \"\"\"\n        Find the keyslot for a given key.\n\n        See: https://redis.io/docs/manual/scaling/#redis-cluster-data-sharding\n        \"\"\"\n        return key_slot(self.encoder.encode(key))\n\n    def get_encoder(self) -> Encoder:\n        \"\"\"Get the encoder object of the client.\"\"\"\n        return self.encoder\n\n    def get_connection_kwargs(self) -> Dict[str, Optional[Any]]:\n        \"\"\"Get the kwargs passed to :class:`~redis.asyncio.connection.Connection`.\"\"\"\n        return self.connection_kwargs\n\n    def set_retry(self, retry: Retry) -> None:\n        self.retry = retry\n\n    def set_response_callback(self, command: str, callback: ResponseCallbackT) -> None:\n        \"\"\"Set a custom response callback.\"\"\"\n        self.response_callbacks[command] = callback\n\n    async def _determine_nodes(\n        self,\n        command: str,\n        *args: Any,\n        request_policy: RequestPolicy,\n        node_flag: Optional[str] = None,\n    ) -> List[\"ClusterNode\"]:\n        # Determine which nodes should be executed the command on.\n        # Returns a list of target nodes.\n        if not node_flag:\n            # get the nodes group for this command if it was predefined\n            node_flag = self.command_flags.get(command)\n\n        if node_flag in self._command_flags_mapping:\n            request_policy = self._command_flags_mapping[node_flag]\n\n        policy_callback = self._policies_callback_mapping[request_policy]\n\n        if request_policy == RequestPolicy.DEFAULT_KEYED:\n            nodes = await policy_callback(command, *args)\n        elif request_policy == RequestPolicy.DEFAULT_KEYLESS:\n            nodes = policy_callback(command)\n        else:\n            nodes = policy_callback()\n\n        if command.lower() == \"ft.aggregate\":\n            self._aggregate_nodes = nodes\n\n        return nodes\n\n    async def _determine_slot(self, command: str, *args: Any) -> int:\n        if self.command_flags.get(command) == SLOT_ID:\n            # The command contains the slot ID\n            return int(args[0])\n\n        # Get the keys in the command\n\n        # EVAL and EVALSHA are common enough that it's wasteful to go to the\n        # redis server to parse the keys. Besides, there is a bug in redis<7.0\n        # where `self._get_command_keys()` fails anyway. So, we special case\n        # EVAL/EVALSHA.\n        # - issue: https://github.com/redis/redis/issues/9493\n        # - fix: https://github.com/redis/redis/pull/9733\n        if command.upper() in (\"EVAL\", \"EVALSHA\"):\n            # command syntax: EVAL \"script body\" num_keys ...\n            if len(args) < 2:\n                raise RedisClusterException(\n                    f\"Invalid args in command: {command, *args}\"\n                )\n            keys = args[2 : 2 + int(args[1])]\n            # if there are 0 keys, that means the script can be run on any node\n            # so we can just return a random slot\n            if not keys:\n                return random.randrange(0, REDIS_CLUSTER_HASH_SLOTS)\n        else:\n            keys = await self.commands_parser.get_keys(command, *args)\n            if not keys:\n                # FCALL can call a function with 0 keys, that means the function\n                #  can be run on any node so we can just return a random slot\n                if command.upper() in (\"FCALL\", \"FCALL_RO\"):\n                    return random.randrange(0, REDIS_CLUSTER_HASH_SLOTS)\n                raise RedisClusterException(\n                    \"No way to dispatch this command to Redis Cluster. \"\n                    \"Missing key.\\nYou can execute the command by specifying \"\n                    f\"target nodes.\\nCommand: {args}\"\n                )\n\n        # single key command\n        if len(keys) == 1:\n            return self.keyslot(keys[0])\n\n        # multi-key command; we need to make sure all keys are mapped to\n        # the same slot\n        slots = {self.keyslot(key) for key in keys}\n        if len(slots) != 1:\n            raise RedisClusterException(\n                f\"{command} - all keys must map to the same key slot\"\n            )\n\n        return slots.pop()\n\n    def _is_node_flag(self, target_nodes: Any) -> bool:\n        return isinstance(target_nodes, str) and target_nodes in self.node_flags\n\n    def _parse_target_nodes(self, target_nodes: Any) -> List[\"ClusterNode\"]:\n        if isinstance(target_nodes, list):\n            nodes = target_nodes\n        elif isinstance(target_nodes, ClusterNode):\n            # Supports passing a single ClusterNode as a variable\n            nodes = [target_nodes]\n        elif isinstance(target_nodes, dict):\n            # Supports dictionaries of the format {node_name: node}.\n            # It enables to execute commands with multi nodes as follows:\n            # rc.cluster_save_config(rc.get_primaries())\n            nodes = list(target_nodes.values())\n        else:\n            raise TypeError(\n                \"target_nodes type can be one of the following: \"\n                \"node_flag (PRIMARIES, REPLICAS, RANDOM, ALL_NODES),\"\n                \"ClusterNode, list<ClusterNode>, or dict<any, ClusterNode>. \"\n                f\"The passed type is {type(target_nodes)}\"\n            )\n        return nodes\n\n    async def _record_error_metric(\n        self,\n        error: Exception,\n        connection: Union[Connection, \"ClusterNode\"],\n        is_internal: bool = True,\n        retry_attempts: Optional[int] = None,\n    ):\n        \"\"\"\n        Records error count metric directly.\n        Accepts either a Connection or ClusterNode object.\n        \"\"\"\n        await record_error_count(\n            server_address=connection.host,\n            server_port=connection.port,\n            network_peer_address=connection.host,\n            network_peer_port=connection.port,\n            error_type=error,\n            retry_attempts=retry_attempts if retry_attempts is not None else 0,\n            is_internal=is_internal,\n        )\n\n    async def _record_command_metric(\n        self,\n        command_name: str,\n        duration_seconds: float,\n        connection: Union[Connection, \"ClusterNode\"],\n        error: Optional[Exception] = None,\n    ):\n        \"\"\"\n        Records operation duration metric directly.\n        Accepts either a Connection or ClusterNode object.\n        \"\"\"\n        # Connection has db attribute, ClusterNode has connection_kwargs\n        if hasattr(connection, \"db\"):\n            db = connection.db\n        else:\n            db = connection.connection_kwargs.get(\"db\", 0)\n        await record_operation_duration(\n            command_name=command_name,\n            duration_seconds=duration_seconds,\n            server_address=connection.host,\n            server_port=connection.port,\n            db_namespace=str(db) if db is not None else None,\n            error=error,\n        )\n\n    async def execute_command(self, *args: EncodableT, **kwargs: Any) -> Any:\n        \"\"\"\n        Execute a raw command on the appropriate cluster node or target_nodes.\n\n        It will retry the command as specified by the retries property of\n        the :attr:`retry` & then raise an exception.\n\n        :param args:\n            | Raw command args\n        :param kwargs:\n\n            - target_nodes: :attr:`NODE_FLAGS` or :class:`~.ClusterNode`\n              or List[:class:`~.ClusterNode`] or Dict[Any, :class:`~.ClusterNode`]\n            - Rest of the kwargs are passed to the Redis connection\n\n        :raises RedisClusterException: if target_nodes is not provided & the command\n            can't be mapped to a slot\n        \"\"\"\n        command = args[0]\n        target_nodes = []\n        target_nodes_specified = False\n        retry_attempts = self.retry.get_retries()\n\n        passed_targets = kwargs.pop(\"target_nodes\", None)\n        if passed_targets and not self._is_node_flag(passed_targets):\n            target_nodes = self._parse_target_nodes(passed_targets)\n            target_nodes_specified = True\n            retry_attempts = 0\n\n        command_policies = await self._policy_resolver.resolve(args[0].lower())\n\n        if not command_policies and not target_nodes_specified:\n            command_flag = self.command_flags.get(command)\n            if not command_flag:\n                # Fallback to default policy\n                if not self.get_default_node():\n                    slot = None\n                else:\n                    slot = await self._determine_slot(*args)\n                if slot is None:\n                    command_policies = CommandPolicies()\n                else:\n                    command_policies = CommandPolicies(\n                        request_policy=RequestPolicy.DEFAULT_KEYED,\n                        response_policy=ResponsePolicy.DEFAULT_KEYED,\n                    )\n            else:\n                if command_flag in self._command_flags_mapping:\n                    command_policies = CommandPolicies(\n                        request_policy=self._command_flags_mapping[command_flag]\n                    )\n                else:\n                    command_policies = CommandPolicies()\n        elif not command_policies and target_nodes_specified:\n            command_policies = CommandPolicies()\n\n        # Add one for the first execution\n        execute_attempts = 1 + retry_attempts\n        failure_count = 0\n\n        # Start timing for observability\n        start_time = time.monotonic()\n\n        for _ in range(execute_attempts):\n            if self._initialize:\n                await self.initialize()\n                if (\n                    len(target_nodes) == 1\n                    and target_nodes[0] == self.get_default_node()\n                ):\n                    # Replace the default cluster node\n                    self.replace_default_node()\n            try:\n                if not target_nodes_specified:\n                    # Determine the nodes to execute the command on\n                    target_nodes = await self._determine_nodes(\n                        *args,\n                        request_policy=command_policies.request_policy,\n                        node_flag=passed_targets,\n                    )\n                    if not target_nodes:\n                        raise RedisClusterException(\n                            f\"No targets were found to execute {args} command on\"\n                        )\n\n                if len(target_nodes) == 1:\n                    # Return the processed result\n                    ret = await self._execute_command(target_nodes[0], *args, **kwargs)\n                    if command in self.result_callbacks:\n                        ret = self.result_callbacks[command](\n                            command, {target_nodes[0].name: ret}, **kwargs\n                        )\n                    return self._policies_callback_mapping[\n                        command_policies.response_policy\n                    ](ret)\n                else:\n                    keys = [node.name for node in target_nodes]\n                    values = await asyncio.gather(\n                        *(\n                            asyncio.create_task(\n                                self._execute_command(node, *args, **kwargs)\n                            )\n                            for node in target_nodes\n                        )\n                    )\n                    if command in self.result_callbacks:\n                        return self.result_callbacks[command](\n                            command, dict(zip(keys, values)), **kwargs\n                        )\n                    return self._policies_callback_mapping[\n                        command_policies.response_policy\n                    ](dict(zip(keys, values)))\n            except Exception as e:\n                if retry_attempts > 0 and type(e) in self.__class__.ERRORS_ALLOW_RETRY:\n                    # The nodes and slots cache were should be reinitialized.\n                    # Try again with the new cluster setup.\n                    retry_attempts -= 1\n                    failure_count += 1\n\n                    if hasattr(e, \"connection\"):\n                        await self._record_command_metric(\n                            command_name=command,\n                            duration_seconds=time.monotonic() - start_time,\n                            connection=e.connection,\n                            error=e,\n                        )\n                        await self._record_error_metric(\n                            error=e,\n                            connection=e.connection,\n                            retry_attempts=failure_count,\n                        )\n                    continue\n                else:\n                    # raise the exception\n                    if hasattr(e, \"connection\"):\n                        await self._record_error_metric(\n                            error=e,\n                            connection=e.connection,\n                            retry_attempts=failure_count,\n                            is_internal=False,\n                        )\n                    raise e\n\n    async def _execute_command(\n        self, target_node: \"ClusterNode\", *args: Union[KeyT, EncodableT], **kwargs: Any\n    ) -> Any:\n        asking = moved = False\n        redirect_addr = None\n        ttl = self.RedisClusterRequestTTL\n        command = args[0]\n        start_time = time.monotonic()\n\n        while ttl > 0:\n            ttl -= 1\n            try:\n                if asking:\n                    target_node = self.get_node(node_name=redirect_addr)\n                    await target_node.execute_command(\"ASKING\")\n                    asking = False\n                elif moved:\n                    # MOVED occurred and the slots cache was updated,\n                    # refresh the target node\n                    slot = await self._determine_slot(*args)\n                    target_node = self.nodes_manager.get_node_from_slot(\n                        slot,\n                        self.read_from_replicas and args[0] in READ_COMMANDS,\n                        self.load_balancing_strategy\n                        if args[0] in READ_COMMANDS\n                        else None,\n                    )\n                    moved = False\n\n                response = await target_node.execute_command(*args, **kwargs)\n                await self._record_command_metric(\n                    command_name=command,\n                    duration_seconds=time.monotonic() - start_time,\n                    connection=target_node,\n                )\n                return response\n            except BusyLoadingError as e:\n                e.connection = target_node\n                await self._record_command_metric(\n                    command_name=command,\n                    duration_seconds=time.monotonic() - start_time,\n                    connection=target_node,\n                    error=e,\n                )\n                raise\n            except MaxConnectionsError as e:\n                # MaxConnectionsError indicates client-side resource exhaustion\n                # (too many connections in the pool), not a node failure.\n                # Don't treat this as a node failure - just re-raise the error\n                # without reinitializing the cluster.\n                e.connection = target_node\n                await self._record_command_metric(\n                    command_name=command,\n                    duration_seconds=time.monotonic() - start_time,\n                    connection=target_node,\n                    error=e,\n                )\n                raise\n            except (ConnectionError, TimeoutError) as e:\n                # Connection retries are being handled in the node's\n                # Retry object.\n                # Mark active connections for reconnect and disconnect free ones\n                # This handles connection state (like READONLY) that may be stale\n                target_node.update_active_connections_for_reconnect()\n                await target_node.disconnect_free_connections()\n\n                # Move the failed node to the end of the cached nodes list\n                # so it's tried last during reinitialization\n                self.nodes_manager.move_node_to_end_of_cached_nodes(target_node.name)\n\n                # Signal that reinitialization is needed\n                # The retry loop will handle initialize() AND replace_default_node()\n                self._initialize = True\n                e.connection = target_node\n                await self._record_command_metric(\n                    command_name=command,\n                    duration_seconds=time.monotonic() - start_time,\n                    connection=target_node,\n                    error=e,\n                )\n                raise\n            except (ClusterDownError, SlotNotCoveredError) as e:\n                # ClusterDownError can occur during a failover and to get\n                # self-healed, we will try to reinitialize the cluster layout\n                # and retry executing the command\n\n                # SlotNotCoveredError can occur when the cluster is not fully\n                # initialized or can be temporary issue.\n                # We will try to reinitialize the cluster topology\n                # and retry executing the command\n\n                await self.aclose()\n                await asyncio.sleep(0.25)\n                e.connection = target_node\n                await self._record_command_metric(\n                    command_name=command,\n                    duration_seconds=time.monotonic() - start_time,\n                    connection=target_node,\n                    error=e,\n                )\n                raise\n            except MovedError as e:\n                # First, we will try to patch the slots/nodes cache with the\n                # redirected node output and try again. If MovedError exceeds\n                # 'reinitialize_steps' number of times, we will force\n                # reinitializing the tables, and then try again.\n                # 'reinitialize_steps' counter will increase faster when\n                # the same client object is shared between multiple threads. To\n                # reduce the frequency you can set this variable in the\n                # RedisCluster constructor.\n                self.reinitialize_counter += 1\n                if (\n                    self.reinitialize_steps\n                    and self.reinitialize_counter % self.reinitialize_steps == 0\n                ):\n                    await self.aclose()\n                    # Reset the counter\n                    self.reinitialize_counter = 0\n                else:\n                    self.nodes_manager.move_slot(e)\n                moved = True\n                await self._record_command_metric(\n                    command_name=command,\n                    duration_seconds=time.monotonic() - start_time,\n                    connection=target_node,\n                    error=e,\n                )\n                await self._record_error_metric(\n                    error=e,\n                    connection=target_node,\n                )\n            except AskError as e:\n                redirect_addr = get_node_name(host=e.host, port=e.port)\n                asking = True\n                await self._record_command_metric(\n                    command_name=command,\n                    duration_seconds=time.monotonic() - start_time,\n                    connection=target_node,\n                    error=e,\n                )\n                await self._record_error_metric(\n                    error=e,\n                    connection=target_node,\n                )\n            except TryAgainError as e:\n                if ttl < self.RedisClusterRequestTTL / 2:\n                    await asyncio.sleep(0.05)\n                await self._record_command_metric(\n                    command_name=command,\n                    duration_seconds=time.monotonic() - start_time,\n                    connection=target_node,\n                    error=e,\n                )\n                await self._record_error_metric(\n                    error=e,\n                    connection=target_node,\n                )\n            except ResponseError as e:\n                e.connection = target_node\n                await self._record_command_metric(\n                    command_name=command,\n                    duration_seconds=time.monotonic() - start_time,\n                    connection=target_node,\n                    error=e,\n                )\n                raise\n            except Exception as e:\n                e.connection = target_node\n                await self._record_command_metric(\n                    command_name=command,\n                    duration_seconds=time.monotonic() - start_time,\n                    connection=target_node,\n                    error=e,\n                )\n                raise\n\n        e = ClusterError(\"TTL exhausted.\")\n        e.connection = target_node\n        await self._record_command_metric(\n            command_name=command,\n            duration_seconds=time.monotonic() - start_time,\n            connection=target_node,\n            error=e,\n        )\n        raise e\n\n    def pipeline(\n        self, transaction: Optional[Any] = None, shard_hint: Optional[Any] = None\n    ) -> \"ClusterPipeline\":\n        \"\"\"\n        Create & return a new :class:`~.ClusterPipeline` object.\n\n        Cluster implementation of pipeline does not support transaction or shard_hint.\n\n        :raises RedisClusterException: if transaction or shard_hint are truthy values\n        \"\"\"\n        if shard_hint:\n            raise RedisClusterException(\"shard_hint is deprecated in cluster mode\")\n\n        return ClusterPipeline(self, transaction)\n\n    def lock(\n        self,\n        name: KeyT,\n        timeout: Optional[float] = None,\n        sleep: float = 0.1,\n        blocking: bool = True,\n        blocking_timeout: Optional[float] = None,\n        lock_class: Optional[Type[Lock]] = None,\n        thread_local: bool = True,\n        raise_on_release_error: bool = True,\n    ) -> Lock:\n        \"\"\"\n        Return a new Lock object using key ``name`` that mimics\n        the behavior of threading.Lock.\n\n        If specified, ``timeout`` indicates a maximum life for the lock.\n        By default, it will remain locked until release() is called.\n\n        ``sleep`` indicates the amount of time to sleep per loop iteration\n        when the lock is in blocking mode and another client is currently\n        holding the lock.\n\n        ``blocking`` indicates whether calling ``acquire`` should block until\n        the lock has been acquired or to fail immediately, causing ``acquire``\n        to return False and the lock not being acquired. Defaults to True.\n        Note this value can be overridden by passing a ``blocking``\n        argument to ``acquire``.\n\n        ``blocking_timeout`` indicates the maximum amount of time in seconds to\n        spend trying to acquire the lock. A value of ``None`` indicates\n        continue trying forever. ``blocking_timeout`` can be specified as a\n        float or integer, both representing the number of seconds to wait.\n\n        ``lock_class`` forces the specified lock implementation. Note that as\n        of redis-py 3.0, the only lock class we implement is ``Lock`` (which is\n        a Lua-based lock). So, it's unlikely you'll need this parameter, unless\n        you have created your own custom lock class.\n\n        ``thread_local`` indicates whether the lock token is placed in\n        thread-local storage. By default, the token is placed in thread local\n        storage so that a thread only sees its token, not a token set by\n        another thread. Consider the following timeline:\n\n            time: 0, thread-1 acquires `my-lock`, with a timeout of 5 seconds.\n                     thread-1 sets the token to \"abc\"\n            time: 1, thread-2 blocks trying to acquire `my-lock` using the\n                     Lock instance.\n            time: 5, thread-1 has not yet completed. redis expires the lock\n                     key.\n            time: 5, thread-2 acquired `my-lock` now that it's available.\n                     thread-2 sets the token to \"xyz\"\n            time: 6, thread-1 finishes its work and calls release(). if the\n                     token is *not* stored in thread local storage, then\n                     thread-1 would see the token value as \"xyz\" and would be\n                     able to successfully release the thread-2's lock.\n\n        ``raise_on_release_error`` indicates whether to raise an exception when\n        the lock is no longer owned when exiting the context manager. By default,\n        this is True, meaning an exception will be raised. If False, the warning\n        will be logged and the exception will be suppressed.\n\n        In some use cases it's necessary to disable thread local storage. For\n        example, if you have code where one thread acquires a lock and passes\n        that lock instance to a worker thread to release later. If thread\n        local storage isn't disabled in this case, the worker thread won't see\n        the token set by the thread that acquired the lock. Our assumption\n        is that these cases aren't common and as such default to using\n        thread local storage.\"\"\"\n        if lock_class is None:\n            lock_class = Lock\n        return lock_class(\n            self,\n            name,\n            timeout=timeout,\n            sleep=sleep,\n            blocking=blocking,\n            blocking_timeout=blocking_timeout,\n            thread_local=thread_local,\n            raise_on_release_error=raise_on_release_error,\n        )\n\n    async def transaction(\n        self, func: Coroutine[None, \"ClusterPipeline\", Any], *watches, **kwargs\n    ):\n        \"\"\"\n        Convenience method for executing the callable `func` as a transaction\n        while watching all keys specified in `watches`. The 'func' callable\n        should expect a single argument which is a Pipeline object.\n        \"\"\"\n        shard_hint = kwargs.pop(\"shard_hint\", None)\n        value_from_callable = kwargs.pop(\"value_from_callable\", False)\n        watch_delay = kwargs.pop(\"watch_delay\", None)\n        async with self.pipeline(True, shard_hint) as pipe:\n            while True:\n                try:\n                    if watches:\n                        await pipe.watch(*watches)\n                    func_value = await func(pipe)\n                    exec_value = await pipe.execute()\n                    return func_value if value_from_callable else exec_value\n                except WatchError:\n                    if watch_delay is not None and watch_delay > 0:\n                        time.sleep(watch_delay)\n                    continue\n\n\nclass ClusterNode:\n    \"\"\"\n    Create a new ClusterNode.\n\n    Each ClusterNode manages multiple :class:`~redis.asyncio.connection.Connection`\n    objects for the (host, port).\n    \"\"\"\n\n    __slots__ = (\n        \"_connections\",\n        \"_free\",\n        \"_lock\",\n        \"_event_dispatcher\",\n        \"connection_class\",\n        \"connection_kwargs\",\n        \"host\",\n        \"max_connections\",\n        \"name\",\n        \"port\",\n        \"response_callbacks\",\n        \"server_type\",\n    )\n\n    def __init__(\n        self,\n        host: str,\n        port: Union[str, int],\n        server_type: Optional[str] = None,\n        *,\n        max_connections: int = 2**31,\n        connection_class: Type[Connection] = Connection,\n        **connection_kwargs: Any,\n    ) -> None:\n        if host == \"localhost\":\n            host = socket.gethostbyname(host)\n\n        connection_kwargs[\"host\"] = host\n        connection_kwargs[\"port\"] = port\n        self.host = host\n        self.port = port\n        self.name = get_node_name(host, port)\n        self.server_type = server_type\n\n        self.max_connections = max_connections\n        self.connection_class = connection_class\n        self.connection_kwargs = connection_kwargs\n        self.response_callbacks = connection_kwargs.pop(\"response_callbacks\", {})\n\n        self._connections: List[Connection] = []\n        self._free: Deque[Connection] = collections.deque(maxlen=self.max_connections)\n        self._event_dispatcher = self.connection_kwargs.get(\"event_dispatcher\", None)\n        if self._event_dispatcher is None:\n            self._event_dispatcher = EventDispatcher()\n\n    def __repr__(self) -> str:\n        return (\n            f\"[host={self.host}, port={self.port}, \"\n            f\"name={self.name}, server_type={self.server_type}]\"\n        )\n\n    def __eq__(self, obj: Any) -> bool:\n        return isinstance(obj, ClusterNode) and obj.name == self.name\n\n    def __hash__(self) -> int:\n        return hash(self.name)\n\n    _DEL_MESSAGE = \"Unclosed ClusterNode object\"\n\n    def __del__(\n        self,\n        _warn: Any = warnings.warn,\n        _grl: Any = asyncio.get_running_loop,\n    ) -> None:\n        for connection in self._connections:\n            if connection.is_connected:\n                _warn(f\"{self._DEL_MESSAGE} {self!r}\", ResourceWarning, source=self)\n\n                try:\n                    context = {\"client\": self, \"message\": self._DEL_MESSAGE}\n                    _grl().call_exception_handler(context)\n                except RuntimeError:\n                    pass\n                break\n\n    async def disconnect(self) -> None:\n        ret = await asyncio.gather(\n            *(\n                asyncio.create_task(connection.disconnect())\n                for connection in self._connections\n            ),\n            return_exceptions=True,\n        )\n        exc = next((res for res in ret if isinstance(res, Exception)), None)\n        if exc:\n            raise exc\n\n    def acquire_connection(self) -> Connection:\n        try:\n            return self._free.popleft()\n        except IndexError:\n            if len(self._connections) < self.max_connections:\n                # We are configuring the connection pool not to retry\n                # connections on lower level clients to avoid retrying\n                # connections to nodes that are not reachable\n                # and to avoid blocking the connection pool.\n                # The only error that will have some handling in the lower\n                # level clients is ConnectionError which will trigger disconnection\n                # of the socket.\n                # The retries will be handled on cluster client level\n                # where we will have proper handling of the cluster topology\n                retry = Retry(\n                    backoff=NoBackoff(),\n                    retries=0,\n                    supported_errors=(ConnectionError,),\n                )\n                connection_kwargs = self.connection_kwargs.copy()\n                connection_kwargs[\"retry\"] = retry\n                connection = self.connection_class(**connection_kwargs)\n                self._connections.append(connection)\n                return connection\n\n            raise MaxConnectionsError()\n\n    async def disconnect_if_needed(self, connection: Connection) -> None:\n        \"\"\"\n        Disconnect a connection if it's marked for reconnect.\n        This implements lazy disconnection to avoid race conditions.\n        The connection will auto-reconnect on next use.\n        \"\"\"\n        if connection.should_reconnect():\n            await connection.disconnect()\n\n    def release(self, connection: Connection) -> None:\n        \"\"\"\n        Release connection back to free queue.\n        If the connection is marked for reconnect, it will be disconnected\n        lazily when next acquired via disconnect_if_needed().\n        \"\"\"\n        self._free.append(connection)\n\n    def update_active_connections_for_reconnect(self) -> None:\n        \"\"\"\n        Mark all in-use (active) connections for reconnect.\n        In-use connections are those in _connections but not currently in _free.\n        They will be disconnected when released back to the pool.\n        \"\"\"\n        free_set = set(self._free)\n        for connection in self._connections:\n            if connection not in free_set:\n                connection.mark_for_reconnect()\n\n    async def disconnect_free_connections(self) -> None:\n        \"\"\"\n        Disconnect all free/idle connections in the pool.\n        This is useful after topology changes (e.g., failover) to clear\n        stale connection state like READONLY mode.\n        The connections remain in the pool and will reconnect on next use.\n        \"\"\"\n        if self._free:\n            # Take a snapshot to avoid issues if _free changes during await\n            await asyncio.gather(\n                *(connection.disconnect() for connection in tuple(self._free)),\n                return_exceptions=True,\n            )\n\n    async def parse_response(\n        self, connection: Connection, command: str, **kwargs: Any\n    ) -> Any:\n        try:\n            if NEVER_DECODE in kwargs:\n                response = await connection.read_response(disable_decoding=True)\n                kwargs.pop(NEVER_DECODE)\n            else:\n                response = await connection.read_response()\n        except ResponseError:\n            if EMPTY_RESPONSE in kwargs:\n                return kwargs[EMPTY_RESPONSE]\n            raise\n\n        if EMPTY_RESPONSE in kwargs:\n            kwargs.pop(EMPTY_RESPONSE)\n\n        # Remove keys entry, it needs only for cache.\n        kwargs.pop(\"keys\", None)\n\n        # Return response\n        if command in self.response_callbacks:\n            return self.response_callbacks[command](response, **kwargs)\n\n        return response\n\n    async def execute_command(self, *args: Any, **kwargs: Any) -> Any:\n        # Acquire connection\n        connection = self.acquire_connection()\n        # Handle lazy disconnect for connections marked for reconnect\n        await self.disconnect_if_needed(connection)\n\n        # Execute command\n        await connection.send_packed_command(connection.pack_command(*args), False)\n\n        # Read response\n        try:\n            return await self.parse_response(connection, args[0], **kwargs)\n        finally:\n            await self.disconnect_if_needed(connection)\n            # Release connection\n            self._free.append(connection)\n\n    async def execute_pipeline(self, commands: List[\"PipelineCommand\"]) -> bool:\n        # Acquire connection\n        connection = self.acquire_connection()\n        # Handle lazy disconnect for connections marked for reconnect\n        await self.disconnect_if_needed(connection)\n\n        # Execute command\n        await connection.send_packed_command(\n            connection.pack_commands(cmd.args for cmd in commands), False\n        )\n\n        # Read responses\n        ret = False\n        for cmd in commands:\n            try:\n                cmd.result = await self.parse_response(\n                    connection, cmd.args[0], **cmd.kwargs\n                )\n            except Exception as e:\n                cmd.result = e\n                ret = True\n\n        # Release connection\n        await self.disconnect_if_needed(connection)\n        self._free.append(connection)\n\n        return ret\n\n    async def re_auth_callback(self, token: TokenInterface):\n        tmp_queue = collections.deque()\n        while self._free:\n            conn = self._free.popleft()\n            await conn.retry.call_with_retry(\n                lambda: conn.send_command(\n                    \"AUTH\", token.try_get(\"oid\"), token.get_value()\n                ),\n                lambda error: self._mock(error),\n            )\n            await conn.retry.call_with_retry(\n                lambda: conn.read_response(), lambda error: self._mock(error)\n            )\n            tmp_queue.append(conn)\n\n        while tmp_queue:\n            conn = tmp_queue.popleft()\n            self._free.append(conn)\n\n    async def _mock(self, error: RedisError):\n        \"\"\"\n        Dummy functions, needs to be passed as error callback to retry object.\n        :param error:\n        :return:\n        \"\"\"\n        pass\n\n\nclass NodesManager:\n    __slots__ = (\n        \"_dynamic_startup_nodes\",\n        \"_event_dispatcher\",\n        \"_background_tasks\",\n        \"connection_kwargs\",\n        \"default_node\",\n        \"nodes_cache\",\n        \"_epoch\",\n        \"read_load_balancer\",\n        \"_initialize_lock\",\n        \"require_full_coverage\",\n        \"slots_cache\",\n        \"startup_nodes\",\n        \"address_remap\",\n    )\n\n    def __init__(\n        self,\n        startup_nodes: List[\"ClusterNode\"],\n        require_full_coverage: bool,\n        connection_kwargs: Dict[str, Any],\n        dynamic_startup_nodes: bool = True,\n        address_remap: Optional[Callable[[Tuple[str, int]], Tuple[str, int]]] = None,\n        event_dispatcher: Optional[EventDispatcher] = None,\n    ) -> None:\n        self.startup_nodes = {node.name: node for node in startup_nodes}\n        self.require_full_coverage = require_full_coverage\n        self.connection_kwargs = connection_kwargs\n        self.address_remap = address_remap\n\n        self.default_node: \"ClusterNode\" = None\n        self.nodes_cache: Dict[str, \"ClusterNode\"] = {}\n        self.slots_cache: Dict[int, List[\"ClusterNode\"]] = {}\n        self._epoch: int = 0\n        self.read_load_balancer = LoadBalancer()\n        self._initialize_lock: asyncio.Lock = asyncio.Lock()\n\n        self._background_tasks: Set[asyncio.Task] = set()\n        self._dynamic_startup_nodes: bool = dynamic_startup_nodes\n        if event_dispatcher is None:\n            self._event_dispatcher = EventDispatcher()\n        else:\n            self._event_dispatcher = event_dispatcher\n\n    def get_node(\n        self,\n        host: Optional[str] = None,\n        port: Optional[int] = None,\n        node_name: Optional[str] = None,\n    ) -> Optional[\"ClusterNode\"]:\n        if host and port:\n            # the user passed host and port\n            if host == \"localhost\":\n                host = socket.gethostbyname(host)\n            return self.nodes_cache.get(get_node_name(host=host, port=port))\n        elif node_name:\n            return self.nodes_cache.get(node_name)\n        else:\n            raise DataError(\n                \"get_node requires one of the following: 1. node name 2. host and port\"\n            )\n\n    def set_nodes(\n        self,\n        old: Dict[str, \"ClusterNode\"],\n        new: Dict[str, \"ClusterNode\"],\n        remove_old: bool = False,\n    ) -> None:\n        if remove_old:\n            for name in list(old.keys()):\n                if name not in new:\n                    # Node is removed from cache before disconnect starts,\n                    # so it won't be found in lookups during disconnect\n                    # Mark active connections for reconnect so they get disconnected after current command completes\n                    # and disconnect free connections immediately\n                    # the node is removed from the cache before the connections changes so it won't be used and should be safe\n                    # not to wait for the disconnects\n                    removed_node = old.pop(name)\n                    removed_node.update_active_connections_for_reconnect()\n                    task = asyncio.create_task(\n                        removed_node.disconnect_free_connections()\n                    )\n                    self._background_tasks.add(task)\n                    task.add_done_callback(self._background_tasks.discard)\n\n        for name, node in new.items():\n            if name in old:\n                # Preserve the existing node but mark connections for reconnect.\n                # This method is sync so we can't call disconnect_free_connections()\n                # which is async. Instead, we mark free connections for reconnect\n                # and they will be lazily disconnected when acquired via\n                # disconnect_if_needed() to avoid race conditions.\n                # TODO: Make this method async in the next major release to allow\n                # immediate disconnection of free connections.\n                existing_node = old[name]\n                existing_node.update_active_connections_for_reconnect()\n                for conn in existing_node._free:\n                    conn.mark_for_reconnect()\n                continue\n            # New node is detected and should be added to the pool\n            old[name] = node\n\n    def move_node_to_end_of_cached_nodes(self, node_name: str) -> None:\n        \"\"\"\n        Move a failing node to the end of startup_nodes and nodes_cache so it's\n        tried last during reinitialization and when selecting the default node.\n        If the node is not in the respective list, nothing is done.\n        \"\"\"\n        # Move in startup_nodes\n        if node_name in self.startup_nodes and len(self.startup_nodes) > 1:\n            node = self.startup_nodes.pop(node_name)\n            self.startup_nodes[node_name] = node  # Re-insert at end\n\n        # Move in nodes_cache - this affects get_nodes_by_server_type ordering\n        # which is used to select the default_node during initialize()\n        if node_name in self.nodes_cache and len(self.nodes_cache) > 1:\n            node = self.nodes_cache.pop(node_name)\n            self.nodes_cache[node_name] = node  # Re-insert at end\n\n    def move_slot(self, e: AskError | MovedError):\n        redirected_node = self.get_node(host=e.host, port=e.port)\n        if redirected_node:\n            # The node already exists\n            if redirected_node.server_type != PRIMARY:\n                # Update the node's server type\n                redirected_node.server_type = PRIMARY\n        else:\n            # This is a new node, we will add it to the nodes cache\n            redirected_node = ClusterNode(\n                e.host, e.port, PRIMARY, **self.connection_kwargs\n            )\n            self.set_nodes(self.nodes_cache, {redirected_node.name: redirected_node})\n        slot_nodes = self.slots_cache[e.slot_id]\n        if redirected_node not in slot_nodes:\n            # The new slot owner is a new server, or a server from a different\n            # shard. We need to remove all current nodes from the slot's list\n            # (including replications) and add just the new node.\n            self.slots_cache[e.slot_id] = [redirected_node]\n        elif redirected_node is not slot_nodes[0]:\n            # The MOVED error resulted from a failover, and the new slot owner\n            # had previously been a replica.\n            old_primary = slot_nodes[0]\n            # Update the old primary to be a replica and add it to the end of\n            # the slot's node list\n            old_primary.server_type = REPLICA\n            slot_nodes.append(old_primary)\n            # Remove the old replica, which is now a primary, from the slot's\n            # node list\n            slot_nodes.remove(redirected_node)\n            # Override the old primary with the new one\n            slot_nodes[0] = redirected_node\n            if self.default_node == old_primary:\n                # Update the default node with the new primary\n                self.default_node = redirected_node\n        # else: circular MOVED to current primary -> no-op\n\n    def get_node_from_slot(\n        self,\n        slot: int,\n        read_from_replicas: bool = False,\n        load_balancing_strategy=None,\n    ) -> \"ClusterNode\":\n        if read_from_replicas is True and load_balancing_strategy is None:\n            load_balancing_strategy = LoadBalancingStrategy.ROUND_ROBIN\n\n        try:\n            if len(self.slots_cache[slot]) > 1 and load_balancing_strategy:\n                # get the server index using the strategy defined in load_balancing_strategy\n                primary_name = self.slots_cache[slot][0].name\n                node_idx = self.read_load_balancer.get_server_index(\n                    primary_name, len(self.slots_cache[slot]), load_balancing_strategy\n                )\n                return self.slots_cache[slot][node_idx]\n            return self.slots_cache[slot][0]\n        except (IndexError, TypeError):\n            raise SlotNotCoveredError(\n                f'Slot \"{slot}\" not covered by the cluster. '\n                f'\"require_full_coverage={self.require_full_coverage}\"'\n            )\n\n    def get_nodes_by_server_type(self, server_type: str) -> List[\"ClusterNode\"]:\n        return [\n            node\n            for node in self.nodes_cache.values()\n            if node.server_type == server_type\n        ]\n\n    async def initialize(self) -> None:\n        self.read_load_balancer.reset()\n        tmp_nodes_cache: Dict[str, \"ClusterNode\"] = {}\n        tmp_slots: Dict[int, List[\"ClusterNode\"]] = {}\n        disagreements = []\n        startup_nodes_reachable = False\n        fully_covered = False\n        exception = None\n        epoch = self._epoch\n\n        async with self._initialize_lock:\n            if self._epoch != epoch:\n                # another initialize call has already reinitialized the\n                # nodes since we started waiting for the lock;\n                # we don't need to do it again.\n                return\n\n            # Convert to tuple to prevent RuntimeError if self.startup_nodes\n            # is modified during iteration\n            for startup_node in tuple(self.startup_nodes.values()):\n                try:\n                    # Make sure cluster mode is enabled on this node\n                    try:\n                        self._event_dispatcher.dispatch(\n                            AfterAsyncClusterInstantiationEvent(\n                                self.nodes_cache,\n                                self.connection_kwargs.get(\"credential_provider\", None),\n                            )\n                        )\n                        cluster_slots = await startup_node.execute_command(\n                            \"CLUSTER SLOTS\"\n                        )\n                    except ResponseError:\n                        raise RedisClusterException(\n                            \"Cluster mode is not enabled on this node\"\n                        )\n                    startup_nodes_reachable = True\n                except Exception as e:\n                    # Try the next startup node.\n                    # The exception is saved and raised only if we have no more nodes.\n                    exception = e\n                    continue\n\n                # CLUSTER SLOTS command results in the following output:\n                # [[slot_section[from_slot,to_slot,master,replica1,...,replicaN]]]\n                # where each node contains the following list: [IP, port, node_id]\n                # Therefore, cluster_slots[0][2][0] will be the IP address of the\n                # primary node of the first slot section.\n                # If there's only one server in the cluster, its ``host`` is ''\n                # Fix it to the host in startup_nodes\n                if (\n                    len(cluster_slots) == 1\n                    and not cluster_slots[0][2][0]\n                    and len(self.startup_nodes) == 1\n                ):\n                    cluster_slots[0][2][0] = startup_node.host\n\n                for slot in cluster_slots:\n                    for i in range(2, len(slot)):\n                        slot[i] = [str_if_bytes(val) for val in slot[i]]\n                    primary_node = slot[2]\n                    host = primary_node[0]\n                    if host == \"\":\n                        host = startup_node.host\n                    port = int(primary_node[1])\n                    host, port = self.remap_host_port(host, port)\n\n                    nodes_for_slot = []\n\n                    target_node = tmp_nodes_cache.get(get_node_name(host, port))\n                    if not target_node:\n                        target_node = ClusterNode(\n                            host, port, PRIMARY, **self.connection_kwargs\n                        )\n                    # add this node to the nodes cache\n                    tmp_nodes_cache[target_node.name] = target_node\n                    nodes_for_slot.append(target_node)\n\n                    replica_nodes = slot[3:]\n                    for replica_node in replica_nodes:\n                        host = replica_node[0]\n                        port = replica_node[1]\n                        host, port = self.remap_host_port(host, port)\n\n                        target_replica_node = tmp_nodes_cache.get(\n                            get_node_name(host, port)\n                        )\n                        if not target_replica_node:\n                            target_replica_node = ClusterNode(\n                                host, port, REPLICA, **self.connection_kwargs\n                            )\n                        # add this node to the nodes cache\n                        tmp_nodes_cache[target_replica_node.name] = target_replica_node\n                        nodes_for_slot.append(target_replica_node)\n\n                    for i in range(int(slot[0]), int(slot[1]) + 1):\n                        if i not in tmp_slots:\n                            tmp_slots[i] = nodes_for_slot\n                        else:\n                            # Validate that 2 nodes want to use the same slot cache\n                            # setup\n                            tmp_slot = tmp_slots[i][0]\n                            if tmp_slot.name != target_node.name:\n                                disagreements.append(\n                                    f\"{tmp_slot.name} vs {target_node.name} on slot: {i}\"\n                                )\n\n                                if len(disagreements) > 5:\n                                    raise RedisClusterException(\n                                        f\"startup_nodes could not agree on a valid \"\n                                        f\"slots cache: {', '.join(disagreements)}\"\n                                    )\n\n                # Validate if all slots are covered or if we should try next startup node\n                fully_covered = True\n                for i in range(REDIS_CLUSTER_HASH_SLOTS):\n                    if i not in tmp_slots:\n                        fully_covered = False\n                        break\n                if fully_covered:\n                    break\n\n            if not startup_nodes_reachable:\n                raise RedisClusterException(\n                    f\"Redis Cluster cannot be connected. Please provide at least \"\n                    f\"one reachable node: {str(exception)}\"\n                ) from exception\n\n            # Check if the slots are not fully covered\n            if not fully_covered and self.require_full_coverage:\n                # Despite the requirement that the slots be covered, there\n                # isn't a full coverage\n                raise RedisClusterException(\n                    f\"All slots are not covered after query all startup_nodes. \"\n                    f\"{len(tmp_slots)} of {REDIS_CLUSTER_HASH_SLOTS} \"\n                    f\"covered...\"\n                )\n\n            # Set the tmp variables to the real variables\n            self.slots_cache = tmp_slots\n            self.set_nodes(self.nodes_cache, tmp_nodes_cache, remove_old=True)\n\n            if self._dynamic_startup_nodes:\n                # Populate the startup nodes with all discovered nodes\n                self.set_nodes(self.startup_nodes, self.nodes_cache, remove_old=True)\n\n            # Set the default node\n            self.default_node = self.get_nodes_by_server_type(PRIMARY)[0]\n            self._epoch += 1\n\n    async def aclose(self, attr: str = \"nodes_cache\") -> None:\n        self.default_node = None\n        await asyncio.gather(\n            *(\n                asyncio.create_task(node.disconnect())\n                for node in getattr(self, attr).values()\n            )\n        )\n\n    def remap_host_port(self, host: str, port: int) -> Tuple[str, int]:\n        \"\"\"\n        Remap the host and port returned from the cluster to a different\n        internal value.  Useful if the client is not connecting directly\n        to the cluster.\n        \"\"\"\n        if self.address_remap:\n            return self.address_remap((host, port))\n        return host, port\n\n\nclass ClusterPipeline(AbstractRedis, AbstractRedisCluster, AsyncRedisClusterCommands):\n    \"\"\"\n    Create a new ClusterPipeline object.\n\n    Usage::\n\n        result = await (\n            rc.pipeline()\n            .set(\"A\", 1)\n            .get(\"A\")\n            .hset(\"K\", \"F\", \"V\")\n            .hgetall(\"K\")\n            .mset_nonatomic({\"A\": 2, \"B\": 3})\n            .get(\"A\")\n            .get(\"B\")\n            .delete(\"A\", \"B\", \"K\")\n            .execute()\n        )\n        # result = [True, \"1\", 1, {\"F\": \"V\"}, True, True, \"2\", \"3\", 1, 1, 1]\n\n    Note: For commands `DELETE`, `EXISTS`, `TOUCH`, `UNLINK`, `mset_nonatomic`, which\n    are split across multiple nodes, you'll get multiple results for them in the array.\n\n    Retryable errors:\n        - :class:`~.ClusterDownError`\n        - :class:`~.ConnectionError`\n        - :class:`~.TimeoutError`\n\n    Redirection errors:\n        - :class:`~.TryAgainError`\n        - :class:`~.MovedError`\n        - :class:`~.AskError`\n\n    :param client:\n        | Existing :class:`~.RedisCluster` client\n    \"\"\"\n\n    __slots__ = (\n        \"cluster_client\",\n        \"_transaction\",\n        \"_execution_strategy\",\n    )\n\n    # Type discrimination marker for @overload self-type pattern\n    _is_async_client: Literal[True] = True\n\n    def __init__(\n        self, client: RedisCluster, transaction: Optional[bool] = None\n    ) -> None:\n        self.cluster_client = client\n        self._transaction = transaction\n        self._execution_strategy: ExecutionStrategy = (\n            PipelineStrategy(self)\n            if not self._transaction\n            else TransactionStrategy(self)\n        )\n\n    @property\n    def nodes_manager(self) -> \"NodesManager\":\n        \"\"\"Get the nodes manager from the cluster client.\"\"\"\n        return self.cluster_client.nodes_manager\n\n    def set_response_callback(self, command: str, callback: ResponseCallbackT) -> None:\n        \"\"\"Set a custom response callback on the cluster client.\"\"\"\n        self.cluster_client.set_response_callback(command, callback)\n\n    async def initialize(self) -> \"ClusterPipeline\":\n        await self._execution_strategy.initialize()\n        return self\n\n    async def __aenter__(self) -> \"ClusterPipeline\":\n        return await self.initialize()\n\n    async def __aexit__(self, exc_type: None, exc_value: None, traceback: None) -> None:\n        await self.reset()\n\n    def __await__(self) -> Generator[Any, None, \"ClusterPipeline\"]:\n        return self.initialize().__await__()\n\n    def __bool__(self) -> bool:\n        \"Pipeline instances should  always evaluate to True on Python 3+\"\n        return True\n\n    def __len__(self) -> int:\n        return len(self._execution_strategy)\n\n    def execute_command(\n        self, *args: Union[KeyT, EncodableT], **kwargs: Any\n    ) -> \"ClusterPipeline\":\n        \"\"\"\n        Append a raw command to the pipeline.\n\n        :param args:\n            | Raw command args\n        :param kwargs:\n\n            - target_nodes: :attr:`NODE_FLAGS` or :class:`~.ClusterNode`\n              or List[:class:`~.ClusterNode`] or Dict[Any, :class:`~.ClusterNode`]\n            - Rest of the kwargs are passed to the Redis connection\n        \"\"\"\n        return self._execution_strategy.execute_command(*args, **kwargs)\n\n    async def execute(\n        self, raise_on_error: bool = True, allow_redirections: bool = True\n    ) -> List[Any]:\n        \"\"\"\n        Execute the pipeline.\n\n        It will retry the commands as specified by retries specified in :attr:`retry`\n        & then raise an exception.\n\n        :param raise_on_error:\n            | Raise the first error if there are any errors\n        :param allow_redirections:\n            | Whether to retry each failed command individually in case of redirection\n              errors\n\n        :raises RedisClusterException: if target_nodes is not provided & the command\n            can't be mapped to a slot\n        \"\"\"\n        try:\n            return await self._execution_strategy.execute(\n                raise_on_error, allow_redirections\n            )\n        finally:\n            await self.reset()\n\n    def _split_command_across_slots(\n        self, command: str, *keys: KeyT\n    ) -> \"ClusterPipeline\":\n        for slot_keys in self.cluster_client._partition_keys_by_slot(keys).values():\n            self.execute_command(command, *slot_keys)\n\n        return self\n\n    async def reset(self):\n        \"\"\"\n        Reset back to empty pipeline.\n        \"\"\"\n        await self._execution_strategy.reset()\n\n    def multi(self):\n        \"\"\"\n        Start a transactional block of the pipeline after WATCH commands\n        are issued. End the transactional block with `execute`.\n        \"\"\"\n        self._execution_strategy.multi()\n\n    async def discard(self):\n        \"\"\" \"\"\"\n        await self._execution_strategy.discard()\n\n    async def watch(self, *names):\n        \"\"\"Watches the values at keys ``names``\"\"\"\n        await self._execution_strategy.watch(*names)\n\n    async def unwatch(self):\n        \"\"\"Unwatches all previously specified keys\"\"\"\n        await self._execution_strategy.unwatch()\n\n    async def unlink(self, *names):\n        await self._execution_strategy.unlink(*names)\n\n    def mset_nonatomic(\n        self, mapping: Mapping[AnyKeyT, EncodableT]\n    ) -> \"ClusterPipeline\":\n        return self._execution_strategy.mset_nonatomic(mapping)\n\n\nfor command in PIPELINE_BLOCKED_COMMANDS:\n    command = command.replace(\" \", \"_\").lower()\n    if command == \"mset_nonatomic\":\n        continue\n\n    setattr(ClusterPipeline, command, block_pipeline_command(command))\n\n\nclass PipelineCommand:\n    def __init__(self, position: int, *args: Any, **kwargs: Any) -> None:\n        self.args = args\n        self.kwargs = kwargs\n        self.position = position\n        self.result: Union[Any, Exception] = None\n        self.command_policies: Optional[CommandPolicies] = None\n\n    def __repr__(self) -> str:\n        return f\"[{self.position}] {self.args} ({self.kwargs})\"\n\n\nclass ExecutionStrategy(ABC):\n    @abstractmethod\n    async def initialize(self) -> \"ClusterPipeline\":\n        \"\"\"\n        Initialize the execution strategy.\n\n        See ClusterPipeline.initialize()\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def execute_command(\n        self, *args: Union[KeyT, EncodableT], **kwargs: Any\n    ) -> \"ClusterPipeline\":\n        \"\"\"\n        Append a raw command to the pipeline.\n\n        See ClusterPipeline.execute_command()\n        \"\"\"\n        pass\n\n    @abstractmethod\n    async def execute(\n        self, raise_on_error: bool = True, allow_redirections: bool = True\n    ) -> List[Any]:\n        \"\"\"\n        Execute the pipeline.\n\n        It will retry the commands as specified by retries specified in :attr:`retry`\n        & then raise an exception.\n\n        See ClusterPipeline.execute()\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def mset_nonatomic(\n        self, mapping: Mapping[AnyKeyT, EncodableT]\n    ) -> \"ClusterPipeline\":\n        \"\"\"\n        Executes multiple MSET commands according to the provided slot/pairs mapping.\n\n        See ClusterPipeline.mset_nonatomic()\n        \"\"\"\n        pass\n\n    @abstractmethod\n    async def reset(self):\n        \"\"\"\n        Resets current execution strategy.\n\n        See: ClusterPipeline.reset()\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def multi(self):\n        \"\"\"\n        Starts transactional context.\n\n        See: ClusterPipeline.multi()\n        \"\"\"\n        pass\n\n    @abstractmethod\n    async def watch(self, *names):\n        \"\"\"\n        Watch given keys.\n\n        See: ClusterPipeline.watch()\n        \"\"\"\n        pass\n\n    @abstractmethod\n    async def unwatch(self):\n        \"\"\"\n        Unwatches all previously specified keys\n\n        See: ClusterPipeline.unwatch()\n        \"\"\"\n        pass\n\n    @abstractmethod\n    async def discard(self):\n        pass\n\n    @abstractmethod\n    async def unlink(self, *names):\n        \"\"\"\n        \"Unlink a key specified by ``names``\"\n\n        See: ClusterPipeline.unlink()\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def __len__(self) -> int:\n        pass\n\n\nclass AbstractStrategy(ExecutionStrategy):\n    def __init__(self, pipe: ClusterPipeline) -> None:\n        self._pipe: ClusterPipeline = pipe\n        self._command_queue: List[\"PipelineCommand\"] = []\n\n    async def initialize(self) -> \"ClusterPipeline\":\n        if self._pipe.cluster_client._initialize:\n            await self._pipe.cluster_client.initialize()\n        self._command_queue = []\n        return self._pipe\n\n    def execute_command(\n        self, *args: Union[KeyT, EncodableT], **kwargs: Any\n    ) -> \"ClusterPipeline\":\n        self._command_queue.append(\n            PipelineCommand(len(self._command_queue), *args, **kwargs)\n        )\n        return self._pipe\n\n    def _annotate_exception(self, exception, number, command):\n        \"\"\"\n        Provides extra context to the exception prior to it being handled\n        \"\"\"\n        cmd = \" \".join(map(safe_str, command))\n        msg = (\n            f\"Command # {number} ({truncate_text(cmd)}) of pipeline \"\n            f\"caused error: {exception.args[0]}\"\n        )\n        exception.args = (msg,) + exception.args[1:]\n\n    @abstractmethod\n    def mset_nonatomic(\n        self, mapping: Mapping[AnyKeyT, EncodableT]\n    ) -> \"ClusterPipeline\":\n        pass\n\n    @abstractmethod\n    async def execute(\n        self, raise_on_error: bool = True, allow_redirections: bool = True\n    ) -> List[Any]:\n        pass\n\n    @abstractmethod\n    async def reset(self):\n        pass\n\n    @abstractmethod\n    def multi(self):\n        pass\n\n    @abstractmethod\n    async def watch(self, *names):\n        pass\n\n    @abstractmethod\n    async def unwatch(self):\n        pass\n\n    @abstractmethod\n    async def discard(self):\n        pass\n\n    @abstractmethod\n    async def unlink(self, *names):\n        pass\n\n    def __len__(self) -> int:\n        return len(self._command_queue)\n\n\nclass PipelineStrategy(AbstractStrategy):\n    def __init__(self, pipe: ClusterPipeline) -> None:\n        super().__init__(pipe)\n\n    def mset_nonatomic(\n        self, mapping: Mapping[AnyKeyT, EncodableT]\n    ) -> \"ClusterPipeline\":\n        encoder = self._pipe.cluster_client.encoder\n\n        slots_pairs = {}\n        for pair in mapping.items():\n            slot = key_slot(encoder.encode(pair[0]))\n            slots_pairs.setdefault(slot, []).extend(pair)\n\n        for pairs in slots_pairs.values():\n            self.execute_command(\"MSET\", *pairs)\n\n        return self._pipe\n\n    async def execute(\n        self, raise_on_error: bool = True, allow_redirections: bool = True\n    ) -> List[Any]:\n        if not self._command_queue:\n            return []\n\n        try:\n            retry_attempts = self._pipe.cluster_client.retry.get_retries()\n            while True:\n                try:\n                    if self._pipe.cluster_client._initialize:\n                        await self._pipe.cluster_client.initialize()\n                    return await self._execute(\n                        self._pipe.cluster_client,\n                        self._command_queue,\n                        raise_on_error=raise_on_error,\n                        allow_redirections=allow_redirections,\n                    )\n\n                except RedisCluster.ERRORS_ALLOW_RETRY as e:\n                    if retry_attempts > 0:\n                        # Try again with the new cluster setup. All other errors\n                        # should be raised.\n                        retry_attempts -= 1\n                        await self._pipe.cluster_client.aclose()\n                        await asyncio.sleep(0.25)\n                    else:\n                        # All other errors should be raised.\n                        raise e\n        finally:\n            await self.reset()\n\n    async def _execute(\n        self,\n        client: \"RedisCluster\",\n        stack: List[\"PipelineCommand\"],\n        raise_on_error: bool = True,\n        allow_redirections: bool = True,\n    ) -> List[Any]:\n        todo = [\n            cmd for cmd in stack if not cmd.result or isinstance(cmd.result, Exception)\n        ]\n\n        nodes = {}\n        for cmd in todo:\n            passed_targets = cmd.kwargs.pop(\"target_nodes\", None)\n            command_policies = await client._policy_resolver.resolve(\n                cmd.args[0].lower()\n            )\n\n            if passed_targets and not client._is_node_flag(passed_targets):\n                target_nodes = client._parse_target_nodes(passed_targets)\n\n                if not command_policies:\n                    command_policies = CommandPolicies()\n            else:\n                if not command_policies:\n                    command_flag = client.command_flags.get(cmd.args[0])\n                    if not command_flag:\n                        # Fallback to default policy\n                        if not client.get_default_node():\n                            slot = None\n                        else:\n                            slot = await client._determine_slot(*cmd.args)\n                        if slot is None:\n                            command_policies = CommandPolicies()\n                        else:\n                            command_policies = CommandPolicies(\n                                request_policy=RequestPolicy.DEFAULT_KEYED,\n                                response_policy=ResponsePolicy.DEFAULT_KEYED,\n                            )\n                    else:\n                        if command_flag in client._command_flags_mapping:\n                            command_policies = CommandPolicies(\n                                request_policy=client._command_flags_mapping[\n                                    command_flag\n                                ]\n                            )\n                        else:\n                            command_policies = CommandPolicies()\n\n                target_nodes = await client._determine_nodes(\n                    *cmd.args,\n                    request_policy=command_policies.request_policy,\n                    node_flag=passed_targets,\n                )\n                if not target_nodes:\n                    raise RedisClusterException(\n                        f\"No targets were found to execute {cmd.args} command on\"\n                    )\n            cmd.command_policies = command_policies\n            if len(target_nodes) > 1:\n                raise RedisClusterException(f\"Too many targets for command {cmd.args}\")\n            node = target_nodes[0]\n            if node.name not in nodes:\n                nodes[node.name] = (node, [])\n            nodes[node.name][1].append(cmd)\n\n        # Start timing for observability\n        start_time = time.monotonic()\n\n        errors = await asyncio.gather(\n            *(\n                asyncio.create_task(node[0].execute_pipeline(node[1]))\n                for node in nodes.values()\n            )\n        )\n\n        # Record operation duration for each node\n        for node_name, (node, commands) in nodes.items():\n            # Find the first error in this node's commands, if any\n            node_error = None\n            for cmd in commands:\n                if isinstance(cmd.result, Exception):\n                    node_error = cmd.result\n                    break\n\n            db = node.connection_kwargs.get(\"db\", 0)\n            await record_operation_duration(\n                command_name=\"PIPELINE\",\n                duration_seconds=time.monotonic() - start_time,\n                server_address=node.host,\n                server_port=node.port,\n                db_namespace=str(db) if db is not None else None,\n                error=node_error,\n            )\n\n        if any(errors):\n            if allow_redirections:\n                # send each errored command individually\n                for cmd in todo:\n                    if isinstance(cmd.result, (TryAgainError, MovedError, AskError)):\n                        try:\n                            cmd.result = client._policies_callback_mapping[\n                                cmd.command_policies.response_policy\n                            ](await client.execute_command(*cmd.args, **cmd.kwargs))\n                        except Exception as e:\n                            cmd.result = e\n\n            if raise_on_error:\n                for cmd in todo:\n                    result = cmd.result\n                    if isinstance(result, Exception):\n                        command = \" \".join(map(safe_str, cmd.args))\n                        msg = (\n                            f\"Command # {cmd.position + 1} \"\n                            f\"({truncate_text(command)}) \"\n                            f\"of pipeline caused error: {result.args}\"\n                        )\n                        result.args = (msg,) + result.args[1:]\n                        raise result\n\n            default_cluster_node = client.get_default_node()\n\n            # Check whether the default node was used. In some cases,\n            # 'client.get_default_node()' may return None. The check below\n            # prevents a potential AttributeError.\n            if default_cluster_node is not None:\n                default_node = nodes.get(default_cluster_node.name)\n                if default_node is not None:\n                    # This pipeline execution used the default node, check if we need\n                    # to replace it.\n                    # Note: when the error is raised we'll reset the default node in the\n                    # caller function.\n                    for cmd in default_node[1]:\n                        # Check if it has a command that failed with a relevant\n                        # exception\n                        if type(cmd.result) in RedisCluster.ERRORS_ALLOW_RETRY:\n                            client.replace_default_node()\n                            break\n\n        return [cmd.result for cmd in stack]\n\n    async def reset(self):\n        \"\"\"\n        Reset back to empty pipeline.\n        \"\"\"\n        self._command_queue = []\n\n    def multi(self):\n        raise RedisClusterException(\n            \"method multi() is not supported outside of transactional context\"\n        )\n\n    async def watch(self, *names):\n        raise RedisClusterException(\n            \"method watch() is not supported outside of transactional context\"\n        )\n\n    async def unwatch(self):\n        raise RedisClusterException(\n            \"method unwatch() is not supported outside of transactional context\"\n        )\n\n    async def discard(self):\n        raise RedisClusterException(\n            \"method discard() is not supported outside of transactional context\"\n        )\n\n    async def unlink(self, *names):\n        if len(names) != 1:\n            raise RedisClusterException(\n                \"unlinking multiple keys is not implemented in pipeline command\"\n            )\n\n        return self.execute_command(\"UNLINK\", names[0])\n\n\nclass TransactionStrategy(AbstractStrategy):\n    NO_SLOTS_COMMANDS = {\"UNWATCH\"}\n    IMMEDIATE_EXECUTE_COMMANDS = {\"WATCH\", \"UNWATCH\"}\n    UNWATCH_COMMANDS = {\"DISCARD\", \"EXEC\", \"UNWATCH\"}\n    SLOT_REDIRECT_ERRORS = (AskError, MovedError)\n    CONNECTION_ERRORS = (\n        ConnectionError,\n        OSError,\n        ClusterDownError,\n        SlotNotCoveredError,\n    )\n\n    def __init__(self, pipe: ClusterPipeline) -> None:\n        super().__init__(pipe)\n        self._explicit_transaction = False\n        self._watching = False\n        self._pipeline_slots: Set[int] = set()\n        self._transaction_node: Optional[ClusterNode] = None\n        self._transaction_connection: Optional[Connection] = None\n        self._executing = False\n        self._retry = copy(self._pipe.cluster_client.retry)\n        self._retry.update_supported_errors(\n            RedisCluster.ERRORS_ALLOW_RETRY + self.SLOT_REDIRECT_ERRORS\n        )\n\n    def _get_client_and_connection_for_transaction(\n        self,\n    ) -> Tuple[ClusterNode, Connection]:\n        \"\"\"\n        Find a connection for a pipeline transaction.\n\n        For running an atomic transaction, watch keys ensure that contents have not been\n        altered as long as the watch commands for those keys were sent over the same\n        connection. So once we start watching a key, we fetch a connection to the\n        node that owns that slot and reuse it.\n        \"\"\"\n        if not self._pipeline_slots:\n            raise RedisClusterException(\n                \"At least a command with a key is needed to identify a node\"\n            )\n\n        node: ClusterNode = self._pipe.cluster_client.nodes_manager.get_node_from_slot(\n            list(self._pipeline_slots)[0], False\n        )\n        self._transaction_node = node\n\n        if not self._transaction_connection:\n            connection: Connection = self._transaction_node.acquire_connection()\n            self._transaction_connection = connection\n\n        return self._transaction_node, self._transaction_connection\n\n    def execute_command(self, *args: Union[KeyT, EncodableT], **kwargs: Any) -> \"Any\":\n        # Given the limitation of ClusterPipeline sync API, we have to run it in thread.\n        response = None\n        error = None\n\n        def runner():\n            nonlocal response\n            nonlocal error\n            try:\n                response = asyncio.run(self._execute_command(*args, **kwargs))\n            except Exception as e:\n                error = e\n\n        thread = threading.Thread(target=runner)\n        thread.start()\n        thread.join()\n\n        if error:\n            raise error\n\n        return response\n\n    async def _execute_command(\n        self, *args: Union[KeyT, EncodableT], **kwargs: Any\n    ) -> Any:\n        if self._pipe.cluster_client._initialize:\n            await self._pipe.cluster_client.initialize()\n\n        slot_number: Optional[int] = None\n        if args[0] not in self.NO_SLOTS_COMMANDS:\n            slot_number = await self._pipe.cluster_client._determine_slot(*args)\n\n        if (\n            self._watching or args[0] in self.IMMEDIATE_EXECUTE_COMMANDS\n        ) and not self._explicit_transaction:\n            if args[0] == \"WATCH\":\n                self._validate_watch()\n\n            if slot_number is not None:\n                if self._pipeline_slots and slot_number not in self._pipeline_slots:\n                    raise CrossSlotTransactionError(\n                        \"Cannot watch or send commands on different slots\"\n                    )\n\n                self._pipeline_slots.add(slot_number)\n            elif args[0] not in self.NO_SLOTS_COMMANDS:\n                raise RedisClusterException(\n                    f\"Cannot identify slot number for command: {args[0]},\"\n                    \"it cannot be triggered in a transaction\"\n                )\n\n            return self._immediate_execute_command(*args, **kwargs)\n        else:\n            if slot_number is not None:\n                self._pipeline_slots.add(slot_number)\n\n            return super().execute_command(*args, **kwargs)\n\n    def _validate_watch(self):\n        if self._explicit_transaction:\n            raise RedisError(\"Cannot issue a WATCH after a MULTI\")\n\n        self._watching = True\n\n    async def _immediate_execute_command(self, *args, **options):\n        return await self._retry.call_with_retry(\n            lambda: self._get_connection_and_send_command(*args, **options),\n            self._reinitialize_on_error,\n            with_failure_count=True,\n        )\n\n    async def _get_connection_and_send_command(self, *args, **options):\n        redis_node, connection = self._get_client_and_connection_for_transaction()\n        # Only disconnect if not watching - disconnecting would lose WATCH state\n        if not self._watching:\n            await redis_node.disconnect_if_needed(connection)\n\n        # Start timing for observability\n        start_time = time.monotonic()\n\n        try:\n            response = await self._send_command_parse_response(\n                connection, redis_node, args[0], *args, **options\n            )\n\n            await record_operation_duration(\n                command_name=args[0],\n                duration_seconds=time.monotonic() - start_time,\n                server_address=connection.host,\n                server_port=connection.port,\n                db_namespace=str(connection.db),\n            )\n\n            return response\n        except Exception as e:\n            e.connection = connection\n            await record_operation_duration(\n                command_name=args[0],\n                duration_seconds=time.monotonic() - start_time,\n                server_address=connection.host,\n                server_port=connection.port,\n                db_namespace=str(connection.db),\n                error=e,\n            )\n            raise\n\n    async def _send_command_parse_response(\n        self,\n        connection: Connection,\n        redis_node: ClusterNode,\n        command_name,\n        *args,\n        **options,\n    ):\n        \"\"\"\n        Send a command and parse the response\n        \"\"\"\n\n        await connection.send_command(*args)\n        output = await redis_node.parse_response(connection, command_name, **options)\n\n        if command_name in self.UNWATCH_COMMANDS:\n            self._watching = False\n        return output\n\n    async def _reinitialize_on_error(self, error, failure_count):\n        if hasattr(error, \"connection\"):\n            await record_error_count(\n                server_address=error.connection.host,\n                server_port=error.connection.port,\n                network_peer_address=error.connection.host,\n                network_peer_port=error.connection.port,\n                error_type=error,\n                retry_attempts=failure_count,\n                is_internal=True,\n            )\n\n        if self._watching:\n            if type(error) in self.SLOT_REDIRECT_ERRORS and self._executing:\n                raise WatchError(\"Slot rebalancing occurred while watching keys\")\n\n        if (\n            type(error) in self.SLOT_REDIRECT_ERRORS\n            or type(error) in self.CONNECTION_ERRORS\n        ):\n            if self._transaction_connection and self._transaction_node:\n                # Disconnect and release back to pool\n                await self._transaction_connection.disconnect()\n                self._transaction_node.release(self._transaction_connection)\n                self._transaction_connection = None\n\n            self._pipe.cluster_client.reinitialize_counter += 1\n            if (\n                self._pipe.cluster_client.reinitialize_steps\n                and self._pipe.cluster_client.reinitialize_counter\n                % self._pipe.cluster_client.reinitialize_steps\n                == 0\n            ):\n                await self._pipe.cluster_client.nodes_manager.initialize()\n                self.reinitialize_counter = 0\n            else:\n                if isinstance(error, AskError):\n                    self._pipe.cluster_client.nodes_manager.move_slot(error)\n\n        self._executing = False\n\n    async def _raise_first_error(self, responses, stack, start_time):\n        \"\"\"\n        Raise the first exception on the stack\n        \"\"\"\n        for r, cmd in zip(responses, stack):\n            if isinstance(r, Exception):\n                self._annotate_exception(r, cmd.position + 1, cmd.args)\n\n                await record_operation_duration(\n                    command_name=\"TRANSACTION\",\n                    duration_seconds=time.monotonic() - start_time,\n                    server_address=self._transaction_connection.host,\n                    server_port=self._transaction_connection.port,\n                    db_namespace=str(self._transaction_connection.db),\n                    error=r,\n                )\n\n                raise r\n\n    def mset_nonatomic(\n        self, mapping: Mapping[AnyKeyT, EncodableT]\n    ) -> \"ClusterPipeline\":\n        raise NotImplementedError(\"Method is not supported in transactional context.\")\n\n    async def execute(\n        self, raise_on_error: bool = True, allow_redirections: bool = True\n    ) -> List[Any]:\n        stack = self._command_queue\n        if not stack and (not self._watching or not self._pipeline_slots):\n            return []\n\n        return await self._execute_transaction_with_retries(stack, raise_on_error)\n\n    async def _execute_transaction_with_retries(\n        self, stack: List[\"PipelineCommand\"], raise_on_error: bool\n    ):\n        return await self._retry.call_with_retry(\n            lambda: self._execute_transaction(stack, raise_on_error),\n            lambda error, failure_count: self._reinitialize_on_error(\n                error, failure_count\n            ),\n            with_failure_count=True,\n        )\n\n    async def _execute_transaction(\n        self, stack: List[\"PipelineCommand\"], raise_on_error: bool\n    ):\n        if len(self._pipeline_slots) > 1:\n            raise CrossSlotTransactionError(\n                \"All keys involved in a cluster transaction must map to the same slot\"\n            )\n\n        self._executing = True\n\n        redis_node, connection = self._get_client_and_connection_for_transaction()\n        # Only disconnect if not watching - disconnecting would lose WATCH state\n        if not self._watching:\n            await redis_node.disconnect_if_needed(connection)\n\n        stack = chain(\n            [PipelineCommand(0, \"MULTI\")],\n            stack,\n            [PipelineCommand(0, \"EXEC\")],\n        )\n        commands = [c.args for c in stack if EMPTY_RESPONSE not in c.kwargs]\n        packed_commands = connection.pack_commands(commands)\n\n        # Start timing for observability\n        start_time = time.monotonic()\n\n        await connection.send_packed_command(packed_commands)\n        errors = []\n\n        # parse off the response for MULTI\n        # NOTE: we need to handle ResponseErrors here and continue\n        # so that we read all the additional command messages from\n        # the socket\n        try:\n            await redis_node.parse_response(connection, \"MULTI\")\n        except ResponseError as e:\n            self._annotate_exception(e, 0, \"MULTI\")\n            errors.append(e)\n        except self.CONNECTION_ERRORS as cluster_error:\n            self._annotate_exception(cluster_error, 0, \"MULTI\")\n            cluster_error.connection = connection\n            raise\n\n        # and all the other commands\n        for i, command in enumerate(self._command_queue):\n            if EMPTY_RESPONSE in command.kwargs:\n                errors.append((i, command.kwargs[EMPTY_RESPONSE]))\n            else:\n                try:\n                    _ = await redis_node.parse_response(connection, \"_\")\n                except self.SLOT_REDIRECT_ERRORS as slot_error:\n                    self._annotate_exception(slot_error, i + 1, command.args)\n                    errors.append(slot_error)\n                except self.CONNECTION_ERRORS as cluster_error:\n                    self._annotate_exception(cluster_error, i + 1, command.args)\n                    cluster_error.connection = connection\n                    raise\n                except ResponseError as e:\n                    self._annotate_exception(e, i + 1, command.args)\n                    errors.append(e)\n\n        response = None\n        # parse the EXEC.\n        try:\n            response = await redis_node.parse_response(connection, \"EXEC\")\n        except ExecAbortError:\n            if errors:\n                raise errors[0]\n            raise\n\n        self._executing = False\n\n        # EXEC clears any watched keys\n        self._watching = False\n\n        if response is None:\n            raise WatchError(\"Watched variable changed.\")\n\n        # put any parse errors into the response\n        for i, e in errors:\n            response.insert(i, e)\n\n        if len(response) != len(self._command_queue):\n            raise InvalidPipelineStack(\n                \"Unexpected response length for cluster pipeline EXEC.\"\n                \" Command stack was {} but response had length {}\".format(\n                    [c.args[0] for c in self._command_queue], len(response)\n                )\n            )\n\n        # find any errors in the response and raise if necessary\n        if raise_on_error or len(errors) > 0:\n            await self._raise_first_error(\n                response,\n                self._command_queue,\n                start_time,\n            )\n\n        # We have to run response callbacks manually\n        data = []\n        for r, cmd in zip(response, self._command_queue):\n            if not isinstance(r, Exception):\n                command_name = cmd.args[0]\n                if command_name in self._pipe.cluster_client.response_callbacks:\n                    r = self._pipe.cluster_client.response_callbacks[command_name](\n                        r, **cmd.kwargs\n                    )\n            data.append(r)\n\n        await record_operation_duration(\n            command_name=\"TRANSACTION\",\n            duration_seconds=time.monotonic() - start_time,\n            server_address=connection.host,\n            server_port=connection.port,\n            db_namespace=str(connection.db),\n        )\n\n        return data\n\n    async def reset(self):\n        self._command_queue = []\n\n        # make sure to reset the connection state in the event that we were\n        # watching something\n        if self._transaction_connection:\n            try:\n                if self._watching:\n                    # call this manually since our unwatch or\n                    # immediate_execute_command methods can call reset()\n                    await self._transaction_connection.send_command(\"UNWATCH\")\n                    await self._transaction_connection.read_response()\n                # we can safely return the connection to the pool here since we're\n                # sure we're no longer WATCHing anything\n                self._transaction_node.release(self._transaction_connection)\n                self._transaction_connection = None\n            except self.CONNECTION_ERRORS:\n                # disconnect will also remove any previous WATCHes\n                if self._transaction_connection and self._transaction_node:\n                    await self._transaction_connection.disconnect()\n                    self._transaction_node.release(self._transaction_connection)\n                    self._transaction_connection = None\n\n        # clean up the other instance attributes\n        self._transaction_node = None\n        self._watching = False\n        self._explicit_transaction = False\n        self._pipeline_slots = set()\n        self._executing = False\n\n    def multi(self):\n        if self._explicit_transaction:\n            raise RedisError(\"Cannot issue nested calls to MULTI\")\n        if self._command_queue:\n            raise RedisError(\n                \"Commands without an initial WATCH have already been issued\"\n            )\n        self._explicit_transaction = True\n\n    async def watch(self, *names):\n        if self._explicit_transaction:\n            raise RedisError(\"Cannot issue a WATCH after a MULTI\")\n\n        return await self.execute_command(\"WATCH\", *names)\n\n    async def unwatch(self):\n        if self._watching:\n            return await self.execute_command(\"UNWATCH\")\n\n        return True\n\n    async def discard(self):\n        await self.reset()\n\n    async def unlink(self, *names):\n        return self.execute_command(\"UNLINK\", *names)\n"
  },
  {
    "path": "redis/asyncio/connection.py",
    "content": "import asyncio\nimport copy\nimport enum\nimport inspect\nimport socket\nimport sys\nimport time\nimport warnings\nimport weakref\nfrom abc import abstractmethod\nfrom itertools import chain\nfrom types import MappingProxyType\nfrom typing import (\n    Any,\n    Callable,\n    Iterable,\n    List,\n    Mapping,\n    Optional,\n    Protocol,\n    Set,\n    Tuple,\n    Type,\n    TypedDict,\n    TypeVar,\n    Union,\n)\nfrom urllib.parse import ParseResult, parse_qs, unquote, urlparse\n\nfrom ..observability.attributes import (\n    DB_CLIENT_CONNECTION_POOL_NAME,\n    DB_CLIENT_CONNECTION_STATE,\n    AttributeBuilder,\n    ConnectionState,\n    get_pool_name,\n)\nfrom ..utils import SSL_AVAILABLE\n\nif SSL_AVAILABLE:\n    import ssl\n    from ssl import SSLContext, TLSVersion, VerifyFlags\nelse:\n    ssl = None\n    TLSVersion = None\n    SSLContext = None\n    VerifyFlags = None\n\nfrom ..auth.token import TokenInterface\nfrom ..driver_info import DriverInfo, resolve_driver_info\nfrom ..event import AsyncAfterConnectionReleasedEvent, EventDispatcher\nfrom ..utils import deprecated_args, format_error_message\n\n# the functionality is available in 3.11.x but has a major issue before\n# 3.11.3. See https://github.com/redis/redis-py/issues/2633\nif sys.version_info >= (3, 11, 3):\n    from asyncio import timeout as async_timeout\nelse:\n    from async_timeout import timeout as async_timeout\n\nfrom redis.asyncio.observability.recorder import (\n    record_connection_closed,\n    record_connection_count,\n    record_connection_create_time,\n    record_connection_wait_time,\n    record_error_count,\n)\nfrom redis.asyncio.retry import Retry\nfrom redis.backoff import NoBackoff\nfrom redis.connection import DEFAULT_RESP_VERSION\nfrom redis.credentials import CredentialProvider, UsernamePasswordCredentialProvider\nfrom redis.exceptions import (\n    AuthenticationError,\n    AuthenticationWrongNumberOfArgsError,\n    ConnectionError,\n    DataError,\n    MaxConnectionsError,\n    RedisError,\n    ResponseError,\n    TimeoutError,\n)\nfrom redis.observability.metrics import CloseReason\nfrom redis.typing import EncodableT\nfrom redis.utils import HIREDIS_AVAILABLE, str_if_bytes\n\nfrom .._parsers import (\n    BaseParser,\n    Encoder,\n    _AsyncHiredisParser,\n    _AsyncRESP2Parser,\n    _AsyncRESP3Parser,\n)\n\nSYM_STAR = b\"*\"\nSYM_DOLLAR = b\"$\"\nSYM_CRLF = b\"\\r\\n\"\nSYM_LF = b\"\\n\"\nSYM_EMPTY = b\"\"\n\n\nclass _Sentinel(enum.Enum):\n    sentinel = object()\n\n\nSENTINEL = _Sentinel.sentinel\n\n\nDefaultParser: Type[Union[_AsyncRESP2Parser, _AsyncRESP3Parser, _AsyncHiredisParser]]\nif HIREDIS_AVAILABLE:\n    DefaultParser = _AsyncHiredisParser\nelse:\n    DefaultParser = _AsyncRESP2Parser\n\n\nclass ConnectCallbackProtocol(Protocol):\n    def __call__(self, connection: \"AbstractConnection\"): ...\n\n\nclass AsyncConnectCallbackProtocol(Protocol):\n    async def __call__(self, connection: \"AbstractConnection\"): ...\n\n\nConnectCallbackT = Union[ConnectCallbackProtocol, AsyncConnectCallbackProtocol]\n\n\nclass AbstractConnection:\n    \"\"\"Manages communication to and from a Redis server\"\"\"\n\n    __slots__ = (\n        \"db\",\n        \"username\",\n        \"client_name\",\n        \"lib_name\",\n        \"lib_version\",\n        \"credential_provider\",\n        \"password\",\n        \"socket_timeout\",\n        \"socket_connect_timeout\",\n        \"redis_connect_func\",\n        \"retry_on_timeout\",\n        \"retry_on_error\",\n        \"health_check_interval\",\n        \"next_health_check\",\n        \"last_active_at\",\n        \"encoder\",\n        \"ssl_context\",\n        \"protocol\",\n        \"_reader\",\n        \"_writer\",\n        \"_parser\",\n        \"_connect_callbacks\",\n        \"_buffer_cutoff\",\n        \"_lock\",\n        \"_socket_read_size\",\n        \"__dict__\",\n    )\n\n    @deprecated_args(\n        args_to_warn=[\"lib_name\", \"lib_version\"],\n        reason=\"Use 'driver_info' parameter instead. \"\n        \"lib_name and lib_version will be removed in a future version.\",\n    )\n    def __init__(\n        self,\n        *,\n        db: Union[str, int] = 0,\n        password: Optional[str] = None,\n        socket_timeout: Optional[float] = None,\n        socket_connect_timeout: Optional[float] = None,\n        retry_on_timeout: bool = False,\n        retry_on_error: Union[list, _Sentinel] = SENTINEL,\n        encoding: str = \"utf-8\",\n        encoding_errors: str = \"strict\",\n        decode_responses: bool = False,\n        parser_class: Type[BaseParser] = DefaultParser,\n        socket_read_size: int = 65536,\n        health_check_interval: float = 0,\n        client_name: Optional[str] = None,\n        lib_name: Optional[str] = None,\n        lib_version: Optional[str] = None,\n        driver_info: Optional[DriverInfo] = None,\n        username: Optional[str] = None,\n        retry: Optional[Retry] = None,\n        redis_connect_func: Optional[ConnectCallbackT] = None,\n        encoder_class: Type[Encoder] = Encoder,\n        credential_provider: Optional[CredentialProvider] = None,\n        protocol: Optional[int] = 2,\n        event_dispatcher: Optional[EventDispatcher] = None,\n    ):\n        \"\"\"\n        Initialize a new async Connection.\n\n        Parameters\n        ----------\n        driver_info : DriverInfo, optional\n            Driver metadata for CLIENT SETINFO. If provided, lib_name and lib_version\n            are ignored. If not provided, a DriverInfo will be created from lib_name\n            and lib_version (or defaults if those are also None).\n        lib_name : str, optional\n            **Deprecated.** Use driver_info instead. Library name for CLIENT SETINFO.\n        lib_version : str, optional\n            **Deprecated.** Use driver_info instead. Library version for CLIENT SETINFO.\n        \"\"\"\n        if (username or password) and credential_provider is not None:\n            raise DataError(\n                \"'username' and 'password' cannot be passed along with 'credential_\"\n                \"provider'. Please provide only one of the following arguments: \\n\"\n                \"1. 'password' and (optional) 'username'\\n\"\n                \"2. 'credential_provider'\"\n            )\n        if event_dispatcher is None:\n            self._event_dispatcher = EventDispatcher()\n        else:\n            self._event_dispatcher = event_dispatcher\n        self.db = db\n        self.client_name = client_name\n\n        # Handle driver_info: if provided, use it; otherwise create from lib_name/lib_version\n        self.driver_info = resolve_driver_info(driver_info, lib_name, lib_version)\n\n        self.credential_provider = credential_provider\n        self.password = password\n        self.username = username\n        self.socket_timeout = socket_timeout\n        if socket_connect_timeout is None:\n            socket_connect_timeout = socket_timeout\n        self.socket_connect_timeout = socket_connect_timeout\n        self.retry_on_timeout = retry_on_timeout\n        if retry_on_error is SENTINEL:\n            retry_on_error = []\n        if retry_on_timeout:\n            retry_on_error.append(TimeoutError)\n            retry_on_error.append(socket.timeout)\n            retry_on_error.append(asyncio.TimeoutError)\n        self.retry_on_error = retry_on_error\n        if retry or retry_on_error:\n            if not retry:\n                self.retry = Retry(NoBackoff(), 1)\n            else:\n                # deep-copy the Retry object as it is mutable\n                self.retry = copy.deepcopy(retry)\n            # Update the retry's supported errors with the specified errors\n            self.retry.update_supported_errors(retry_on_error)\n        else:\n            self.retry = Retry(NoBackoff(), 0)\n        self.health_check_interval = health_check_interval\n        self.next_health_check: float = -1\n        self.encoder = encoder_class(encoding, encoding_errors, decode_responses)\n        self.redis_connect_func = redis_connect_func\n        self._reader: Optional[asyncio.StreamReader] = None\n        self._writer: Optional[asyncio.StreamWriter] = None\n        self._socket_read_size = socket_read_size\n        self.set_parser(parser_class)\n        self._connect_callbacks: List[weakref.WeakMethod[ConnectCallbackT]] = []\n        self._buffer_cutoff = 6000\n        self._re_auth_token: Optional[TokenInterface] = None\n        self._should_reconnect = False\n\n        try:\n            p = int(protocol)\n        except TypeError:\n            p = DEFAULT_RESP_VERSION\n        except ValueError:\n            raise ConnectionError(\"protocol must be an integer\")\n        else:\n            if p < 2 or p > 3:\n                raise ConnectionError(\"protocol must be either 2 or 3\")\n        self.protocol = p\n\n    def __del__(self, _warnings: Any = warnings):\n        # For some reason, the individual streams don't get properly garbage\n        # collected and therefore produce no resource warnings.  We add one\n        # here, in the same style as those from the stdlib.\n        if getattr(self, \"_writer\", None):\n            _warnings.warn(\n                f\"unclosed Connection {self!r}\", ResourceWarning, source=self\n            )\n\n            try:\n                asyncio.get_running_loop()\n                self._close()\n            except RuntimeError:\n                # No actions been taken if pool already closed.\n                pass\n\n    def _close(self):\n        \"\"\"\n        Internal method to silently close the connection without waiting\n        \"\"\"\n        if self._writer:\n            self._writer.close()\n            self._writer = self._reader = None\n\n    def __repr__(self):\n        repr_args = \",\".join((f\"{k}={v}\" for k, v in self.repr_pieces()))\n        return f\"<{self.__class__.__module__}.{self.__class__.__name__}({repr_args})>\"\n\n    @abstractmethod\n    def repr_pieces(self):\n        pass\n\n    @property\n    def is_connected(self):\n        return self._reader is not None and self._writer is not None\n\n    def register_connect_callback(self, callback):\n        \"\"\"\n        Register a callback to be called when the connection is established either\n        initially or reconnected.  This allows listeners to issue commands that\n        are ephemeral to the connection, for example pub/sub subscription or\n        key tracking.  The callback must be a _method_ and will be kept as\n        a weak reference.\n        \"\"\"\n        wm = weakref.WeakMethod(callback)\n        if wm not in self._connect_callbacks:\n            self._connect_callbacks.append(wm)\n\n    def deregister_connect_callback(self, callback):\n        \"\"\"\n        De-register a previously registered callback.  It will no-longer receive\n        notifications on connection events.  Calling this is not required when the\n        listener goes away, since the callbacks are kept as weak methods.\n        \"\"\"\n        try:\n            self._connect_callbacks.remove(weakref.WeakMethod(callback))\n        except ValueError:\n            pass\n\n    def set_parser(self, parser_class: Type[BaseParser]) -> None:\n        \"\"\"\n        Creates a new instance of parser_class with socket size:\n        _socket_read_size and assigns it to the parser for the connection\n        :param parser_class: The required parser class\n        \"\"\"\n        self._parser = parser_class(socket_read_size=self._socket_read_size)\n\n    async def connect(self):\n        \"\"\"Connects to the Redis server if not already connected\"\"\"\n        # try once the socket connect with the handshake, retry the whole\n        # connect/handshake flow based on retry policy\n        await self.retry.call_with_retry(\n            lambda: self.connect_check_health(\n                check_health=True, retry_socket_connect=False\n            ),\n            lambda error, failure_count: self.disconnect(\n                error=error, failure_count=failure_count\n            ),\n            with_failure_count=True,\n        )\n\n    async def connect_check_health(\n        self, check_health: bool = True, retry_socket_connect: bool = True\n    ):\n        if self.is_connected:\n            return\n        # Track actual retry attempts for error reporting\n        actual_retry_attempts = 0\n\n        def failure_callback(error, failure_count):\n            nonlocal actual_retry_attempts\n            actual_retry_attempts = failure_count\n            return self.disconnect(error=error, failure_count=failure_count)\n\n        try:\n            if retry_socket_connect:\n                await self.retry.call_with_retry(\n                    lambda: self._connect(),\n                    failure_callback,\n                    with_failure_count=True,\n                )\n            else:\n                await self._connect()\n        except asyncio.CancelledError:\n            raise  # in 3.7 and earlier, this is an Exception, not BaseException\n        except (socket.timeout, asyncio.TimeoutError):\n            e = TimeoutError(\"Timeout connecting to server\")\n            await record_error_count(\n                server_address=getattr(self, \"host\", None),\n                server_port=getattr(self, \"port\", None),\n                network_peer_address=getattr(self, \"host\", None),\n                network_peer_port=getattr(self, \"port\", None),\n                error_type=e,\n                retry_attempts=actual_retry_attempts,\n                is_internal=False,\n            )\n            raise e\n        except OSError as e:\n            e = ConnectionError(self._error_message(e))\n            await record_error_count(\n                server_address=getattr(self, \"host\", None),\n                server_port=getattr(self, \"port\", None),\n                network_peer_address=getattr(self, \"host\", None),\n                network_peer_port=getattr(self, \"port\", None),\n                error_type=e,\n                retry_attempts=actual_retry_attempts,\n                is_internal=False,\n            )\n            raise e\n        except Exception as exc:\n            raise ConnectionError(exc) from exc\n\n        try:\n            if not self.redis_connect_func:\n                # Use the default on_connect function\n                await self.on_connect_check_health(check_health=check_health)\n            else:\n                # Use the passed function redis_connect_func\n                (\n                    await self.redis_connect_func(self)\n                    if asyncio.iscoroutinefunction(self.redis_connect_func)\n                    else self.redis_connect_func(self)\n                )\n        except RedisError:\n            # clean up after any error in on_connect\n            await self.disconnect()\n            raise\n\n        # run any user callbacks. right now the only internal callback\n        # is for pubsub channel/pattern resubscription\n        # first, remove any dead weakrefs\n        self._connect_callbacks = [ref for ref in self._connect_callbacks if ref()]\n        for ref in self._connect_callbacks:\n            callback = ref()\n            task = callback(self)\n            if task and inspect.isawaitable(task):\n                await task\n\n    def mark_for_reconnect(self):\n        self._should_reconnect = True\n\n    def should_reconnect(self):\n        return self._should_reconnect\n\n    def reset_should_reconnect(self):\n        self._should_reconnect = False\n\n    @abstractmethod\n    async def _connect(self):\n        pass\n\n    @abstractmethod\n    def _host_error(self) -> str:\n        pass\n\n    def _error_message(self, exception: BaseException) -> str:\n        return format_error_message(self._host_error(), exception)\n\n    def get_protocol(self):\n        return self.protocol\n\n    async def on_connect(self) -> None:\n        \"\"\"Initialize the connection, authenticate and select a database\"\"\"\n        await self.on_connect_check_health(check_health=True)\n\n    async def on_connect_check_health(self, check_health: bool = True) -> None:\n        self._parser.on_connect(self)\n        parser = self._parser\n\n        auth_args = None\n        # if credential provider or username and/or password are set, authenticate\n        if self.credential_provider or (self.username or self.password):\n            cred_provider = (\n                self.credential_provider\n                or UsernamePasswordCredentialProvider(self.username, self.password)\n            )\n            auth_args = await cred_provider.get_credentials_async()\n\n            # if resp version is specified and we have auth args,\n            # we need to send them via HELLO\n        if auth_args and self.protocol not in [2, \"2\"]:\n            if isinstance(self._parser, _AsyncRESP2Parser):\n                self.set_parser(_AsyncRESP3Parser)\n                # update cluster exception classes\n                self._parser.EXCEPTION_CLASSES = parser.EXCEPTION_CLASSES\n                self._parser.on_connect(self)\n            if len(auth_args) == 1:\n                auth_args = [\"default\", auth_args[0]]\n            # avoid checking health here -- PING will fail if we try\n            # to check the health prior to the AUTH\n            await self.send_command(\n                \"HELLO\", self.protocol, \"AUTH\", *auth_args, check_health=False\n            )\n            response = await self.read_response()\n            if response.get(b\"proto\") != int(self.protocol) and response.get(\n                \"proto\"\n            ) != int(self.protocol):\n                raise ConnectionError(\"Invalid RESP version\")\n        # avoid checking health here -- PING will fail if we try\n        # to check the health prior to the AUTH\n        elif auth_args:\n            await self.send_command(\"AUTH\", *auth_args, check_health=False)\n\n            try:\n                auth_response = await self.read_response()\n            except AuthenticationWrongNumberOfArgsError:\n                # a username and password were specified but the Redis\n                # server seems to be < 6.0.0 which expects a single password\n                # arg. retry auth with just the password.\n                # https://github.com/andymccurdy/redis-py/issues/1274\n                await self.send_command(\"AUTH\", auth_args[-1], check_health=False)\n                auth_response = await self.read_response()\n\n            if str_if_bytes(auth_response) != \"OK\":\n                raise AuthenticationError(\"Invalid Username or Password\")\n\n        # if resp version is specified, switch to it\n        elif self.protocol not in [2, \"2\"]:\n            if isinstance(self._parser, _AsyncRESP2Parser):\n                self.set_parser(_AsyncRESP3Parser)\n                # update cluster exception classes\n                self._parser.EXCEPTION_CLASSES = parser.EXCEPTION_CLASSES\n                self._parser.on_connect(self)\n            await self.send_command(\"HELLO\", self.protocol, check_health=check_health)\n            response = await self.read_response()\n            # if response.get(b\"proto\") != self.protocol and response.get(\n            #     \"proto\"\n            # ) != self.protocol:\n            #     raise ConnectionError(\"Invalid RESP version\")\n\n        # if a client_name is given, set it\n        if self.client_name:\n            await self.send_command(\n                \"CLIENT\",\n                \"SETNAME\",\n                self.client_name,\n                check_health=check_health,\n            )\n            if str_if_bytes(await self.read_response()) != \"OK\":\n                raise ConnectionError(\"Error setting client name\")\n\n        # Set the library name and version from driver_info, pipeline for lower startup latency\n        lib_name_sent = False\n        lib_version_sent = False\n\n        if self.driver_info and self.driver_info.formatted_name:\n            await self.send_command(\n                \"CLIENT\",\n                \"SETINFO\",\n                \"LIB-NAME\",\n                self.driver_info.formatted_name,\n                check_health=check_health,\n            )\n            lib_name_sent = True\n\n        if self.driver_info and self.driver_info.lib_version:\n            await self.send_command(\n                \"CLIENT\",\n                \"SETINFO\",\n                \"LIB-VER\",\n                self.driver_info.lib_version,\n                check_health=check_health,\n            )\n            lib_version_sent = True\n\n        # if a database is specified, switch to it. Also pipeline this\n        if self.db:\n            await self.send_command(\"SELECT\", self.db, check_health=check_health)\n\n        # read responses from pipeline\n        for _ in range(sum([lib_name_sent, lib_version_sent])):\n            try:\n                await self.read_response()\n            except ResponseError:\n                pass\n\n        if self.db:\n            if str_if_bytes(await self.read_response()) != \"OK\":\n                raise ConnectionError(\"Invalid Database\")\n\n    async def disconnect(\n        self,\n        nowait: bool = False,\n        error: Optional[Exception] = None,\n        failure_count: Optional[int] = None,\n        health_check_failed: bool = False,\n    ) -> None:\n        \"\"\"Disconnects from the Redis server\"\"\"\n        try:\n            async with async_timeout(self.socket_connect_timeout):\n                self._parser.on_disconnect()\n                # Reset the reconnect flag\n                self.reset_should_reconnect()\n                if not self.is_connected:\n                    return\n                try:\n                    self._writer.close()  # type: ignore[union-attr]\n                    # wait for close to finish, except when handling errors and\n                    # forcefully disconnecting.\n                    if not nowait:\n                        await self._writer.wait_closed()  # type: ignore[union-attr]\n                except OSError:\n                    pass\n                finally:\n                    self._reader = None\n                    self._writer = None\n        except asyncio.TimeoutError:\n            raise TimeoutError(\n                f\"Timed out closing connection after {self.socket_connect_timeout}\"\n            ) from None\n\n        if error:\n            if health_check_failed:\n                close_reason = CloseReason.HEALTHCHECK_FAILED\n            else:\n                close_reason = CloseReason.ERROR\n\n            if failure_count is not None and failure_count > self.retry.get_retries():\n                await record_error_count(\n                    server_address=getattr(self, \"host\", None),\n                    server_port=getattr(self, \"port\", None),\n                    network_peer_address=getattr(self, \"host\", None),\n                    network_peer_port=getattr(self, \"port\", None),\n                    error_type=error,\n                    retry_attempts=failure_count,\n                )\n\n            await record_connection_closed(\n                close_reason=close_reason,\n                error_type=error,\n            )\n        else:\n            await record_connection_closed(\n                close_reason=CloseReason.APPLICATION_CLOSE,\n            )\n\n    async def _send_ping(self):\n        \"\"\"Send PING, expect PONG in return\"\"\"\n        await self.send_command(\"PING\", check_health=False)\n        if str_if_bytes(await self.read_response()) != \"PONG\":\n            raise ConnectionError(\"Bad response from PING health check\")\n\n    async def _ping_failed(self, error, failure_count):\n        \"\"\"Function to call when PING fails\"\"\"\n        await self.disconnect(\n            error=error, failure_count=failure_count, health_check_failed=True\n        )\n\n    async def check_health(self):\n        \"\"\"Check the health of the connection with a PING/PONG\"\"\"\n        if (\n            self.health_check_interval\n            and asyncio.get_running_loop().time() > self.next_health_check\n        ):\n            await self.retry.call_with_retry(\n                self._send_ping, self._ping_failed, with_failure_count=True\n            )\n\n    async def _send_packed_command(self, command: Iterable[bytes]) -> None:\n        self._writer.writelines(command)\n        await self._writer.drain()\n\n    async def send_packed_command(\n        self, command: Union[bytes, str, Iterable[bytes]], check_health: bool = True\n    ) -> None:\n        if not self.is_connected:\n            await self.connect_check_health(check_health=False)\n        if check_health:\n            await self.check_health()\n\n        try:\n            if isinstance(command, str):\n                command = command.encode()\n            if isinstance(command, bytes):\n                command = [command]\n            if self.socket_timeout:\n                await asyncio.wait_for(\n                    self._send_packed_command(command), self.socket_timeout\n                )\n            else:\n                self._writer.writelines(command)\n                await self._writer.drain()\n        except asyncio.TimeoutError:\n            await self.disconnect(nowait=True)\n            raise TimeoutError(\"Timeout writing to socket\") from None\n        except OSError as e:\n            await self.disconnect(nowait=True)\n            if len(e.args) == 1:\n                err_no, errmsg = \"UNKNOWN\", e.args[0]\n            else:\n                err_no = e.args[0]\n                errmsg = e.args[1]\n            raise ConnectionError(\n                f\"Error {err_no} while writing to socket. {errmsg}.\"\n            ) from e\n        except BaseException:\n            # BaseExceptions can be raised when a socket send operation is not\n            # finished, e.g. due to a timeout.  Ideally, a caller could then re-try\n            # to send un-sent data. However, the send_packed_command() API\n            # does not support it so there is no point in keeping the connection open.\n            await self.disconnect(nowait=True)\n            raise\n\n    async def send_command(self, *args: Any, **kwargs: Any) -> None:\n        \"\"\"Pack and send a command to the Redis server\"\"\"\n        await self.send_packed_command(\n            self.pack_command(*args), check_health=kwargs.get(\"check_health\", True)\n        )\n\n    async def can_read_destructive(self):\n        \"\"\"Poll the socket to see if there's data that can be read.\"\"\"\n        try:\n            return await self._parser.can_read_destructive()\n        except OSError as e:\n            await self.disconnect(nowait=True)\n            host_error = self._host_error()\n            raise ConnectionError(f\"Error while reading from {host_error}: {e.args}\")\n\n    async def read_response(\n        self,\n        disable_decoding: bool = False,\n        timeout: Optional[float] = None,\n        *,\n        disconnect_on_error: bool = True,\n        push_request: Optional[bool] = False,\n    ):\n        \"\"\"Read the response from a previously sent command\"\"\"\n        read_timeout = timeout if timeout is not None else self.socket_timeout\n        host_error = self._host_error()\n        try:\n            if read_timeout is not None and self.protocol in [\"3\", 3]:\n                async with async_timeout(read_timeout):\n                    response = await self._parser.read_response(\n                        disable_decoding=disable_decoding, push_request=push_request\n                    )\n            elif read_timeout is not None:\n                async with async_timeout(read_timeout):\n                    response = await self._parser.read_response(\n                        disable_decoding=disable_decoding\n                    )\n            elif self.protocol in [\"3\", 3]:\n                response = await self._parser.read_response(\n                    disable_decoding=disable_decoding, push_request=push_request\n                )\n            else:\n                response = await self._parser.read_response(\n                    disable_decoding=disable_decoding\n                )\n        except asyncio.TimeoutError:\n            if timeout is not None:\n                # user requested timeout, return None. Operation can be retried\n                return None\n            # it was a self.socket_timeout error.\n            if disconnect_on_error:\n                await self.disconnect(nowait=True)\n            raise TimeoutError(f\"Timeout reading from {host_error}\")\n        except OSError as e:\n            if disconnect_on_error:\n                await self.disconnect(nowait=True)\n            raise ConnectionError(f\"Error while reading from {host_error} : {e.args}\")\n        except BaseException:\n            # Also by default close in case of BaseException.  A lot of code\n            # relies on this behaviour when doing Command/Response pairs.\n            # See #1128.\n            if disconnect_on_error:\n                await self.disconnect(nowait=True)\n            raise\n\n        if self.health_check_interval:\n            next_time = asyncio.get_running_loop().time() + self.health_check_interval\n            self.next_health_check = next_time\n\n        if isinstance(response, ResponseError):\n            raise response from None\n        return response\n\n    def pack_command(self, *args: EncodableT) -> List[bytes]:\n        \"\"\"Pack a series of arguments into the Redis protocol\"\"\"\n        output = []\n        # the client might have included 1 or more literal arguments in\n        # the command name, e.g., 'CONFIG GET'. The Redis server expects these\n        # arguments to be sent separately, so split the first argument\n        # manually. These arguments should be bytestrings so that they are\n        # not encoded.\n        assert not isinstance(args[0], float)\n        if isinstance(args[0], str):\n            args = tuple(args[0].encode().split()) + args[1:]\n        elif b\" \" in args[0]:\n            args = tuple(args[0].split()) + args[1:]\n\n        buff = SYM_EMPTY.join((SYM_STAR, str(len(args)).encode(), SYM_CRLF))\n\n        buffer_cutoff = self._buffer_cutoff\n        for arg in map(self.encoder.encode, args):\n            # to avoid large string mallocs, chunk the command into the\n            # output list if we're sending large values or memoryviews\n            arg_length = len(arg)\n            if (\n                len(buff) > buffer_cutoff\n                or arg_length > buffer_cutoff\n                or isinstance(arg, memoryview)\n            ):\n                buff = SYM_EMPTY.join(\n                    (buff, SYM_DOLLAR, str(arg_length).encode(), SYM_CRLF)\n                )\n                output.append(buff)\n                output.append(arg)\n                buff = SYM_CRLF\n            else:\n                buff = SYM_EMPTY.join(\n                    (\n                        buff,\n                        SYM_DOLLAR,\n                        str(arg_length).encode(),\n                        SYM_CRLF,\n                        arg,\n                        SYM_CRLF,\n                    )\n                )\n        output.append(buff)\n        return output\n\n    def pack_commands(self, commands: Iterable[Iterable[EncodableT]]) -> List[bytes]:\n        \"\"\"Pack multiple commands into the Redis protocol\"\"\"\n        output: List[bytes] = []\n        pieces: List[bytes] = []\n        buffer_length = 0\n        buffer_cutoff = self._buffer_cutoff\n\n        for cmd in commands:\n            for chunk in self.pack_command(*cmd):\n                chunklen = len(chunk)\n                if (\n                    buffer_length > buffer_cutoff\n                    or chunklen > buffer_cutoff\n                    or isinstance(chunk, memoryview)\n                ):\n                    if pieces:\n                        output.append(SYM_EMPTY.join(pieces))\n                    buffer_length = 0\n                    pieces = []\n\n                if chunklen > buffer_cutoff or isinstance(chunk, memoryview):\n                    output.append(chunk)\n                else:\n                    pieces.append(chunk)\n                    buffer_length += chunklen\n\n        if pieces:\n            output.append(SYM_EMPTY.join(pieces))\n        return output\n\n    def _socket_is_empty(self):\n        \"\"\"Check if the socket is empty\"\"\"\n        return len(self._reader._buffer) == 0\n\n    async def process_invalidation_messages(self):\n        while not self._socket_is_empty():\n            await self.read_response(push_request=True)\n\n    def set_re_auth_token(self, token: TokenInterface):\n        self._re_auth_token = token\n\n    async def re_auth(self):\n        if self._re_auth_token is not None:\n            await self.send_command(\n                \"AUTH\",\n                self._re_auth_token.try_get(\"oid\"),\n                self._re_auth_token.get_value(),\n            )\n            await self.read_response()\n            self._re_auth_token = None\n\n\nclass Connection(AbstractConnection):\n    \"Manages TCP communication to and from a Redis server\"\n\n    def __init__(\n        self,\n        *,\n        host: str = \"localhost\",\n        port: Union[str, int] = 6379,\n        socket_keepalive: bool = False,\n        socket_keepalive_options: Optional[Mapping[int, Union[int, bytes]]] = None,\n        socket_type: int = 0,\n        **kwargs,\n    ):\n        self.host = host\n        self.port = int(port)\n        self.socket_keepalive = socket_keepalive\n        self.socket_keepalive_options = socket_keepalive_options or {}\n        self.socket_type = socket_type\n        super().__init__(**kwargs)\n\n    def repr_pieces(self):\n        pieces = [(\"host\", self.host), (\"port\", self.port), (\"db\", self.db)]\n        if self.client_name:\n            pieces.append((\"client_name\", self.client_name))\n        return pieces\n\n    def _connection_arguments(self) -> Mapping:\n        return {\"host\": self.host, \"port\": self.port}\n\n    async def _connect(self):\n        \"\"\"Create a TCP socket connection\"\"\"\n        async with async_timeout(self.socket_connect_timeout):\n            reader, writer = await asyncio.open_connection(\n                **self._connection_arguments()\n            )\n        self._reader = reader\n        self._writer = writer\n        sock = writer.transport.get_extra_info(\"socket\")\n        if sock:\n            sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)\n            try:\n                # TCP_KEEPALIVE\n                if self.socket_keepalive:\n                    sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)\n                    for k, v in self.socket_keepalive_options.items():\n                        sock.setsockopt(socket.SOL_TCP, k, v)\n\n            except (OSError, TypeError):\n                # `socket_keepalive_options` might contain invalid options\n                # causing an error. Do not leave the connection open.\n                writer.close()\n                raise\n\n    def _host_error(self) -> str:\n        return f\"{self.host}:{self.port}\"\n\n\nclass SSLConnection(Connection):\n    \"\"\"Manages SSL connections to and from the Redis server(s).\n    This class extends the Connection class, adding SSL functionality, and making\n    use of ssl.SSLContext (https://docs.python.org/3/library/ssl.html#ssl.SSLContext)\n    \"\"\"\n\n    def __init__(\n        self,\n        ssl_keyfile: Optional[str] = None,\n        ssl_certfile: Optional[str] = None,\n        ssl_cert_reqs: Union[str, ssl.VerifyMode] = \"required\",\n        ssl_include_verify_flags: Optional[List[\"ssl.VerifyFlags\"]] = None,\n        ssl_exclude_verify_flags: Optional[List[\"ssl.VerifyFlags\"]] = None,\n        ssl_ca_certs: Optional[str] = None,\n        ssl_ca_data: Optional[str] = None,\n        ssl_ca_path: Optional[str] = None,\n        ssl_check_hostname: bool = True,\n        ssl_min_version: Optional[TLSVersion] = None,\n        ssl_ciphers: Optional[str] = None,\n        ssl_password: Optional[str] = None,\n        **kwargs,\n    ):\n        if not SSL_AVAILABLE:\n            raise RedisError(\"Python wasn't built with SSL support\")\n\n        self.ssl_context: RedisSSLContext = RedisSSLContext(\n            keyfile=ssl_keyfile,\n            certfile=ssl_certfile,\n            cert_reqs=ssl_cert_reqs,\n            include_verify_flags=ssl_include_verify_flags,\n            exclude_verify_flags=ssl_exclude_verify_flags,\n            ca_certs=ssl_ca_certs,\n            ca_data=ssl_ca_data,\n            ca_path=ssl_ca_path,\n            check_hostname=ssl_check_hostname,\n            min_version=ssl_min_version,\n            ciphers=ssl_ciphers,\n            password=ssl_password,\n        )\n        super().__init__(**kwargs)\n\n    def _connection_arguments(self) -> Mapping:\n        kwargs = super()._connection_arguments()\n        kwargs[\"ssl\"] = self.ssl_context.get()\n        return kwargs\n\n    @property\n    def keyfile(self):\n        return self.ssl_context.keyfile\n\n    @property\n    def certfile(self):\n        return self.ssl_context.certfile\n\n    @property\n    def cert_reqs(self):\n        return self.ssl_context.cert_reqs\n\n    @property\n    def include_verify_flags(self):\n        return self.ssl_context.include_verify_flags\n\n    @property\n    def exclude_verify_flags(self):\n        return self.ssl_context.exclude_verify_flags\n\n    @property\n    def ca_certs(self):\n        return self.ssl_context.ca_certs\n\n    @property\n    def ca_data(self):\n        return self.ssl_context.ca_data\n\n    @property\n    def check_hostname(self):\n        return self.ssl_context.check_hostname\n\n    @property\n    def min_version(self):\n        return self.ssl_context.min_version\n\n\nclass RedisSSLContext:\n    __slots__ = (\n        \"keyfile\",\n        \"certfile\",\n        \"cert_reqs\",\n        \"include_verify_flags\",\n        \"exclude_verify_flags\",\n        \"ca_certs\",\n        \"ca_data\",\n        \"ca_path\",\n        \"context\",\n        \"check_hostname\",\n        \"min_version\",\n        \"ciphers\",\n        \"password\",\n    )\n\n    def __init__(\n        self,\n        keyfile: Optional[str] = None,\n        certfile: Optional[str] = None,\n        cert_reqs: Optional[Union[str, ssl.VerifyMode]] = None,\n        include_verify_flags: Optional[List[\"ssl.VerifyFlags\"]] = None,\n        exclude_verify_flags: Optional[List[\"ssl.VerifyFlags\"]] = None,\n        ca_certs: Optional[str] = None,\n        ca_data: Optional[str] = None,\n        ca_path: Optional[str] = None,\n        check_hostname: bool = False,\n        min_version: Optional[TLSVersion] = None,\n        ciphers: Optional[str] = None,\n        password: Optional[str] = None,\n    ):\n        if not SSL_AVAILABLE:\n            raise RedisError(\"Python wasn't built with SSL support\")\n\n        self.keyfile = keyfile\n        self.certfile = certfile\n        if cert_reqs is None:\n            cert_reqs = ssl.CERT_NONE\n        elif isinstance(cert_reqs, str):\n            CERT_REQS = {  # noqa: N806\n                \"none\": ssl.CERT_NONE,\n                \"optional\": ssl.CERT_OPTIONAL,\n                \"required\": ssl.CERT_REQUIRED,\n            }\n            if cert_reqs not in CERT_REQS:\n                raise RedisError(\n                    f\"Invalid SSL Certificate Requirements Flag: {cert_reqs}\"\n                )\n            cert_reqs = CERT_REQS[cert_reqs]\n        self.cert_reqs = cert_reqs\n        self.include_verify_flags = include_verify_flags\n        self.exclude_verify_flags = exclude_verify_flags\n        self.ca_certs = ca_certs\n        self.ca_data = ca_data\n        self.ca_path = ca_path\n        self.check_hostname = (\n            check_hostname if self.cert_reqs != ssl.CERT_NONE else False\n        )\n        self.min_version = min_version\n        self.ciphers = ciphers\n        self.password = password\n        self.context: Optional[SSLContext] = None\n\n    def get(self) -> SSLContext:\n        if not self.context:\n            context = ssl.create_default_context()\n            context.check_hostname = self.check_hostname\n            context.verify_mode = self.cert_reqs\n            if self.include_verify_flags:\n                for flag in self.include_verify_flags:\n                    context.verify_flags |= flag\n            if self.exclude_verify_flags:\n                for flag in self.exclude_verify_flags:\n                    context.verify_flags &= ~flag\n            if self.certfile or self.keyfile:\n                context.load_cert_chain(\n                    certfile=self.certfile,\n                    keyfile=self.keyfile,\n                    password=self.password,\n                )\n            if self.ca_certs or self.ca_data or self.ca_path:\n                context.load_verify_locations(\n                    cafile=self.ca_certs, capath=self.ca_path, cadata=self.ca_data\n                )\n            if self.min_version is not None:\n                context.minimum_version = self.min_version\n            if self.ciphers is not None:\n                context.set_ciphers(self.ciphers)\n            self.context = context\n        return self.context\n\n\nclass UnixDomainSocketConnection(AbstractConnection):\n    \"Manages UDS communication to and from a Redis server\"\n\n    def __init__(self, *, path: str = \"\", **kwargs):\n        self.path = path\n        super().__init__(**kwargs)\n\n    def repr_pieces(self) -> Iterable[Tuple[str, Union[str, int]]]:\n        pieces = [(\"path\", self.path), (\"db\", self.db)]\n        if self.client_name:\n            pieces.append((\"client_name\", self.client_name))\n        return pieces\n\n    async def _connect(self):\n        async with async_timeout(self.socket_connect_timeout):\n            reader, writer = await asyncio.open_unix_connection(path=self.path)\n        self._reader = reader\n        self._writer = writer\n        await self.on_connect()\n\n    def _host_error(self) -> str:\n        return self.path\n\n\nFALSE_STRINGS = (\"0\", \"F\", \"FALSE\", \"N\", \"NO\")\n\n\ndef to_bool(value) -> Optional[bool]:\n    if value is None or value == \"\":\n        return None\n    if isinstance(value, str) and value.upper() in FALSE_STRINGS:\n        return False\n    return bool(value)\n\n\ndef parse_ssl_verify_flags(value):\n    # flags are passed in as a string representation of a list,\n    # e.g. VERIFY_X509_STRICT, VERIFY_X509_PARTIAL_CHAIN\n    verify_flags_str = value.replace(\"[\", \"\").replace(\"]\", \"\")\n\n    verify_flags = []\n    for flag in verify_flags_str.split(\",\"):\n        flag = flag.strip()\n        if not hasattr(VerifyFlags, flag):\n            raise ValueError(f\"Invalid ssl verify flag: {flag}\")\n        verify_flags.append(getattr(VerifyFlags, flag))\n    return verify_flags\n\n\nURL_QUERY_ARGUMENT_PARSERS: Mapping[str, Callable[..., object]] = MappingProxyType(\n    {\n        \"db\": int,\n        \"socket_timeout\": float,\n        \"socket_connect_timeout\": float,\n        \"socket_keepalive\": to_bool,\n        \"retry_on_timeout\": to_bool,\n        \"max_connections\": int,\n        \"health_check_interval\": int,\n        \"ssl_check_hostname\": to_bool,\n        \"ssl_include_verify_flags\": parse_ssl_verify_flags,\n        \"ssl_exclude_verify_flags\": parse_ssl_verify_flags,\n        \"timeout\": float,\n    }\n)\n\n\nclass ConnectKwargs(TypedDict, total=False):\n    username: str\n    password: str\n    connection_class: Type[AbstractConnection]\n    host: str\n    port: int\n    db: int\n    path: str\n\n\ndef parse_url(url: str) -> ConnectKwargs:\n    parsed: ParseResult = urlparse(url)\n    kwargs: ConnectKwargs = {}\n\n    for name, value_list in parse_qs(parsed.query).items():\n        if value_list and len(value_list) > 0:\n            value = unquote(value_list[0])\n            parser = URL_QUERY_ARGUMENT_PARSERS.get(name)\n            if parser:\n                try:\n                    kwargs[name] = parser(value)\n                except (TypeError, ValueError):\n                    raise ValueError(f\"Invalid value for '{name}' in connection URL.\")\n            else:\n                kwargs[name] = value\n\n    if parsed.username:\n        kwargs[\"username\"] = unquote(parsed.username)\n    if parsed.password:\n        kwargs[\"password\"] = unquote(parsed.password)\n\n    # We only support redis://, rediss:// and unix:// schemes.\n    if parsed.scheme == \"unix\":\n        if parsed.path:\n            kwargs[\"path\"] = unquote(parsed.path)\n        kwargs[\"connection_class\"] = UnixDomainSocketConnection\n\n    elif parsed.scheme in (\"redis\", \"rediss\"):\n        if parsed.hostname:\n            kwargs[\"host\"] = unquote(parsed.hostname)\n        if parsed.port:\n            kwargs[\"port\"] = int(parsed.port)\n\n        # If there's a path argument, use it as the db argument if a\n        # querystring value wasn't specified\n        if parsed.path and \"db\" not in kwargs:\n            try:\n                kwargs[\"db\"] = int(unquote(parsed.path).replace(\"/\", \"\"))\n            except (AttributeError, ValueError):\n                pass\n\n        if parsed.scheme == \"rediss\":\n            kwargs[\"connection_class\"] = SSLConnection\n\n    else:\n        valid_schemes = \"redis://, rediss://, unix://\"\n        raise ValueError(\n            f\"Redis URL must specify one of the following schemes ({valid_schemes})\"\n        )\n\n    return kwargs\n\n\n_CP = TypeVar(\"_CP\", bound=\"ConnectionPool\")\n\n\nclass ConnectionPool:\n    \"\"\"\n    Create a connection pool. ``If max_connections`` is set, then this\n    object raises :py:class:`~redis.ConnectionError` when the pool's\n    limit is reached.\n\n    By default, TCP connections are created unless ``connection_class``\n    is specified. Use :py:class:`~redis.UnixDomainSocketConnection` for\n    unix sockets.\n    :py:class:`~redis.SSLConnection` can be used for SSL enabled connections.\n\n    Any additional keyword arguments are passed to the constructor of\n    ``connection_class``.\n    \"\"\"\n\n    @classmethod\n    def from_url(cls: Type[_CP], url: str, **kwargs) -> _CP:\n        \"\"\"\n        Return a connection pool configured from the given URL.\n\n        For example::\n\n            redis://[[username]:[password]]@localhost:6379/0\n            rediss://[[username]:[password]]@localhost:6379/0\n            unix://[username@]/path/to/socket.sock?db=0[&password=password]\n\n        Three URL schemes are supported:\n\n        - `redis://` creates a TCP socket connection. See more at:\n          <https://www.iana.org/assignments/uri-schemes/prov/redis>\n        - `rediss://` creates a SSL wrapped TCP socket connection. See more at:\n          <https://www.iana.org/assignments/uri-schemes/prov/rediss>\n        - ``unix://``: creates a Unix Domain Socket connection.\n\n        The username, password, hostname, path and all querystring values\n        are passed through urllib.parse.unquote in order to replace any\n        percent-encoded values with their corresponding characters.\n\n        There are several ways to specify a database number. The first value\n        found will be used:\n\n        1. A ``db`` querystring option, e.g. redis://localhost?db=0\n\n        2. If using the redis:// or rediss:// schemes, the path argument\n               of the url, e.g. redis://localhost/0\n\n        3. A ``db`` keyword argument to this function.\n\n        If none of these options are specified, the default db=0 is used.\n\n        All querystring options are cast to their appropriate Python types.\n        Boolean arguments can be specified with string values \"True\"/\"False\"\n        or \"Yes\"/\"No\". Values that cannot be properly cast cause a\n        ``ValueError`` to be raised. Once parsed, the querystring arguments\n        and keyword arguments are passed to the ``ConnectionPool``'s\n        class initializer. In the case of conflicting arguments, querystring\n        arguments always win.\n        \"\"\"\n        url_options = parse_url(url)\n        kwargs.update(url_options)\n        return cls(**kwargs)\n\n    def __init__(\n        self,\n        connection_class: Type[AbstractConnection] = Connection,\n        max_connections: Optional[int] = None,\n        **connection_kwargs,\n    ):\n        max_connections = max_connections or 2**31\n        if not isinstance(max_connections, int) or max_connections < 0:\n            raise ValueError('\"max_connections\" must be a positive integer')\n\n        self.connection_class = connection_class\n        self.connection_kwargs = connection_kwargs\n        self.max_connections = max_connections\n\n        self._available_connections: List[AbstractConnection] = []\n        self._in_use_connections: Set[AbstractConnection] = set()\n        self.encoder_class = self.connection_kwargs.get(\"encoder_class\", Encoder)\n        self._lock = asyncio.Lock()\n        self._event_dispatcher = self.connection_kwargs.get(\"event_dispatcher\", None)\n        if self._event_dispatcher is None:\n            self._event_dispatcher = EventDispatcher()\n\n    # Keys that should be redacted in __repr__ to avoid exposing sensitive information\n    SENSITIVE_REPR_KEYS = frozenset(\n        {\n            \"password\",\n            \"username\",\n            \"ssl_password\",\n            \"credential_provider\",\n        }\n    )\n\n    def __repr__(self):\n        conn_kwargs = \",\".join(\n            [\n                f\"{k}={'<REDACTED>' if k in self.SENSITIVE_REPR_KEYS else v}\"\n                for k, v in self.connection_kwargs.items()\n            ]\n        )\n        return (\n            f\"<{self.__class__.__module__}.{self.__class__.__name__}\"\n            f\"(<{self.connection_class.__module__}.{self.connection_class.__name__}\"\n            f\"({conn_kwargs})>)>\"\n        )\n\n    def reset(self):\n        # Record metrics for connections being removed before clearing\n        # (only if attributes exist - they won't during __init__)\n        if hasattr(self, \"_available_connections\") and hasattr(\n            self, \"_in_use_connections\"\n        ):\n            idle_count = len(self._available_connections)\n            in_use_count = len(self._in_use_connections)\n            if idle_count > 0 or in_use_count > 0:\n                pool_name = get_pool_name(self)\n                # Note: Using sync version since reset() is sync\n                from redis.observability.recorder import (\n                    record_connection_count as sync_record_connection_count,\n                )\n\n                if idle_count > 0:\n                    sync_record_connection_count(\n                        pool_name=pool_name,\n                        connection_state=ConnectionState.IDLE,\n                        counter=-idle_count,\n                    )\n                if in_use_count > 0:\n                    sync_record_connection_count(\n                        pool_name=pool_name,\n                        connection_state=ConnectionState.USED,\n                        counter=-in_use_count,\n                    )\n\n        self._available_connections = []\n        self._in_use_connections = weakref.WeakSet()\n\n    def __del__(self) -> None:\n        \"\"\"Clean up connection pool and record metrics when garbage collected.\"\"\"\n        try:\n            if not hasattr(self, \"_available_connections\") or not hasattr(\n                self, \"_in_use_connections\"\n            ):\n                return\n            idle_count = len(self._available_connections)\n            in_use_count = len(self._in_use_connections)\n            if idle_count > 0 or in_use_count > 0:\n                pool_name = get_pool_name(self)\n                # Note: Using sync version since __del__ is sync\n                from redis.observability.recorder import (\n                    record_connection_count as sync_record_connection_count,\n                )\n\n                if idle_count > 0:\n                    sync_record_connection_count(\n                        pool_name=pool_name,\n                        connection_state=ConnectionState.IDLE,\n                        counter=-idle_count,\n                    )\n                if in_use_count > 0:\n                    sync_record_connection_count(\n                        pool_name=pool_name,\n                        connection_state=ConnectionState.USED,\n                        counter=-in_use_count,\n                    )\n        except Exception:\n            pass\n\n    def can_get_connection(self) -> bool:\n        \"\"\"Return True if a connection can be retrieved from the pool.\"\"\"\n        return (\n            self._available_connections\n            or len(self._in_use_connections) < self.max_connections\n        )\n\n    @deprecated_args(\n        args_to_warn=[\"*\"],\n        reason=\"Use get_connection() without args instead\",\n        version=\"5.3.0\",\n    )\n    async def get_connection(self, command_name=None, *keys, **options):\n        \"\"\"Get a connected connection from the pool\"\"\"\n        # Track connection count before to detect if a new connection is created\n        async with self._lock:\n            connections_before = len(self._available_connections) + len(\n                self._in_use_connections\n            )\n            start_time_created = time.monotonic()\n            connection = self.get_available_connection()\n            connections_after = len(self._available_connections) + len(\n                self._in_use_connections\n            )\n            is_created = connections_after > connections_before\n\n        # Record state transition for observability\n        # This ensures counters stay balanced if ensure_connection() fails and release() is called\n        pool_name = get_pool_name(self)\n        if is_created:\n            # New connection created and acquired: just USED +1\n            await record_connection_count(\n                pool_name=pool_name,\n                connection_state=ConnectionState.USED,\n                counter=1,\n            )\n        else:\n            # Existing connection acquired from pool: IDLE -> USED\n            await record_connection_count(\n                pool_name=pool_name,\n                connection_state=ConnectionState.IDLE,\n                counter=-1,\n            )\n            await record_connection_count(\n                pool_name=pool_name,\n                connection_state=ConnectionState.USED,\n                counter=1,\n            )\n\n        # We now perform the connection check outside of the lock.\n        try:\n            await self.ensure_connection(connection)\n\n            if is_created:\n                await record_connection_create_time(\n                    connection_pool=self,\n                    duration_seconds=time.monotonic() - start_time_created,\n                )\n\n            return connection\n        except BaseException:\n            await self.release(connection)\n            raise\n\n    def get_available_connection(self):\n        \"\"\"Get a connection from the pool, without making sure it is connected\"\"\"\n        try:\n            connection = self._available_connections.pop()\n        except IndexError:\n            if len(self._in_use_connections) >= self.max_connections:\n                raise MaxConnectionsError(\"Too many connections\") from None\n            connection = self.make_connection()\n        self._in_use_connections.add(connection)\n        return connection\n\n    def get_encoder(self):\n        \"\"\"Return an encoder based on encoding settings\"\"\"\n        kwargs = self.connection_kwargs\n        return self.encoder_class(\n            encoding=kwargs.get(\"encoding\", \"utf-8\"),\n            encoding_errors=kwargs.get(\"encoding_errors\", \"strict\"),\n            decode_responses=kwargs.get(\"decode_responses\", False),\n        )\n\n    def make_connection(self):\n        \"\"\"Create a new connection.  Can be overridden by child classes.\"\"\"\n        # Note: We don't record IDLE here because async uses a sync make_connection\n        # but async record_connection_count. The recording is handled in get_connection.\n        return self.connection_class(**self.connection_kwargs)\n\n    async def ensure_connection(self, connection: AbstractConnection):\n        \"\"\"Ensure that the connection object is connected and valid\"\"\"\n        await connection.connect()\n        # connections that the pool provides should be ready to send\n        # a command. if not, the connection was either returned to the\n        # pool before all data has been read or the socket has been\n        # closed. either way, reconnect and verify everything is good.\n        try:\n            if await connection.can_read_destructive():\n                raise ConnectionError(\"Connection has data\") from None\n        except (ConnectionError, TimeoutError, OSError):\n            await connection.disconnect()\n            await connection.connect()\n            if await connection.can_read_destructive():\n                raise ConnectionError(\"Connection not ready\") from None\n\n    async def release(self, connection: AbstractConnection):\n        \"\"\"Releases the connection back to the pool\"\"\"\n        # Connections should always be returned to the correct pool,\n        # not doing so is an error that will cause an exception here.\n        self._in_use_connections.remove(connection)\n\n        if connection.should_reconnect():\n            await connection.disconnect()\n\n        self._available_connections.append(connection)\n        await self._event_dispatcher.dispatch_async(\n            AsyncAfterConnectionReleasedEvent(connection)\n        )\n\n        # Record state transition: USED -> IDLE\n        pool_name = get_pool_name(self)\n        await record_connection_count(\n            pool_name=pool_name,\n            connection_state=ConnectionState.USED,\n            counter=-1,\n        )\n        await record_connection_count(\n            pool_name=pool_name,\n            connection_state=ConnectionState.IDLE,\n            counter=1,\n        )\n\n    async def disconnect(self, inuse_connections: bool = True):\n        \"\"\"\n        Disconnects connections in the pool\n\n        If ``inuse_connections`` is True, disconnect connections that are\n        current in use, potentially by other tasks. Otherwise only disconnect\n        connections that are idle in the pool.\n        \"\"\"\n        if inuse_connections:\n            connections: Iterable[AbstractConnection] = chain(\n                self._available_connections, self._in_use_connections\n            )\n        else:\n            connections = self._available_connections\n        resp = await asyncio.gather(\n            *(connection.disconnect() for connection in connections),\n            return_exceptions=True,\n        )\n\n        exc = next((r for r in resp if isinstance(r, BaseException)), None)\n        if exc:\n            raise exc\n\n    async def update_active_connections_for_reconnect(self):\n        \"\"\"\n        Mark all active connections for reconnect.\n        \"\"\"\n        async with self._lock:\n            for conn in self._in_use_connections:\n                conn.mark_for_reconnect()\n\n    async def aclose(self) -> None:\n        \"\"\"Close the pool, disconnecting all connections\"\"\"\n        await self.disconnect()\n\n    def set_retry(self, retry: \"Retry\") -> None:\n        for conn in self._available_connections:\n            conn.retry = retry\n        for conn in self._in_use_connections:\n            conn.retry = retry\n\n    async def re_auth_callback(self, token: TokenInterface):\n        async with self._lock:\n            for conn in self._available_connections:\n                await conn.retry.call_with_retry(\n                    lambda: conn.send_command(\n                        \"AUTH\", token.try_get(\"oid\"), token.get_value()\n                    ),\n                    lambda error: self._mock(error),\n                )\n                await conn.retry.call_with_retry(\n                    lambda: conn.read_response(), lambda error: self._mock(error)\n                )\n            for conn in self._in_use_connections:\n                conn.set_re_auth_token(token)\n\n    async def _mock(self, error: RedisError):\n        \"\"\"\n        Dummy functions, needs to be passed as error callback to retry object.\n        :param error:\n        :return:\n        \"\"\"\n        pass\n\n    def get_connection_count(self) -> List[tuple[int, dict]]:\n        \"\"\"\n        Returns a connection count (both idle and in use).\n        \"\"\"\n        attributes = AttributeBuilder.build_base_attributes()\n        attributes[DB_CLIENT_CONNECTION_POOL_NAME] = get_pool_name(self)\n        free_connections_attributes = attributes.copy()\n        in_use_connections_attributes = attributes.copy()\n\n        free_connections_attributes[DB_CLIENT_CONNECTION_STATE] = (\n            ConnectionState.IDLE.value\n        )\n        in_use_connections_attributes[DB_CLIENT_CONNECTION_STATE] = (\n            ConnectionState.USED.value\n        )\n\n        return [\n            (len(self._available_connections), free_connections_attributes),\n            (len(self._in_use_connections), in_use_connections_attributes),\n        ]\n\n\nclass BlockingConnectionPool(ConnectionPool):\n    \"\"\"\n    A blocking connection pool::\n\n        >>> from redis.asyncio import Redis, BlockingConnectionPool\n        >>> client = Redis.from_pool(BlockingConnectionPool())\n\n    It performs the same function as the default\n    :py:class:`~redis.asyncio.ConnectionPool` implementation, in that,\n    it maintains a pool of reusable connections that can be shared by\n    multiple async redis clients.\n\n    The difference is that, in the event that a client tries to get a\n    connection from the pool when all of connections are in use, rather than\n    raising a :py:class:`~redis.ConnectionError` (as the default\n    :py:class:`~redis.asyncio.ConnectionPool` implementation does), it\n    blocks the current `Task` for a specified number of seconds until\n    a connection becomes available.\n\n    Use ``max_connections`` to increase / decrease the pool size::\n\n        >>> pool = BlockingConnectionPool(max_connections=10)\n\n    Use ``timeout`` to tell it either how many seconds to wait for a connection\n    to become available, or to block forever:\n\n        >>> # Block forever.\n        >>> pool = BlockingConnectionPool(timeout=None)\n\n        >>> # Raise a ``ConnectionError`` after five seconds if a connection is\n        >>> # not available.\n        >>> pool = BlockingConnectionPool(timeout=5)\n    \"\"\"\n\n    def __init__(\n        self,\n        max_connections: int = 50,\n        timeout: Optional[float] = 20,\n        connection_class: Type[AbstractConnection] = Connection,\n        queue_class: Type[asyncio.Queue] = asyncio.LifoQueue,  # deprecated\n        **connection_kwargs,\n    ):\n        super().__init__(\n            connection_class=connection_class,\n            max_connections=max_connections,\n            **connection_kwargs,\n        )\n        self._condition = asyncio.Condition()\n        self.timeout = timeout\n\n    @deprecated_args(\n        args_to_warn=[\"*\"],\n        reason=\"Use get_connection() without args instead\",\n        version=\"5.3.0\",\n    )\n    async def get_connection(self, command_name=None, *keys, **options):\n        \"\"\"Gets a connection from the pool, blocking until one is available\"\"\"\n        # Start timing for wait time observability\n        start_time_acquired = time.monotonic()\n\n        try:\n            async with self._condition:\n                async with async_timeout(self.timeout):\n                    await self._condition.wait_for(self.can_get_connection)\n                    # Track connection count before to detect if a new connection is created\n                    connections_before = len(self._available_connections) + len(\n                        self._in_use_connections\n                    )\n                    start_time_created = time.monotonic()\n                    connection = super().get_available_connection()\n                    connections_after = len(self._available_connections) + len(\n                        self._in_use_connections\n                    )\n                    is_created = connections_after > connections_before\n        except asyncio.TimeoutError as err:\n            raise ConnectionError(\"No connection available.\") from err\n\n        # We now perform the connection check outside of the lock.\n        try:\n            await self.ensure_connection(connection)\n\n            if is_created:\n                await record_connection_create_time(\n                    connection_pool=self,\n                    duration_seconds=time.monotonic() - start_time_created,\n                )\n\n            await record_connection_wait_time(\n                pool_name=get_pool_name(self),\n                duration_seconds=time.monotonic() - start_time_acquired,\n            )\n\n            return connection\n        except BaseException:\n            await self.release(connection)\n            raise\n\n    async def release(self, connection: AbstractConnection):\n        \"\"\"Releases the connection back to the pool.\"\"\"\n        async with self._condition:\n            await super().release(connection)\n            self._condition.notify()\n"
  },
  {
    "path": "redis/asyncio/http/__init__.py",
    "content": ""
  },
  {
    "path": "redis/asyncio/http/http_client.py",
    "content": "import asyncio\nfrom abc import ABC, abstractmethod\nfrom concurrent.futures import ThreadPoolExecutor\nfrom typing import Any, Mapping, Optional, Union\n\nfrom redis.http.http_client import HttpClient, HttpResponse\n\nDEFAULT_USER_AGENT = \"HttpClient/1.0 (+https://example.invalid)\"\nDEFAULT_TIMEOUT = 30.0\nRETRY_STATUS_CODES = {429, 500, 502, 503, 504}\n\n\nclass AsyncHTTPClient(ABC):\n    @abstractmethod\n    async def get(\n        self,\n        path: str,\n        params: Optional[\n            Mapping[str, Union[None, str, int, float, bool, list, tuple]]\n        ] = None,\n        headers: Optional[Mapping[str, str]] = None,\n        timeout: Optional[float] = None,\n        expect_json: bool = True,\n    ) -> Union[HttpResponse, Any]:\n        \"\"\"\n        Invoke HTTP GET request.\"\"\"\n        pass\n\n    @abstractmethod\n    async def delete(\n        self,\n        path: str,\n        params: Optional[\n            Mapping[str, Union[None, str, int, float, bool, list, tuple]]\n        ] = None,\n        headers: Optional[Mapping[str, str]] = None,\n        timeout: Optional[float] = None,\n        expect_json: bool = True,\n    ) -> Union[HttpResponse, Any]:\n        \"\"\"\n        Invoke HTTP DELETE request.\"\"\"\n        pass\n\n    @abstractmethod\n    async def post(\n        self,\n        path: str,\n        json_body: Optional[Any] = None,\n        data: Optional[Union[bytes, str]] = None,\n        params: Optional[\n            Mapping[str, Union[None, str, int, float, bool, list, tuple]]\n        ] = None,\n        headers: Optional[Mapping[str, str]] = None,\n        timeout: Optional[float] = None,\n        expect_json: bool = True,\n    ) -> Union[HttpResponse, Any]:\n        \"\"\"\n        Invoke HTTP POST request.\"\"\"\n        pass\n\n    @abstractmethod\n    async def put(\n        self,\n        path: str,\n        json_body: Optional[Any] = None,\n        data: Optional[Union[bytes, str]] = None,\n        params: Optional[\n            Mapping[str, Union[None, str, int, float, bool, list, tuple]]\n        ] = None,\n        headers: Optional[Mapping[str, str]] = None,\n        timeout: Optional[float] = None,\n        expect_json: bool = True,\n    ) -> Union[HttpResponse, Any]:\n        \"\"\"\n        Invoke HTTP PUT request.\"\"\"\n        pass\n\n    @abstractmethod\n    async def patch(\n        self,\n        path: str,\n        json_body: Optional[Any] = None,\n        data: Optional[Union[bytes, str]] = None,\n        params: Optional[\n            Mapping[str, Union[None, str, int, float, bool, list, tuple]]\n        ] = None,\n        headers: Optional[Mapping[str, str]] = None,\n        timeout: Optional[float] = None,\n        expect_json: bool = True,\n    ) -> Union[HttpResponse, Any]:\n        \"\"\"\n        Invoke HTTP PATCH request.\"\"\"\n        pass\n\n    @abstractmethod\n    async def request(\n        self,\n        method: str,\n        path: str,\n        params: Optional[\n            Mapping[str, Union[None, str, int, float, bool, list, tuple]]\n        ] = None,\n        headers: Optional[Mapping[str, str]] = None,\n        body: Optional[Union[bytes, str]] = None,\n        timeout: Optional[float] = None,\n    ) -> HttpResponse:\n        \"\"\"\n        Invoke HTTP request with given method.\"\"\"\n        pass\n\n\nclass AsyncHTTPClientWrapper(AsyncHTTPClient):\n    \"\"\"\n    An async wrapper around sync HTTP client with thread pool execution.\n    \"\"\"\n\n    def __init__(self, client: HttpClient, max_workers: int = 10) -> None:\n        \"\"\"\n        Initialize a new HTTP client instance.\n\n        Args:\n            client: Sync HTTP client instance.\n            max_workers: Maximum number of concurrent requests.\n\n        The client supports both regular HTTPS with server verification and mutual TLS\n        authentication. For server verification, provide CA certificate information via\n        ca_file, ca_path or ca_data. For mutual TLS, additionally provide a client\n        certificate and key via client_cert_file and client_key_file.\n        \"\"\"\n        self.client = client\n        self._executor = ThreadPoolExecutor(max_workers=max_workers)\n\n    async def get(\n        self,\n        path: str,\n        params: Optional[\n            Mapping[str, Union[None, str, int, float, bool, list, tuple]]\n        ] = None,\n        headers: Optional[Mapping[str, str]] = None,\n        timeout: Optional[float] = None,\n        expect_json: bool = True,\n    ) -> Union[HttpResponse, Any]:\n        loop = asyncio.get_event_loop()\n        return await loop.run_in_executor(\n            self._executor, self.client.get, path, params, headers, timeout, expect_json\n        )\n\n    async def delete(\n        self,\n        path: str,\n        params: Optional[\n            Mapping[str, Union[None, str, int, float, bool, list, tuple]]\n        ] = None,\n        headers: Optional[Mapping[str, str]] = None,\n        timeout: Optional[float] = None,\n        expect_json: bool = True,\n    ) -> Union[HttpResponse, Any]:\n        loop = asyncio.get_event_loop()\n        return await loop.run_in_executor(\n            self._executor,\n            self.client.delete,\n            path,\n            params,\n            headers,\n            timeout,\n            expect_json,\n        )\n\n    async def post(\n        self,\n        path: str,\n        json_body: Optional[Any] = None,\n        data: Optional[Union[bytes, str]] = None,\n        params: Optional[\n            Mapping[str, Union[None, str, int, float, bool, list, tuple]]\n        ] = None,\n        headers: Optional[Mapping[str, str]] = None,\n        timeout: Optional[float] = None,\n        expect_json: bool = True,\n    ) -> Union[HttpResponse, Any]:\n        loop = asyncio.get_event_loop()\n        return await loop.run_in_executor(\n            self._executor,\n            self.client.post,\n            path,\n            json_body,\n            data,\n            params,\n            headers,\n            timeout,\n            expect_json,\n        )\n\n    async def put(\n        self,\n        path: str,\n        json_body: Optional[Any] = None,\n        data: Optional[Union[bytes, str]] = None,\n        params: Optional[\n            Mapping[str, Union[None, str, int, float, bool, list, tuple]]\n        ] = None,\n        headers: Optional[Mapping[str, str]] = None,\n        timeout: Optional[float] = None,\n        expect_json: bool = True,\n    ) -> Union[HttpResponse, Any]:\n        loop = asyncio.get_event_loop()\n        return await loop.run_in_executor(\n            self._executor,\n            self.client.put,\n            path,\n            json_body,\n            data,\n            params,\n            headers,\n            timeout,\n            expect_json,\n        )\n\n    async def patch(\n        self,\n        path: str,\n        json_body: Optional[Any] = None,\n        data: Optional[Union[bytes, str]] = None,\n        params: Optional[\n            Mapping[str, Union[None, str, int, float, bool, list, tuple]]\n        ] = None,\n        headers: Optional[Mapping[str, str]] = None,\n        timeout: Optional[float] = None,\n        expect_json: bool = True,\n    ) -> Union[HttpResponse, Any]:\n        loop = asyncio.get_event_loop()\n        return await loop.run_in_executor(\n            self._executor,\n            self.client.patch,\n            path,\n            json_body,\n            data,\n            params,\n            headers,\n            timeout,\n            expect_json,\n        )\n\n    async def request(\n        self,\n        method: str,\n        path: str,\n        params: Optional[\n            Mapping[str, Union[None, str, int, float, bool, list, tuple]]\n        ] = None,\n        headers: Optional[Mapping[str, str]] = None,\n        body: Optional[Union[bytes, str]] = None,\n        timeout: Optional[float] = None,\n    ) -> HttpResponse:\n        loop = asyncio.get_event_loop()\n        return await loop.run_in_executor(\n            self._executor,\n            self.client.request,\n            method,\n            path,\n            params,\n            headers,\n            body,\n            timeout,\n        )\n"
  },
  {
    "path": "redis/asyncio/lock.py",
    "content": "import asyncio\nimport logging\nimport threading\nimport uuid\nfrom types import SimpleNamespace\nfrom typing import TYPE_CHECKING, Awaitable, Optional, Union\n\nfrom redis.exceptions import LockError, LockNotOwnedError\nfrom redis.typing import Number\n\nif TYPE_CHECKING:\n    from redis.asyncio import Redis, RedisCluster\n\nlogger = logging.getLogger(__name__)\n\n\nclass Lock:\n    \"\"\"\n    A shared, distributed Lock. Using Redis for locking allows the Lock\n    to be shared across processes and/or machines.\n\n    It's left to the user to resolve deadlock issues and make sure\n    multiple clients play nicely together.\n    \"\"\"\n\n    lua_release = None\n    lua_extend = None\n    lua_reacquire = None\n\n    # KEYS[1] - lock name\n    # ARGV[1] - token\n    # return 1 if the lock was released, otherwise 0\n    LUA_RELEASE_SCRIPT = \"\"\"\n        local token = redis.call('get', KEYS[1])\n        if not token or token ~= ARGV[1] then\n            return 0\n        end\n        redis.call('del', KEYS[1])\n        return 1\n    \"\"\"\n\n    # KEYS[1] - lock name\n    # ARGV[1] - token\n    # ARGV[2] - additional milliseconds\n    # ARGV[3] - \"0\" if the additional time should be added to the lock's\n    #           existing ttl or \"1\" if the existing ttl should be replaced\n    # return 1 if the locks time was extended, otherwise 0\n    LUA_EXTEND_SCRIPT = \"\"\"\n        local token = redis.call('get', KEYS[1])\n        if not token or token ~= ARGV[1] then\n            return 0\n        end\n        local expiration = redis.call('pttl', KEYS[1])\n        if not expiration then\n            expiration = 0\n        end\n        if expiration < 0 then\n            return 0\n        end\n\n        local newttl = ARGV[2]\n        if ARGV[3] == \"0\" then\n            newttl = ARGV[2] + expiration\n        end\n        redis.call('pexpire', KEYS[1], newttl)\n        return 1\n    \"\"\"\n\n    # KEYS[1] - lock name\n    # ARGV[1] - token\n    # ARGV[2] - milliseconds\n    # return 1 if the locks time was reacquired, otherwise 0\n    LUA_REACQUIRE_SCRIPT = \"\"\"\n        local token = redis.call('get', KEYS[1])\n        if not token or token ~= ARGV[1] then\n            return 0\n        end\n        redis.call('pexpire', KEYS[1], ARGV[2])\n        return 1\n    \"\"\"\n\n    def __init__(\n        self,\n        redis: Union[\"Redis\", \"RedisCluster\"],\n        name: Union[str, bytes, memoryview],\n        timeout: Optional[float] = None,\n        sleep: float = 0.1,\n        blocking: bool = True,\n        blocking_timeout: Optional[Number] = None,\n        thread_local: bool = True,\n        raise_on_release_error: bool = True,\n    ):\n        \"\"\"\n        Create a new Lock instance named ``name`` using the Redis client\n        supplied by ``redis``.\n\n        ``timeout`` indicates a maximum life for the lock in seconds.\n        By default, it will remain locked until release() is called.\n        ``timeout`` can be specified as a float or integer, both representing\n        the number of seconds to wait.\n\n        ``sleep`` indicates the amount of time to sleep in seconds per loop\n        iteration when the lock is in blocking mode and another client is\n        currently holding the lock.\n\n        ``blocking`` indicates whether calling ``acquire`` should block until\n        the lock has been acquired or to fail immediately, causing ``acquire``\n        to return False and the lock not being acquired. Defaults to True.\n        Note this value can be overridden by passing a ``blocking``\n        argument to ``acquire``.\n\n        ``blocking_timeout`` indicates the maximum amount of time in seconds to\n        spend trying to acquire the lock. A value of ``None`` indicates\n        continue trying forever. ``blocking_timeout`` can be specified as a\n        float or integer, both representing the number of seconds to wait.\n\n        ``thread_local`` indicates whether the lock token is placed in\n        thread-local storage. By default, the token is placed in thread local\n        storage so that a thread only sees its token, not a token set by\n        another thread. Consider the following timeline:\n\n            time: 0, thread-1 acquires `my-lock`, with a timeout of 5 seconds.\n                     thread-1 sets the token to \"abc\"\n            time: 1, thread-2 blocks trying to acquire `my-lock` using the\n                     Lock instance.\n            time: 5, thread-1 has not yet completed. redis expires the lock\n                     key.\n            time: 5, thread-2 acquired `my-lock` now that it's available.\n                     thread-2 sets the token to \"xyz\"\n            time: 6, thread-1 finishes its work and calls release(). if the\n                     token is *not* stored in thread local storage, then\n                     thread-1 would see the token value as \"xyz\" and would be\n                     able to successfully release the thread-2's lock.\n\n        ``raise_on_release_error`` indicates whether to raise an exception when\n        the lock is no longer owned when exiting the context manager. By default,\n        this is True, meaning an exception will be raised. If False, the warning\n        will be logged and the exception will be suppressed.\n\n        In some use cases it's necessary to disable thread local storage. For\n        example, if you have code where one thread acquires a lock and passes\n        that lock instance to a worker thread to release later. If thread\n        local storage isn't disabled in this case, the worker thread won't see\n        the token set by the thread that acquired the lock. Our assumption\n        is that these cases aren't common and as such default to using\n        thread local storage.\n        \"\"\"\n        self.redis = redis\n        self.name = name\n        self.timeout = timeout\n        self.sleep = sleep\n        self.blocking = blocking\n        self.blocking_timeout = blocking_timeout\n        self.thread_local = bool(thread_local)\n        self.local = threading.local() if self.thread_local else SimpleNamespace()\n        self.raise_on_release_error = raise_on_release_error\n        self.local.token = None\n        self.register_scripts()\n\n    def register_scripts(self):\n        cls = self.__class__\n        client = self.redis\n        if cls.lua_release is None:\n            cls.lua_release = client.register_script(cls.LUA_RELEASE_SCRIPT)\n        if cls.lua_extend is None:\n            cls.lua_extend = client.register_script(cls.LUA_EXTEND_SCRIPT)\n        if cls.lua_reacquire is None:\n            cls.lua_reacquire = client.register_script(cls.LUA_REACQUIRE_SCRIPT)\n\n    async def __aenter__(self):\n        if await self.acquire():\n            return self\n        raise LockError(\"Unable to acquire lock within the time specified\")\n\n    async def __aexit__(self, exc_type, exc_value, traceback):\n        try:\n            await self.release()\n        except LockError:\n            if self.raise_on_release_error:\n                raise\n            logger.warning(\n                \"Lock was unlocked or no longer owned when exiting context manager.\"\n            )\n\n    async def acquire(\n        self,\n        blocking: Optional[bool] = None,\n        blocking_timeout: Optional[Number] = None,\n        token: Optional[Union[str, bytes]] = None,\n    ):\n        \"\"\"\n        Use Redis to hold a shared, distributed lock named ``name``.\n        Returns True once the lock is acquired.\n\n        If ``blocking`` is False, always return immediately. If the lock\n        was acquired, return True, otherwise return False.\n\n        ``blocking_timeout`` specifies the maximum number of seconds to\n        wait trying to acquire the lock.\n\n        ``token`` specifies the token value to be used. If provided, token\n        must be a bytes object or a string that can be encoded to a bytes\n        object with the default encoding. If a token isn't specified, a UUID\n        will be generated.\n        \"\"\"\n        sleep = self.sleep\n        if token is None:\n            token = uuid.uuid1().hex.encode()\n        else:\n            try:\n                encoder = self.redis.connection_pool.get_encoder()\n            except AttributeError:\n                # Cluster\n                encoder = self.redis.get_encoder()\n            token = encoder.encode(token)\n        if blocking is None:\n            blocking = self.blocking\n        if blocking_timeout is None:\n            blocking_timeout = self.blocking_timeout\n        stop_trying_at = None\n        if blocking_timeout is not None:\n            stop_trying_at = asyncio.get_running_loop().time() + blocking_timeout\n        while True:\n            if await self.do_acquire(token):\n                self.local.token = token\n                return True\n            if not blocking:\n                return False\n            next_try_at = asyncio.get_running_loop().time() + sleep\n            if stop_trying_at is not None and next_try_at > stop_trying_at:\n                return False\n            await asyncio.sleep(sleep)\n\n    async def do_acquire(self, token: Union[str, bytes]) -> bool:\n        if self.timeout:\n            # convert to milliseconds\n            timeout = int(self.timeout * 1000)\n        else:\n            timeout = None\n        if await self.redis.set(self.name, token, nx=True, px=timeout):\n            return True\n        return False\n\n    async def locked(self) -> bool:\n        \"\"\"\n        Returns True if this key is locked by any process, otherwise False.\n        \"\"\"\n        return await self.redis.get(self.name) is not None\n\n    async def owned(self) -> bool:\n        \"\"\"\n        Returns True if this key is locked by this lock, otherwise False.\n        \"\"\"\n        stored_token = await self.redis.get(self.name)\n        # need to always compare bytes to bytes\n        # TODO: this can be simplified when the context manager is finished\n        if stored_token and not isinstance(stored_token, bytes):\n            try:\n                encoder = self.redis.connection_pool.get_encoder()\n            except AttributeError:\n                # Cluster\n                encoder = self.redis.get_encoder()\n            stored_token = encoder.encode(stored_token)\n        return self.local.token is not None and stored_token == self.local.token\n\n    async def release(self) -> None:\n        \"\"\"Releases the already acquired lock.\n\n        The token is only cleared after the Redis release operation completes\n        successfully. This ensures that if the release is cancelled mid-operation,\n        the lock state remains consistent and can be retried.\n        \"\"\"\n        expected_token = self.local.token\n        if expected_token is None:\n            raise LockError(\n                \"Cannot release a lock that's not owned or is already unlocked.\",\n                lock_name=self.name,\n            )\n        try:\n            await self.do_release(expected_token)\n        except LockNotOwnedError:\n            # Lock doesn't exist in Redis, safe to clear token\n            self.local.token = None\n            raise\n        # Only clear token after successful release\n        self.local.token = None\n\n    async def do_release(self, expected_token: bytes) -> None:\n        if not bool(\n            await self.lua_release(\n                keys=[self.name], args=[expected_token], client=self.redis\n            )\n        ):\n            raise LockNotOwnedError(\"Cannot release a lock that's no longer owned\")\n\n    def extend(\n        self, additional_time: Number, replace_ttl: bool = False\n    ) -> Awaitable[bool]:\n        \"\"\"\n        Adds more time to an already acquired lock.\n\n        ``additional_time`` can be specified as an integer or a float, both\n        representing the number of seconds to add.\n\n        ``replace_ttl`` if False (the default), add `additional_time` to\n        the lock's existing ttl. If True, replace the lock's ttl with\n        `additional_time`.\n        \"\"\"\n        if self.local.token is None:\n            raise LockError(\"Cannot extend an unlocked lock\")\n        if self.timeout is None:\n            raise LockError(\"Cannot extend a lock with no timeout\")\n        return self.do_extend(additional_time, replace_ttl)\n\n    async def do_extend(self, additional_time, replace_ttl) -> bool:\n        additional_time = int(additional_time * 1000)\n        if not bool(\n            await self.lua_extend(\n                keys=[self.name],\n                args=[self.local.token, additional_time, replace_ttl and \"1\" or \"0\"],\n                client=self.redis,\n            )\n        ):\n            raise LockNotOwnedError(\"Cannot extend a lock that's no longer owned\")\n        return True\n\n    def reacquire(self) -> Awaitable[bool]:\n        \"\"\"\n        Resets a TTL of an already acquired lock back to a timeout value.\n        \"\"\"\n        if self.local.token is None:\n            raise LockError(\"Cannot reacquire an unlocked lock\")\n        if self.timeout is None:\n            raise LockError(\"Cannot reacquire a lock with no timeout\")\n        return self.do_reacquire()\n\n    async def do_reacquire(self) -> bool:\n        timeout = int(self.timeout * 1000)\n        if not bool(\n            await self.lua_reacquire(\n                keys=[self.name], args=[self.local.token, timeout], client=self.redis\n            )\n        ):\n            raise LockNotOwnedError(\"Cannot reacquire a lock that's no longer owned\")\n        return True\n"
  },
  {
    "path": "redis/asyncio/multidb/__init__.py",
    "content": ""
  },
  {
    "path": "redis/asyncio/multidb/client.py",
    "content": "import asyncio\nimport logging\nfrom typing import Any, Awaitable, Callable, List, Literal, Optional, Union\n\nfrom redis.asyncio.client import PubSubHandler\nfrom redis.asyncio.multidb.command_executor import DefaultCommandExecutor\nfrom redis.asyncio.multidb.config import (\n    DEFAULT_GRACE_PERIOD,\n    DatabaseConfig,\n    InitialHealthCheck,\n    MultiDbConfig,\n)\nfrom redis.asyncio.multidb.database import AsyncDatabase, Database, Databases\nfrom redis.asyncio.multidb.failure_detector import AsyncFailureDetector\nfrom redis.asyncio.multidb.healthcheck import HealthCheck, HealthCheckPolicy\nfrom redis.asyncio.retry import Retry\nfrom redis.background import BackgroundScheduler\nfrom redis.backoff import NoBackoff\nfrom redis.commands import AsyncCoreCommands, AsyncRedisModuleCommands\nfrom redis.multidb.circuit import CircuitBreaker\nfrom redis.multidb.circuit import State as CBState\nfrom redis.multidb.exception import (\n    InitialHealthCheckFailedError,\n    NoValidDatabaseException,\n    UnhealthyDatabaseException,\n)\nfrom redis.observability.attributes import GeoFailoverReason\nfrom redis.typing import ChannelT, EncodableT, KeyT\nfrom redis.utils import experimental\n\nlogger = logging.getLogger(__name__)\n\n\n@experimental\nclass MultiDBClient(AsyncRedisModuleCommands, AsyncCoreCommands):\n    \"\"\"\n    Client that operates on multiple logical Redis databases.\n    Should be used in Client-side geographic failover database setups.\n    \"\"\"\n\n    def __init__(self, config: MultiDbConfig):\n        self._databases = config.databases()\n        self._health_checks = (\n            config.default_health_checks()\n            if not config.health_checks\n            else config.health_checks\n        )\n        self._health_check_interval = config.health_check_interval\n        self._health_check_policy: HealthCheckPolicy = (\n            config.health_check_policy.value()\n        )\n        self._failure_detectors = (\n            config.default_failure_detectors()\n            if not config.failure_detectors\n            else config.failure_detectors\n        )\n\n        self._failover_strategy = (\n            config.default_failover_strategy()\n            if config.failover_strategy is None\n            else config.failover_strategy\n        )\n        self._failover_strategy.set_databases(self._databases)\n        self._auto_fallback_interval = config.auto_fallback_interval\n        self._event_dispatcher = config.event_dispatcher\n        self._command_retry = config.command_retry\n        self._command_retry.update_supported_errors([ConnectionRefusedError])\n        self.command_executor = DefaultCommandExecutor(\n            failure_detectors=self._failure_detectors,\n            databases=self._databases,\n            command_retry=self._command_retry,\n            failover_strategy=self._failover_strategy,\n            failover_attempts=config.failover_attempts,\n            failover_delay=config.failover_delay,\n            event_dispatcher=self._event_dispatcher,\n            auto_fallback_interval=self._auto_fallback_interval,\n        )\n        self.initialized = False\n        self._hc_lock = asyncio.Lock()\n        self._bg_scheduler = BackgroundScheduler()\n        self._config = config\n        self._recurring_hc_task = None\n        self._hc_tasks = []\n        self._half_open_state_task = None\n\n    async def __aenter__(self: \"MultiDBClient\") -> \"MultiDBClient\":\n        if not self.initialized:\n            await self.initialize()\n        return self\n\n    async def aclose(self):\n        # Cancel background tasks\n        if self._recurring_hc_task:\n            self._recurring_hc_task.cancel()\n        if self._half_open_state_task:\n            self._half_open_state_task.cancel()\n        for hc_task in self._hc_tasks:\n            hc_task.cancel()\n\n        # Close health check connection pools\n        await self._health_check_policy.close()\n\n        # Close database client\n        if self.command_executor.active_database:\n            await self.command_executor.active_database.client.aclose()\n\n    async def __aexit__(self, exc_type, exc_value, traceback):\n        await self.aclose()\n\n    async def initialize(self):\n        \"\"\"\n        Perform initialization of databases to define their initial state.\n        \"\"\"\n\n        # Initial databases check to define initial state\n        await self._perform_initial_health_check()\n\n        # Starts recurring health checks on the background.\n        self._recurring_hc_task = asyncio.create_task(\n            self._bg_scheduler.run_recurring_async(\n                self._health_check_interval,\n                self._check_databases_health,\n            )\n        )\n\n        is_active_db_found = False\n\n        for database, weight in self._databases:\n            # Set on state changed callback for each circuit.\n            database.circuit.on_state_changed(self._on_circuit_state_change_callback)\n\n            # Set states according to a weights and circuit state\n            if database.circuit.state == CBState.CLOSED and not is_active_db_found:\n                # Directly set the active database during initialization\n                # without recording a geo failover metric\n                self.command_executor._active_database = database\n                is_active_db_found = True\n\n        if not is_active_db_found:\n            raise NoValidDatabaseException(\n                \"Initial connection failed - no active database found\"\n            )\n\n        self.initialized = True\n\n    def get_databases(self) -> Databases:\n        \"\"\"\n        Returns a sorted (by weight) list of all databases.\n        \"\"\"\n        return self._databases\n\n    async def set_active_database(self, database: AsyncDatabase) -> None:\n        \"\"\"\n        Promote one of the existing databases to become an active.\n        \"\"\"\n        exists = None\n\n        for existing_db, _ in self._databases:\n            if existing_db == database:\n                exists = True\n                break\n\n        if not exists:\n            raise ValueError(\"Given database is not a member of database list\")\n\n        await self._check_db_health(database)\n\n        if database.circuit.state == CBState.CLOSED:\n            highest_weighted_db, _ = self._databases.get_top_n(1)[0]\n            await self.command_executor.set_active_database(\n                database, GeoFailoverReason.MANUAL\n            )\n            return\n\n        raise NoValidDatabaseException(\n            \"Cannot set active database, database is unhealthy\"\n        )\n\n    async def add_database(\n        self, config: DatabaseConfig, skip_initial_health_check: bool = True\n    ):\n        \"\"\"\n        Adds a new database to the database list.\n\n        Args:\n            config: DatabaseConfig object that contains the database configuration.\n            skip_initial_health_check: If True, adds the database even if it is unhealthy.\n        \"\"\"\n        # The retry object is not used in the lower level clients, so we can safely remove it.\n        # We rely on command_retry in terms of global retries.\n        config.client_kwargs.update({\"retry\": Retry(retries=0, backoff=NoBackoff())})\n\n        if config.from_url:\n            client = self._config.client_class.from_url(\n                config.from_url, **config.client_kwargs\n            )\n        elif config.from_pool:\n            config.from_pool.set_retry(Retry(retries=0, backoff=NoBackoff()))\n            client = self._config.client_class.from_pool(\n                connection_pool=config.from_pool\n            )\n        else:\n            client = self._config.client_class(**config.client_kwargs)\n\n        circuit = (\n            config.default_circuit_breaker()\n            if config.circuit is None\n            else config.circuit\n        )\n\n        database = Database(\n            client=client,\n            circuit=circuit,\n            weight=config.weight,\n            health_check_url=config.health_check_url,\n        )\n\n        try:\n            await self._check_db_health(database)\n        except UnhealthyDatabaseException:\n            if not skip_initial_health_check:\n                raise\n\n        highest_weighted_db, highest_weight = self._databases.get_top_n(1)[0]\n        self._databases.add(database, database.weight)\n        await self._change_active_database(database, highest_weighted_db)\n\n    async def _change_active_database(\n        self, new_database: AsyncDatabase, highest_weight_database: AsyncDatabase\n    ):\n        if (\n            new_database.weight > highest_weight_database.weight\n            and new_database.circuit.state == CBState.CLOSED\n        ):\n            await self.command_executor.set_active_database(\n                new_database, GeoFailoverReason.AUTOMATIC\n            )\n\n    async def remove_database(self, database: AsyncDatabase):\n        \"\"\"\n        Removes a database from the database list.\n        \"\"\"\n        weight = self._databases.remove(database)\n        highest_weighted_db, highest_weight = self._databases.get_top_n(1)[0]\n\n        if (\n            highest_weight <= weight\n            and highest_weighted_db.circuit.state == CBState.CLOSED\n        ):\n            await self.command_executor.set_active_database(\n                highest_weighted_db, GeoFailoverReason.MANUAL\n            )\n\n    async def update_database_weight(self, database: AsyncDatabase, weight: float):\n        \"\"\"\n        Updates a database from the database list.\n        \"\"\"\n        exists = None\n\n        for existing_db, _ in self._databases:\n            if existing_db == database:\n                exists = True\n                break\n\n        if not exists:\n            raise ValueError(\"Given database is not a member of database list\")\n\n        highest_weighted_db, highest_weight = self._databases.get_top_n(1)[0]\n        self._databases.update_weight(database, weight)\n        database.weight = weight\n        await self._change_active_database(database, highest_weighted_db)\n\n    def add_failure_detector(self, failure_detector: AsyncFailureDetector):\n        \"\"\"\n        Adds a new failure detector to the database.\n        \"\"\"\n        self._failure_detectors.append(failure_detector)\n\n    async def add_health_check(self, healthcheck: HealthCheck):\n        \"\"\"\n        Adds a new health check to the database.\n        \"\"\"\n        async with self._hc_lock:\n            self._health_checks.append(healthcheck)\n\n    async def execute_command(self, *args, **options):\n        \"\"\"\n        Executes a single command and return its result.\n        \"\"\"\n        if not self.initialized:\n            await self.initialize()\n\n        return await self.command_executor.execute_command(*args, **options)\n\n    def pipeline(self):\n        \"\"\"\n        Enters into pipeline mode of the client.\n        \"\"\"\n        return Pipeline(self)\n\n    async def transaction(\n        self,\n        func: Callable[[\"Pipeline\"], Union[Any, Awaitable[Any]]],\n        *watches: KeyT,\n        shard_hint: Optional[str] = None,\n        value_from_callable: bool = False,\n        watch_delay: Optional[float] = None,\n    ):\n        \"\"\"\n        Executes callable as transaction.\n        \"\"\"\n        if not self.initialized:\n            await self.initialize()\n\n        return await self.command_executor.execute_transaction(\n            func,\n            *watches,\n            shard_hint=shard_hint,\n            value_from_callable=value_from_callable,\n            watch_delay=watch_delay,\n        )\n\n    async def pubsub(self, **kwargs):\n        \"\"\"\n        Return a Publish/Subscribe object. With this object, you can\n        subscribe to channels and listen for messages that get published to\n        them.\n        \"\"\"\n        if not self.initialized:\n            await self.initialize()\n\n        return PubSub(self, **kwargs)\n\n    async def _check_databases_health(self) -> dict[Database, bool]:\n        \"\"\"\n        Runs health checks as a recurring task.\n        Runs health checks against all databases.\n        \"\"\"\n        task_to_db: dict[asyncio.Task, Database] = {}\n\n        self._hc_tasks = []\n        for database, _ in self._databases:\n            task = asyncio.create_task(self._check_db_health(database))\n            task_to_db[task] = database\n            self._hc_tasks.append(task)\n\n        results = await asyncio.gather(*self._hc_tasks, return_exceptions=True)\n\n        # Map end results to databases\n        db_results = {\n            task_to_db[task]: result for task, result in zip(self._hc_tasks, results)\n        }\n\n        for database, result in db_results.items():\n            if isinstance(result, UnhealthyDatabaseException):\n                unhealthy_db = result.database\n                unhealthy_db.circuit.state = CBState.OPEN\n\n                logger.debug(\n                    \"Health check failed, due to exception\",\n                    exc_info=result.original_exception,\n                )\n\n                db_results[unhealthy_db] = False\n\n        return db_results\n\n    async def _perform_initial_health_check(self):\n        \"\"\"\n        Runs initial health check and evaluate healthiness based on initial_health_check_policy.\n        \"\"\"\n        results = await self._check_databases_health()\n        is_healthy = True\n\n        if self._config.initial_health_check_policy == InitialHealthCheck.ALL_AVAILABLE:\n            is_healthy = False not in results.values()\n        elif (\n            self._config.initial_health_check_policy\n            == InitialHealthCheck.MAJORITY_AVAILABLE\n        ):\n            is_healthy = sum(results.values()) > len(results) / 2\n        elif (\n            self._config.initial_health_check_policy == InitialHealthCheck.ONE_AVAILABLE\n        ):\n            is_healthy = True in results.values()\n\n        if not is_healthy:\n            raise InitialHealthCheckFailedError(\n                f\"Initial health check failed. Initial health check policy: {self._config.initial_health_check_policy}\"\n            )\n\n    async def _check_db_health(self, database: AsyncDatabase) -> bool:\n        \"\"\"\n        Runs health checks on the given database until first failure.\n        \"\"\"\n        # Health check will setup circuit state\n        is_healthy = await self._health_check_policy.execute(\n            self._health_checks, database\n        )\n\n        if not is_healthy:\n            if database.circuit.state != CBState.OPEN:\n                database.circuit.state = CBState.OPEN\n            return is_healthy\n        elif is_healthy and database.circuit.state != CBState.CLOSED:\n            database.circuit.state = CBState.CLOSED\n\n        return is_healthy\n\n    def _on_circuit_state_change_callback(\n        self, circuit: CircuitBreaker, old_state: CBState, new_state: CBState\n    ):\n        loop = asyncio.get_running_loop()\n\n        if new_state == CBState.HALF_OPEN:\n            self._half_open_state_task = asyncio.create_task(\n                self._check_db_health(circuit.database)\n            )\n            return\n\n        if old_state == CBState.CLOSED and new_state == CBState.OPEN:\n            logger.warning(\n                f\"Database {circuit.database} is unreachable. Failover has been initiated.\"\n            )\n            loop.call_later(DEFAULT_GRACE_PERIOD, _half_open_circuit, circuit)\n\n        if old_state != CBState.CLOSED and new_state == CBState.CLOSED:\n            logger.info(f\"Database {circuit.database} is reachable again.\")\n\n\ndef _half_open_circuit(circuit: CircuitBreaker):\n    circuit.state = CBState.HALF_OPEN\n\n\nclass Pipeline(AsyncRedisModuleCommands, AsyncCoreCommands):\n    \"\"\"\n    Pipeline implementation for multiple logical Redis databases.\n    \"\"\"\n\n    _is_async_client: Literal[True] = True\n\n    def __init__(self, client: MultiDBClient):\n        self._command_stack = []\n        self._client = client\n\n    async def __aenter__(self: \"Pipeline\") -> \"Pipeline\":\n        return self\n\n    async def __aexit__(self, exc_type, exc_value, traceback):\n        await self.reset()\n        await self._client.__aexit__(exc_type, exc_value, traceback)\n\n    def __await__(self):\n        return self._async_self().__await__()\n\n    async def _async_self(self):\n        return self\n\n    def __len__(self) -> int:\n        return len(self._command_stack)\n\n    def __bool__(self) -> bool:\n        \"\"\"Pipeline instances should always evaluate to True\"\"\"\n        return True\n\n    async def reset(self) -> None:\n        self._command_stack = []\n\n    async def aclose(self) -> None:\n        \"\"\"Close the pipeline\"\"\"\n        await self.reset()\n\n    def pipeline_execute_command(self, *args, **options) -> \"Pipeline\":\n        \"\"\"\n        Stage a command to be executed when execute() is next called\n\n        Returns the current Pipeline object back so commands can be\n        chained together, such as:\n\n        pipe = pipe.set('foo', 'bar').incr('baz').decr('bang')\n\n        At some other point, you can then run: pipe.execute(),\n        which will execute all commands queued in the pipe.\n        \"\"\"\n        self._command_stack.append((args, options))\n        return self\n\n    def execute_command(self, *args, **kwargs):\n        \"\"\"Adds a command to the stack\"\"\"\n        return self.pipeline_execute_command(*args, **kwargs)\n\n    async def execute(self) -> List[Any]:\n        \"\"\"Execute all the commands in the current pipeline\"\"\"\n        if not self._client.initialized:\n            await self._client.initialize()\n\n        try:\n            return await self._client.command_executor.execute_pipeline(\n                tuple(self._command_stack)\n            )\n        finally:\n            await self.reset()\n\n\nclass PubSub:\n    \"\"\"\n    PubSub object for multi database client.\n    \"\"\"\n\n    def __init__(self, client: MultiDBClient, **kwargs):\n        \"\"\"Initialize the PubSub object for a multi-database client.\n\n        Args:\n            client: MultiDBClient instance to use for pub/sub operations\n            **kwargs: Additional keyword arguments to pass to the underlying pubsub implementation\n        \"\"\"\n\n        self._client = client\n        self._client.command_executor.pubsub(**kwargs)\n\n    async def __aenter__(self) -> \"PubSub\":\n        return self\n\n    async def __aexit__(self, exc_type, exc_value, traceback) -> None:\n        await self.aclose()\n\n    async def aclose(self):\n        return await self._client.command_executor.execute_pubsub_method(\"aclose\")\n\n    @property\n    def subscribed(self) -> bool:\n        return self._client.command_executor.active_pubsub.subscribed\n\n    async def execute_command(self, *args: EncodableT):\n        return await self._client.command_executor.execute_pubsub_method(\n            \"execute_command\", *args\n        )\n\n    async def psubscribe(self, *args: ChannelT, **kwargs: PubSubHandler):\n        \"\"\"\n        Subscribe to channel patterns. Patterns supplied as keyword arguments\n        expect a pattern name as the key and a callable as the value. A\n        pattern's callable will be invoked automatically when a message is\n        received on that pattern rather than producing a message via\n        ``listen()``.\n        \"\"\"\n        return await self._client.command_executor.execute_pubsub_method(\n            \"psubscribe\", *args, **kwargs\n        )\n\n    async def punsubscribe(self, *args: ChannelT):\n        \"\"\"\n        Unsubscribe from the supplied patterns. If empty, unsubscribe from\n        all patterns.\n        \"\"\"\n        return await self._client.command_executor.execute_pubsub_method(\n            \"punsubscribe\", *args\n        )\n\n    async def subscribe(self, *args: ChannelT, **kwargs: Callable):\n        \"\"\"\n        Subscribe to channels. Channels supplied as keyword arguments expect\n        a channel name as the key and a callable as the value. A channel's\n        callable will be invoked automatically when a message is received on\n        that channel rather than producing a message via ``listen()`` or\n        ``get_message()``.\n        \"\"\"\n        return await self._client.command_executor.execute_pubsub_method(\n            \"subscribe\", *args, **kwargs\n        )\n\n    async def unsubscribe(self, *args):\n        \"\"\"\n        Unsubscribe from the supplied channels. If empty, unsubscribe from\n        all channels\n        \"\"\"\n        return await self._client.command_executor.execute_pubsub_method(\n            \"unsubscribe\", *args\n        )\n\n    async def get_message(\n        self, ignore_subscribe_messages: bool = False, timeout: Optional[float] = 0.0\n    ):\n        \"\"\"\n        Get the next message if one is available, otherwise None.\n\n        If timeout is specified, the system will wait for `timeout` seconds\n        before returning. Timeout should be specified as a floating point\n        number or None to wait indefinitely.\n        \"\"\"\n        return await self._client.command_executor.execute_pubsub_method(\n            \"get_message\",\n            ignore_subscribe_messages=ignore_subscribe_messages,\n            timeout=timeout,\n        )\n\n    async def run(\n        self,\n        *,\n        exception_handler=None,\n        poll_timeout: float = 1.0,\n    ) -> None:\n        \"\"\"Process pub/sub messages using registered callbacks.\n\n        This is the equivalent of :py:meth:`redis.PubSub.run_in_thread` in\n        redis-py, but it is a coroutine. To launch it as a separate task, use\n        ``asyncio.create_task``:\n\n            >>> task = asyncio.create_task(pubsub.run())\n\n        To shut it down, use asyncio cancellation:\n\n            >>> task.cancel()\n            >>> await task\n        \"\"\"\n        return await self._client.command_executor.execute_pubsub_run(\n            sleep_time=poll_timeout, exception_handler=exception_handler, pubsub=self\n        )\n"
  },
  {
    "path": "redis/asyncio/multidb/command_executor.py",
    "content": "from abc import abstractmethod\nfrom asyncio import iscoroutinefunction\nfrom datetime import datetime\nfrom typing import Any, Awaitable, Callable, List, Optional, Union\n\nfrom redis.asyncio import RedisCluster\nfrom redis.asyncio.client import Pipeline, PubSub\nfrom redis.asyncio.multidb.database import AsyncDatabase, Database, Databases\nfrom redis.asyncio.multidb.event import (\n    AsyncActiveDatabaseChanged,\n    CloseConnectionOnActiveDatabaseChanged,\n    RegisterCommandFailure,\n    ResubscribeOnActiveDatabaseChanged,\n)\nfrom redis.asyncio.multidb.failover import (\n    DEFAULT_FAILOVER_ATTEMPTS,\n    DEFAULT_FAILOVER_DELAY,\n    AsyncFailoverStrategy,\n    DefaultFailoverStrategyExecutor,\n    FailoverStrategyExecutor,\n)\nfrom redis.asyncio.multidb.failure_detector import AsyncFailureDetector\nfrom redis.asyncio.observability.recorder import record_geo_failover\nfrom redis.asyncio.retry import Retry\nfrom redis.event import AsyncOnCommandsFailEvent, EventDispatcherInterface\nfrom redis.multidb.circuit import State as CBState\nfrom redis.multidb.command_executor import BaseCommandExecutor, CommandExecutor\nfrom redis.multidb.config import DEFAULT_AUTO_FALLBACK_INTERVAL\nfrom redis.observability.attributes import GeoFailoverReason\nfrom redis.typing import KeyT\n\n\nclass AsyncCommandExecutor(CommandExecutor):\n    @property\n    @abstractmethod\n    def databases(self) -> Databases:\n        \"\"\"Returns a list of databases.\"\"\"\n        pass\n\n    @property\n    @abstractmethod\n    def failure_detectors(self) -> List[AsyncFailureDetector]:\n        \"\"\"Returns a list of failure detectors.\"\"\"\n        pass\n\n    @abstractmethod\n    def add_failure_detector(self, failure_detector: AsyncFailureDetector) -> None:\n        \"\"\"Adds a new failure detector to the list of failure detectors.\"\"\"\n        pass\n\n    @property\n    @abstractmethod\n    def active_database(self) -> Optional[AsyncDatabase]:\n        \"\"\"Returns currently active database.\"\"\"\n        pass\n\n    @abstractmethod\n    async def set_active_database(\n        self, database: AsyncDatabase, reason: GeoFailoverReason\n    ) -> None:\n        \"\"\"Sets the currently active database.\n\n        Args:\n            database: The new active database.\n            reason: The reason for the failover.\n        \"\"\"\n        pass\n\n    @property\n    @abstractmethod\n    def active_pubsub(self) -> Optional[PubSub]:\n        \"\"\"Returns currently active pubsub.\"\"\"\n        pass\n\n    @active_pubsub.setter\n    @abstractmethod\n    def active_pubsub(self, pubsub: PubSub) -> None:\n        \"\"\"Sets currently active pubsub.\"\"\"\n        pass\n\n    @property\n    @abstractmethod\n    def failover_strategy_executor(self) -> FailoverStrategyExecutor:\n        \"\"\"Returns failover strategy executor.\"\"\"\n        pass\n\n    @property\n    @abstractmethod\n    def command_retry(self) -> Retry:\n        \"\"\"Returns command retry object.\"\"\"\n        pass\n\n    @abstractmethod\n    async def pubsub(self, **kwargs):\n        \"\"\"Initializes a PubSub object on a currently active database\"\"\"\n        pass\n\n    @abstractmethod\n    async def execute_command(self, *args, **options):\n        \"\"\"Executes a command and returns the result.\"\"\"\n        pass\n\n    @abstractmethod\n    async def execute_pipeline(self, command_stack: tuple):\n        \"\"\"Executes a stack of commands in pipeline.\"\"\"\n        pass\n\n    @abstractmethod\n    async def execute_transaction(\n        self, transaction: Callable[[Pipeline], None], *watches, **options\n    ):\n        \"\"\"Executes a transaction block wrapped in callback.\"\"\"\n        pass\n\n    @abstractmethod\n    async def execute_pubsub_method(self, method_name: str, *args, **kwargs):\n        \"\"\"Executes a given method on active pub/sub.\"\"\"\n        pass\n\n    @abstractmethod\n    async def execute_pubsub_run(self, sleep_time: float, **kwargs) -> Any:\n        \"\"\"Executes pub/sub run in a thread.\"\"\"\n        pass\n\n\nclass DefaultCommandExecutor(BaseCommandExecutor, AsyncCommandExecutor):\n    def __init__(\n        self,\n        failure_detectors: List[AsyncFailureDetector],\n        databases: Databases,\n        command_retry: Retry,\n        failover_strategy: AsyncFailoverStrategy,\n        event_dispatcher: EventDispatcherInterface,\n        failover_attempts: int = DEFAULT_FAILOVER_ATTEMPTS,\n        failover_delay: float = DEFAULT_FAILOVER_DELAY,\n        auto_fallback_interval: float = DEFAULT_AUTO_FALLBACK_INTERVAL,\n    ):\n        \"\"\"\n        Initialize the DefaultCommandExecutor instance.\n\n        Args:\n            failure_detectors: List of failure detector instances to monitor database health\n            databases: Collection of available databases to execute commands on\n            command_retry: Retry policy for failed command execution\n            failover_strategy: Strategy for handling database failover\n            event_dispatcher: Interface for dispatching events\n            failover_attempts: Number of failover attempts\n            failover_delay: Delay between failover attempts\n            auto_fallback_interval: Time interval in seconds between attempts to fall back to a primary database\n        \"\"\"\n        super().__init__(auto_fallback_interval)\n\n        for fd in failure_detectors:\n            fd.set_command_executor(command_executor=self)\n\n        self._databases = databases\n        self._failure_detectors = failure_detectors\n        self._command_retry = command_retry\n        self._failover_strategy_executor = DefaultFailoverStrategyExecutor(\n            failover_strategy, failover_attempts, failover_delay\n        )\n        self._event_dispatcher = event_dispatcher\n        self._active_database: Optional[Database] = None\n        self._active_pubsub: Optional[PubSub] = None\n        self._active_pubsub_kwargs = {}\n        self._setup_event_dispatcher()\n        self._schedule_next_fallback()\n\n    @property\n    def databases(self) -> Databases:\n        return self._databases\n\n    @property\n    def failure_detectors(self) -> List[AsyncFailureDetector]:\n        return self._failure_detectors\n\n    def add_failure_detector(self, failure_detector: AsyncFailureDetector) -> None:\n        self._failure_detectors.append(failure_detector)\n\n    @property\n    def active_database(self) -> Optional[AsyncDatabase]:\n        return self._active_database\n\n    async def set_active_database(\n        self, database: AsyncDatabase, reason: GeoFailoverReason\n    ) -> None:\n        old_active = self._active_database\n        self._active_database = database\n\n        if old_active is not None and old_active is not database:\n            await record_geo_failover(\n                fail_from=old_active,\n                fail_to=database,\n                reason=reason,\n            )\n            await self._event_dispatcher.dispatch_async(\n                AsyncActiveDatabaseChanged(\n                    old_active,\n                    self._active_database,\n                    self,\n                    **self._active_pubsub_kwargs,\n                )\n            )\n\n    @property\n    def active_pubsub(self) -> Optional[PubSub]:\n        return self._active_pubsub\n\n    @active_pubsub.setter\n    def active_pubsub(self, pubsub: PubSub) -> None:\n        self._active_pubsub = pubsub\n\n    @property\n    def failover_strategy_executor(self) -> FailoverStrategyExecutor:\n        return self._failover_strategy_executor\n\n    @property\n    def command_retry(self) -> Retry:\n        return self._command_retry\n\n    def pubsub(self, **kwargs):\n        if self._active_pubsub is None:\n            if isinstance(self._active_database.client, RedisCluster):\n                raise ValueError(\"PubSub is not supported for RedisCluster\")\n\n            self._active_pubsub = self._active_database.client.pubsub(**kwargs)\n            self._active_pubsub_kwargs = kwargs\n\n    async def execute_command(self, *args, **options):\n        async def callback():\n            response = await self._active_database.client.execute_command(\n                *args, **options\n            )\n            await self._register_command_execution(args)\n            return response\n\n        return await self._execute_with_failure_detection(callback, args)\n\n    async def execute_pipeline(self, command_stack: tuple):\n        async def callback():\n            async with self._active_database.client.pipeline() as pipe:\n                for command, options in command_stack:\n                    pipe.execute_command(*command, **options)\n\n                response = await pipe.execute()\n                await self._register_command_execution(command_stack)\n                return response\n\n        return await self._execute_with_failure_detection(callback, command_stack)\n\n    async def execute_transaction(\n        self,\n        func: Callable[[\"Pipeline\"], Union[Any, Awaitable[Any]]],\n        *watches: KeyT,\n        shard_hint: Optional[str] = None,\n        value_from_callable: bool = False,\n        watch_delay: Optional[float] = None,\n    ):\n        async def callback():\n            response = await self._active_database.client.transaction(\n                func,\n                *watches,\n                shard_hint=shard_hint,\n                value_from_callable=value_from_callable,\n                watch_delay=watch_delay,\n            )\n            await self._register_command_execution(())\n            return response\n\n        return await self._execute_with_failure_detection(callback)\n\n    async def execute_pubsub_method(self, method_name: str, *args, **kwargs):\n        async def callback():\n            method = getattr(self.active_pubsub, method_name)\n            if iscoroutinefunction(method):\n                response = await method(*args, **kwargs)\n            else:\n                response = method(*args, **kwargs)\n\n            await self._register_command_execution(args)\n            return response\n\n        return await self._execute_with_failure_detection(callback, *args)\n\n    async def execute_pubsub_run(\n        self, sleep_time: float, exception_handler=None, pubsub=None\n    ) -> Any:\n        async def callback():\n            return await self._active_pubsub.run(\n                poll_timeout=sleep_time,\n                exception_handler=exception_handler,\n                pubsub=pubsub,\n            )\n\n        return await self._execute_with_failure_detection(callback)\n\n    async def _execute_with_failure_detection(\n        self, callback: Callable, cmds: tuple = ()\n    ):\n        \"\"\"\n        Execute a commands execution callback with failure detection.\n        \"\"\"\n\n        async def wrapper():\n            # On each retry we need to check active database as it might change.\n            await self._check_active_database()\n            return await callback()\n\n        return await self._command_retry.call_with_retry(\n            lambda: wrapper(),\n            lambda error: self._on_command_fail(error, *cmds),\n        )\n\n    async def _check_active_database(self):\n        \"\"\"\n        Checks if active a database needs to be updated.\n        \"\"\"\n        if (\n            self._active_database is None\n            or self._active_database.circuit.state != CBState.CLOSED\n            or (\n                self._auto_fallback_interval > 0\n                and self._next_fallback_attempt <= datetime.now()\n            )\n        ):\n            await self.set_active_database(\n                await self._failover_strategy_executor.execute(),\n                GeoFailoverReason.AUTOMATIC,\n            )\n            self._schedule_next_fallback()\n\n    async def _on_command_fail(self, error, *args):\n        await self._event_dispatcher.dispatch_async(\n            AsyncOnCommandsFailEvent(args, error)\n        )\n\n    async def _register_command_execution(self, cmd: tuple):\n        for detector in self._failure_detectors:\n            await detector.register_command_execution(cmd)\n\n    def _setup_event_dispatcher(self):\n        \"\"\"\n        Registers necessary listeners.\n        \"\"\"\n        failure_listener = RegisterCommandFailure(self._failure_detectors)\n        resubscribe_listener = ResubscribeOnActiveDatabaseChanged()\n        close_connection_listener = CloseConnectionOnActiveDatabaseChanged()\n        self._event_dispatcher.register_listeners(\n            {\n                AsyncOnCommandsFailEvent: [failure_listener],\n                AsyncActiveDatabaseChanged: [\n                    close_connection_listener,\n                    resubscribe_listener,\n                ],\n            }\n        )\n"
  },
  {
    "path": "redis/asyncio/multidb/config.py",
    "content": "from dataclasses import dataclass, field\nfrom enum import Enum\nfrom typing import List, Optional, Type, Union\n\nimport pybreaker\n\nfrom redis.asyncio import ConnectionPool, Redis, RedisCluster\nfrom redis.asyncio.multidb.database import Database, Databases\nfrom redis.asyncio.multidb.failover import (\n    DEFAULT_FAILOVER_ATTEMPTS,\n    DEFAULT_FAILOVER_DELAY,\n    AsyncFailoverStrategy,\n    WeightBasedFailoverStrategy,\n)\nfrom redis.asyncio.multidb.failure_detector import (\n    AsyncFailureDetector,\n    FailureDetectorAsyncWrapper,\n)\nfrom redis.asyncio.multidb.healthcheck import (\n    DEFAULT_HEALTH_CHECK_DELAY,\n    DEFAULT_HEALTH_CHECK_INTERVAL,\n    DEFAULT_HEALTH_CHECK_POLICY,\n    DEFAULT_HEALTH_CHECK_PROBES,\n    DEFAULT_HEALTH_CHECK_TIMEOUT,\n    HealthCheck,\n    HealthCheckPolicies,\n    PingHealthCheck,\n)\nfrom redis.asyncio.retry import Retry\nfrom redis.backoff import ExponentialWithJitterBackoff, NoBackoff\nfrom redis.data_structure import WeightedList\nfrom redis.event import EventDispatcher, EventDispatcherInterface\nfrom redis.multidb.circuit import (\n    DEFAULT_GRACE_PERIOD,\n    CircuitBreaker,\n    PBCircuitBreakerAdapter,\n)\nfrom redis.multidb.failure_detector import (\n    DEFAULT_FAILURE_RATE_THRESHOLD,\n    DEFAULT_FAILURES_DETECTION_WINDOW,\n    DEFAULT_MIN_NUM_FAILURES,\n    CommandFailureDetector,\n)\n\nDEFAULT_AUTO_FALLBACK_INTERVAL = 120\n\n\nclass InitialHealthCheck(Enum):\n    ALL_AVAILABLE = \"all_available\"\n    MAJORITY_AVAILABLE = \"majority_available\"\n    ONE_AVAILABLE = \"one_available\"\n\n\ndef default_event_dispatcher() -> EventDispatcherInterface:\n    return EventDispatcher()\n\n\n@dataclass\nclass DatabaseConfig:\n    \"\"\"\n    Dataclass representing the configuration for a database connection.\n\n    This class is used to store configuration settings for a database connection,\n    including client options, connection sourcing details, circuit breaker settings,\n    and cluster-specific properties. It provides a structure for defining these\n    attributes and allows for the creation of customized configurations for various\n    database setups.\n\n    Attributes:\n        weight (float): Weight of the database to define the active one.\n        client_kwargs (dict): Additional parameters for the database client connection.\n        from_url (Optional[str]): Redis URL way of connecting to the database.\n        from_pool (Optional[ConnectionPool]): A pre-configured connection pool to use.\n        circuit (Optional[CircuitBreaker]): Custom circuit breaker implementation.\n        grace_period (float): Grace period after which we need to check if the circuit could be closed again.\n        health_check_url (Optional[str]): URL for health checks. Cluster FQDN is typically used\n            on public Redis Enterprise endpoints.\n\n    Methods:\n        default_circuit_breaker:\n            Generates and returns a default CircuitBreaker instance adapted for use.\n    \"\"\"\n\n    weight: float = 1.0\n    client_kwargs: dict = field(default_factory=dict)\n    from_url: Optional[str] = None\n    from_pool: Optional[ConnectionPool] = None\n    circuit: Optional[CircuitBreaker] = None\n    grace_period: float = DEFAULT_GRACE_PERIOD\n    health_check_url: Optional[str] = None\n\n    def default_circuit_breaker(self) -> CircuitBreaker:\n        circuit_breaker = pybreaker.CircuitBreaker(reset_timeout=self.grace_period)\n        return PBCircuitBreakerAdapter(circuit_breaker)\n\n\n@dataclass\nclass MultiDbConfig:\n    \"\"\"\n    Configuration class for managing multiple database connections in a resilient and fail-safe manner.\n\n    Attributes:\n        databases_config: A list of database configurations.\n        client_class: The client class used to manage database connections.\n        command_retry: Retry strategy for executing database commands.\n        failure_detectors: Optional list of additional failure detectors for monitoring database failures.\n        min_num_failures: Minimal count of failures required for failover\n        failure_rate_threshold: Percentage of failures required for failover\n        failures_detection_window: Time interval for tracking database failures.\n        health_checks: Optional list of additional health checks performed on databases.\n        health_check_interval: Time interval for executing health checks.\n        health_check_probes: Number of attempts to evaluate the health of a database.\n        health_check_delay: Delay between health check attempts.\n        health_check_timeout: Timeout for the full health check operation (including all probes).\n        health_check_policy: Policy for determining database health based on health checks.\n        failover_strategy: Optional strategy for handling database failover scenarios.\n        failover_attempts: Number of retries allowed for failover operations.\n        failover_delay: Delay between failover attempts.\n        auto_fallback_interval: Time interval to trigger automatic fallback.\n        event_dispatcher: Interface for dispatching events related to database operations.\n        initial_health_check_policy: Defines the policy used to determine whether the databases setup is\n                                     healthy during the initial health check.\n\n    Methods:\n        databases:\n            Retrieves a collection of database clients managed by weighted configurations.\n            Initializes database clients based on the provided configuration and removes\n            redundant retry objects for lower-level clients to rely on global retry logic.\n\n        default_failure_detectors:\n            Returns the default list of failure detectors used to monitor database failures.\n\n        default_health_checks:\n            Returns the default list of health checks used to monitor database health\n            with specific retry and backoff strategies.\n\n        default_failover_strategy:\n            Provides the default failover strategy used for handling failover scenarios\n            with defined retry and backoff configurations.\n    \"\"\"\n\n    databases_config: List[DatabaseConfig]\n    client_class: Type[Union[Redis, RedisCluster]] = Redis\n    command_retry: Retry = Retry(\n        backoff=ExponentialWithJitterBackoff(base=1, cap=10), retries=3\n    )\n    failure_detectors: Optional[List[AsyncFailureDetector]] = None\n    min_num_failures: int = DEFAULT_MIN_NUM_FAILURES\n    failure_rate_threshold: float = DEFAULT_FAILURE_RATE_THRESHOLD\n    failures_detection_window: float = DEFAULT_FAILURES_DETECTION_WINDOW\n    health_checks: Optional[List[HealthCheck]] = None\n    health_check_interval: float = DEFAULT_HEALTH_CHECK_INTERVAL\n    health_check_probes: int = DEFAULT_HEALTH_CHECK_PROBES\n    health_check_delay: float = DEFAULT_HEALTH_CHECK_DELAY\n    health_check_timeout: float = DEFAULT_HEALTH_CHECK_TIMEOUT\n    health_check_policy: HealthCheckPolicies = DEFAULT_HEALTH_CHECK_POLICY\n    failover_strategy: Optional[AsyncFailoverStrategy] = None\n    failover_attempts: int = DEFAULT_FAILOVER_ATTEMPTS\n    failover_delay: float = DEFAULT_FAILOVER_DELAY\n    auto_fallback_interval: float = DEFAULT_AUTO_FALLBACK_INTERVAL\n    event_dispatcher: EventDispatcherInterface = field(\n        default_factory=default_event_dispatcher\n    )\n    initial_health_check_policy: InitialHealthCheck = InitialHealthCheck.ALL_AVAILABLE\n\n    def databases(self) -> Databases:\n        databases = WeightedList()\n\n        for database_config in self.databases_config:\n            # The retry object is not used in the lower level clients, so we can safely remove it.\n            # We rely on command_retry in terms of global retries.\n            database_config.client_kwargs.update(\n                {\"retry\": Retry(retries=0, backoff=NoBackoff())}\n            )\n\n            if database_config.from_url:\n                client = self.client_class.from_url(\n                    database_config.from_url, **database_config.client_kwargs\n                )\n            elif database_config.from_pool:\n                database_config.from_pool.set_retry(\n                    Retry(retries=0, backoff=NoBackoff())\n                )\n                client = self.client_class.from_pool(\n                    connection_pool=database_config.from_pool\n                )\n            else:\n                client = self.client_class(**database_config.client_kwargs)\n\n            circuit = (\n                database_config.default_circuit_breaker()\n                if database_config.circuit is None\n                else database_config.circuit\n            )\n            databases.add(\n                Database(\n                    client=client,\n                    circuit=circuit,\n                    weight=database_config.weight,\n                    health_check_url=database_config.health_check_url,\n                ),\n                database_config.weight,\n            )\n\n        return databases\n\n    def default_failure_detectors(self) -> List[AsyncFailureDetector]:\n        return [\n            FailureDetectorAsyncWrapper(\n                CommandFailureDetector(\n                    min_num_failures=self.min_num_failures,\n                    failure_rate_threshold=self.failure_rate_threshold,\n                    failure_detection_window=self.failures_detection_window,\n                )\n            ),\n        ]\n\n    def default_health_checks(self) -> List[HealthCheck]:\n        return [\n            PingHealthCheck(\n                health_check_probes=self.health_check_probes,\n                health_check_delay=self.health_check_delay,\n                health_check_timeout=self.health_check_timeout,\n            ),\n        ]\n\n    def default_failover_strategy(self) -> AsyncFailoverStrategy:\n        return WeightBasedFailoverStrategy()\n"
  },
  {
    "path": "redis/asyncio/multidb/database.py",
    "content": "from abc import abstractmethod\nfrom typing import Optional, Union\n\nfrom redis.asyncio import Redis, RedisCluster\nfrom redis.data_structure import WeightedList\nfrom redis.multidb.circuit import CircuitBreaker\nfrom redis.multidb.database import AbstractDatabase, BaseDatabase\nfrom redis.typing import Number\n\n\nclass AsyncDatabase(AbstractDatabase):\n    \"\"\"Database with an underlying asynchronous redis client.\"\"\"\n\n    @property\n    @abstractmethod\n    def client(self) -> Union[Redis, RedisCluster]:\n        \"\"\"The underlying redis client.\"\"\"\n        pass\n\n    @client.setter\n    @abstractmethod\n    def client(self, client: Union[Redis, RedisCluster]):\n        \"\"\"Set the underlying redis client.\"\"\"\n        pass\n\n    @property\n    @abstractmethod\n    def circuit(self) -> CircuitBreaker:\n        \"\"\"Circuit breaker for the current database.\"\"\"\n        pass\n\n    @circuit.setter\n    @abstractmethod\n    def circuit(self, circuit: CircuitBreaker):\n        \"\"\"Set the circuit breaker for the current database.\"\"\"\n        pass\n\n\nDatabases = WeightedList[tuple[AsyncDatabase, Number]]\n\n\nclass Database(BaseDatabase, AsyncDatabase):\n    def __init__(\n        self,\n        client: Union[Redis, RedisCluster],\n        circuit: CircuitBreaker,\n        weight: float,\n        health_check_url: Optional[str] = None,\n    ):\n        self._client = client\n        self._cb = circuit\n        self._cb.database = self\n        super().__init__(weight, health_check_url)\n\n    @property\n    def client(self) -> Union[Redis, RedisCluster]:\n        return self._client\n\n    @client.setter\n    def client(self, client: Union[Redis, RedisCluster]):\n        self._client = client\n\n    @property\n    def circuit(self) -> CircuitBreaker:\n        return self._cb\n\n    @circuit.setter\n    def circuit(self, circuit: CircuitBreaker):\n        self._cb = circuit\n\n    def __repr__(self):\n        return f\"Database(client={self.client}, weight={self.weight})\"\n"
  },
  {
    "path": "redis/asyncio/multidb/event.py",
    "content": "from typing import List\n\nfrom redis.asyncio import Redis\nfrom redis.asyncio.multidb.database import AsyncDatabase\nfrom redis.asyncio.multidb.failure_detector import AsyncFailureDetector\nfrom redis.event import AsyncEventListenerInterface, AsyncOnCommandsFailEvent\n\n\nclass AsyncActiveDatabaseChanged:\n    \"\"\"\n    Event fired when an async active database has been changed.\n    \"\"\"\n\n    def __init__(\n        self,\n        old_database: AsyncDatabase,\n        new_database: AsyncDatabase,\n        command_executor,\n        **kwargs,\n    ):\n        self._old_database = old_database\n        self._new_database = new_database\n        self._command_executor = command_executor\n        self._kwargs = kwargs\n\n    @property\n    def old_database(self) -> AsyncDatabase:\n        return self._old_database\n\n    @property\n    def new_database(self) -> AsyncDatabase:\n        return self._new_database\n\n    @property\n    def command_executor(self):\n        return self._command_executor\n\n    @property\n    def kwargs(self):\n        return self._kwargs\n\n\nclass ResubscribeOnActiveDatabaseChanged(AsyncEventListenerInterface):\n    \"\"\"\n    Re-subscribe the currently active pub / sub to a new active database.\n    \"\"\"\n\n    async def listen(self, event: AsyncActiveDatabaseChanged):\n        old_pubsub = event.command_executor.active_pubsub\n\n        if old_pubsub is not None:\n            # Re-assign old channels and patterns so they will be automatically subscribed on connection.\n            new_pubsub = event.new_database.client.pubsub(**event.kwargs)\n            new_pubsub.channels = old_pubsub.channels\n            new_pubsub.patterns = old_pubsub.patterns\n            await new_pubsub.on_connect(None)\n            event.command_executor.active_pubsub = new_pubsub\n            await old_pubsub.aclose()\n\n\nclass CloseConnectionOnActiveDatabaseChanged(AsyncEventListenerInterface):\n    \"\"\"\n    Close connection to the old active database.\n    \"\"\"\n\n    async def listen(self, event: AsyncActiveDatabaseChanged):\n        await event.old_database.client.aclose()\n\n        if isinstance(event.old_database.client, Redis):\n            await event.old_database.client.connection_pool.update_active_connections_for_reconnect()\n            await event.old_database.client.connection_pool.disconnect()\n\n\nclass RegisterCommandFailure(AsyncEventListenerInterface):\n    \"\"\"\n    Event listener that registers command failures and passing it to the failure detectors.\n    \"\"\"\n\n    def __init__(self, failure_detectors: List[AsyncFailureDetector]):\n        self._failure_detectors = failure_detectors\n\n    async def listen(self, event: AsyncOnCommandsFailEvent) -> None:\n        for failure_detector in self._failure_detectors:\n            await failure_detector.register_failure(event.exception, event.commands)\n"
  },
  {
    "path": "redis/asyncio/multidb/failover.py",
    "content": "import time\nfrom abc import ABC, abstractmethod\n\nfrom redis.asyncio.multidb.database import AsyncDatabase, Databases\nfrom redis.data_structure import WeightedList\nfrom redis.multidb.circuit import State as CBState\nfrom redis.multidb.exception import (\n    NoValidDatabaseException,\n    TemporaryUnavailableException,\n)\n\nDEFAULT_FAILOVER_ATTEMPTS = 10\nDEFAULT_FAILOVER_DELAY = 12\n\n\nclass AsyncFailoverStrategy(ABC):\n    @abstractmethod\n    async def database(self) -> AsyncDatabase:\n        \"\"\"Select the database according to the strategy.\"\"\"\n        pass\n\n    @abstractmethod\n    def set_databases(self, databases: Databases) -> None:\n        \"\"\"Set the database strategy operates on.\"\"\"\n        pass\n\n\nclass FailoverStrategyExecutor(ABC):\n    @property\n    @abstractmethod\n    def failover_attempts(self) -> int:\n        \"\"\"The number of failover attempts.\"\"\"\n        pass\n\n    @property\n    @abstractmethod\n    def failover_delay(self) -> float:\n        \"\"\"The delay between failover attempts.\"\"\"\n        pass\n\n    @property\n    @abstractmethod\n    def strategy(self) -> AsyncFailoverStrategy:\n        \"\"\"The strategy to execute.\"\"\"\n        pass\n\n    @abstractmethod\n    async def execute(self) -> AsyncDatabase:\n        \"\"\"Execute the failover strategy.\"\"\"\n        pass\n\n\nclass WeightBasedFailoverStrategy(AsyncFailoverStrategy):\n    \"\"\"\n    Failover strategy based on database weights.\n    \"\"\"\n\n    def __init__(self):\n        self._databases = WeightedList()\n\n    async def database(self) -> AsyncDatabase:\n        for database, _ in self._databases:\n            if database.circuit.state == CBState.CLOSED:\n                return database\n\n        raise NoValidDatabaseException(\"No valid database available for communication\")\n\n    def set_databases(self, databases: Databases) -> None:\n        self._databases = databases\n\n\nclass DefaultFailoverStrategyExecutor(FailoverStrategyExecutor):\n    \"\"\"\n    Executes given failover strategy.\n    \"\"\"\n\n    def __init__(\n        self,\n        strategy: AsyncFailoverStrategy,\n        failover_attempts: int = DEFAULT_FAILOVER_ATTEMPTS,\n        failover_delay: float = DEFAULT_FAILOVER_DELAY,\n    ):\n        self._strategy = strategy\n        self._failover_attempts = failover_attempts\n        self._failover_delay = failover_delay\n        self._next_attempt_ts: int = 0\n        self._failover_counter: int = 0\n\n    @property\n    def failover_attempts(self) -> int:\n        return self._failover_attempts\n\n    @property\n    def failover_delay(self) -> float:\n        return self._failover_delay\n\n    @property\n    def strategy(self) -> AsyncFailoverStrategy:\n        return self._strategy\n\n    async def execute(self) -> AsyncDatabase:\n        try:\n            database = await self._strategy.database()\n            self._reset()\n            return database\n        except NoValidDatabaseException as e:\n            if self._next_attempt_ts == 0:\n                self._next_attempt_ts = time.time() + self._failover_delay\n                self._failover_counter += 1\n            elif time.time() >= self._next_attempt_ts:\n                self._next_attempt_ts += self._failover_delay\n                self._failover_counter += 1\n\n            if self._failover_counter > self._failover_attempts:\n                self._reset()\n                raise e\n            else:\n                raise TemporaryUnavailableException(\n                    \"No database connections currently available. \"\n                    \"This is a temporary condition - please retry the operation.\"\n                )\n\n    def _reset(self) -> None:\n        self._next_attempt_ts = 0\n        self._failover_counter = 0\n"
  },
  {
    "path": "redis/asyncio/multidb/failure_detector.py",
    "content": "from abc import ABC, abstractmethod\n\nfrom redis.multidb.failure_detector import FailureDetector\n\n\nclass AsyncFailureDetector(ABC):\n    @abstractmethod\n    async def register_failure(self, exception: Exception, cmd: tuple) -> None:\n        \"\"\"Register a failure that occurred during command execution.\"\"\"\n        pass\n\n    @abstractmethod\n    async def register_command_execution(self, cmd: tuple) -> None:\n        \"\"\"Register a command execution.\"\"\"\n        pass\n\n    @abstractmethod\n    def set_command_executor(self, command_executor) -> None:\n        \"\"\"Set the command executor for this failure.\"\"\"\n        pass\n\n\nclass FailureDetectorAsyncWrapper(AsyncFailureDetector):\n    \"\"\"\n    Async wrapper for the failure detector.\n    \"\"\"\n\n    def __init__(self, failure_detector: FailureDetector) -> None:\n        self._failure_detector = failure_detector\n\n    async def register_failure(self, exception: Exception, cmd: tuple) -> None:\n        self._failure_detector.register_failure(exception, cmd)\n\n    async def register_command_execution(self, cmd: tuple) -> None:\n        self._failure_detector.register_command_execution(cmd)\n\n    def set_command_executor(self, command_executor) -> None:\n        self._failure_detector.set_command_executor(command_executor)\n"
  },
  {
    "path": "redis/asyncio/multidb/healthcheck.py",
    "content": "import asyncio\nimport inspect\nimport logging\nfrom abc import ABC, abstractmethod\nfrom enum import Enum\nfrom typing import List, Optional, Tuple, Type, Union\n\nfrom redis.asyncio import Redis as AsyncRedis\nfrom redis.asyncio.cluster import RedisCluster as AsyncRedisCluster\nfrom redis.asyncio.http.http_client import DEFAULT_TIMEOUT, AsyncHTTPClientWrapper\nfrom redis.backoff import NoBackoff\nfrom redis.client import Redis as SyncRedis\nfrom redis.cluster import RedisCluster as SyncRedisCluster\nfrom redis.http.http_client import HttpClient\nfrom redis.multidb.exception import UnhealthyDatabaseException\nfrom redis.retry import Retry\n\n# Type alias for async Redis clients (standalone or cluster)\nAsyncRedisClientT = Union[AsyncRedis, AsyncRedisCluster]\n\n\ndef _get_init_params(cls: Type) -> frozenset:\n    \"\"\"Extract parameter names from a class's __init__ method.\"\"\"\n    sig = inspect.signature(cls.__init__)\n    return frozenset(\n        name\n        for name, param in sig.parameters.items()\n        if name != \"self\"\n        and param.kind\n        in (\n            inspect.Parameter.POSITIONAL_OR_KEYWORD,\n            inspect.Parameter.KEYWORD_ONLY,\n        )\n    )\n\n\ndef _filter_kwargs(kwargs: dict, cls: Type) -> dict:\n    \"\"\"Filter kwargs to only include parameters accepted by the class's __init__.\"\"\"\n    allowed = _get_init_params(cls)\n    return {k: v for k, v in kwargs.items() if k in allowed}\n\n\nDEFAULT_HEALTH_CHECK_PROBES = 3\nDEFAULT_HEALTH_CHECK_INTERVAL = 5\nDEFAULT_HEALTH_CHECK_TIMEOUT = 3\nDEFAULT_HEALTH_CHECK_DELAY = 0.5\nDEFAULT_LAG_AWARE_TOLERANCE = 5000\n\nlogger = logging.getLogger(__name__)\n\n\nclass HealthCheck(ABC):\n    \"\"\"\n    Health check interface.\n    \"\"\"\n\n    @property\n    @abstractmethod\n    def health_check_probes(self) -> int:\n        \"\"\"Number of probes to execute health checks.\"\"\"\n        pass\n\n    @property\n    @abstractmethod\n    def health_check_delay(self) -> float:\n        \"\"\"Delay between health check probes.\"\"\"\n        pass\n\n    @property\n    @abstractmethod\n    def health_check_timeout(self) -> float:\n        \"\"\"Timeout for the full health check operation (including all probes).\"\"\"\n        pass\n\n    @abstractmethod\n    async def check_health(self, database, hc_client: AsyncRedisClientT) -> bool:\n        \"\"\"\n        Function to determine the health status.\n\n        Args:\n            database: The database being checked\n            hc_client: A Redis client (AsyncRedis or AsyncRedisCluster) to use for\n                health checks. This client follows topology changes automatically.\n\n        Returns:\n            True if the database is healthy, False otherwise.\n        \"\"\"\n        pass\n\n\nclass HealthCheckPolicy(ABC):\n    \"\"\"\n    Health checks execution policy.\n    \"\"\"\n\n    @abstractmethod\n    async def execute(self, health_checks: List[HealthCheck], database) -> bool:\n        \"\"\"Execute health checks and return database health status.\"\"\"\n        pass\n\n    @abstractmethod\n    async def _execute(self, health_check: HealthCheck, database) -> bool:\n        \"\"\"\n        Executes health check against given database.\n        \"\"\"\n        pass\n\n    @abstractmethod\n    async def get_client(self, database) -> AsyncRedisClientT:\n        \"\"\"\n        Get a health check client for the database.\n        \"\"\"\n        pass\n\n    @abstractmethod\n    async def close(self) -> None:\n        \"\"\"Close all health check clients.\"\"\"\n        pass\n\n\nclass AbstractHealthCheckPolicy(HealthCheckPolicy):\n    \"\"\"\n    Abstract health check policy.\n    \"\"\"\n\n    def __init__(self):\n        # Single client per database, keyed by database id\n        self._clients: dict[int, AsyncRedisClientT] = {}\n\n    async def execute(self, health_checks: List[HealthCheck], database) -> bool:\n        \"\"\"\n        Execute all health checks concurrently with individual timeouts.\n        Each health check runs with its own timeout, and all run in parallel.\n\n        All exception handling is centralized here - _execute() methods just\n        propagate exceptions naturally.\n        \"\"\"\n\n        # Create wrapper tasks that apply individual timeouts\n        async def execute_with_timeout(health_check: HealthCheck):\n            return await asyncio.wait_for(\n                self._execute(health_check, database),\n                timeout=health_check.health_check_timeout,\n            )\n\n        # Run all health checks concurrently and collect results/exceptions\n        results = await asyncio.gather(\n            *[execute_with_timeout(hc) for hc in health_checks],\n            return_exceptions=True,\n        )\n\n        # Check results - handle exceptions and failures\n        for result in results:\n            if isinstance(result, Exception):\n                # Any exception (including TimeoutError) makes the database unhealthy\n                raise UnhealthyDatabaseException(\"Unhealthy database\", database, result)\n            elif not result:\n                # Health check returned False\n                return False\n\n        return True\n\n    async def get_client(self, database) -> AsyncRedisClientT:\n        \"\"\"\n        Get or create a health check client for the database.\n\n        Creates a single client instance per database that follows topology\n        changes automatically. For cluster databases, the client handles\n        node discovery and slot mapping internally.\n        \"\"\"\n        db_id = id(database)\n        client = self._clients.get(db_id)\n\n        if client is None:\n            # Check for both sync and async standalone Redis clients\n            if isinstance(database.client, (AsyncRedis, SyncRedis)):\n                conn_kwargs = database.client.get_connection_kwargs()\n                filtered_kwargs = _filter_kwargs(conn_kwargs, AsyncRedis)\n                client = AsyncRedis(**filtered_kwargs)\n            elif isinstance(database.client, (AsyncRedisCluster, SyncRedisCluster)):\n                # Cluster client - create a single cluster client that handles\n                # topology changes internally\n                conn_kwargs = database.client.get_connection_kwargs().copy()\n                filtered_kwargs = _filter_kwargs(conn_kwargs, AsyncRedisCluster)\n                startup_nodes = database.client.startup_nodes\n                # Use the first node as the startup node\n                if startup_nodes:\n                    first_node = startup_nodes[0]\n                    client = AsyncRedisCluster(\n                        host=first_node.host,\n                        port=first_node.port,\n                        dynamic_startup_nodes=database.client.nodes_manager._dynamic_startup_nodes,\n                        address_remap=database.client.nodes_manager.address_remap,\n                        require_full_coverage=database.client.nodes_manager._require_full_coverage,\n                        retry=database.client.retry,\n                        **filtered_kwargs,\n                    )\n                else:\n                    raise ValueError(\n                        \"Cluster client has no nodes - cannot create health check client\"\n                    )\n            else:\n                raise TypeError(f\"Unsupported client type: {type(database.client)}\")\n            self._clients[db_id] = client\n\n        return client\n\n    async def close(self) -> None:\n        \"\"\"Close all health check clients.\"\"\"\n        close_tasks = [\n            asyncio.create_task(client.aclose()) for client in self._clients.values()\n        ]\n\n        if close_tasks:\n            await asyncio.gather(*close_tasks, return_exceptions=True)\n\n        self._clients.clear()\n\n    @abstractmethod\n    async def _execute(self, health_check: HealthCheck, database) -> bool:\n        \"\"\"\n        Executes health check against given database.\n        \"\"\"\n        pass\n\n\nclass HealthyAllPolicy(AbstractHealthCheckPolicy):\n    \"\"\"\n    Policy that returns True if all health check probes are successful.\n    \"\"\"\n\n    async def _execute(self, health_check: HealthCheck, database) -> bool:\n        \"\"\"\n        Executes health check against given database.\n\n        Uses a single client that handles topology changes automatically.\n        \"\"\"\n        client = await self.get_client(database)\n        probes = health_check.health_check_probes\n\n        for attempt in range(probes):\n            result = await health_check.check_health(database, client)\n            if not result:\n                return False\n\n            if attempt < probes - 1:\n                await asyncio.sleep(health_check.health_check_delay)\n\n        return True\n\n\nclass HealthyMajorityPolicy(AbstractHealthCheckPolicy):\n    \"\"\"\n    Policy that returns True if a majority of health check probes are successful.\n\n    Majority means more than half must pass:\n    - 3 probes: need 2+ to pass (1 failure allowed)\n    - 4 probes: need 3+ to pass (1 failure allowed, tie = unhealthy)\n    - 5 probes: need 3+ to pass (2 failures allowed)\n    \"\"\"\n\n    async def _execute(self, health_check: HealthCheck, database) -> bool:\n        \"\"\"\n        Executes health check against given database.\n\n        Uses a single client that handles topology changes automatically.\n        \"\"\"\n        probes = health_check.health_check_probes\n        # Strict majority: more than half must pass\n        # (probes - 1) // 2 gives the max allowed failures\n        allowed_unsuccessful_probes = (probes - 1) // 2\n        client = await self.get_client(database)\n        last_exception = None\n\n        for attempt in range(probes):\n            try:\n                result = await health_check.check_health(database, client)\n                if not result:\n                    # Probe failed (returned False)\n                    allowed_unsuccessful_probes -= 1\n                    if allowed_unsuccessful_probes < 0:\n                        return False\n            except Exception as e:\n                # Probe failed (exception)\n                last_exception = e\n                allowed_unsuccessful_probes -= 1\n                if allowed_unsuccessful_probes < 0:\n                    raise last_exception\n\n            if attempt < probes - 1:\n                await asyncio.sleep(health_check.health_check_delay)\n\n        return True\n\n\nclass HealthyAnyPolicy(AbstractHealthCheckPolicy):\n    \"\"\"\n    Policy that returns True if at least one health check probe is successful.\n    \"\"\"\n\n    async def _execute(self, health_check: HealthCheck, database) -> bool:\n        \"\"\"\n        Executes health check against given database.\n\n        Uses a single client that handles topology changes automatically.\n        \"\"\"\n        probes = health_check.health_check_probes\n        last_exception = None\n        client = await self.get_client(database)\n\n        for attempt in range(probes):\n            try:\n                result = await health_check.check_health(database, client)\n                if result:\n                    # At least one probe succeeded\n                    return True\n            except Exception as e:\n                last_exception = e\n\n            if attempt < probes - 1:\n                await asyncio.sleep(health_check.health_check_delay)\n\n        # All probes failed\n        if last_exception:\n            raise last_exception\n\n        return False\n\n\nclass HealthCheckPolicies(Enum):\n    HEALTHY_ALL = HealthyAllPolicy\n    HEALTHY_MAJORITY = HealthyMajorityPolicy\n    HEALTHY_ANY = HealthyAnyPolicy\n\n\nDEFAULT_HEALTH_CHECK_POLICY: HealthCheckPolicies = HealthCheckPolicies.HEALTHY_ALL\n\n\nclass AbstractHealthCheck(HealthCheck):\n    def __init__(\n        self,\n        health_check_probes: int = DEFAULT_HEALTH_CHECK_PROBES,\n        health_check_delay: float = DEFAULT_HEALTH_CHECK_DELAY,\n        health_check_timeout: float = DEFAULT_HEALTH_CHECK_TIMEOUT,\n    ):\n        if health_check_probes < 1:\n            raise ValueError(\"health_check_probes must be greater than 0\")\n        self._health_check_probes = health_check_probes\n        self._health_check_delay = health_check_delay\n        self._health_check_timeout = health_check_timeout\n\n    @property\n    def health_check_probes(self) -> int:\n        return self._health_check_probes\n\n    @property\n    def health_check_delay(self) -> float:\n        return self._health_check_delay\n\n    @property\n    def health_check_timeout(self) -> float:\n        return self._health_check_timeout\n\n    @abstractmethod\n    async def check_health(self, database, hc_client: AsyncRedisClientT) -> bool:\n        pass\n\n\nclass PingHealthCheck(AbstractHealthCheck):\n    \"\"\"\n    Health check based on PING command.\n    \"\"\"\n\n    async def check_health(self, database, hc_client: AsyncRedisClientT) -> bool:\n        if isinstance(hc_client, AsyncRedis):\n            return await hc_client.execute_command(\"PING\")\n        else:\n            # For a cluster checks if all nodes are healthy.\n            all_nodes = hc_client.get_nodes()\n            for node in all_nodes:\n                if not await node.redis_connection.execute_command(\"PING\"):\n                    return False\n\n            return True\n\n\nclass LagAwareHealthCheck(AbstractHealthCheck):\n    \"\"\"\n    Health check available for Redis Enterprise deployments.\n    Verify via REST API that the database is healthy based on different lags.\n    \"\"\"\n\n    def __init__(\n        self,\n        rest_api_port: int = 9443,\n        lag_aware_tolerance: int = DEFAULT_LAG_AWARE_TOLERANCE,\n        http_timeout: float = DEFAULT_TIMEOUT,\n        auth_basic: Optional[Tuple[str, str]] = None,\n        verify_tls: bool = True,\n        # TLS verification (server) options\n        ca_file: Optional[str] = None,\n        ca_path: Optional[str] = None,\n        ca_data: Optional[Union[str, bytes]] = None,\n        # Mutual TLS (client cert) options\n        client_cert_file: Optional[str] = None,\n        client_key_file: Optional[str] = None,\n        client_key_password: Optional[str] = None,\n        # Health check configuration\n        health_check_probes: int = DEFAULT_HEALTH_CHECK_PROBES,\n        health_check_delay: float = DEFAULT_HEALTH_CHECK_DELAY,\n        health_check_timeout: float = DEFAULT_HEALTH_CHECK_TIMEOUT,\n    ):\n        \"\"\"\n        Initialize LagAwareHealthCheck with the specified parameters.\n\n        Args:\n            rest_api_port: Port number for Redis Enterprise REST API (default: 9443)\n            lag_aware_tolerance: Tolerance in lag between databases in MS (default: 100)\n            http_timeout: Request timeout in seconds (default: DEFAULT_TIMEOUT)\n            auth_basic: Tuple of (username, password) for basic authentication\n            verify_tls: Whether to verify TLS certificates (default: True)\n            ca_file: Path to CA certificate file for TLS verification\n            ca_path: Path to CA certificates directory for TLS verification\n            ca_data: CA certificate data as string or bytes\n            client_cert_file: Path to client certificate file for mutual TLS\n            client_key_file: Path to client private key file for mutual TLS\n            client_key_password: Password for encrypted client private key\n        \"\"\"\n        self._http_client = AsyncHTTPClientWrapper(\n            HttpClient(\n                timeout=http_timeout,\n                auth_basic=auth_basic,\n                retry=Retry(NoBackoff(), retries=0),\n                verify_tls=verify_tls,\n                ca_file=ca_file,\n                ca_path=ca_path,\n                ca_data=ca_data,\n                client_cert_file=client_cert_file,\n                client_key_file=client_key_file,\n                client_key_password=client_key_password,\n            )\n        )\n        self._rest_api_port = rest_api_port\n        self._lag_aware_tolerance = lag_aware_tolerance\n        super().__init__(\n            health_check_probes=health_check_probes,\n            health_check_delay=health_check_delay,\n            health_check_timeout=health_check_timeout,\n        )\n\n    async def check_health(self, database, hc_client: AsyncRedisClientT) -> bool:\n        \"\"\"\n        Check database health via Redis Enterprise REST API.\n\n        Note: The client parameter is not used for this health check as it\n        relies on the REST API instead of Redis protocol. The client is\n        accepted for interface compatibility.\n        \"\"\"\n        if database.health_check_url is None:\n            raise ValueError(\n                \"Database health check url is not set. Please check DatabaseConfig for the current database.\"\n            )\n\n        if isinstance(database.client, (AsyncRedis, SyncRedis)):\n            db_host = database.client.get_connection_kwargs()[\"host\"]\n        else:\n            # Cluster client\n            db_host = database.client.get_nodes()[0].host\n\n        base_url = f\"{database.health_check_url}:{self._rest_api_port}\"\n        self._http_client.client.base_url = base_url\n\n        # Find bdb matching to the current database host\n        matching_bdb = None\n        for bdb in await self._http_client.get(\"/v1/bdbs\"):\n            for endpoint in bdb[\"endpoints\"]:\n                if endpoint[\"dns_name\"] == db_host:\n                    matching_bdb = bdb\n                    break\n\n                # In case if the host was set as public IP\n                for addr in endpoint[\"addr\"]:\n                    if addr == db_host:\n                        matching_bdb = bdb\n                        break\n\n        if matching_bdb is None:\n            logger.warning(\"LagAwareHealthCheck failed: Couldn't find a matching bdb\")\n            raise ValueError(\"Could not find a matching bdb\")\n\n        url = (\n            f\"/v1/bdbs/{matching_bdb['uid']}/availability\"\n            f\"?extend_check=lag&availability_lag_tolerance_ms={self._lag_aware_tolerance}\"\n        )\n        await self._http_client.get(url, expect_json=False)\n\n        # Status checked in an http client, otherwise HttpError will be raised\n        return True\n"
  },
  {
    "path": "redis/asyncio/observability/__init__.py",
    "content": "\"\"\"\nAsync observability module for Redis async clients.\n\nThis module provides async-safe APIs for recording Redis metrics using OpenTelemetry.\n\nUsage:\n    from redis.asyncio.observability.recorder import record_operation_duration\n\nConfiguration is shared with the sync observability module:\n    from redis.observability import get_observability_instance, OTelConfig\n\n    otel = get_observability_instance()\n    otel.init(OTelConfig())\n\"\"\"\n"
  },
  {
    "path": "redis/asyncio/observability/recorder.py",
    "content": "\"\"\"\nAsync-compatible API for recording observability metrics.\n\nThis module provides an async-safe interface for Redis async client code to record\nmetrics without needing to know about OpenTelemetry internals. It reuses the same\nRedisMetricsCollector and configuration as the sync recorder.\n\nUsage in Redis async client code:\n    from redis.asyncio.observability.recorder import record_operation_duration\n\n    start_time = time.monotonic()\n    # ... execute Redis command ...\n    await record_operation_duration(\n        command_name='SET',\n        duration_seconds=time.monotonic() - start_time,\n        server_address='localhost',\n        server_port=6379,\n        db_namespace='0',\n        error=None\n    )\n\"\"\"\n\nfrom datetime import datetime\nfrom typing import TYPE_CHECKING, List, Optional\n\nfrom redis.observability.attributes import (\n    ConnectionState,\n    GeoFailoverReason,\n    PubSubDirection,\n)\nfrom redis.observability.metrics import CloseReason, RedisMetricsCollector\nfrom redis.observability.providers import get_observability_instance\nfrom redis.observability.registry import get_observables_registry_instance\nfrom redis.utils import deprecated_function, str_if_bytes\n\nif TYPE_CHECKING:\n    from redis.asyncio.connection import ConnectionPool\n    from redis.asyncio.multidb.database import AsyncDatabase\n    from redis.observability.config import OTelConfig\n\n# Global metrics collector instance (lazy-initialized)\n_async_metrics_collector: Optional[RedisMetricsCollector] = None\n\nCONNECTION_COUNT_REGISTRY_KEY = \"connection_count\"\n\n\ndef _get_or_create_collector() -> Optional[RedisMetricsCollector]:\n    \"\"\"\n    Get or create the global metrics collector.\n\n    Returns:\n        RedisMetricsCollector instance if observability is enabled, None otherwise\n    \"\"\"\n    global _async_metrics_collector\n\n    if _async_metrics_collector is not None:\n        return _async_metrics_collector\n\n    try:\n        manager = get_observability_instance().get_provider_manager()\n        if manager is None or not manager.config.enabled_telemetry:\n            return None\n\n        # Get meter from the global MeterProvider\n        meter = manager.get_meter_provider().get_meter(\n            RedisMetricsCollector.METER_NAME, RedisMetricsCollector.METER_VERSION\n        )\n\n        _async_metrics_collector = RedisMetricsCollector(meter, manager.config)\n        return _async_metrics_collector\n\n    except ImportError:\n        # Observability module not available\n        return None\n    except Exception:\n        # Any other error - don't break Redis operations\n        return None\n\n\nasync def _get_config() -> Optional[\"OTelConfig\"]:\n    \"\"\"\n    Get the OTel configuration from the observability manager.\n\n    Returns:\n        OTelConfig instance if observability is enabled, None otherwise\n    \"\"\"\n    try:\n        manager = get_observability_instance().get_provider_manager()\n        if manager is None:\n            return None\n        return manager.config\n    except Exception:\n        return None\n\n\nasync def record_operation_duration(\n    command_name: str,\n    duration_seconds: float,\n    server_address: Optional[str] = None,\n    server_port: Optional[int] = None,\n    db_namespace: Optional[str] = None,\n    error: Optional[Exception] = None,\n    is_blocking: Optional[bool] = None,\n    retry_attempts: Optional[int] = None,\n) -> None:\n    \"\"\"\n    Record a Redis command execution duration.\n\n    This is an async-safe API that Redis async client code can call directly.\n    If observability is not enabled, this returns immediately with zero overhead.\n\n    Args:\n        command_name: Redis command name (e.g., 'GET', 'SET')\n        duration_seconds: Command execution time in seconds\n        server_address: Redis server address\n        server_port: Redis server port\n        db_namespace: Redis database index\n        error: Exception if command failed, None if successful\n        is_blocking: Whether the operation is a blocking command\n        retry_attempts: Number of retry attempts made\n\n    Example:\n        >>> start = time.monotonic()\n        >>> # ... execute command ...\n        >>> await record_operation_duration('SET', time.monotonic() - start, 'localhost', 6379, '0')\n    \"\"\"\n    collector = _get_or_create_collector()\n    if collector is None:\n        return\n\n    try:\n        collector.record_operation_duration(\n            command_name=command_name,\n            duration_seconds=duration_seconds,\n            server_address=server_address,\n            server_port=server_port,\n            db_namespace=db_namespace,\n            error_type=error,\n            network_peer_address=server_address,\n            network_peer_port=server_port,\n            is_blocking=is_blocking,\n            retry_attempts=retry_attempts,\n        )\n    except Exception:\n        pass\n\n\nasync def record_connection_create_time(\n    connection_pool: \"ConnectionPool\",\n    duration_seconds: float,\n) -> None:\n    \"\"\"\n    Record connection creation time.\n\n    Args:\n        connection_pool: Connection pool implementation\n        duration_seconds: Time taken to create connection in seconds\n    \"\"\"\n    collector = _get_or_create_collector()\n    if collector is None:\n        return\n\n    try:\n        collector.record_connection_create_time(\n            connection_pool=connection_pool,\n            duration_seconds=duration_seconds,\n        )\n    except Exception:\n        pass\n\n\nasync def record_connection_count(\n    pool_name: str,\n    connection_state: ConnectionState,\n    counter: int = 1,\n) -> None:\n    \"\"\"\n    Record a connection count change for a single state.\n\n    Args:\n        pool_name: Connection pool identifier\n        connection_state: State to update (IDLE or USED)\n        counter: Number to add (positive) or subtract (negative)\n    \"\"\"\n    collector = _get_or_create_collector()\n    if collector is None:\n        return\n\n    try:\n        collector.record_connection_count(\n            pool_name=pool_name,\n            connection_state=connection_state,\n            counter=counter,\n        )\n    except Exception:\n        pass\n\n\n@deprecated_function(\n    reason=\"Connection count is now tracked via record_connection_count(). \"\n    \"This functionality will be removed in the next major version\",\n    version=\"7.4.0\",\n)\nasync def init_connection_count() -> None:\n    \"\"\"\n    Initialize observable gauge for connection count metric.\n    \"\"\"\n    collector = _get_or_create_collector()\n    if collector is None:\n        return\n\n    def observable_callback(__):\n        observables_registry = get_observables_registry_instance()\n        callbacks = observables_registry.get(CONNECTION_COUNT_REGISTRY_KEY)\n        observations = []\n\n        for callback in callbacks:\n            observations.extend(callback())\n\n        return observations\n\n    try:\n        collector.init_connection_count(\n            callback=observable_callback,\n        )\n    except Exception:\n        pass\n\n\n@deprecated_function(\n    reason=\"Connection count is now tracked via record_connection_count(). \"\n    \"This functionality will be removed in the next major version\",\n    version=\"7.4.0\",\n)\nasync def register_pools_connection_count(\n    connection_pools: List[\"ConnectionPool\"],\n) -> None:\n    \"\"\"\n    Add connection pools to connection count observable registry.\n    \"\"\"\n    collector = _get_or_create_collector()\n    if collector is None:\n        return\n\n    try:\n        # Lazy import\n        from opentelemetry.metrics import Observation\n\n        def connection_count_callback():\n            observations = []\n            for connection_pool in connection_pools:\n                for count, attributes in connection_pool.get_connection_count():\n                    observations.append(Observation(count, attributes=attributes))\n            return observations\n\n        observables_registry = get_observables_registry_instance()\n        observables_registry.register(\n            CONNECTION_COUNT_REGISTRY_KEY, connection_count_callback\n        )\n    except Exception:\n        pass\n\n\nasync def record_connection_timeout(\n    pool_name: str,\n) -> None:\n    \"\"\"\n    Record a connection timeout event.\n\n    Args:\n        pool_name: Connection pool identifier\n    \"\"\"\n    collector = _get_or_create_collector()\n    if collector is None:\n        return\n\n    try:\n        collector.record_connection_timeout(\n            pool_name=pool_name,\n        )\n    except Exception:\n        pass\n\n\nasync def record_connection_wait_time(\n    pool_name: str,\n    duration_seconds: float,\n) -> None:\n    \"\"\"\n    Record time taken to obtain a connection from the pool.\n\n    Args:\n        pool_name: Connection pool identifier\n        duration_seconds: Wait time in seconds\n    \"\"\"\n    collector = _get_or_create_collector()\n    if collector is None:\n        return\n\n    try:\n        collector.record_connection_wait_time(\n            pool_name=pool_name,\n            duration_seconds=duration_seconds,\n        )\n    except Exception:\n        pass\n\n\nasync def record_connection_closed(\n    close_reason: Optional[CloseReason] = None,\n    error_type: Optional[Exception] = None,\n) -> None:\n    \"\"\"\n    Record a connection closed event.\n\n    Args:\n        close_reason: Reason for closing (e.g. 'error', 'application_close')\n        error_type: Error type if closed due to error\n    \"\"\"\n    collector = _get_or_create_collector()\n    if collector is None:\n        return\n\n    try:\n        collector.record_connection_closed(\n            close_reason=close_reason,\n            error_type=error_type,\n        )\n    except Exception:\n        pass\n\n\nasync def record_connection_relaxed_timeout(\n    connection_name: str,\n    maint_notification: str,\n    relaxed: bool,\n) -> None:\n    \"\"\"\n    Record a connection timeout relaxation event.\n\n    Args:\n        connection_name: Connection identifier (pool name)\n        maint_notification: Maintenance notification type\n        relaxed: True to count up (relaxed), False to count down (unrelaxed)\n    \"\"\"\n    collector = _get_or_create_collector()\n    if collector is None:\n        return\n\n    try:\n        collector.record_connection_relaxed_timeout(\n            connection_name=connection_name,\n            maint_notification=maint_notification,\n            relaxed=relaxed,\n        )\n    except Exception:\n        pass\n\n\nasync def record_connection_handoff(\n    pool_name: str,\n) -> None:\n    \"\"\"\n    Record a connection handoff event (e.g., after MOVING notification).\n\n    Args:\n        pool_name: Connection pool identifier\n    \"\"\"\n    collector = _get_or_create_collector()\n    if collector is None:\n        return\n\n    try:\n        collector.record_connection_handoff(\n            pool_name=pool_name,\n        )\n    except Exception:\n        pass\n\n\nasync def record_error_count(\n    server_address: str,\n    server_port: int,\n    network_peer_address: str,\n    network_peer_port: int,\n    error_type: Exception,\n    retry_attempts: int,\n    is_internal: bool = True,\n) -> None:\n    \"\"\"\n    Record error count.\n\n    Args:\n        server_address: Server address\n        server_port: Server port\n        network_peer_address: Network peer address\n        network_peer_port: Network peer port\n        error_type: Error type (Exception)\n        retry_attempts: Retry attempts\n        is_internal: Whether the error is internal (e.g., timeout, network error)\n    \"\"\"\n    collector = _get_or_create_collector()\n    if collector is None:\n        return\n\n    try:\n        collector.record_error_count(\n            server_address=server_address,\n            server_port=server_port,\n            network_peer_address=network_peer_address,\n            network_peer_port=network_peer_port,\n            error_type=error_type,\n            retry_attempts=retry_attempts,\n            is_internal=is_internal,\n        )\n    except Exception:\n        pass\n\n\nasync def record_pubsub_message(\n    direction: PubSubDirection,\n    channel: Optional[str] = None,\n    sharded: Optional[bool] = None,\n) -> None:\n    \"\"\"\n    Record a PubSub message (published or received).\n\n    Args:\n        direction: Message direction ('publish' or 'receive')\n        channel: Pub/Sub channel name\n        sharded: True if sharded Pub/Sub channel\n    \"\"\"\n    collector = _get_or_create_collector()\n    if collector is None:\n        return\n\n    # Check if channel names should be hidden\n    effective_channel = channel\n    if channel is not None:\n        config = await _get_config()\n        if config is not None and config.hide_pubsub_channel_names:\n            effective_channel = None\n        else:\n            # Normalize bytes to str for OTel attributes\n            effective_channel = str_if_bytes(channel)\n\n    try:\n        collector.record_pubsub_message(\n            direction=direction,\n            channel=effective_channel,\n            sharded=sharded,\n        )\n    except Exception:\n        pass\n\n\nasync def record_streaming_lag(\n    lag_seconds: float,\n    stream_name: Optional[str] = None,\n    consumer_group: Optional[str] = None,\n) -> None:\n    \"\"\"\n    Record the lag of a streaming message.\n\n    Args:\n        lag_seconds: Lag in seconds\n        stream_name: Stream name\n        consumer_group: Consumer group name\n    \"\"\"\n    collector = _get_or_create_collector()\n    if collector is None:\n        return\n\n    # Check if stream names should be hidden\n    effective_stream_name = stream_name\n    if stream_name is not None:\n        config = await _get_config()\n        if config is not None and config.hide_stream_names:\n            effective_stream_name = None\n\n    try:\n        collector.record_streaming_lag(\n            lag_seconds=lag_seconds,\n            stream_name=effective_stream_name,\n            consumer_group=consumer_group,\n        )\n    except Exception:\n        pass\n\n\nasync def record_streaming_lag_from_response(\n    response,\n    consumer_group: Optional[str] = None,\n) -> None:\n    \"\"\"\n    Record streaming lag from XREAD/XREADGROUP response.\n\n    Parses the response and calculates lag for each message based on message ID timestamp.\n\n    Args:\n        response: Response from XREAD/XREADGROUP command\n        consumer_group: Consumer group name (for XREADGROUP)\n    \"\"\"\n    collector = _get_or_create_collector()\n    if collector is None:\n        return\n\n    if not response:\n        return\n\n    try:\n        now = datetime.now().timestamp()\n\n        # Check if stream names should be hidden\n        config = await _get_config()\n        hide_stream_names = config is not None and config.hide_stream_names\n\n        # RESP3 format: dict\n        if isinstance(response, dict):\n            for stream_name, stream_messages in response.items():\n                effective_stream_name = (\n                    None if hide_stream_names else str_if_bytes(stream_name)\n                )\n                for messages in stream_messages:\n                    for message in messages:\n                        message_id, _ = message\n                        message_id = str_if_bytes(message_id)\n                        timestamp, _ = message_id.split(\"-\")\n                        # Ensure lag is non-negative (clock skew can cause negative values)\n                        lag_seconds = max(0.0, now - int(timestamp) / 1000)\n\n                        collector.record_streaming_lag(\n                            lag_seconds=lag_seconds,\n                            stream_name=effective_stream_name,\n                            consumer_group=consumer_group,\n                        )\n        else:\n            # RESP2 format: list\n            for stream_entry in response:\n                stream_name = str_if_bytes(stream_entry[0])\n                effective_stream_name = None if hide_stream_names else stream_name\n\n                for message in stream_entry[1]:\n                    message_id, _ = message\n                    message_id = str_if_bytes(message_id)\n                    timestamp, _ = message_id.split(\"-\")\n                    # Ensure lag is non-negative (clock skew can cause negative values)\n                    lag_seconds = max(0.0, now - int(timestamp) / 1000)\n\n                    collector.record_streaming_lag(\n                        lag_seconds=lag_seconds,\n                        stream_name=effective_stream_name,\n                        consumer_group=consumer_group,\n                    )\n    except Exception:\n        pass\n\n\nasync def record_maint_notification_count(\n    server_address: str,\n    server_port: int,\n    network_peer_address: str,\n    network_peer_port: int,\n    maint_notification: str,\n) -> None:\n    \"\"\"\n    Record a maintenance notification count.\n\n    Args:\n        server_address: Server address\n        server_port: Server port\n        network_peer_address: Network peer address\n        network_peer_port: Network peer port\n        maint_notification: Maintenance notification type (e.g., 'MOVING', 'MIGRATING')\n    \"\"\"\n    collector = _get_or_create_collector()\n    if collector is None:\n        return\n\n    try:\n        collector.record_maint_notification_count(\n            server_address=server_address,\n            server_port=server_port,\n            network_peer_address=network_peer_address,\n            network_peer_port=network_peer_port,\n            maint_notification=maint_notification,\n        )\n    except Exception:\n        pass\n\n\nasync def record_geo_failover(\n    fail_from: \"AsyncDatabase\",\n    fail_to: \"AsyncDatabase\",\n    reason: GeoFailoverReason,\n) -> None:\n    \"\"\"\n    Record a geo failover.\n\n    Args:\n        fail_from: Database failed from\n        fail_to: Database failed to\n        reason: Reason for the failover\n    \"\"\"\n    collector = _get_or_create_collector()\n    if collector is None:\n        return\n\n    try:\n        collector.record_geo_failover(\n            fail_from=fail_from,\n            fail_to=fail_to,\n            reason=reason,\n        )\n    except Exception:\n        pass\n\n\ndef reset_collector() -> None:\n    \"\"\"\n    Reset the global async collector (used for testing or re-initialization).\n    \"\"\"\n    global _async_metrics_collector\n    _async_metrics_collector = None\n\n\nasync def is_enabled() -> bool:\n    \"\"\"\n    Check if observability is enabled.\n\n    Returns:\n        True if metrics are being collected, False otherwise\n    \"\"\"\n    collector = _get_or_create_collector()\n    return collector is not None\n"
  },
  {
    "path": "redis/asyncio/retry.py",
    "content": "from asyncio import sleep\nfrom typing import (\n    TYPE_CHECKING,\n    Any,\n    Awaitable,\n    Callable,\n    Optional,\n    Tuple,\n    Type,\n    TypeVar,\n    Union,\n)\n\nfrom redis.exceptions import ConnectionError, RedisError, TimeoutError\nfrom redis.retry import AbstractRetry\n\nT = TypeVar(\"T\")\n\nif TYPE_CHECKING:\n    from redis.backoff import AbstractBackoff\n\n\nclass Retry(AbstractRetry[RedisError]):\n    __hash__ = AbstractRetry.__hash__\n\n    def __init__(\n        self,\n        backoff: \"AbstractBackoff\",\n        retries: int,\n        supported_errors: Tuple[Type[RedisError], ...] = (\n            ConnectionError,\n            TimeoutError,\n        ),\n    ):\n        super().__init__(backoff, retries, supported_errors)\n\n    def __eq__(self, other: Any) -> bool:\n        if not isinstance(other, Retry):\n            return NotImplemented\n\n        return (\n            self._backoff == other._backoff\n            and self._retries == other._retries\n            and set(self._supported_errors) == set(other._supported_errors)\n        )\n\n    async def call_with_retry(\n        self,\n        do: Callable[[], Awaitable[T]],\n        fail: Union[\n            Callable[[Exception], Any],\n            Callable[[Exception, int], Any],\n        ],\n        is_retryable: Optional[Callable[[Exception], bool]] = None,\n        with_failure_count: bool = False,\n    ) -> T:\n        \"\"\"\n        Execute an operation that might fail and returns its result, or\n        raise the exception that was thrown depending on the `Backoff` object.\n        `do`: the operation to call. Expects no argument.\n        `fail`: the failure handler, expects the last error that was thrown\n        ``is_retryable``: optional function to determine if an error is retryable\n        ``with_failure_count``: if True, the failure count is passed to the failure handler\n        \"\"\"\n        self._backoff.reset()\n        failures = 0\n        while True:\n            try:\n                return await do()\n            except self._supported_errors as error:\n                if is_retryable and not is_retryable(error):\n                    raise\n                failures += 1\n\n                if with_failure_count:\n                    await fail(error, failures)\n                else:\n                    await fail(error)\n\n                if self._retries >= 0 and failures > self._retries:\n                    raise error\n                backoff = self._backoff.compute(failures)\n                if backoff > 0:\n                    await sleep(backoff)\n"
  },
  {
    "path": "redis/asyncio/sentinel.py",
    "content": "import asyncio\nimport random\nimport weakref\nfrom typing import AsyncIterator, Iterable, Mapping, Optional, Sequence, Tuple, Type\n\nfrom redis.asyncio.client import Redis\nfrom redis.asyncio.connection import (\n    Connection,\n    ConnectionPool,\n    EncodableT,\n    SSLConnection,\n)\nfrom redis.commands import AsyncSentinelCommands\nfrom redis.exceptions import (\n    ConnectionError,\n    ReadOnlyError,\n    ResponseError,\n    TimeoutError,\n)\n\n\nclass MasterNotFoundError(ConnectionError):\n    pass\n\n\nclass SlaveNotFoundError(ConnectionError):\n    pass\n\n\nclass SentinelManagedConnection(Connection):\n    def __init__(self, **kwargs):\n        self.connection_pool = kwargs.pop(\"connection_pool\")\n        super().__init__(**kwargs)\n\n    def __repr__(self):\n        s = f\"<{self.__class__.__module__}.{self.__class__.__name__}\"\n        if self.host:\n            host_info = f\",host={self.host},port={self.port}\"\n            s += host_info\n        return s + \")>\"\n\n    async def connect_to(self, address):\n        self.host, self.port = address\n        await self.connect_check_health(\n            check_health=self.connection_pool.check_connection,\n            retry_socket_connect=False,\n        )\n\n    async def _connect_retry(self):\n        if self._reader:\n            return  # already connected\n        if self.connection_pool.is_master:\n            await self.connect_to(await self.connection_pool.get_master_address())\n        else:\n            async for slave in self.connection_pool.rotate_slaves():\n                try:\n                    return await self.connect_to(slave)\n                except ConnectionError:\n                    continue\n            raise SlaveNotFoundError  # Never be here\n\n    async def connect(self):\n        return await self.retry.call_with_retry(\n            self._connect_retry,\n            lambda error: asyncio.sleep(0),\n        )\n\n    async def read_response(\n        self,\n        disable_decoding: bool = False,\n        timeout: Optional[float] = None,\n        *,\n        disconnect_on_error: Optional[float] = True,\n        push_request: Optional[bool] = False,\n    ):\n        try:\n            return await super().read_response(\n                disable_decoding=disable_decoding,\n                timeout=timeout,\n                disconnect_on_error=disconnect_on_error,\n                push_request=push_request,\n            )\n        except ReadOnlyError:\n            if self.connection_pool.is_master:\n                # When talking to a master, a ReadOnlyError when likely\n                # indicates that the previous master that we're still connected\n                # to has been demoted to a slave and there's a new master.\n                # calling disconnect will force the connection to re-query\n                # sentinel during the next connect() attempt.\n                await self.disconnect()\n                raise ConnectionError(\"The previous master is now a slave\")\n            raise\n\n\nclass SentinelManagedSSLConnection(SentinelManagedConnection, SSLConnection):\n    pass\n\n\nclass SentinelConnectionPool(ConnectionPool):\n    \"\"\"\n    Sentinel backed connection pool.\n\n    If ``check_connection`` flag is set to True, SentinelManagedConnection\n    sends a PING command right after establishing the connection.\n    \"\"\"\n\n    def __init__(self, service_name, sentinel_manager, **kwargs):\n        kwargs[\"connection_class\"] = kwargs.get(\n            \"connection_class\",\n            (\n                SentinelManagedSSLConnection\n                if kwargs.pop(\"ssl\", False)\n                else SentinelManagedConnection\n            ),\n        )\n        self.is_master = kwargs.pop(\"is_master\", True)\n        self.check_connection = kwargs.pop(\"check_connection\", False)\n        super().__init__(**kwargs)\n        self.connection_kwargs[\"connection_pool\"] = weakref.proxy(self)\n        self.service_name = service_name\n        self.sentinel_manager = sentinel_manager\n        self.master_address = None\n        self.slave_rr_counter = None\n\n    def __repr__(self):\n        return (\n            f\"<{self.__class__.__module__}.{self.__class__.__name__}\"\n            f\"(service={self.service_name}({self.is_master and 'master' or 'slave'}))>\"\n        )\n\n    def reset(self):\n        super().reset()\n        self.master_address = None\n        self.slave_rr_counter = None\n\n    def owns_connection(self, connection: Connection):\n        check = not self.is_master or (\n            self.is_master and self.master_address == (connection.host, connection.port)\n        )\n        return check and super().owns_connection(connection)\n\n    async def get_master_address(self):\n        master_address = await self.sentinel_manager.discover_master(self.service_name)\n        if self.is_master:\n            if self.master_address != master_address:\n                self.master_address = master_address\n                # disconnect any idle connections so that they reconnect\n                # to the new master the next time that they are used.\n                await self.disconnect(inuse_connections=False)\n        return master_address\n\n    async def rotate_slaves(self) -> AsyncIterator:\n        \"\"\"Round-robin slave balancer\"\"\"\n        slaves = await self.sentinel_manager.discover_slaves(self.service_name)\n        if slaves:\n            if self.slave_rr_counter is None:\n                self.slave_rr_counter = random.randint(0, len(slaves) - 1)\n            for _ in range(len(slaves)):\n                self.slave_rr_counter = (self.slave_rr_counter + 1) % len(slaves)\n                slave = slaves[self.slave_rr_counter]\n                yield slave\n        # Fallback to the master connection\n        try:\n            yield await self.get_master_address()\n        except MasterNotFoundError:\n            pass\n        raise SlaveNotFoundError(f\"No slave found for {self.service_name!r}\")\n\n\nclass Sentinel(AsyncSentinelCommands):\n    \"\"\"\n    Redis Sentinel cluster client\n\n    >>> from redis.sentinel import Sentinel\n    >>> sentinel = Sentinel([('localhost', 26379)], socket_timeout=0.1)\n    >>> master = sentinel.master_for('mymaster', socket_timeout=0.1)\n    >>> await master.set('foo', 'bar')\n    >>> slave = sentinel.slave_for('mymaster', socket_timeout=0.1)\n    >>> await slave.get('foo')\n    b'bar'\n\n    ``sentinels`` is a list of sentinel nodes. Each node is represented by\n    a pair (hostname, port).\n\n    ``min_other_sentinels`` defined a minimum number of peers for a sentinel.\n    When querying a sentinel, if it doesn't meet this threshold, responses\n    from that sentinel won't be considered valid.\n\n    ``sentinel_kwargs`` is a dictionary of connection arguments used when\n    connecting to sentinel instances. Any argument that can be passed to\n    a normal Redis connection can be specified here. If ``sentinel_kwargs`` is\n    not specified, any socket_timeout and socket_keepalive options specified\n    in ``connection_kwargs`` will be used.\n\n    ``connection_kwargs`` are keyword arguments that will be used when\n    establishing a connection to a Redis server.\n    \"\"\"\n\n    def __init__(\n        self,\n        sentinels,\n        min_other_sentinels=0,\n        sentinel_kwargs=None,\n        force_master_ip=None,\n        **connection_kwargs,\n    ):\n        # if sentinel_kwargs isn't defined, use the socket_* options from\n        # connection_kwargs\n        if sentinel_kwargs is None:\n            sentinel_kwargs = {\n                k: v for k, v in connection_kwargs.items() if k.startswith(\"socket_\")\n            }\n        self.sentinel_kwargs = sentinel_kwargs\n\n        self.sentinels = [\n            Redis(host=hostname, port=port, **self.sentinel_kwargs)\n            for hostname, port in sentinels\n        ]\n        self.min_other_sentinels = min_other_sentinels\n        self.connection_kwargs = connection_kwargs\n        self._force_master_ip = force_master_ip\n\n    async def execute_command(self, *args, **kwargs):\n        \"\"\"\n        Execute Sentinel command in sentinel nodes.\n        once - If set to True, then execute the resulting command on a single\n               node at random, rather than across the entire sentinel cluster.\n        \"\"\"\n        once = bool(kwargs.pop(\"once\", False))\n\n        # Check if command is supposed to return the original\n        # responses instead of boolean value.\n        return_responses = bool(kwargs.pop(\"return_responses\", False))\n\n        if once:\n            response = await random.choice(self.sentinels).execute_command(\n                *args, **kwargs\n            )\n            if return_responses:\n                return [response]\n            else:\n                return True if response else False\n\n        tasks = [\n            asyncio.Task(sentinel.execute_command(*args, **kwargs))\n            for sentinel in self.sentinels\n        ]\n        responses = await asyncio.gather(*tasks)\n\n        if return_responses:\n            return responses\n\n        return all(responses)\n\n    def __repr__(self):\n        sentinel_addresses = []\n        for sentinel in self.sentinels:\n            sentinel_addresses.append(\n                f\"{sentinel.connection_pool.connection_kwargs['host']}:\"\n                f\"{sentinel.connection_pool.connection_kwargs['port']}\"\n            )\n        return (\n            f\"<{self.__class__}.{self.__class__.__name__}\"\n            f\"(sentinels=[{','.join(sentinel_addresses)}])>\"\n        )\n\n    def check_master_state(self, state: dict, service_name: str) -> bool:\n        if not state[\"is_master\"] or state[\"is_sdown\"] or state[\"is_odown\"]:\n            return False\n        # Check if our sentinel doesn't see other nodes\n        if state[\"num-other-sentinels\"] < self.min_other_sentinels:\n            return False\n        return True\n\n    async def discover_master(self, service_name: str):\n        \"\"\"\n        Asks sentinel servers for the Redis master's address corresponding\n        to the service labeled ``service_name``.\n\n        Returns a pair (address, port) or raises MasterNotFoundError if no\n        master is found.\n        \"\"\"\n        collected_errors = list()\n        for sentinel_no, sentinel in enumerate(self.sentinels):\n            try:\n                masters = await sentinel.sentinel_masters()\n            except (ConnectionError, TimeoutError) as e:\n                collected_errors.append(f\"{sentinel} - {e!r}\")\n                continue\n            state = masters.get(service_name)\n            if state and self.check_master_state(state, service_name):\n                # Put this sentinel at the top of the list\n                self.sentinels[0], self.sentinels[sentinel_no] = (\n                    sentinel,\n                    self.sentinels[0],\n                )\n\n                ip = (\n                    self._force_master_ip\n                    if self._force_master_ip is not None\n                    else state[\"ip\"]\n                )\n                return ip, state[\"port\"]\n\n        error_info = \"\"\n        if len(collected_errors) > 0:\n            error_info = f\" : {', '.join(collected_errors)}\"\n        raise MasterNotFoundError(f\"No master found for {service_name!r}{error_info}\")\n\n    def filter_slaves(\n        self, slaves: Iterable[Mapping]\n    ) -> Sequence[Tuple[EncodableT, EncodableT]]:\n        \"\"\"Remove slaves that are in an ODOWN or SDOWN state\"\"\"\n        slaves_alive = []\n        for slave in slaves:\n            if slave[\"is_odown\"] or slave[\"is_sdown\"]:\n                continue\n            slaves_alive.append((slave[\"ip\"], slave[\"port\"]))\n        return slaves_alive\n\n    async def discover_slaves(\n        self, service_name: str\n    ) -> Sequence[Tuple[EncodableT, EncodableT]]:\n        \"\"\"Returns a list of alive slaves for service ``service_name``\"\"\"\n        for sentinel in self.sentinels:\n            try:\n                slaves = await sentinel.sentinel_slaves(service_name)\n            except (ConnectionError, ResponseError, TimeoutError):\n                continue\n            slaves = self.filter_slaves(slaves)\n            if slaves:\n                return slaves\n        return []\n\n    def master_for(\n        self,\n        service_name: str,\n        redis_class: Type[Redis] = Redis,\n        connection_pool_class: Type[SentinelConnectionPool] = SentinelConnectionPool,\n        **kwargs,\n    ):\n        \"\"\"\n        Returns a redis client instance for the ``service_name`` master.\n        Sentinel client will detect failover and reconnect Redis clients\n        automatically.\n\n        A :py:class:`~redis.sentinel.SentinelConnectionPool` class is\n        used to retrieve the master's address before establishing a new\n        connection.\n\n        NOTE: If the master's address has changed, any cached connections to\n        the old master are closed.\n\n        By default clients will be a :py:class:`~redis.Redis` instance.\n        Specify a different class to the ``redis_class`` argument if you\n        desire something different.\n\n        The ``connection_pool_class`` specifies the connection pool to\n        use.  The :py:class:`~redis.sentinel.SentinelConnectionPool`\n        will be used by default.\n\n        All other keyword arguments are merged with any connection_kwargs\n        passed to this class and passed to the connection pool as keyword\n        arguments to be used to initialize Redis connections.\n        \"\"\"\n        kwargs[\"is_master\"] = True\n        connection_kwargs = dict(self.connection_kwargs)\n        connection_kwargs.update(kwargs)\n\n        connection_pool = connection_pool_class(service_name, self, **connection_kwargs)\n        # The Redis object \"owns\" the pool\n        return redis_class.from_pool(connection_pool)\n\n    def slave_for(\n        self,\n        service_name: str,\n        redis_class: Type[Redis] = Redis,\n        connection_pool_class: Type[SentinelConnectionPool] = SentinelConnectionPool,\n        **kwargs,\n    ):\n        \"\"\"\n        Returns redis client instance for the ``service_name`` slave(s).\n\n        A SentinelConnectionPool class is used to retrieve the slave's\n        address before establishing a new connection.\n\n        By default clients will be a :py:class:`~redis.Redis` instance.\n        Specify a different class to the ``redis_class`` argument if you\n        desire something different.\n\n        The ``connection_pool_class`` specifies the connection pool to use.\n        The SentinelConnectionPool will be used by default.\n\n        All other keyword arguments are merged with any connection_kwargs\n        passed to this class and passed to the connection pool as keyword\n        arguments to be used to initialize Redis connections.\n        \"\"\"\n        kwargs[\"is_master\"] = False\n        connection_kwargs = dict(self.connection_kwargs)\n        connection_kwargs.update(kwargs)\n\n        connection_pool = connection_pool_class(service_name, self, **connection_kwargs)\n        # The Redis object \"owns\" the pool\n        return redis_class.from_pool(connection_pool)\n"
  },
  {
    "path": "redis/asyncio/utils.py",
    "content": "from typing import TYPE_CHECKING, Any\n\nif TYPE_CHECKING:\n    from redis.asyncio.client import Pipeline, Redis\n\n\ndef from_url(url: str, **kwargs: Any) -> \"Redis\":\n    \"\"\"\n    Returns an active Redis client generated from the given database URL.\n\n    Will attempt to extract the database id from the path url fragment, if\n    none is provided.\n    \"\"\"\n    from redis.asyncio.client import Redis\n\n    return Redis.from_url(url, **kwargs)\n\n\nclass pipeline:  # noqa: N801\n    def __init__(self, redis_obj: \"Redis\"):\n        self.p: \"Pipeline\" = redis_obj.pipeline()\n\n    async def __aenter__(self) -> \"Pipeline\":\n        return self.p\n\n    async def __aexit__(self, exc_type, exc_value, traceback):\n        await self.p.execute()\n        del self.p\n"
  },
  {
    "path": "redis/auth/__init__.py",
    "content": ""
  },
  {
    "path": "redis/auth/err.py",
    "content": "from typing import Iterable\n\n\nclass RequestTokenErr(Exception):\n    \"\"\"\n    Represents an exception during token request.\n    \"\"\"\n\n    def __init__(self, *args):\n        super().__init__(*args)\n\n\nclass InvalidTokenSchemaErr(Exception):\n    \"\"\"\n    Represents an exception related to invalid token schema.\n    \"\"\"\n\n    def __init__(self, missing_fields: Iterable[str] = []):\n        super().__init__(\n            \"Unexpected token schema. Following fields are missing: \"\n            + \", \".join(missing_fields)\n        )\n\n\nclass TokenRenewalErr(Exception):\n    \"\"\"\n    Represents an exception during token renewal process.\n    \"\"\"\n\n    def __init__(self, *args):\n        super().__init__(*args)\n"
  },
  {
    "path": "redis/auth/idp.py",
    "content": "from abc import ABC, abstractmethod\n\nfrom redis.auth.token import TokenInterface\n\n\"\"\"\nThis interface is the facade of an identity provider\n\"\"\"\n\n\nclass IdentityProviderInterface(ABC):\n    \"\"\"\n    Receive a token from the identity provider.\n    Receiving a token only works when being authenticated.\n    \"\"\"\n\n    @abstractmethod\n    def request_token(self, force_refresh=False) -> TokenInterface:\n        pass\n\n\nclass IdentityProviderConfigInterface(ABC):\n    \"\"\"\n    Configuration class that provides a configured identity provider.\n    \"\"\"\n\n    @abstractmethod\n    def get_provider(self) -> IdentityProviderInterface:\n        pass\n"
  },
  {
    "path": "redis/auth/token.py",
    "content": "from abc import ABC, abstractmethod\nfrom datetime import datetime, timezone\n\nfrom redis.auth.err import InvalidTokenSchemaErr\n\n\nclass TokenInterface(ABC):\n    @abstractmethod\n    def is_expired(self) -> bool:\n        pass\n\n    @abstractmethod\n    def ttl(self) -> float:\n        pass\n\n    @abstractmethod\n    def try_get(self, key: str) -> str:\n        pass\n\n    @abstractmethod\n    def get_value(self) -> str:\n        pass\n\n    @abstractmethod\n    def get_expires_at_ms(self) -> float:\n        pass\n\n    @abstractmethod\n    def get_received_at_ms(self) -> float:\n        pass\n\n\nclass TokenResponse:\n    def __init__(self, token: TokenInterface):\n        self._token = token\n\n    def get_token(self) -> TokenInterface:\n        return self._token\n\n    def get_ttl_ms(self) -> float:\n        return self._token.get_expires_at_ms() - self._token.get_received_at_ms()\n\n\nclass SimpleToken(TokenInterface):\n    def __init__(\n        self, value: str, expires_at_ms: float, received_at_ms: float, claims: dict\n    ) -> None:\n        self.value = value\n        self.expires_at = expires_at_ms\n        self.received_at = received_at_ms\n        self.claims = claims\n\n    def ttl(self) -> float:\n        if self.expires_at == -1:\n            return -1\n\n        return self.expires_at - (datetime.now(timezone.utc).timestamp() * 1000)\n\n    def is_expired(self) -> bool:\n        if self.expires_at == -1:\n            return False\n\n        return self.ttl() <= 0\n\n    def try_get(self, key: str) -> str:\n        return self.claims.get(key)\n\n    def get_value(self) -> str:\n        return self.value\n\n    def get_expires_at_ms(self) -> float:\n        return self.expires_at\n\n    def get_received_at_ms(self) -> float:\n        return self.received_at\n\n\nclass JWToken(TokenInterface):\n    REQUIRED_FIELDS = {\"exp\"}\n\n    def __init__(self, token: str):\n        try:\n            import jwt\n        except ImportError as ie:\n            raise ImportError(\n                f\"The PyJWT library is required for {self.__class__.__name__}.\",\n            ) from ie\n        self._value = token\n        self._decoded = jwt.decode(\n            self._value,\n            options={\"verify_signature\": False},\n            algorithms=[jwt.get_unverified_header(self._value).get(\"alg\")],\n        )\n        self._validate_token()\n\n    def is_expired(self) -> bool:\n        exp = self._decoded[\"exp\"]\n        if exp == -1:\n            return False\n\n        return (\n            self._decoded[\"exp\"] * 1000 <= datetime.now(timezone.utc).timestamp() * 1000\n        )\n\n    def ttl(self) -> float:\n        exp = self._decoded[\"exp\"]\n        if exp == -1:\n            return -1\n\n        return (\n            self._decoded[\"exp\"] * 1000 - datetime.now(timezone.utc).timestamp() * 1000\n        )\n\n    def try_get(self, key: str) -> str:\n        return self._decoded.get(key)\n\n    def get_value(self) -> str:\n        return self._value\n\n    def get_expires_at_ms(self) -> float:\n        return float(self._decoded[\"exp\"] * 1000)\n\n    def get_received_at_ms(self) -> float:\n        return datetime.now(timezone.utc).timestamp() * 1000\n\n    def _validate_token(self):\n        actual_fields = {x for x in self._decoded.keys()}\n\n        if len(self.REQUIRED_FIELDS - actual_fields) != 0:\n            raise InvalidTokenSchemaErr(self.REQUIRED_FIELDS - actual_fields)\n"
  },
  {
    "path": "redis/auth/token_manager.py",
    "content": "import asyncio\nimport logging\nimport threading\nfrom datetime import datetime, timezone\nfrom time import sleep\nfrom typing import Any, Awaitable, Callable, Union\n\nfrom redis.auth.err import RequestTokenErr, TokenRenewalErr\nfrom redis.auth.idp import IdentityProviderInterface\nfrom redis.auth.token import TokenResponse\n\nlogger = logging.getLogger(__name__)\n\n\nclass CredentialsListener:\n    \"\"\"\n    Listeners that will be notified on events related to credentials.\n    Accepts callbacks and awaitable callbacks.\n    \"\"\"\n\n    def __init__(self):\n        self._on_next = None\n        self._on_error = None\n\n    @property\n    def on_next(self) -> Union[Callable[[Any], None], Awaitable]:\n        return self._on_next\n\n    @on_next.setter\n    def on_next(self, callback: Union[Callable[[Any], None], Awaitable]) -> None:\n        self._on_next = callback\n\n    @property\n    def on_error(self) -> Union[Callable[[Exception], None], Awaitable]:\n        return self._on_error\n\n    @on_error.setter\n    def on_error(self, callback: Union[Callable[[Exception], None], Awaitable]) -> None:\n        self._on_error = callback\n\n\nclass RetryPolicy:\n    def __init__(self, max_attempts: int, delay_in_ms: float):\n        self.max_attempts = max_attempts\n        self.delay_in_ms = delay_in_ms\n\n    def get_max_attempts(self) -> int:\n        \"\"\"\n        Retry attempts before exception will be thrown.\n\n        :return: int\n        \"\"\"\n        return self.max_attempts\n\n    def get_delay_in_ms(self) -> float:\n        \"\"\"\n        Delay between retries in seconds.\n\n        :return: int\n        \"\"\"\n        return self.delay_in_ms\n\n\nclass TokenManagerConfig:\n    def __init__(\n        self,\n        expiration_refresh_ratio: float,\n        lower_refresh_bound_millis: int,\n        token_request_execution_timeout_in_ms: int,\n        retry_policy: RetryPolicy,\n    ):\n        self._expiration_refresh_ratio = expiration_refresh_ratio\n        self._lower_refresh_bound_millis = lower_refresh_bound_millis\n        self._token_request_execution_timeout_in_ms = (\n            token_request_execution_timeout_in_ms\n        )\n        self._retry_policy = retry_policy\n\n    def get_expiration_refresh_ratio(self) -> float:\n        \"\"\"\n        Represents the ratio of a token's lifetime at which a refresh should be triggered. # noqa: E501\n        For example, a value of 0.75 means the token should be refreshed\n        when 75% of its lifetime has elapsed (or when 25% of its lifetime remains).\n\n        :return: float\n        \"\"\"\n\n        return self._expiration_refresh_ratio\n\n    def get_lower_refresh_bound_millis(self) -> int:\n        \"\"\"\n        Represents the minimum time in milliseconds before token expiration\n        to trigger a refresh, in milliseconds.\n        This value sets a fixed lower bound for when a token refresh should occur,\n        regardless of the token's total lifetime.\n        If set to 0 there will be no lower bound and the refresh will be triggered\n        based on the expirationRefreshRatio only.\n\n        :return: int\n        \"\"\"\n        return self._lower_refresh_bound_millis\n\n    def get_token_request_execution_timeout_in_ms(self) -> int:\n        \"\"\"\n        Represents the maximum time in milliseconds to wait\n        for a token request to complete.\n\n        :return: int\n        \"\"\"\n        return self._token_request_execution_timeout_in_ms\n\n    def get_retry_policy(self) -> RetryPolicy:\n        \"\"\"\n        Represents the retry policy for token requests.\n\n        :return: RetryPolicy\n        \"\"\"\n        return self._retry_policy\n\n\nclass TokenManager:\n    def __init__(\n        self, identity_provider: IdentityProviderInterface, config: TokenManagerConfig\n    ):\n        self._idp = identity_provider\n        self._config = config\n        self._next_timer = None\n        self._listener = None\n        self._init_timer = None\n        self._retries = 0\n\n    def __del__(self):\n        logger.info(\"Token manager are disposed\")\n        self.stop()\n\n    def start(\n        self,\n        listener: CredentialsListener,\n        skip_initial: bool = False,\n    ) -> Callable[[], None]:\n        self._listener = listener\n\n        try:\n            loop = asyncio.get_running_loop()\n        except RuntimeError:\n            # Run loop in a separate thread to unblock main thread.\n            loop = asyncio.new_event_loop()\n\n            # Use threading.Event to signal when loop is ready\n            loop_ready = threading.Event()\n\n            def start_loop():\n                # This runs in the background thread. First, bind the event loop to\n                # this thread, then signal that the loop is ready so the calling\n                # thread can safely schedule work (via call_soon_threadsafe) before\n                # we block in run_forever().\n                asyncio.set_event_loop(loop)\n                loop_ready.set()  # Signal that loop is ready for cross-thread use\n                loop.run_forever()\n\n            thread = threading.Thread(target=start_loop, daemon=True)\n            thread.start()\n\n            # Wait for the loop to be ready before scheduling\n            loop_ready.wait()\n\n        # Use thread-safe Event for cross-thread synchronization\n        init_done = threading.Event()\n\n        def renew_with_callback():\n            try:\n                self._renew_token(skip_initial)\n            finally:\n                init_done.set()\n\n        # Schedule using call_soon_threadsafe for thread-safe scheduling\n        self._init_timer = loop.call_soon_threadsafe(renew_with_callback)\n        logger.info(\"Token manager started\")\n\n        # Blocks using thread-safe Event\n        init_done.wait()\n        return self.stop\n\n    async def start_async(\n        self,\n        listener: CredentialsListener,\n        block_for_initial: bool = False,\n        initial_delay_in_ms: float = 0,\n        skip_initial: bool = False,\n    ) -> Callable[[], None]:\n        self._listener = listener\n\n        loop = asyncio.get_running_loop()\n        init_event = asyncio.Event()\n\n        # Wraps the async callback with async wrapper to schedule with loop.call_later()\n        wrapped = _async_to_sync_wrapper(\n            loop, self._renew_token_async, skip_initial, init_event\n        )\n        self._init_timer = loop.call_later(initial_delay_in_ms / 1000, wrapped)\n        logger.info(\"Token manager started\")\n\n        if block_for_initial:\n            await init_event.wait()\n\n        return self.stop\n\n    def stop(self):\n        if self._init_timer is not None:\n            self._init_timer.cancel()\n        if self._next_timer is not None:\n            self._next_timer.cancel()\n\n    def acquire_token(self, force_refresh=False) -> TokenResponse:\n        try:\n            token = self._idp.request_token(force_refresh)\n        except RequestTokenErr as e:\n            if self._retries < self._config.get_retry_policy().get_max_attempts():\n                self._retries += 1\n                sleep(self._config.get_retry_policy().get_delay_in_ms() / 1000)\n                return self.acquire_token(force_refresh)\n            else:\n                raise e\n\n        self._retries = 0\n        return TokenResponse(token)\n\n    async def acquire_token_async(self, force_refresh=False) -> TokenResponse:\n        try:\n            token = self._idp.request_token(force_refresh)\n        except RequestTokenErr as e:\n            if self._retries < self._config.get_retry_policy().get_max_attempts():\n                self._retries += 1\n                await asyncio.sleep(\n                    self._config.get_retry_policy().get_delay_in_ms() / 1000\n                )\n                return await self.acquire_token_async(force_refresh)\n            else:\n                raise e\n\n        self._retries = 0\n        return TokenResponse(token)\n\n    def _calculate_renewal_delay(self, expire_date: float, issue_date: float) -> float:\n        delay_for_lower_refresh = self._delay_for_lower_refresh(expire_date)\n        delay_for_ratio_refresh = self._delay_for_ratio_refresh(expire_date, issue_date)\n        delay = min(delay_for_ratio_refresh, delay_for_lower_refresh)\n\n        return 0 if delay < 0 else delay / 1000\n\n    def _delay_for_lower_refresh(self, expire_date: float):\n        return (\n            expire_date\n            - self._config.get_lower_refresh_bound_millis()\n            - (datetime.now(timezone.utc).timestamp() * 1000)\n        )\n\n    def _delay_for_ratio_refresh(self, expire_date: float, issue_date: float):\n        token_ttl = expire_date - issue_date\n        refresh_before = token_ttl - (\n            token_ttl * self._config.get_expiration_refresh_ratio()\n        )\n\n        return (\n            expire_date\n            - refresh_before\n            - (datetime.now(timezone.utc).timestamp() * 1000)\n        )\n\n    def _renew_token(self, skip_initial: bool = False):\n        \"\"\"\n        Task to renew token from identity provider.\n        Schedules renewal tasks based on token TTL.\n        \"\"\"\n\n        try:\n            token_res = self.acquire_token(force_refresh=True)\n            delay = self._calculate_renewal_delay(\n                token_res.get_token().get_expires_at_ms(),\n                token_res.get_token().get_received_at_ms(),\n            )\n\n            if token_res.get_token().is_expired():\n                raise TokenRenewalErr(\"Requested token is expired\")\n\n            if self._listener.on_next is None:\n                logger.warning(\n                    \"No registered callback for token renewal task. Renewal cancelled\"\n                )\n                return\n\n            if not skip_initial:\n                try:\n                    self._listener.on_next(token_res.get_token())\n                except Exception as e:\n                    raise TokenRenewalErr(e)\n\n            if delay <= 0:\n                return\n\n            loop = asyncio.get_running_loop()\n            self._next_timer = loop.call_later(delay, self._renew_token)\n            logger.info(f\"Next token renewal scheduled in {delay} seconds\")\n            return token_res\n        except Exception as e:\n            if self._listener.on_error is None:\n                raise e\n\n            self._listener.on_error(e)\n\n    async def _renew_token_async(\n        self, skip_initial: bool = False, init_event: asyncio.Event = None\n    ):\n        \"\"\"\n        Async task to renew tokens from identity provider.\n        Schedules renewal tasks based on token TTL.\n        \"\"\"\n\n        try:\n            token_res = await self.acquire_token_async(force_refresh=True)\n            delay = self._calculate_renewal_delay(\n                token_res.get_token().get_expires_at_ms(),\n                token_res.get_token().get_received_at_ms(),\n            )\n\n            if token_res.get_token().is_expired():\n                raise TokenRenewalErr(\"Requested token is expired\")\n\n            if self._listener.on_next is None:\n                logger.warning(\n                    \"No registered callback for token renewal task. Renewal cancelled\"\n                )\n                return\n\n            if not skip_initial:\n                try:\n                    await self._listener.on_next(token_res.get_token())\n                except Exception as e:\n                    raise TokenRenewalErr(e)\n\n            if delay <= 0:\n                return\n\n            loop = asyncio.get_running_loop()\n            wrapped = _async_to_sync_wrapper(loop, self._renew_token_async)\n            logger.info(f\"Next token renewal scheduled in {delay} seconds\")\n            loop.call_later(delay, wrapped)\n        except Exception as e:\n            if self._listener.on_error is None:\n                raise e\n\n            await self._listener.on_error(e)\n        finally:\n            if init_event:\n                init_event.set()\n\n\ndef _async_to_sync_wrapper(loop, coro_func, *args, **kwargs):\n    \"\"\"\n    Wraps an asynchronous function so it can be used with loop.call_later.\n\n    :param loop: The event loop in which the coroutine will be executed.\n    :param coro_func: The coroutine function to wrap.\n    :param args: Positional arguments to pass to the coroutine function.\n    :param kwargs: Keyword arguments to pass to the coroutine function.\n    :return: A regular function suitable for loop.call_later.\n    \"\"\"\n\n    def wrapped():\n        # Schedule the coroutine in the event loop\n        asyncio.ensure_future(coro_func(*args, **kwargs), loop=loop)\n\n    return wrapped\n"
  },
  {
    "path": "redis/background.py",
    "content": "import asyncio\nimport logging\nimport threading\nfrom typing import Any, Callable, Coroutine\n\n\nclass BackgroundScheduler:\n    \"\"\"\n    Schedules background tasks execution either in separate thread or in the running event loop.\n    \"\"\"\n\n    def __init__(self):\n        self._next_timer = None\n        self._event_loops = []\n        self._lock = threading.Lock()\n        self._stopped = False\n        # Dedicated loop for health checks - ensures all health checks use the same loop\n        self._health_check_loop: asyncio.AbstractEventLoop | None = None\n        self._health_check_thread: threading.Thread | None = None\n        # Event to signal when health check loop is ready\n        self._health_check_loop_ready = threading.Event()\n\n    def __del__(self):\n        self.stop()\n\n    def stop(self):\n        \"\"\"\n        Stop all scheduled tasks and clean up resources.\n        \"\"\"\n        with self._lock:\n            if self._stopped:\n                return\n            self._stopped = True\n\n            if self._next_timer:\n                self._next_timer.cancel()\n                self._next_timer = None\n\n            # Stop all event loops\n            for loop in self._event_loops:\n                if loop.is_running():\n                    loop.call_soon_threadsafe(loop.stop)\n\n            self._event_loops.clear()\n\n    def run_once(self, delay: float, callback: Callable, *args):\n        \"\"\"\n        Runs callable task once after certain delay in seconds.\n        \"\"\"\n        with self._lock:\n            if self._stopped:\n                return\n\n        # Run loop in a separate thread to unblock main thread.\n        loop = asyncio.new_event_loop()\n\n        with self._lock:\n            self._event_loops.append(loop)\n\n        thread = threading.Thread(\n            target=_start_event_loop_in_thread,\n            args=(loop, self._call_later, delay, callback, *args),\n            daemon=True,\n        )\n        thread.start()\n\n    def run_recurring(self, interval: float, callback: Callable, *args):\n        \"\"\"\n        Runs recurring callable task with given interval in seconds.\n        \"\"\"\n        with self._lock:\n            if self._stopped:\n                return\n\n        # Run loop in a separate thread to unblock main thread.\n        loop = asyncio.new_event_loop()\n\n        with self._lock:\n            self._event_loops.append(loop)\n\n        thread = threading.Thread(\n            target=_start_event_loop_in_thread,\n            args=(loop, self._call_later_recurring, interval, callback, *args),\n            daemon=True,\n        )\n        thread.start()\n\n    def run_recurring_coro(\n        self, interval: float, coro: Callable[..., Coroutine[Any, Any, Any]], *args\n    ):\n        \"\"\"\n        Runs recurring coroutine with given interval in seconds in a background thread.\n        Uses a shared event loop to ensure connection pools remain valid across calls.\n\n        This is useful for sync code that needs to run async health checks.\n        \"\"\"\n        with self._lock:\n            if self._stopped:\n                return\n\n        # Use the shared health check loop, creating it if needed\n        self._ensure_health_check_loop()\n\n        with self._lock:\n            loop = self._health_check_loop\n\n        # Schedule recurring execution in the shared loop\n        loop.call_soon_threadsafe(\n            self._call_later_recurring_coro, loop, interval, coro, *args\n        )\n\n    def run_coro_sync(\n        self,\n        coro: Callable[..., Coroutine[Any, Any, Any]],\n        *args,\n        timeout: float | None = 10.0,\n    ) -> Any:\n        \"\"\"\n        Runs a coroutine synchronously and returns its result.\n        Uses the shared health check event loop to ensure connection pools\n        created here remain valid for subsequent recurring health checks.\n\n        This is useful for running the initial health check before starting\n        recurring checks.\n\n        Args:\n            coro: Coroutine function to execute\n            *args: Arguments to pass to the coroutine\n            timeout: Maximum seconds to wait for the result. None means wait\n                forever. Default is 10 seconds to avoid blocking indefinitely\n                if the event loop is busy with long-running health checks.\n\n        Returns:\n            The result of the coroutine\n\n        Raises:\n            TimeoutError: If the coroutine doesn't complete within timeout\n            Any exception raised by the coroutine\n        \"\"\"\n\n        with self._lock:\n            if self._stopped:\n                raise RuntimeError(\"Scheduler is stopped\")\n\n        # Ensure the shared loop exists\n        self._ensure_health_check_loop()\n\n        with self._lock:\n            loop = self._health_check_loop\n\n        # Submit the coroutine to the shared loop and wait for result\n        future = asyncio.run_coroutine_threadsafe(coro(*args), loop)\n        try:\n            return future.result(timeout=timeout)\n        except TimeoutError:\n            # Cancel the future to avoid leaving orphaned tasks\n            future.cancel()\n            raise\n\n    def run_coro_fire_and_forget(\n        self, coro: Callable[..., Coroutine[Any, Any, Any]], *args\n    ) -> None:\n        \"\"\"\n        Schedule a coroutine for execution on the shared health check loop\n        without waiting for the result. Exceptions are logged but not raised.\n\n        This is useful for HALF_OPEN recovery health checks that need to run\n        on the same event loop where connection pools were created.\n\n        Args:\n            coro: Coroutine function to execute\n            *args: Arguments to pass to the coroutine\n        \"\"\"\n        with self._lock:\n            if self._stopped:\n                return\n\n        # Ensure the shared loop exists\n        self._ensure_health_check_loop()\n\n        with self._lock:\n            loop = self._health_check_loop\n\n        def on_complete(future: asyncio.Future):\n            \"\"\"Log any exceptions from the coroutine.\"\"\"\n            if future.cancelled():\n                logging.getLogger(__name__).debug(\"Fire-and-forget coroutine cancelled\")\n            elif future.exception() is not None:\n                logging.getLogger(__name__).debug(\n                    \"Fire-and-forget coroutine raised exception\",\n                    exc_info=future.exception(),\n                )\n\n        # Schedule on the shared loop without waiting\n        future = asyncio.run_coroutine_threadsafe(coro(*args), loop)\n        future.add_done_callback(on_complete)\n\n    def _ensure_health_check_loop(self, timeout: float = 5.0):\n        \"\"\"\n        Ensure the shared health check loop and thread are running.\n\n        Args:\n            timeout: Maximum seconds to wait for the loop to start.\n\n        Raises:\n            RuntimeError: If the loop fails to start within the timeout.\n        \"\"\"\n        # Fast path: if loop is already running, return immediately\n        if self._health_check_loop_ready.is_set():\n            with self._lock:\n                if (\n                    self._health_check_loop is not None\n                    and self._health_check_loop.is_running()\n                ):\n                    return\n\n        with self._lock:\n            # Double-check after acquiring the lock\n            if (\n                self._health_check_loop is not None\n                and self._health_check_loop.is_running()\n            ):\n                return\n\n            # Clear the event - we're about to start a new loop\n            self._health_check_loop_ready.clear()\n\n            # Create a new event loop for health checks\n            self._health_check_loop = asyncio.new_event_loop()\n            self._event_loops.append(self._health_check_loop)\n\n            # Start the loop in a background thread\n            self._health_check_thread = threading.Thread(\n                target=self._run_health_check_loop,\n                daemon=True,\n            )\n            self._health_check_thread.start()\n\n            # Wait for loop to be running INSIDE the lock with a timeout.\n            # This prevents other threads from trying to create another loop\n            # before this one is fully started, while avoiding permanent deadlock\n            # if the background thread fails to start the loop.\n            if not self._health_check_loop_ready.wait(timeout=timeout):\n                # Timeout expired - the loop failed to start\n                # Clean up the failed loop to allow retry\n                failed_loop = self._health_check_loop\n                self._health_check_loop = None\n                if failed_loop in self._event_loops:\n                    self._event_loops.remove(failed_loop)\n                try:\n                    failed_loop.close()\n                except Exception:\n                    pass\n                raise RuntimeError(\n                    f\"Health check event loop failed to start within {timeout} seconds\"\n                )\n\n    def _run_health_check_loop(self):\n        \"\"\"Run the shared health check event loop.\"\"\"\n        asyncio.set_event_loop(self._health_check_loop)\n\n        # Signal that the loop is ready before running\n        # Use call_soon to signal after run_forever starts processing\n        self._health_check_loop.call_soon(self._health_check_loop_ready.set)\n\n        try:\n            self._health_check_loop.run_forever()\n        finally:\n            try:\n                pending = asyncio.all_tasks(self._health_check_loop)\n                for task in pending:\n                    task.cancel()\n                self._health_check_loop.run_until_complete(\n                    asyncio.gather(*pending, return_exceptions=True)\n                )\n            except Exception:\n                pass\n            finally:\n                self._health_check_loop.close()\n\n    def _call_later_recurring_coro(\n        self,\n        loop: asyncio.AbstractEventLoop,\n        interval: float,\n        coro: Callable[..., Coroutine[Any, Any, Any]],\n        *args,\n    ):\n        \"\"\"Schedule first execution of recurring coroutine.\"\"\"\n        with self._lock:\n            if self._stopped:\n                return\n        self._call_later(\n            loop, interval, self._execute_recurring_coro, loop, interval, coro, *args\n        )\n\n    def _execute_recurring_coro(\n        self,\n        loop: asyncio.AbstractEventLoop,\n        interval: float,\n        coro: Callable[..., Coroutine[Any, Any, Any]],\n        *args,\n    ):\n        \"\"\"\n        Executes recurring coroutine with given interval in seconds.\n        Schedules next execution only after current one completes to prevent overlap.\n        \"\"\"\n        with self._lock:\n            if self._stopped:\n                return\n\n        def on_complete(task: asyncio.Task):\n            \"\"\"Callback when coroutine completes - schedule next execution.\"\"\"\n            # Log any exceptions (prevents \"Task exception was never retrieved\")\n            if task.cancelled():\n                pass  # Task was cancelled, ignore\n            elif task.exception() is not None:\n                # Log the exception but don't crash the scheduler\n                logging.getLogger(__name__).debug(\n                    \"Background coroutine raised exception\",\n                    exc_info=task.exception(),\n                )\n\n            # Schedule next execution after completion\n            with self._lock:\n                if self._stopped:\n                    return\n\n            self._call_later(\n                loop,\n                interval,\n                self._execute_recurring_coro,\n                loop,\n                interval,\n                coro,\n                *args,\n            )\n\n        try:\n            task = asyncio.ensure_future(coro(*args))\n            # Add callback to handle completion and schedule next run\n            task.add_done_callback(on_complete)\n        except Exception:\n            # If scheduling fails (e.g., during shutdown), try to schedule next run anyway\n            with self._lock:\n                if self._stopped:\n                    return\n            self._call_later(\n                loop,\n                interval,\n                self._execute_recurring_coro,\n                loop,\n                interval,\n                coro,\n                *args,\n            )\n\n    async def run_recurring_async(\n        self, interval: float, coro: Callable[..., Coroutine[Any, Any, Any]], *args\n    ):\n        \"\"\"\n        Runs recurring coroutine with given interval in seconds in the current event loop.\n        To be used only from an async context. No additional threads are created.\n\n        Prevents overlapping executions by scheduling the next run only after\n        the current one completes.\n\n        Raises:\n            RuntimeError: If called without a running event loop (programming error)\n        \"\"\"\n        with self._lock:\n            if self._stopped:\n                return\n\n        # This is an async method - it must be awaited in a running event loop.\n        # If get_running_loop() raises RuntimeError, let it propagate as that\n        # indicates a programming error (calling async method outside async context).\n        loop = asyncio.get_running_loop()\n\n        def schedule_next():\n            \"\"\"Schedule the next execution after the current one completes.\"\"\"\n            with self._lock:\n                if self._stopped:\n                    return\n            self._next_timer = loop.call_later(interval, execute_and_reschedule)\n\n        def execute_and_reschedule():\n            \"\"\"Execute the coroutine and schedule next run after completion.\"\"\"\n            with self._lock:\n                if self._stopped:\n                    return\n\n            def on_complete(task: asyncio.Task):\n                \"\"\"Callback when coroutine completes - schedule next execution.\"\"\"\n                # Log any exceptions (prevents \"Task exception was never retrieved\")\n                if task.cancelled():\n                    pass\n                elif task.exception() is not None:\n                    logging.getLogger(__name__).debug(\n                        \"Recurring async coroutine raised exception\",\n                        exc_info=task.exception(),\n                    )\n                # Schedule next execution AFTER this one completes\n                schedule_next()\n\n            try:\n                task = asyncio.ensure_future(coro(*args))\n                task.add_done_callback(on_complete)\n            except Exception:\n                # If scheduling fails, still try to schedule next run\n                logging.getLogger(__name__).debug(\n                    \"Failed to schedule recurring async coroutine\", exc_info=True\n                )\n                schedule_next()\n\n        # Schedule first execution\n        self._next_timer = loop.call_later(interval, execute_and_reschedule)\n\n    def _call_later(\n        self, loop: asyncio.AbstractEventLoop, delay: float, callback: Callable, *args\n    ):\n        with self._lock:\n            if self._stopped:\n                return\n        self._next_timer = loop.call_later(delay, callback, *args)\n\n    def _call_later_recurring(\n        self,\n        loop: asyncio.AbstractEventLoop,\n        interval: float,\n        callback: Callable,\n        *args,\n    ):\n        with self._lock:\n            if self._stopped:\n                return\n        self._call_later(\n            loop, interval, self._execute_recurring, loop, interval, callback, *args\n        )\n\n    def _execute_recurring(\n        self,\n        loop: asyncio.AbstractEventLoop,\n        interval: float,\n        callback: Callable,\n        *args,\n    ):\n        \"\"\"\n        Executes recurring callable task with given interval in seconds.\n        \"\"\"\n        with self._lock:\n            if self._stopped:\n                return\n\n        try:\n            callback(*args)\n        except Exception:\n            # Silently ignore exceptions during shutdown\n            pass\n\n        with self._lock:\n            if self._stopped:\n                return\n\n        self._call_later(\n            loop, interval, self._execute_recurring, loop, interval, callback, *args\n        )\n\n\ndef _start_event_loop_in_thread(\n    event_loop: asyncio.AbstractEventLoop, call_soon_cb: Callable, *args\n):\n    \"\"\"\n    Starts event loop in a thread and schedule callback as soon as event loop is ready.\n    Used to be able to schedule tasks using loop.call_later.\n\n    :param event_loop:\n    :return:\n    \"\"\"\n    asyncio.set_event_loop(event_loop)\n    event_loop.call_soon(call_soon_cb, event_loop, *args)\n    try:\n        event_loop.run_forever()\n    finally:\n        try:\n            # Clean up pending tasks\n            pending = asyncio.all_tasks(event_loop)\n            for task in pending:\n                task.cancel()\n            # Run loop once more to process cancellations\n            event_loop.run_until_complete(\n                asyncio.gather(*pending, return_exceptions=True)\n            )\n        except Exception:\n            pass\n        finally:\n            event_loop.close()\n"
  },
  {
    "path": "redis/backoff.py",
    "content": "import random\nfrom abc import ABC, abstractmethod\n\n# Maximum backoff between each retry in seconds\nDEFAULT_CAP = 0.512\n# Minimum backoff between each retry in seconds\nDEFAULT_BASE = 0.008\n\n\nclass AbstractBackoff(ABC):\n    \"\"\"Backoff interface\"\"\"\n\n    def reset(self):\n        \"\"\"\n        Reset internal state before an operation.\n        `reset` is called once at the beginning of\n        every call to `Retry.call_with_retry`\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def compute(self, failures: int) -> float:\n        \"\"\"Compute backoff in seconds upon failure\"\"\"\n        pass\n\n\nclass ConstantBackoff(AbstractBackoff):\n    \"\"\"Constant backoff upon failure\"\"\"\n\n    def __init__(self, backoff: float) -> None:\n        \"\"\"`backoff`: backoff time in seconds\"\"\"\n        self._backoff = backoff\n\n    def __hash__(self) -> int:\n        return hash((self._backoff,))\n\n    def __eq__(self, other) -> bool:\n        if not isinstance(other, ConstantBackoff):\n            return NotImplemented\n\n        return self._backoff == other._backoff\n\n    def compute(self, failures: int) -> float:\n        return self._backoff\n\n\nclass NoBackoff(ConstantBackoff):\n    \"\"\"No backoff upon failure\"\"\"\n\n    def __init__(self) -> None:\n        super().__init__(0)\n\n\nclass ExponentialBackoff(AbstractBackoff):\n    \"\"\"Exponential backoff upon failure\"\"\"\n\n    def __init__(self, cap: float = DEFAULT_CAP, base: float = DEFAULT_BASE):\n        \"\"\"\n        `cap`: maximum backoff time in seconds\n        `base`: base backoff time in seconds\n        \"\"\"\n        self._cap = cap\n        self._base = base\n\n    def __hash__(self) -> int:\n        return hash((self._base, self._cap))\n\n    def __eq__(self, other) -> bool:\n        if not isinstance(other, ExponentialBackoff):\n            return NotImplemented\n\n        return self._base == other._base and self._cap == other._cap\n\n    def compute(self, failures: int) -> float:\n        return min(self._cap, self._base * 2**failures)\n\n\nclass FullJitterBackoff(AbstractBackoff):\n    \"\"\"Full jitter backoff upon failure\"\"\"\n\n    def __init__(self, cap: float = DEFAULT_CAP, base: float = DEFAULT_BASE) -> None:\n        \"\"\"\n        `cap`: maximum backoff time in seconds\n        `base`: base backoff time in seconds\n        \"\"\"\n        self._cap = cap\n        self._base = base\n\n    def __hash__(self) -> int:\n        return hash((self._base, self._cap))\n\n    def __eq__(self, other) -> bool:\n        if not isinstance(other, FullJitterBackoff):\n            return NotImplemented\n\n        return self._base == other._base and self._cap == other._cap\n\n    def compute(self, failures: int) -> float:\n        return random.uniform(0, min(self._cap, self._base * 2**failures))\n\n\nclass EqualJitterBackoff(AbstractBackoff):\n    \"\"\"Equal jitter backoff upon failure\"\"\"\n\n    def __init__(self, cap: float = DEFAULT_CAP, base: float = DEFAULT_BASE) -> None:\n        \"\"\"\n        `cap`: maximum backoff time in seconds\n        `base`: base backoff time in seconds\n        \"\"\"\n        self._cap = cap\n        self._base = base\n\n    def __hash__(self) -> int:\n        return hash((self._base, self._cap))\n\n    def __eq__(self, other) -> bool:\n        if not isinstance(other, EqualJitterBackoff):\n            return NotImplemented\n\n        return self._base == other._base and self._cap == other._cap\n\n    def compute(self, failures: int) -> float:\n        temp = min(self._cap, self._base * 2**failures) / 2\n        return temp + random.uniform(0, temp)\n\n\nclass DecorrelatedJitterBackoff(AbstractBackoff):\n    \"\"\"Decorrelated jitter backoff upon failure\"\"\"\n\n    def __init__(self, cap: float = DEFAULT_CAP, base: float = DEFAULT_BASE) -> None:\n        \"\"\"\n        `cap`: maximum backoff time in seconds\n        `base`: base backoff time in seconds\n        \"\"\"\n        self._cap = cap\n        self._base = base\n        self._previous_backoff = 0\n\n    def __hash__(self) -> int:\n        return hash((self._base, self._cap))\n\n    def __eq__(self, other) -> bool:\n        if not isinstance(other, DecorrelatedJitterBackoff):\n            return NotImplemented\n\n        return self._base == other._base and self._cap == other._cap\n\n    def reset(self) -> None:\n        self._previous_backoff = 0\n\n    def compute(self, failures: int) -> float:\n        max_backoff = max(self._base, self._previous_backoff * 3)\n        temp = random.uniform(self._base, max_backoff)\n        self._previous_backoff = min(self._cap, temp)\n        return self._previous_backoff\n\n\nclass ExponentialWithJitterBackoff(AbstractBackoff):\n    \"\"\"Exponential backoff upon failure, with jitter\"\"\"\n\n    def __init__(self, cap: float = DEFAULT_CAP, base: float = DEFAULT_BASE) -> None:\n        \"\"\"\n        `cap`: maximum backoff time in seconds\n        `base`: base backoff time in seconds\n        \"\"\"\n        self._cap = cap\n        self._base = base\n\n    def __hash__(self) -> int:\n        return hash((self._base, self._cap))\n\n    def __eq__(self, other) -> bool:\n        if not isinstance(other, ExponentialWithJitterBackoff):\n            return NotImplemented\n\n        return self._base == other._base and self._cap == other._cap\n\n    def compute(self, failures: int) -> float:\n        return min(self._cap, random.random() * self._base * 2**failures)\n\n\ndef default_backoff():\n    return EqualJitterBackoff()\n"
  },
  {
    "path": "redis/cache.py",
    "content": "from abc import ABC, abstractmethod\nfrom collections import OrderedDict\nfrom dataclasses import dataclass\nfrom enum import Enum\nfrom typing import Any, List, Optional, Union\n\nfrom redis.observability.attributes import CSCReason\n\n\nclass CacheEntryStatus(Enum):\n    VALID = \"VALID\"\n    IN_PROGRESS = \"IN_PROGRESS\"\n\n\nclass EvictionPolicyType(Enum):\n    time_based = \"time_based\"\n    frequency_based = \"frequency_based\"\n\n\n@dataclass(frozen=True)\nclass CacheKey:\n    \"\"\"\n    Represents a unique key for a cache entry.\n\n    Attributes:\n        command (str): The Redis command being cached.\n        redis_keys (tuple): The Redis keys involved in the command.\n        redis_args (tuple): Additional arguments for the Redis command.\n            This field is included in the cache key to ensure uniqueness\n            when commands have the same keys but different arguments.\n            Changing this field will affect cache key uniqueness.\n    \"\"\"\n\n    command: str\n    redis_keys: tuple\n    redis_args: tuple = ()  # Additional arguments for the Redis command; affects cache key uniqueness.\n\n\nclass CacheEntry:\n    def __init__(\n        self,\n        cache_key: CacheKey,\n        cache_value: bytes,\n        status: CacheEntryStatus,\n        connection_ref,\n    ):\n        self.cache_key = cache_key\n        self.cache_value = cache_value\n        self.status = status\n        self.connection_ref = connection_ref\n\n    def __hash__(self):\n        return hash(\n            (self.cache_key, self.cache_value, self.status, self.connection_ref)\n        )\n\n    def __eq__(self, other):\n        return hash(self) == hash(other)\n\n\nclass EvictionPolicyInterface(ABC):\n    @property\n    @abstractmethod\n    def cache(self):\n        pass\n\n    @cache.setter\n    @abstractmethod\n    def cache(self, value):\n        pass\n\n    @property\n    @abstractmethod\n    def type(self) -> EvictionPolicyType:\n        pass\n\n    @abstractmethod\n    def evict_next(self) -> CacheKey:\n        pass\n\n    @abstractmethod\n    def evict_many(self, count: int) -> List[CacheKey]:\n        pass\n\n    @abstractmethod\n    def touch(self, cache_key: CacheKey) -> None:\n        pass\n\n\nclass CacheConfigurationInterface(ABC):\n    @abstractmethod\n    def get_cache_class(self):\n        pass\n\n    @abstractmethod\n    def get_max_size(self) -> int:\n        pass\n\n    @abstractmethod\n    def get_eviction_policy(self):\n        pass\n\n    @abstractmethod\n    def is_exceeds_max_size(self, count: int) -> bool:\n        pass\n\n    @abstractmethod\n    def is_allowed_to_cache(self, command: str) -> bool:\n        pass\n\n\nclass CacheInterface(ABC):\n    @property\n    @abstractmethod\n    def collection(self) -> OrderedDict:\n        pass\n\n    @property\n    @abstractmethod\n    def config(self) -> CacheConfigurationInterface:\n        pass\n\n    @property\n    @abstractmethod\n    def eviction_policy(self) -> EvictionPolicyInterface:\n        pass\n\n    @property\n    @abstractmethod\n    def size(self) -> int:\n        pass\n\n    @abstractmethod\n    def get(self, key: CacheKey) -> Union[CacheEntry, None]:\n        pass\n\n    @abstractmethod\n    def set(self, entry: CacheEntry) -> bool:\n        pass\n\n    @abstractmethod\n    def delete_by_cache_keys(self, cache_keys: List[CacheKey]) -> List[bool]:\n        pass\n\n    @abstractmethod\n    def delete_by_redis_keys(self, redis_keys: List[bytes]) -> List[bool]:\n        pass\n\n    @abstractmethod\n    def flush(self) -> int:\n        pass\n\n    @abstractmethod\n    def is_cachable(self, key: CacheKey) -> bool:\n        pass\n\n\nclass DefaultCache(CacheInterface):\n    def __init__(\n        self,\n        cache_config: CacheConfigurationInterface,\n    ) -> None:\n        self._cache = OrderedDict()\n        self._cache_config = cache_config\n        self._eviction_policy = self._cache_config.get_eviction_policy().value()\n        self._eviction_policy.cache = self\n\n    @property\n    def collection(self) -> OrderedDict:\n        return self._cache\n\n    @property\n    def config(self) -> CacheConfigurationInterface:\n        return self._cache_config\n\n    @property\n    def eviction_policy(self) -> EvictionPolicyInterface:\n        return self._eviction_policy\n\n    @property\n    def size(self) -> int:\n        return len(self._cache)\n\n    def set(self, entry: CacheEntry) -> bool:\n        if not self.is_cachable(entry.cache_key):\n            return False\n\n        self._cache[entry.cache_key] = entry\n        self._eviction_policy.touch(entry.cache_key)\n\n        return True\n\n    def get(self, key: CacheKey) -> Union[CacheEntry, None]:\n        entry = self._cache.get(key, None)\n\n        if entry is None:\n            return None\n\n        self._eviction_policy.touch(key)\n        return entry\n\n    def delete_by_cache_keys(self, cache_keys: List[CacheKey]) -> List[bool]:\n        response = []\n\n        for key in cache_keys:\n            if self.get(key) is not None:\n                self._cache.pop(key)\n                response.append(True)\n            else:\n                response.append(False)\n\n        return response\n\n    def delete_by_redis_keys(\n        self, redis_keys: Union[List[bytes], List[str]]\n    ) -> List[bool]:\n        response = []\n        keys_to_delete = []\n\n        for redis_key in redis_keys:\n            # Prepare both versions for lookup\n            candidates = [redis_key]\n            if isinstance(redis_key, str):\n                candidates.append(redis_key.encode(\"utf-8\"))\n            elif isinstance(redis_key, bytes):\n                try:\n                    candidates.append(redis_key.decode(\"utf-8\"))\n                except UnicodeDecodeError:\n                    pass  # Non-UTF-8 bytes, skip str version\n\n            for cache_key in self._cache:\n                if any(candidate in cache_key.redis_keys for candidate in candidates):\n                    keys_to_delete.append(cache_key)\n                    response.append(True)\n\n        for key in keys_to_delete:\n            self._cache.pop(key)\n\n        return response\n\n    def flush(self) -> int:\n        elem_count = len(self._cache)\n        self._cache.clear()\n        return elem_count\n\n    def is_cachable(self, key: CacheKey) -> bool:\n        return self._cache_config.is_allowed_to_cache(key.command)\n\n\nclass CacheProxy(CacheInterface):\n    \"\"\"\n    Proxy object that wraps cache implementations to enable additional logic on top\n    \"\"\"\n\n    def __init__(self, cache: CacheInterface):\n        self._cache = cache\n\n    @property\n    def collection(self) -> OrderedDict:\n        return self._cache.collection\n\n    @property\n    def config(self) -> CacheConfigurationInterface:\n        return self._cache.config\n\n    @property\n    def eviction_policy(self) -> EvictionPolicyInterface:\n        return self._cache.eviction_policy\n\n    @property\n    def size(self) -> int:\n        return self._cache.size\n\n    def get(self, key: CacheKey) -> Union[CacheEntry, None]:\n        return self._cache.get(key)\n\n    def set(self, entry: CacheEntry) -> bool:\n        is_set = self._cache.set(entry)\n\n        if self.config.is_exceeds_max_size(self.size):\n            # Lazy import to avoid circular dependency\n            from redis.observability.recorder import record_csc_eviction\n\n            record_csc_eviction(\n                count=1,\n                reason=CSCReason.FULL,\n            )\n            self.eviction_policy.evict_next()\n\n        return is_set\n\n    def delete_by_cache_keys(self, cache_keys: List[CacheKey]) -> List[bool]:\n        return self._cache.delete_by_cache_keys(cache_keys)\n\n    def delete_by_redis_keys(self, redis_keys: List[bytes]) -> List[bool]:\n        return self._cache.delete_by_redis_keys(redis_keys)\n\n    def flush(self) -> int:\n        return self._cache.flush()\n\n    def is_cachable(self, key: CacheKey) -> bool:\n        return self._cache.is_cachable(key)\n\n\nclass LRUPolicy(EvictionPolicyInterface):\n    def __init__(self):\n        self.cache = None\n\n    @property\n    def cache(self):\n        return self._cache\n\n    @cache.setter\n    def cache(self, cache: CacheInterface):\n        self._cache = cache\n\n    @property\n    def type(self) -> EvictionPolicyType:\n        return EvictionPolicyType.time_based\n\n    def evict_next(self) -> CacheKey:\n        self._assert_cache()\n        popped_entry = self._cache.collection.popitem(last=False)\n        return popped_entry[0]\n\n    def evict_many(self, count: int) -> List[CacheKey]:\n        self._assert_cache()\n        if count > len(self._cache.collection):\n            raise ValueError(\"Evictions count is above cache size\")\n\n        popped_keys = []\n\n        for _ in range(count):\n            popped_entry = self._cache.collection.popitem(last=False)\n            popped_keys.append(popped_entry[0])\n\n        return popped_keys\n\n    def touch(self, cache_key: CacheKey) -> None:\n        self._assert_cache()\n\n        if self._cache.collection.get(cache_key) is None:\n            raise ValueError(\"Given entry does not belong to the cache\")\n\n        self._cache.collection.move_to_end(cache_key)\n\n    def _assert_cache(self):\n        if self.cache is None or not isinstance(self.cache, CacheInterface):\n            raise ValueError(\"Eviction policy should be associated with valid cache.\")\n\n\nclass EvictionPolicy(Enum):\n    LRU = LRUPolicy\n\n\nclass CacheConfig(CacheConfigurationInterface):\n    DEFAULT_CACHE_CLASS = DefaultCache\n    DEFAULT_EVICTION_POLICY = EvictionPolicy.LRU\n    DEFAULT_MAX_SIZE = 10000\n\n    DEFAULT_ALLOW_LIST = [\n        \"BITCOUNT\",\n        \"BITFIELD_RO\",\n        \"BITPOS\",\n        \"EXISTS\",\n        \"GEODIST\",\n        \"GEOHASH\",\n        \"GEOPOS\",\n        \"GEORADIUSBYMEMBER_RO\",\n        \"GEORADIUS_RO\",\n        \"GEOSEARCH\",\n        \"GET\",\n        \"GETBIT\",\n        \"GETRANGE\",\n        \"HEXISTS\",\n        \"HGET\",\n        \"HGETALL\",\n        \"HKEYS\",\n        \"HLEN\",\n        \"HMGET\",\n        \"HSTRLEN\",\n        \"HVALS\",\n        \"JSON.ARRINDEX\",\n        \"JSON.ARRLEN\",\n        \"JSON.GET\",\n        \"JSON.MGET\",\n        \"JSON.OBJKEYS\",\n        \"JSON.OBJLEN\",\n        \"JSON.RESP\",\n        \"JSON.STRLEN\",\n        \"JSON.TYPE\",\n        \"LCS\",\n        \"LINDEX\",\n        \"LLEN\",\n        \"LPOS\",\n        \"LRANGE\",\n        \"MGET\",\n        \"SCARD\",\n        \"SDIFF\",\n        \"SINTER\",\n        \"SINTERCARD\",\n        \"SISMEMBER\",\n        \"SMEMBERS\",\n        \"SMISMEMBER\",\n        \"SORT_RO\",\n        \"STRLEN\",\n        \"SUBSTR\",\n        \"SUNION\",\n        \"TS.GET\",\n        \"TS.INFO\",\n        \"TS.RANGE\",\n        \"TS.REVRANGE\",\n        \"TYPE\",\n        \"XLEN\",\n        \"XPENDING\",\n        \"XRANGE\",\n        \"XREAD\",\n        \"XREVRANGE\",\n        \"ZCARD\",\n        \"ZCOUNT\",\n        \"ZDIFF\",\n        \"ZINTER\",\n        \"ZINTERCARD\",\n        \"ZLEXCOUNT\",\n        \"ZMSCORE\",\n        \"ZRANGE\",\n        \"ZRANGEBYLEX\",\n        \"ZRANGEBYSCORE\",\n        \"ZRANK\",\n        \"ZREVRANGE\",\n        \"ZREVRANGEBYLEX\",\n        \"ZREVRANGEBYSCORE\",\n        \"ZREVRANK\",\n        \"ZSCORE\",\n        \"ZUNION\",\n    ]\n\n    def __init__(\n        self,\n        max_size: int = DEFAULT_MAX_SIZE,\n        cache_class: Any = DEFAULT_CACHE_CLASS,\n        eviction_policy: EvictionPolicy = DEFAULT_EVICTION_POLICY,\n    ):\n        self._cache_class = cache_class\n        self._max_size = max_size\n        self._eviction_policy = eviction_policy\n\n    def get_cache_class(self):\n        return self._cache_class\n\n    def get_max_size(self) -> int:\n        return self._max_size\n\n    def get_eviction_policy(self) -> EvictionPolicy:\n        return self._eviction_policy\n\n    def is_exceeds_max_size(self, count: int) -> bool:\n        return count > self._max_size\n\n    def is_allowed_to_cache(self, command: str) -> bool:\n        return command in self.DEFAULT_ALLOW_LIST\n\n\nclass CacheFactoryInterface(ABC):\n    @abstractmethod\n    def get_cache(self) -> CacheInterface:\n        pass\n\n\nclass CacheFactory(CacheFactoryInterface):\n    def __init__(self, cache_config: Optional[CacheConfig] = None):\n        self._config = cache_config\n\n        if self._config is None:\n            self._config = CacheConfig()\n\n    def get_cache(self) -> CacheInterface:\n        cache_class = self._config.get_cache_class()\n        return CacheProxy(cache_class(cache_config=self._config))\n"
  },
  {
    "path": "redis/client.py",
    "content": "import copy\nimport logging\nimport re\nimport threading\nimport time\nfrom itertools import chain\nfrom typing import (\n    TYPE_CHECKING,\n    Any,\n    Callable,\n    Dict,\n    List,\n    Literal,\n    Mapping,\n    Optional,\n    Set,\n    Type,\n    Union,\n)\n\nfrom redis._parsers.encoders import Encoder\nfrom redis._parsers.helpers import (\n    _RedisCallbacks,\n    _RedisCallbacksRESP2,\n    _RedisCallbacksRESP3,\n    bool_ok,\n)\nfrom redis._parsers.socket import SENTINEL\nfrom redis.backoff import ExponentialWithJitterBackoff\nfrom redis.cache import CacheConfig, CacheInterface\nfrom redis.commands import (\n    CoreCommands,\n    RedisModuleCommands,\n    SentinelCommands,\n    list_or_args,\n)\nfrom redis.commands.core import Script\nfrom redis.connection import (\n    AbstractConnection,\n    Connection,\n    ConnectionPool,\n    SSLConnection,\n    UnixDomainSocketConnection,\n)\nfrom redis.credentials import CredentialProvider\nfrom redis.driver_info import DriverInfo, resolve_driver_info\nfrom redis.event import (\n    AfterPooledConnectionsInstantiationEvent,\n    AfterPubSubConnectionInstantiationEvent,\n    AfterSingleConnectionInstantiationEvent,\n    ClientType,\n    EventDispatcher,\n)\nfrom redis.exceptions import (\n    ConnectionError,\n    ExecAbortError,\n    PubSubError,\n    RedisError,\n    ResponseError,\n    WatchError,\n)\nfrom redis.lock import Lock\nfrom redis.maint_notifications import (\n    MaintNotificationsConfig,\n    OSSMaintNotificationsHandler,\n)\nfrom redis.observability.attributes import PubSubDirection\nfrom redis.observability.recorder import (\n    record_error_count,\n    record_operation_duration,\n    record_pubsub_message,\n)\nfrom redis.retry import Retry\nfrom redis.utils import (\n    _set_info_logger,\n    check_protocol_version,\n    deprecated_args,\n    safe_str,\n    str_if_bytes,\n    truncate_text,\n)\n\nif TYPE_CHECKING:\n    import ssl\n\n    import OpenSSL\n\nSYM_EMPTY = b\"\"\nEMPTY_RESPONSE = \"EMPTY_RESPONSE\"\n\n# some responses (ie. dump) are binary, and just meant to never be decoded\nNEVER_DECODE = \"NEVER_DECODE\"\n\n\nlogger = logging.getLogger(__name__)\n\n\ndef is_debug_log_enabled():\n    return logger.isEnabledFor(logging.DEBUG)\n\n\ndef add_debug_log_for_operation_failure(connection: \"AbstractConnection\"):\n    logger.debug(\n        f\"Operation failed, \"\n        f\"with connection: {connection}, details: {connection.extract_connection_details() if connection else 'no connection'}\",\n    )\n\n\nclass CaseInsensitiveDict(dict):\n    \"Case insensitive dict implementation. Assumes string keys only.\"\n\n    def __init__(self, data: Dict[str, str]) -> None:\n        for k, v in data.items():\n            self[k.upper()] = v\n\n    def __contains__(self, k):\n        return super().__contains__(k.upper())\n\n    def __delitem__(self, k):\n        super().__delitem__(k.upper())\n\n    def __getitem__(self, k):\n        return super().__getitem__(k.upper())\n\n    def get(self, k, default=None):\n        return super().get(k.upper(), default)\n\n    def __setitem__(self, k, v):\n        super().__setitem__(k.upper(), v)\n\n    def update(self, data):\n        data = CaseInsensitiveDict(data)\n        super().update(data)\n\n\nclass AbstractRedis:\n    pass\n\n\nclass Redis(RedisModuleCommands, CoreCommands, SentinelCommands):\n    \"\"\"\n    Implementation of the Redis protocol.\n\n    This abstract class provides a Python interface to all Redis commands\n    and an implementation of the Redis protocol.\n\n    Pipelines derive from this, implementing how\n    the commands are sent and received to the Redis server. Based on\n    configuration, an instance will either use a ConnectionPool, or\n    Connection object to talk to redis.\n\n    It is not safe to pass PubSub or Pipeline objects between threads.\n    \"\"\"\n\n    # Type discrimination marker for @overload self-type pattern\n    _is_async_client: Literal[False] = False\n\n    @classmethod\n    def from_url(cls, url: str, **kwargs) -> \"Redis\":\n        \"\"\"\n        Return a Redis client object configured from the given URL\n\n        For example::\n\n            redis://[[username]:[password]]@localhost:6379/0\n            rediss://[[username]:[password]]@localhost:6379/0\n            unix://[username@]/path/to/socket.sock?db=0[&password=password]\n\n        Three URL schemes are supported:\n\n        - `redis://` creates a TCP socket connection. See more at:\n          <https://www.iana.org/assignments/uri-schemes/prov/redis>\n        - `rediss://` creates a SSL wrapped TCP socket connection. See more at:\n          <https://www.iana.org/assignments/uri-schemes/prov/rediss>\n        - ``unix://``: creates a Unix Domain Socket connection.\n\n        The username, password, hostname, path and all querystring values\n        are passed through urllib.parse.unquote in order to replace any\n        percent-encoded values with their corresponding characters.\n\n        There are several ways to specify a database number. The first value\n        found will be used:\n\n            1. A ``db`` querystring option, e.g. redis://localhost?db=0\n            2. If using the redis:// or rediss:// schemes, the path argument\n               of the url, e.g. redis://localhost/0\n            3. A ``db`` keyword argument to this function.\n\n        If none of these options are specified, the default db=0 is used.\n\n        All querystring options are cast to their appropriate Python types.\n        Boolean arguments can be specified with string values \"True\"/\"False\"\n        or \"Yes\"/\"No\". Values that cannot be properly cast cause a\n        ``ValueError`` to be raised. Once parsed, the querystring arguments\n        and keyword arguments are passed to the ``ConnectionPool``'s\n        class initializer. In the case of conflicting arguments, querystring\n        arguments always win.\n\n        \"\"\"\n        single_connection_client = kwargs.pop(\"single_connection_client\", False)\n        connection_pool = ConnectionPool.from_url(url, **kwargs)\n        client = cls(\n            connection_pool=connection_pool,\n            single_connection_client=single_connection_client,\n        )\n        client.auto_close_connection_pool = True\n        return client\n\n    @classmethod\n    def from_pool(\n        cls: Type[\"Redis\"],\n        connection_pool: ConnectionPool,\n    ) -> \"Redis\":\n        \"\"\"\n        Return a Redis client from the given connection pool.\n        The Redis client will take ownership of the connection pool and\n        close it when the Redis client is closed.\n        \"\"\"\n        client = cls(\n            connection_pool=connection_pool,\n        )\n        client.auto_close_connection_pool = True\n        return client\n\n    @deprecated_args(\n        args_to_warn=[\"retry_on_timeout\"],\n        reason=\"TimeoutError is included by default.\",\n        version=\"6.0.0\",\n    )\n    @deprecated_args(\n        args_to_warn=[\"lib_name\", \"lib_version\"],\n        reason=\"Use 'driver_info' parameter instead. \"\n        \"lib_name and lib_version will be removed in a future version.\",\n    )\n    def __init__(\n        self,\n        host: str = \"localhost\",\n        port: int = 6379,\n        db: int = 0,\n        password: Optional[str] = None,\n        socket_timeout: Optional[float] = None,\n        socket_connect_timeout: Optional[float] = None,\n        socket_keepalive: Optional[bool] = None,\n        socket_keepalive_options: Optional[Mapping[int, Union[int, bytes]]] = None,\n        connection_pool: Optional[ConnectionPool] = None,\n        unix_socket_path: Optional[str] = None,\n        encoding: str = \"utf-8\",\n        encoding_errors: str = \"strict\",\n        decode_responses: bool = False,\n        retry_on_timeout: bool = False,\n        retry: Retry = Retry(\n            backoff=ExponentialWithJitterBackoff(base=1, cap=10), retries=3\n        ),\n        retry_on_error: Optional[List[Type[Exception]]] = None,\n        ssl: bool = False,\n        ssl_keyfile: Optional[str] = None,\n        ssl_certfile: Optional[str] = None,\n        ssl_cert_reqs: Union[str, \"ssl.VerifyMode\"] = \"required\",\n        ssl_include_verify_flags: Optional[List[\"ssl.VerifyFlags\"]] = None,\n        ssl_exclude_verify_flags: Optional[List[\"ssl.VerifyFlags\"]] = None,\n        ssl_ca_certs: Optional[str] = None,\n        ssl_ca_path: Optional[str] = None,\n        ssl_ca_data: Optional[str] = None,\n        ssl_check_hostname: bool = True,\n        ssl_password: Optional[str] = None,\n        ssl_validate_ocsp: bool = False,\n        ssl_validate_ocsp_stapled: bool = False,\n        ssl_ocsp_context: Optional[\"OpenSSL.SSL.Context\"] = None,\n        ssl_ocsp_expected_cert: Optional[str] = None,\n        ssl_min_version: Optional[\"ssl.TLSVersion\"] = None,\n        ssl_ciphers: Optional[str] = None,\n        max_connections: Optional[int] = None,\n        single_connection_client: bool = False,\n        health_check_interval: int = 0,\n        client_name: Optional[str] = None,\n        lib_name: Optional[str] = None,\n        lib_version: Optional[str] = None,\n        driver_info: Optional[\"DriverInfo\"] = None,\n        username: Optional[str] = None,\n        redis_connect_func: Optional[Callable[[], None]] = None,\n        credential_provider: Optional[CredentialProvider] = None,\n        protocol: Optional[int] = 2,\n        cache: Optional[CacheInterface] = None,\n        cache_config: Optional[CacheConfig] = None,\n        event_dispatcher: Optional[EventDispatcher] = None,\n        maint_notifications_config: Optional[MaintNotificationsConfig] = None,\n        oss_cluster_maint_notifications_handler: Optional[\n            OSSMaintNotificationsHandler\n        ] = None,\n    ) -> None:\n        \"\"\"\n        Initialize a new Redis client.\n\n        To specify a retry policy for specific errors, you have two options:\n\n        1. Set the `retry_on_error` to a list of the error/s to retry on, and\n        you can also set `retry` to a valid `Retry` object(in case the default\n        one is not appropriate) - with this approach the retries will be triggered\n        on the default errors specified in the Retry object enriched with the\n        errors specified in `retry_on_error`.\n\n        2. Define a `Retry` object with configured 'supported_errors' and set\n        it to the `retry` parameter - with this approach you completely redefine\n        the errors on which retries will happen.\n\n        `retry_on_timeout` is deprecated - please include the TimeoutError\n        either in the Retry object or in the `retry_on_error` list.\n\n        When 'connection_pool' is provided - the retry configuration of the\n        provided pool will be used.\n\n        Args:\n\n        single_connection_client:\n            if `True`, connection pool is not used. In that case `Redis`\n            instance use is not thread safe.\n        decode_responses:\n            if `True`, the response will be decoded to utf-8.\n            Argument is ignored when connection_pool is provided.\n        driver_info:\n            Optional DriverInfo object to identify upstream libraries.\n            If provided, lib_name and lib_version are ignored.\n            If not provided, a DriverInfo will be created from lib_name and lib_version.\n            Argument is ignored when connection_pool is provided.\n        lib_name:\n            **Deprecated.** Use driver_info instead. Library name for CLIENT SETINFO.\n        lib_version:\n            **Deprecated.** Use driver_info instead. Library version for CLIENT SETINFO.\n        maint_notifications_config:\n            configures the pool to support maintenance notifications - see\n            `redis.maint_notifications.MaintNotificationsConfig` for details.\n            Only supported with RESP3\n            If not provided and protocol is RESP3, the maintenance notifications\n            will be enabled by default (logic is included in the connection pool\n            initialization).\n            Argument is ignored when connection_pool is provided.\n        oss_cluster_maint_notifications_handler:\n            handler for OSS cluster notifications - see\n            `redis.maint_notifications.OSSMaintNotificationsHandler` for details.\n            Only supported with RESP3\n            Argument is ignored when connection_pool is provided.\n        \"\"\"\n        if event_dispatcher is None:\n            self._event_dispatcher = EventDispatcher()\n        else:\n            self._event_dispatcher = event_dispatcher\n        if not connection_pool:\n            if not retry_on_error:\n                retry_on_error = []\n\n            # Handle driver_info: if provided, use it; otherwise create from lib_name/lib_version\n            computed_driver_info = resolve_driver_info(\n                driver_info, lib_name, lib_version\n            )\n\n            kwargs = {\n                \"db\": db,\n                \"username\": username,\n                \"password\": password,\n                \"socket_timeout\": socket_timeout,\n                \"encoding\": encoding,\n                \"encoding_errors\": encoding_errors,\n                \"decode_responses\": decode_responses,\n                \"retry_on_error\": retry_on_error,\n                \"retry\": copy.deepcopy(retry),\n                \"max_connections\": max_connections,\n                \"health_check_interval\": health_check_interval,\n                \"client_name\": client_name,\n                \"driver_info\": computed_driver_info,\n                \"redis_connect_func\": redis_connect_func,\n                \"credential_provider\": credential_provider,\n                \"protocol\": protocol,\n            }\n            # based on input, setup appropriate connection args\n            if unix_socket_path is not None:\n                kwargs.update(\n                    {\n                        \"path\": unix_socket_path,\n                        \"connection_class\": UnixDomainSocketConnection,\n                    }\n                )\n            else:\n                # TCP specific options\n                kwargs.update(\n                    {\n                        \"host\": host,\n                        \"port\": port,\n                        \"socket_connect_timeout\": socket_connect_timeout,\n                        \"socket_keepalive\": socket_keepalive,\n                        \"socket_keepalive_options\": socket_keepalive_options,\n                    }\n                )\n\n                if ssl:\n                    kwargs.update(\n                        {\n                            \"connection_class\": SSLConnection,\n                            \"ssl_keyfile\": ssl_keyfile,\n                            \"ssl_certfile\": ssl_certfile,\n                            \"ssl_cert_reqs\": ssl_cert_reqs,\n                            \"ssl_include_verify_flags\": ssl_include_verify_flags,\n                            \"ssl_exclude_verify_flags\": ssl_exclude_verify_flags,\n                            \"ssl_ca_certs\": ssl_ca_certs,\n                            \"ssl_ca_data\": ssl_ca_data,\n                            \"ssl_check_hostname\": ssl_check_hostname,\n                            \"ssl_password\": ssl_password,\n                            \"ssl_ca_path\": ssl_ca_path,\n                            \"ssl_validate_ocsp_stapled\": ssl_validate_ocsp_stapled,\n                            \"ssl_validate_ocsp\": ssl_validate_ocsp,\n                            \"ssl_ocsp_context\": ssl_ocsp_context,\n                            \"ssl_ocsp_expected_cert\": ssl_ocsp_expected_cert,\n                            \"ssl_min_version\": ssl_min_version,\n                            \"ssl_ciphers\": ssl_ciphers,\n                        }\n                    )\n                if (cache_config or cache) and check_protocol_version(protocol, 3):\n                    kwargs.update(\n                        {\n                            \"cache\": cache,\n                            \"cache_config\": cache_config,\n                        }\n                    )\n                maint_notifications_enabled = (\n                    maint_notifications_config and maint_notifications_config.enabled\n                )\n                if maint_notifications_enabled and protocol not in [\n                    3,\n                    \"3\",\n                ]:\n                    raise RedisError(\n                        \"Maintenance notifications handlers on connection are only supported with RESP version 3\"\n                    )\n                if maint_notifications_config:\n                    kwargs.update(\n                        {\n                            \"maint_notifications_config\": maint_notifications_config,\n                        }\n                    )\n                if oss_cluster_maint_notifications_handler:\n                    kwargs.update(\n                        {\n                            \"oss_cluster_maint_notifications_handler\": oss_cluster_maint_notifications_handler,\n                        }\n                    )\n            connection_pool = ConnectionPool(**kwargs)\n            self._event_dispatcher.dispatch(\n                AfterPooledConnectionsInstantiationEvent(\n                    [connection_pool], ClientType.SYNC, credential_provider\n                )\n            )\n            self.auto_close_connection_pool = True\n        else:\n            self.auto_close_connection_pool = False\n            self._event_dispatcher.dispatch(\n                AfterPooledConnectionsInstantiationEvent(\n                    [connection_pool], ClientType.SYNC, credential_provider\n                )\n            )\n\n        self.connection_pool = connection_pool\n\n        if (cache_config or cache) and self.connection_pool.get_protocol() not in [\n            3,\n            \"3\",\n        ]:\n            raise RedisError(\"Client caching is only supported with RESP version 3\")\n\n        self.single_connection_lock = threading.RLock()\n        self.connection = None\n        self._single_connection_client = single_connection_client\n        if self._single_connection_client:\n            self.connection = self.connection_pool.get_connection()\n            self._event_dispatcher.dispatch(\n                AfterSingleConnectionInstantiationEvent(\n                    self.connection, ClientType.SYNC, self.single_connection_lock\n                )\n            )\n\n        self.response_callbacks = CaseInsensitiveDict(_RedisCallbacks)\n\n        if self.connection_pool.connection_kwargs.get(\"protocol\") in [\"3\", 3]:\n            self.response_callbacks.update(_RedisCallbacksRESP3)\n        else:\n            self.response_callbacks.update(_RedisCallbacksRESP2)\n\n    def __repr__(self) -> str:\n        return (\n            f\"<{type(self).__module__}.{type(self).__name__}\"\n            f\"({repr(self.connection_pool)})>\"\n        )\n\n    def get_encoder(self) -> \"Encoder\":\n        \"\"\"Get the connection pool's encoder\"\"\"\n        return self.connection_pool.get_encoder()\n\n    def get_connection_kwargs(self) -> Dict:\n        \"\"\"Get the connection's key-word arguments\"\"\"\n        return self.connection_pool.connection_kwargs\n\n    def get_retry(self) -> Optional[Retry]:\n        return self.get_connection_kwargs().get(\"retry\")\n\n    def set_retry(self, retry: Retry) -> None:\n        self.get_connection_kwargs().update({\"retry\": retry})\n        self.connection_pool.set_retry(retry)\n\n    def set_response_callback(self, command: str, callback: Callable) -> None:\n        \"\"\"Set a custom Response Callback\"\"\"\n        self.response_callbacks[command] = callback\n\n    def load_external_module(self, funcname, func) -> None:\n        \"\"\"\n        This function can be used to add externally defined redis modules,\n        and their namespaces to the redis client.\n\n        funcname - A string containing the name of the function to create\n        func - The function, being added to this class.\n\n        ex: Assume that one has a custom redis module named foomod that\n        creates command named 'foo.dothing' and 'foo.anotherthing' in redis.\n        To load function functions into this namespace:\n\n        from redis import Redis\n        from foomodule import F\n        r = Redis()\n        r.load_external_module(\"foo\", F)\n        r.foo().dothing('your', 'arguments')\n\n        For a concrete example see the reimport of the redisjson module in\n        tests/test_connection.py::test_loading_external_modules\n        \"\"\"\n        setattr(self, funcname, func)\n\n    def pipeline(self, transaction=True, shard_hint=None) -> \"Pipeline\":\n        \"\"\"\n        Return a new pipeline object that can queue multiple commands for\n        later execution. ``transaction`` indicates whether all commands\n        should be executed atomically. Apart from making a group of operations\n        atomic, pipelines are useful for reducing the back-and-forth overhead\n        between the client and server.\n        \"\"\"\n        return Pipeline(\n            self.connection_pool, self.response_callbacks, transaction, shard_hint\n        )\n\n    def transaction(\n        self, func: Callable[[\"Pipeline\"], None], *watches, **kwargs\n    ) -> Union[List[Any], Any, None]:\n        \"\"\"\n        Convenience method for executing the callable `func` as a transaction\n        while watching all keys specified in `watches`. The 'func' callable\n        should expect a single argument which is a Pipeline object.\n        \"\"\"\n        shard_hint = kwargs.pop(\"shard_hint\", None)\n        value_from_callable = kwargs.pop(\"value_from_callable\", False)\n        watch_delay = kwargs.pop(\"watch_delay\", None)\n        with self.pipeline(True, shard_hint) as pipe:\n            while True:\n                try:\n                    if watches:\n                        pipe.watch(*watches)\n                    func_value = func(pipe)\n                    exec_value = pipe.execute()\n                    return func_value if value_from_callable else exec_value\n                except WatchError:\n                    if watch_delay is not None and watch_delay > 0:\n                        time.sleep(watch_delay)\n                    continue\n\n    def lock(\n        self,\n        name: str,\n        timeout: Optional[float] = None,\n        sleep: float = 0.1,\n        blocking: bool = True,\n        blocking_timeout: Optional[float] = None,\n        lock_class: Union[None, Any] = None,\n        thread_local: bool = True,\n        raise_on_release_error: bool = True,\n    ):\n        \"\"\"\n        Return a new Lock object using key ``name`` that mimics\n        the behavior of threading.Lock.\n\n        If specified, ``timeout`` indicates a maximum life for the lock.\n        By default, it will remain locked until release() is called.\n\n        ``sleep`` indicates the amount of time to sleep per loop iteration\n        when the lock is in blocking mode and another client is currently\n        holding the lock.\n\n        ``blocking`` indicates whether calling ``acquire`` should block until\n        the lock has been acquired or to fail immediately, causing ``acquire``\n        to return False and the lock not being acquired. Defaults to True.\n        Note this value can be overridden by passing a ``blocking``\n        argument to ``acquire``.\n\n        ``blocking_timeout`` indicates the maximum amount of time in seconds to\n        spend trying to acquire the lock. A value of ``None`` indicates\n        continue trying forever. ``blocking_timeout`` can be specified as a\n        float or integer, both representing the number of seconds to wait.\n\n        ``lock_class`` forces the specified lock implementation. Note that as\n        of redis-py 3.0, the only lock class we implement is ``Lock`` (which is\n        a Lua-based lock). So, it's unlikely you'll need this parameter, unless\n        you have created your own custom lock class.\n\n        ``thread_local`` indicates whether the lock token is placed in\n        thread-local storage. By default, the token is placed in thread local\n        storage so that a thread only sees its token, not a token set by\n        another thread. Consider the following timeline:\n\n            time: 0, thread-1 acquires `my-lock`, with a timeout of 5 seconds.\n                     thread-1 sets the token to \"abc\"\n            time: 1, thread-2 blocks trying to acquire `my-lock` using the\n                     Lock instance.\n            time: 5, thread-1 has not yet completed. redis expires the lock\n                     key.\n            time: 5, thread-2 acquired `my-lock` now that it's available.\n                     thread-2 sets the token to \"xyz\"\n            time: 6, thread-1 finishes its work and calls release(). if the\n                     token is *not* stored in thread local storage, then\n                     thread-1 would see the token value as \"xyz\" and would be\n                     able to successfully release the thread-2's lock.\n\n        ``raise_on_release_error`` indicates whether to raise an exception when\n        the lock is no longer owned when exiting the context manager. By default,\n        this is True, meaning an exception will be raised. If False, the warning\n        will be logged and the exception will be suppressed.\n\n        In some use cases it's necessary to disable thread local storage. For\n        example, if you have code where one thread acquires a lock and passes\n        that lock instance to a worker thread to release later. If thread\n        local storage isn't disabled in this case, the worker thread won't see\n        the token set by the thread that acquired the lock. Our assumption\n        is that these cases aren't common and as such default to using\n        thread local storage.\"\"\"\n        if lock_class is None:\n            lock_class = Lock\n        return lock_class(\n            self,\n            name,\n            timeout=timeout,\n            sleep=sleep,\n            blocking=blocking,\n            blocking_timeout=blocking_timeout,\n            thread_local=thread_local,\n            raise_on_release_error=raise_on_release_error,\n        )\n\n    def pubsub(self, **kwargs):\n        \"\"\"\n        Return a Publish/Subscribe object. With this object, you can\n        subscribe to channels and listen for messages that get published to\n        them.\n        \"\"\"\n        return PubSub(\n            self.connection_pool, event_dispatcher=self._event_dispatcher, **kwargs\n        )\n\n    def monitor(self):\n        return Monitor(self.connection_pool)\n\n    def client(self):\n        return self.__class__(\n            connection_pool=self.connection_pool,\n            single_connection_client=True,\n        )\n\n    def __enter__(self):\n        return self\n\n    def __exit__(self, exc_type, exc_value, traceback):\n        self.close()\n\n    def __del__(self):\n        try:\n            self.close()\n        except Exception:\n            pass\n\n    def close(self) -> None:\n        # In case a connection property does not yet exist\n        # (due to a crash earlier in the Redis() constructor), return\n        # immediately as there is nothing to clean-up.\n        if not hasattr(self, \"connection\"):\n            return\n\n        conn = self.connection\n        if conn:\n            self.connection = None\n            self.connection_pool.release(conn)\n\n        if self.auto_close_connection_pool:\n            self.connection_pool.disconnect()\n\n    def _send_command_parse_response(self, conn, command_name, *args, **options):\n        \"\"\"\n        Send a command and parse the response\n        \"\"\"\n        conn.send_command(*args, **options)\n        return self.parse_response(conn, command_name, **options)\n\n    def _close_connection(\n        self,\n        conn,\n        error: Optional[Exception] = None,\n        failure_count: Optional[int] = None,\n        start_time: Optional[float] = None,\n        command_name: Optional[str] = None,\n    ) -> None:\n        \"\"\"\n        Close the connection before retrying.\n\n        The supported exceptions are already checked in the\n        retry object so we don't need to do it here.\n\n        After we disconnect the connection, it will try to reconnect and\n        do a health check as part of the send_command logic(on connection level).\n        \"\"\"\n        if error and failure_count <= conn.retry.get_retries():\n            record_operation_duration(\n                command_name=command_name,\n                duration_seconds=time.monotonic() - start_time,\n                server_address=getattr(conn, \"host\", None),\n                server_port=getattr(conn, \"port\", None),\n                db_namespace=str(conn.db),\n                error=error,\n                retry_attempts=failure_count,\n            )\n\n        conn.disconnect()\n\n    # COMMAND EXECUTION AND PROTOCOL PARSING\n    def execute_command(self, *args, **options):\n        return self._execute_command(*args, **options)\n\n    def _execute_command(self, *args, **options):\n        \"\"\"Execute a command and return a parsed response\"\"\"\n        pool = self.connection_pool\n        command_name = args[0]\n        conn = self.connection or pool.get_connection()\n\n        # Start timing for observability\n        start_time = time.monotonic()\n        # Track actual retry attempts for error reporting\n        actual_retry_attempts = [0]\n\n        def failure_callback(error, failure_count):\n            if is_debug_log_enabled():\n                add_debug_log_for_operation_failure(conn)\n            actual_retry_attempts[0] = failure_count\n            self._close_connection(conn, error, failure_count, start_time, command_name)\n\n        if self._single_connection_client:\n            self.single_connection_lock.acquire()\n        try:\n            result = conn.retry.call_with_retry(\n                lambda: self._send_command_parse_response(\n                    conn, command_name, *args, **options\n                ),\n                failure_callback,\n                with_failure_count=True,\n            )\n\n            record_operation_duration(\n                command_name=command_name,\n                duration_seconds=time.monotonic() - start_time,\n                server_address=getattr(conn, \"host\", None),\n                server_port=getattr(conn, \"port\", None),\n                db_namespace=str(conn.db),\n            )\n            return result\n        except Exception as e:\n            record_error_count(\n                server_address=getattr(conn, \"host\", None),\n                server_port=getattr(conn, \"port\", None),\n                network_peer_address=getattr(conn, \"host\", None),\n                network_peer_port=getattr(conn, \"port\", None),\n                error_type=e,\n                retry_attempts=actual_retry_attempts[0],\n                is_internal=False,\n            )\n            raise\n\n        finally:\n            if conn and conn.should_reconnect():\n                self._close_connection(conn)\n                conn.connect()\n            if self._single_connection_client:\n                self.single_connection_lock.release()\n            if not self.connection:\n                pool.release(conn)\n\n    def parse_response(self, connection, command_name, **options):\n        \"\"\"Parses a response from the Redis server\"\"\"\n        try:\n            if NEVER_DECODE in options:\n                response = connection.read_response(disable_decoding=True)\n                options.pop(NEVER_DECODE)\n            else:\n                response = connection.read_response()\n        except ResponseError:\n            if EMPTY_RESPONSE in options:\n                return options[EMPTY_RESPONSE]\n            raise\n\n        if EMPTY_RESPONSE in options:\n            options.pop(EMPTY_RESPONSE)\n\n        # Remove keys entry, it needs only for cache.\n        options.pop(\"keys\", None)\n\n        if command_name in self.response_callbacks:\n            return self.response_callbacks[command_name](response, **options)\n        return response\n\n    def get_cache(self) -> Optional[CacheInterface]:\n        return self.connection_pool.cache\n\n\nStrictRedis = Redis\n\n\nclass Monitor:\n    \"\"\"\n    Monitor is useful for handling the MONITOR command to the redis server.\n    next_command() method returns one command from monitor\n    listen() method yields commands from monitor.\n    \"\"\"\n\n    monitor_re = re.compile(r\"\\[(\\d+) (.*?)\\] (.*)\")\n    command_re = re.compile(r'\"(.*?)(?<!\\\\)\"')\n\n    def __init__(self, connection_pool):\n        self.connection_pool = connection_pool\n        self.connection = self.connection_pool.get_connection()\n\n    def __enter__(self):\n        self._start_monitor()\n        return self\n\n    def __exit__(self, *args):\n        self.connection.disconnect()\n        self.connection_pool.release(self.connection)\n\n    def next_command(self):\n        \"\"\"Parse the response from a monitor command\"\"\"\n        response = self.connection.read_response()\n\n        if response is None:\n            return None\n\n        if isinstance(response, bytes):\n            response = self.connection.encoder.decode(response, force=True)\n\n        command_time, command_data = response.split(\" \", 1)\n        m = self.monitor_re.match(command_data)\n        db_id, client_info, command = m.groups()\n        command = \" \".join(self.command_re.findall(command))\n        # Redis escapes double quotes because each piece of the command\n        # string is surrounded by double quotes. We don't have that\n        # requirement so remove the escaping and leave the quote.\n        command = command.replace('\\\\\"', '\"')\n\n        if client_info == \"lua\":\n            client_address = \"lua\"\n            client_port = \"\"\n            client_type = \"lua\"\n        elif client_info.startswith(\"unix\"):\n            client_address = \"unix\"\n            client_port = client_info[5:]\n            client_type = \"unix\"\n        else:\n            if client_info == \"\":\n                client_address = \"\"\n                client_port = \"\"\n                client_type = \"unknown\"\n            else:\n                # use rsplit as ipv6 addresses contain colons\n                client_address, client_port = client_info.rsplit(\":\", 1)\n                client_type = \"tcp\"\n        return {\n            \"time\": float(command_time),\n            \"db\": int(db_id),\n            \"client_address\": client_address,\n            \"client_port\": client_port,\n            \"client_type\": client_type,\n            \"command\": command,\n        }\n\n    def listen(self):\n        \"\"\"Listen for commands coming to the server.\"\"\"\n        while True:\n            yield self.next_command()\n\n    def _start_monitor(self):\n        self.connection.send_command(\"MONITOR\")\n        # check that monitor returns 'OK', but don't return it to user\n        response = self.connection.read_response()\n\n        if not bool_ok(response):\n            raise RedisError(f\"MONITOR failed: {response}\")\n\n\nclass PubSub:\n    \"\"\"\n    PubSub provides publish, subscribe and listen support to Redis channels.\n\n    After subscribing to one or more channels, the listen() method will block\n    until a message arrives on one of the subscribed channels. That message\n    will be returned and it's safe to start listening again.\n    \"\"\"\n\n    PUBLISH_MESSAGE_TYPES = (\"message\", \"pmessage\", \"smessage\")\n    UNSUBSCRIBE_MESSAGE_TYPES = (\"unsubscribe\", \"punsubscribe\", \"sunsubscribe\")\n    HEALTH_CHECK_MESSAGE = \"redis-py-health-check\"\n\n    def __init__(\n        self,\n        connection_pool,\n        shard_hint=None,\n        ignore_subscribe_messages: bool = False,\n        encoder: Optional[\"Encoder\"] = None,\n        push_handler_func: Union[None, Callable[[str], None]] = None,\n        event_dispatcher: Optional[\"EventDispatcher\"] = None,\n    ):\n        self.connection_pool = connection_pool\n        self.shard_hint = shard_hint\n        self.ignore_subscribe_messages = ignore_subscribe_messages\n        self.connection = None\n        self.subscribed_event = threading.Event()\n        # we need to know the encoding options for this connection in order\n        # to lookup channel and pattern names for callback handlers.\n        self.encoder = encoder\n        self.push_handler_func = push_handler_func\n        if event_dispatcher is None:\n            self._event_dispatcher = EventDispatcher()\n        else:\n            self._event_dispatcher = event_dispatcher\n\n        self._lock = threading.RLock()\n        if self.encoder is None:\n            self.encoder = self.connection_pool.get_encoder()\n        self.health_check_response_b = self.encoder.encode(self.HEALTH_CHECK_MESSAGE)\n        if self.encoder.decode_responses:\n            self.health_check_response = [\"pong\", self.HEALTH_CHECK_MESSAGE]\n        else:\n            self.health_check_response = [b\"pong\", self.health_check_response_b]\n        if self.push_handler_func is None:\n            _set_info_logger()\n        self.reset()\n\n    def __enter__(self) -> \"PubSub\":\n        return self\n\n    def __exit__(self, exc_type, exc_value, traceback) -> None:\n        self.reset()\n\n    def __del__(self) -> None:\n        try:\n            # if this object went out of scope prior to shutting down\n            # subscriptions, close the connection manually before\n            # returning it to the connection pool\n            self.reset()\n        except Exception:\n            pass\n\n    def reset(self) -> None:\n        if self.connection:\n            self.connection.disconnect()\n            self.connection.deregister_connect_callback(self.on_connect)\n            self.connection_pool.release(self.connection)\n            self.connection = None\n        self.health_check_response_counter = 0\n        self.channels = {}\n        self.pending_unsubscribe_channels = set()\n        self.shard_channels = {}\n        self.pending_unsubscribe_shard_channels = set()\n        self.patterns = {}\n        self.pending_unsubscribe_patterns = set()\n        self.subscribed_event.clear()\n\n    def close(self) -> None:\n        self.reset()\n\n    def on_connect(self, connection) -> None:\n        \"Re-subscribe to any channels and patterns previously subscribed to\"\n        # NOTE: for python3, we can't pass bytestrings as keyword arguments\n        # so we need to decode channel/pattern names back to unicode strings\n        # before passing them to [p]subscribe.\n        #\n        # However, channels subscribed without a callback (positional args) may\n        # have binary names that are not valid in the current encoding (e.g.\n        # arbitrary bytes that are not valid UTF-8).  These channels are stored\n        # with a ``None`` handler.  We re-subscribe them as positional args so\n        # that no decoding is required.\n        self.pending_unsubscribe_channels.clear()\n        self.pending_unsubscribe_patterns.clear()\n        self.pending_unsubscribe_shard_channels.clear()\n        if self.channels:\n            channels_with_handlers = {}\n            channels_without_handlers = []\n            for k, v in self.channels.items():\n                if v is not None:\n                    channels_with_handlers[self.encoder.decode(k, force=True)] = v\n                else:\n                    channels_without_handlers.append(k)\n            if channels_with_handlers or channels_without_handlers:\n                self.subscribe(*channels_without_handlers, **channels_with_handlers)\n        if self.patterns:\n            patterns_with_handlers = {}\n            patterns_without_handlers = []\n            for k, v in self.patterns.items():\n                if v is not None:\n                    patterns_with_handlers[self.encoder.decode(k, force=True)] = v\n                else:\n                    patterns_without_handlers.append(k)\n            if patterns_with_handlers or patterns_without_handlers:\n                self.psubscribe(*patterns_without_handlers, **patterns_with_handlers)\n        if self.shard_channels:\n            shard_with_handlers = {}\n            shard_without_handlers = []\n            for k, v in self.shard_channels.items():\n                if v is not None:\n                    shard_with_handlers[self.encoder.decode(k, force=True)] = v\n                else:\n                    shard_without_handlers.append(k)\n            if shard_with_handlers or shard_without_handlers:\n                self.ssubscribe(*shard_without_handlers, **shard_with_handlers)\n\n    @property\n    def subscribed(self) -> bool:\n        \"\"\"Indicates if there are subscriptions to any channels or patterns\"\"\"\n        return self.subscribed_event.is_set()\n\n    def execute_command(self, *args):\n        \"\"\"Execute a publish/subscribe command\"\"\"\n\n        # NOTE: don't parse the response in this function -- it could pull a\n        # legitimate message off the stack if the connection is already\n        # subscribed to one or more channels\n\n        if self.connection is None:\n            self.connection = self.connection_pool.get_connection()\n            # register a callback that re-subscribes to any channels we\n            # were listening to when we were disconnected\n            self.connection.register_connect_callback(self.on_connect)\n            if self.push_handler_func is not None:\n                self.connection._parser.set_pubsub_push_handler(self.push_handler_func)\n            self._event_dispatcher.dispatch(\n                AfterPubSubConnectionInstantiationEvent(\n                    self.connection, self.connection_pool, ClientType.SYNC, self._lock\n                )\n            )\n        connection = self.connection\n        kwargs = {\"check_health\": not self.subscribed}\n        if not self.subscribed:\n            self.clean_health_check_responses()\n        with self._lock:\n            self._execute(connection, connection.send_command, *args, **kwargs)\n\n    def clean_health_check_responses(self) -> None:\n        \"\"\"\n        If any health check responses are present, clean them\n        \"\"\"\n        ttl = 10\n        conn = self.connection\n        while conn and self.health_check_response_counter > 0 and ttl > 0:\n            if self._execute(conn, conn.can_read, timeout=conn.socket_timeout):\n                response = self._execute(conn, conn.read_response)\n                if self.is_health_check_response(response):\n                    self.health_check_response_counter -= 1\n                else:\n                    raise PubSubError(\n                        \"A non health check response was cleaned by \"\n                        \"execute_command: {}\".format(response)\n                    )\n            ttl -= 1\n\n    def _reconnect(\n        self,\n        conn,\n        error: Optional[Exception] = None,\n        failure_count: Optional[int] = None,\n        start_time: Optional[float] = None,\n        command_name: Optional[str] = None,\n    ) -> None:\n        \"\"\"\n        The supported exceptions are already checked in the\n        retry object so we don't need to do it here.\n\n        In this error handler we are trying to reconnect to the server.\n        \"\"\"\n        if error and failure_count <= conn.retry.get_retries():\n            if command_name:\n                record_operation_duration(\n                    command_name=command_name,\n                    duration_seconds=time.monotonic() - start_time,\n                    server_address=getattr(conn, \"host\", None),\n                    server_port=getattr(conn, \"port\", None),\n                    db_namespace=str(conn.db),\n                    error=error,\n                    retry_attempts=failure_count,\n                )\n        conn.disconnect()\n        conn.connect()\n\n    def _execute(self, conn, command, *args, **kwargs):\n        \"\"\"\n        Connect manually upon disconnection. If the Redis server is down,\n        this will fail and raise a ConnectionError as desired.\n        After reconnection, the ``on_connect`` callback should have been\n        called by the # connection to resubscribe us to any channels and\n        patterns we were previously listening to\n        \"\"\"\n\n        if conn.should_reconnect():\n            self._reconnect(conn)\n\n        if not len(args) == 0:\n            command_name = args[0]\n        else:\n            command_name = None\n\n        # Start timing for observability\n        start_time = time.monotonic()\n        # Track actual retry attempts for error reporting\n        actual_retry_attempts = [0]\n\n        def failure_callback(error, failure_count):\n            actual_retry_attempts[0] = failure_count\n            self._reconnect(conn, error, failure_count, start_time, command_name)\n\n        try:\n            response = conn.retry.call_with_retry(\n                lambda: command(*args, **kwargs),\n                failure_callback,\n                with_failure_count=True,\n            )\n\n            if command_name:\n                record_operation_duration(\n                    command_name=command_name,\n                    duration_seconds=time.monotonic() - start_time,\n                    server_address=getattr(conn, \"host\", None),\n                    server_port=getattr(conn, \"port\", None),\n                    db_namespace=str(conn.db),\n                )\n\n            return response\n        except Exception as e:\n            record_error_count(\n                server_address=getattr(conn, \"host\", None),\n                server_port=getattr(conn, \"port\", None),\n                network_peer_address=getattr(conn, \"host\", None),\n                network_peer_port=getattr(conn, \"port\", None),\n                error_type=e,\n                retry_attempts=actual_retry_attempts[0],\n                is_internal=False,\n            )\n            raise\n\n    def parse_response(self, block=True, timeout=0):\n        \"\"\"\n        Parse the response from a publish/subscribe command.\n\n        Args:\n            block: If True, block indefinitely until a message is available.\n                   If False, return immediately if no message is available.\n                   Default: True\n            timeout: The timeout in seconds for reading a response when block=False.\n                     This parameter is ignored when block=True.\n                     Default: 0 (return immediately if no data available)\n\n        Returns:\n            The parsed response from the server, or None if no message is available\n            within the timeout period (when block=False).\n\n        Important:\n            The block and timeout parameters work together:\n            - When block=True: timeout is IGNORED, method blocks indefinitely\n            - When block=False: timeout is USED, method returns after timeout expires\n\n            Typically, you should use get_message(timeout=X) instead of calling\n            parse_response() directly. The get_message() method automatically sets\n            block=False when a timeout is provided, and block=True when timeout=None.\n\n        Example:\n            # Block indefinitely (timeout is ignored)\n            response = pubsub.parse_response(block=True, timeout=0.1)\n\n            # Non-blocking with 0.1 second timeout\n            response = pubsub.parse_response(block=False, timeout=0.1)\n\n            # Non-blocking, return immediately\n            response = pubsub.parse_response(block=False, timeout=0)\n\n            # Recommended: use get_message() instead\n            msg = pubsub.get_message(timeout=0.1)  # automatically sets block=False\n            msg = pubsub.get_message(timeout=None)  # automatically sets block=True\n        \"\"\"\n        conn = self.connection\n        if conn is None:\n            raise RuntimeError(\n                \"pubsub connection not set: \"\n                \"did you forget to call subscribe() or psubscribe()?\"\n            )\n\n        self.check_health()\n\n        def try_read():\n            if not block:\n                if not conn.can_read(timeout=timeout):\n                    return None\n                read_timeout = timeout\n            else:\n                conn.connect()\n                read_timeout = SENTINEL  # Use default socket timeout for blocking\n            return conn.read_response(\n                disconnect_on_error=False, push_request=True, timeout=read_timeout\n            )\n\n        response = self._execute(conn, try_read)\n\n        if self.is_health_check_response(response):\n            # ignore the health check message as user might not expect it\n            self.health_check_response_counter -= 1\n            return None\n        return response\n\n    def is_health_check_response(self, response) -> bool:\n        \"\"\"\n        Check if the response is a health check response.\n        If there are no subscriptions redis responds to PING command with a\n        bulk response, instead of a multi-bulk with \"pong\" and the response.\n        \"\"\"\n        if self.encoder.decode_responses:\n            return (\n                response\n                in [\n                    self.health_check_response,  # If there is a subscription\n                    self.HEALTH_CHECK_MESSAGE,  # If there are no subscriptions and decode_responses=True\n                ]\n            )\n        else:\n            return (\n                response\n                in [\n                    self.health_check_response,  # If there is a subscription\n                    self.health_check_response_b,  # If there isn't a subscription and decode_responses=False\n                ]\n            )\n\n    def check_health(self) -> None:\n        conn = self.connection\n        if conn is None:\n            raise RuntimeError(\n                \"pubsub connection not set: \"\n                \"did you forget to call subscribe() or psubscribe()?\"\n            )\n\n        if conn.health_check_interval and time.monotonic() > conn.next_health_check:\n            conn.send_command(\"PING\", self.HEALTH_CHECK_MESSAGE, check_health=False)\n            self.health_check_response_counter += 1\n\n    def _normalize_keys(self, data) -> Dict:\n        \"\"\"\n        normalize channel/pattern names to be either bytes or strings\n        based on whether responses are automatically decoded. this saves us\n        from coercing the value for each message coming in.\n        \"\"\"\n        encode = self.encoder.encode\n        decode = self.encoder.decode\n        return {decode(encode(k)): v for k, v in data.items()}\n\n    def psubscribe(self, *args, **kwargs):\n        \"\"\"\n        Subscribe to channel patterns. Patterns supplied as keyword arguments\n        expect a pattern name as the key and a callable as the value. A\n        pattern's callable will be invoked automatically when a message is\n        received on that pattern rather than producing a message via\n        ``listen()``.\n        \"\"\"\n        if args:\n            args = list_or_args(args[0], args[1:])\n        new_patterns = dict.fromkeys(args)\n        new_patterns.update(kwargs)\n        ret_val = self.execute_command(\"PSUBSCRIBE\", *new_patterns.keys())\n        # update the patterns dict AFTER we send the command. we don't want to\n        # subscribe twice to these patterns, once for the command and again\n        # for the reconnection.\n        new_patterns = self._normalize_keys(new_patterns)\n        self.patterns.update(new_patterns)\n        if not self.subscribed:\n            # Set the subscribed_event flag to True\n            self.subscribed_event.set()\n            # Clear the health check counter\n            self.health_check_response_counter = 0\n        self.pending_unsubscribe_patterns.difference_update(new_patterns)\n        return ret_val\n\n    def punsubscribe(self, *args):\n        \"\"\"\n        Unsubscribe from the supplied patterns. If empty, unsubscribe from\n        all patterns.\n        \"\"\"\n        if args:\n            args = list_or_args(args[0], args[1:])\n            patterns = self._normalize_keys(dict.fromkeys(args))\n        else:\n            patterns = self.patterns\n        self.pending_unsubscribe_patterns.update(patterns)\n        return self.execute_command(\"PUNSUBSCRIBE\", *args)\n\n    def subscribe(self, *args, **kwargs):\n        \"\"\"\n        Subscribe to channels. Channels supplied as keyword arguments expect\n        a channel name as the key and a callable as the value. A channel's\n        callable will be invoked automatically when a message is received on\n        that channel rather than producing a message via ``listen()`` or\n        ``get_message()``.\n        \"\"\"\n        if args:\n            args = list_or_args(args[0], args[1:])\n        new_channels = dict.fromkeys(args)\n        new_channels.update(kwargs)\n        ret_val = self.execute_command(\"SUBSCRIBE\", *new_channels.keys())\n        # update the channels dict AFTER we send the command. we don't want to\n        # subscribe twice to these channels, once for the command and again\n        # for the reconnection.\n        new_channels = self._normalize_keys(new_channels)\n        self.channels.update(new_channels)\n        if not self.subscribed:\n            # Set the subscribed_event flag to True\n            self.subscribed_event.set()\n            # Clear the health check counter\n            self.health_check_response_counter = 0\n        self.pending_unsubscribe_channels.difference_update(new_channels)\n        return ret_val\n\n    def unsubscribe(self, *args):\n        \"\"\"\n        Unsubscribe from the supplied channels. If empty, unsubscribe from\n        all channels\n        \"\"\"\n        if args:\n            args = list_or_args(args[0], args[1:])\n            channels = self._normalize_keys(dict.fromkeys(args))\n        else:\n            channels = self.channels\n        self.pending_unsubscribe_channels.update(channels)\n        return self.execute_command(\"UNSUBSCRIBE\", *args)\n\n    def ssubscribe(self, *args, target_node=None, **kwargs):\n        \"\"\"\n        Subscribes the client to the specified shard channels.\n        Channels supplied as keyword arguments expect a channel name as the key\n        and a callable as the value. A channel's callable will be invoked automatically\n        when a message is received on that channel rather than producing a message via\n        ``listen()`` or ``get_sharded_message()``.\n        \"\"\"\n        if args:\n            args = list_or_args(args[0], args[1:])\n        new_s_channels = dict.fromkeys(args)\n        new_s_channels.update(kwargs)\n        ret_val = self.execute_command(\"SSUBSCRIBE\", *new_s_channels.keys())\n        # update the s_channels dict AFTER we send the command. we don't want to\n        # subscribe twice to these channels, once for the command and again\n        # for the reconnection.\n        new_s_channels = self._normalize_keys(new_s_channels)\n        self.shard_channels.update(new_s_channels)\n        if not self.subscribed:\n            # Set the subscribed_event flag to True\n            self.subscribed_event.set()\n            # Clear the health check counter\n            self.health_check_response_counter = 0\n        self.pending_unsubscribe_shard_channels.difference_update(new_s_channels)\n        return ret_val\n\n    def sunsubscribe(self, *args, target_node=None):\n        \"\"\"\n        Unsubscribe from the supplied shard_channels. If empty, unsubscribe from\n        all shard_channels\n        \"\"\"\n        if args:\n            args = list_or_args(args[0], args[1:])\n            s_channels = self._normalize_keys(dict.fromkeys(args))\n        else:\n            s_channels = self.shard_channels\n        self.pending_unsubscribe_shard_channels.update(s_channels)\n        return self.execute_command(\"SUNSUBSCRIBE\", *args)\n\n    def listen(self):\n        \"Listen for messages on channels this client has been subscribed to\"\n        while self.subscribed:\n            response = self.handle_message(self.parse_response(block=True))\n            if response is not None:\n                yield response\n\n    def get_message(\n        self, ignore_subscribe_messages: bool = False, timeout: float = 0.0\n    ):\n        \"\"\"\n        Get the next message if one is available, otherwise None.\n\n        If timeout is specified, the system will wait for `timeout` seconds\n        before returning. Timeout should be specified as a floating point\n        number, or None, to wait indefinitely.\n        \"\"\"\n        if not self.subscribed:\n            # Wait for subscription\n            start_time = time.monotonic()\n            if self.subscribed_event.wait(timeout) is True:\n                # The connection was subscribed during the timeout time frame.\n                # The timeout should be adjusted based on the time spent\n                # waiting for the subscription\n                time_spent = time.monotonic() - start_time\n                timeout = max(0.0, timeout - time_spent)\n            else:\n                # The connection isn't subscribed to any channels or patterns,\n                # so no messages are available\n                return None\n\n        response = self.parse_response(block=(timeout is None), timeout=timeout)\n\n        if response:\n            return self.handle_message(response, ignore_subscribe_messages)\n        return None\n\n    get_sharded_message = get_message\n\n    def ping(self, message: Union[str, None] = None) -> bool:\n        \"\"\"\n        Ping the Redis server to test connectivity.\n\n        Sends a PING command to the Redis server and returns True if the server\n        responds with \"PONG\".\n        \"\"\"\n        args = [\"PING\", message] if message is not None else [\"PING\"]\n        return self.execute_command(*args)\n\n    def handle_message(self, response, ignore_subscribe_messages=False):\n        \"\"\"\n        Parses a pub/sub message. If the channel or pattern was subscribed to\n        with a message handler, the handler is invoked instead of a parsed\n        message being returned.\n        \"\"\"\n        if response is None:\n            return None\n        if isinstance(response, bytes):\n            response = [b\"pong\", response] if response != b\"PONG\" else [b\"pong\", b\"\"]\n\n        message_type = str_if_bytes(response[0])\n        if message_type == \"pmessage\":\n            message = {\n                \"type\": message_type,\n                \"pattern\": response[1],\n                \"channel\": response[2],\n                \"data\": response[3],\n            }\n        elif message_type == \"pong\":\n            message = {\n                \"type\": message_type,\n                \"pattern\": None,\n                \"channel\": None,\n                \"data\": response[1],\n            }\n        else:\n            message = {\n                \"type\": message_type,\n                \"pattern\": None,\n                \"channel\": response[1],\n                \"data\": response[2],\n            }\n\n        if message_type in [\"message\", \"pmessage\"]:\n            channel = str_if_bytes(message[\"channel\"])\n            record_pubsub_message(\n                direction=PubSubDirection.RECEIVE,\n                channel=channel,\n            )\n        elif message_type == \"smessage\":\n            channel = str_if_bytes(message[\"channel\"])\n            record_pubsub_message(\n                direction=PubSubDirection.RECEIVE,\n                channel=channel,\n                sharded=True,\n            )\n\n        # if this is an unsubscribe message, remove it from memory\n        if message_type in self.UNSUBSCRIBE_MESSAGE_TYPES:\n            if message_type == \"punsubscribe\":\n                pattern = response[1]\n                if pattern in self.pending_unsubscribe_patterns:\n                    self.pending_unsubscribe_patterns.remove(pattern)\n                    self.patterns.pop(pattern, None)\n            elif message_type == \"sunsubscribe\":\n                s_channel = response[1]\n                if s_channel in self.pending_unsubscribe_shard_channels:\n                    self.pending_unsubscribe_shard_channels.remove(s_channel)\n                    self.shard_channels.pop(s_channel, None)\n            else:\n                channel = response[1]\n                if channel in self.pending_unsubscribe_channels:\n                    self.pending_unsubscribe_channels.remove(channel)\n                    self.channels.pop(channel, None)\n            if not self.channels and not self.patterns and not self.shard_channels:\n                # There are no subscriptions anymore, set subscribed_event flag\n                # to false\n                self.subscribed_event.clear()\n\n        if message_type in self.PUBLISH_MESSAGE_TYPES:\n            # if there's a message handler, invoke it\n            if message_type == \"pmessage\":\n                handler = self.patterns.get(message[\"pattern\"], None)\n            elif message_type == \"smessage\":\n                handler = self.shard_channels.get(message[\"channel\"], None)\n            else:\n                handler = self.channels.get(message[\"channel\"], None)\n            if handler:\n                handler(message)\n                return None\n        elif message_type != \"pong\":\n            # this is a subscribe/unsubscribe message. ignore if we don't\n            # want them\n            if ignore_subscribe_messages or self.ignore_subscribe_messages:\n                return None\n\n        return message\n\n    def run_in_thread(\n        self,\n        sleep_time: float = 0.0,\n        daemon: bool = False,\n        exception_handler: Optional[Callable] = None,\n        pubsub=None,\n        sharded_pubsub: bool = False,\n    ) -> \"PubSubWorkerThread\":\n        for channel, handler in self.channels.items():\n            if handler is None:\n                raise PubSubError(f\"Channel: '{channel}' has no handler registered\")\n        for pattern, handler in self.patterns.items():\n            if handler is None:\n                raise PubSubError(f\"Pattern: '{pattern}' has no handler registered\")\n        for s_channel, handler in self.shard_channels.items():\n            if handler is None:\n                raise PubSubError(\n                    f\"Shard Channel: '{s_channel}' has no handler registered\"\n                )\n\n        pubsub = self if pubsub is None else pubsub\n        thread = PubSubWorkerThread(\n            pubsub,\n            sleep_time,\n            daemon=daemon,\n            exception_handler=exception_handler,\n            sharded_pubsub=sharded_pubsub,\n        )\n        thread.start()\n        return thread\n\n\nclass PubSubWorkerThread(threading.Thread):\n    def __init__(\n        self,\n        pubsub,\n        sleep_time: float,\n        daemon: bool = False,\n        exception_handler: Union[\n            Callable[[Exception, \"PubSub\", \"PubSubWorkerThread\"], None], None\n        ] = None,\n        sharded_pubsub: bool = False,\n    ):\n        super().__init__()\n        self.daemon = daemon\n        self.pubsub = pubsub\n        self.sleep_time = sleep_time\n        self.exception_handler = exception_handler\n        self.sharded_pubsub = sharded_pubsub\n        self._running = threading.Event()\n\n    def run(self) -> None:\n        if self._running.is_set():\n            return\n        self._running.set()\n        pubsub = self.pubsub\n        sleep_time = self.sleep_time\n        while self._running.is_set():\n            try:\n                if not self.sharded_pubsub:\n                    pubsub.get_message(\n                        ignore_subscribe_messages=True, timeout=sleep_time\n                    )\n                else:\n                    pubsub.get_sharded_message(\n                        ignore_subscribe_messages=True, timeout=sleep_time\n                    )\n            except BaseException as e:\n                if self.exception_handler is None:\n                    raise\n                self.exception_handler(e, pubsub, self)\n        pubsub.close()\n\n    def stop(self) -> None:\n        # trip the flag so the run loop exits. the run loop will\n        # close the pubsub connection, which disconnects the socket\n        # and returns the connection to the pool.\n        self._running.clear()\n\n\nclass Pipeline(Redis):\n    \"\"\"\n    Pipelines provide a way to transmit multiple commands to the Redis server\n    in one transmission.  This is convenient for batch processing, such as\n    saving all the values in a list to Redis.\n\n    All commands executed within a pipeline(when running in transactional mode,\n    which is the default behavior) are wrapped with MULTI and EXEC\n    calls. This guarantees all commands executed in the pipeline will be\n    executed atomically.\n\n    Any command raising an exception does *not* halt the execution of\n    subsequent commands in the pipeline. Instead, the exception is caught\n    and its instance is placed into the response list returned by execute().\n    Code iterating over the response list should be able to deal with an\n    instance of an exception as a potential value. In general, these will be\n    ResponseError exceptions, such as those raised when issuing a command\n    on a key of a different datatype.\n    \"\"\"\n\n    UNWATCH_COMMANDS = {\"DISCARD\", \"EXEC\", \"UNWATCH\"}\n\n    def __init__(\n        self,\n        connection_pool: ConnectionPool,\n        response_callbacks,\n        transaction,\n        shard_hint,\n    ):\n        self.connection_pool = connection_pool\n        self.connection: Optional[Connection] = None\n        self.response_callbacks = response_callbacks\n        self.transaction = transaction\n        self.shard_hint = shard_hint\n        self.watching = False\n        self.command_stack = []\n        self.scripts: Set[Script] = set()\n        self.explicit_transaction = False\n\n    def __enter__(self) -> \"Pipeline\":\n        return self\n\n    def __exit__(self, exc_type, exc_value, traceback):\n        self.reset()\n\n    def __del__(self):\n        try:\n            self.reset()\n        except Exception:\n            pass\n\n    def __len__(self) -> int:\n        return len(self.command_stack)\n\n    def __bool__(self) -> bool:\n        \"\"\"Pipeline instances should always evaluate to True\"\"\"\n        return True\n\n    def reset(self) -> None:\n        self.command_stack = []\n        self.scripts = set()\n        # make sure to reset the connection state in the event that we were\n        # watching something\n        if self.watching and self.connection:\n            try:\n                # call this manually since our unwatch or\n                # immediate_execute_command methods can call reset()\n                self.connection.send_command(\"UNWATCH\")\n                self.connection.read_response()\n            except ConnectionError:\n                # disconnect will also remove any previous WATCHes\n                self.connection.disconnect()\n        # clean up the other instance attributes\n        self.watching = False\n        self.explicit_transaction = False\n\n        # we can safely return the connection to the pool here since we're\n        # sure we're no longer WATCHing anything\n        if self.connection:\n            self.connection_pool.release(self.connection)\n            self.connection = None\n\n    def close(self) -> None:\n        \"\"\"Close the pipeline\"\"\"\n        self.reset()\n\n    def multi(self) -> None:\n        \"\"\"\n        Start a transactional block of the pipeline after WATCH commands\n        are issued. End the transactional block with `execute`.\n        \"\"\"\n        if self.explicit_transaction:\n            raise RedisError(\"Cannot issue nested calls to MULTI\")\n        if self.command_stack:\n            raise RedisError(\n                \"Commands without an initial WATCH have already been issued\"\n            )\n        self.explicit_transaction = True\n\n    def execute_command(self, *args, **kwargs):\n        if (self.watching or args[0] == \"WATCH\") and not self.explicit_transaction:\n            return self.immediate_execute_command(*args, **kwargs)\n        return self.pipeline_execute_command(*args, **kwargs)\n\n    def _disconnect_reset_raise_on_watching(\n        self,\n        conn: AbstractConnection,\n        error: Exception,\n        failure_count: Optional[int] = None,\n        start_time: Optional[float] = None,\n        command_name: Optional[str] = None,\n    ) -> None:\n        \"\"\"\n        Close the connection reset watching state and\n        raise an exception if we were watching.\n\n        The supported exceptions are already checked in the\n        retry object so we don't need to do it here.\n\n        After we disconnect the connection, it will try to reconnect and\n        do a health check as part of the send_command logic(on connection level).\n        \"\"\"\n        if error and failure_count <= conn.retry.get_retries():\n            record_operation_duration(\n                command_name=command_name,\n                duration_seconds=time.monotonic() - start_time,\n                server_address=getattr(conn, \"host\", None),\n                server_port=getattr(conn, \"port\", None),\n                db_namespace=str(conn.db),\n                error=error,\n                retry_attempts=failure_count,\n            )\n        conn.disconnect()\n\n        # if we were already watching a variable, the watch is no longer\n        # valid since this connection has died. raise a WatchError, which\n        # indicates the user should retry this transaction.\n        if self.watching:\n            self.reset()\n            raise WatchError(\n                f\"A {type(error).__name__} occurred while watching one or more keys\"\n            )\n\n    def immediate_execute_command(self, *args, **options):\n        \"\"\"\n        Execute a command immediately, but don't auto-retry on the supported\n        errors for retry if we're already WATCHing a variable.\n        Used when issuing WATCH or subsequent commands retrieving their values but before\n        MULTI is called.\n        \"\"\"\n        command_name = args[0]\n        conn = self.connection\n        # if this is the first call, we need a connection\n        if not conn:\n            conn = self.connection_pool.get_connection()\n            self.connection = conn\n\n        # Start timing for observability\n        start_time = time.monotonic()\n        # Track actual retry attempts for error reporting\n        actual_retry_attempts = [0]\n\n        def failure_callback(error, failure_count):\n            if is_debug_log_enabled():\n                add_debug_log_for_operation_failure(conn)\n            actual_retry_attempts[0] = failure_count\n            self._disconnect_reset_raise_on_watching(\n                conn, error, failure_count, start_time, command_name\n            )\n\n        try:\n            response = conn.retry.call_with_retry(\n                lambda: self._send_command_parse_response(\n                    conn, command_name, *args, **options\n                ),\n                failure_callback,\n                with_failure_count=True,\n            )\n\n            record_operation_duration(\n                command_name=command_name,\n                duration_seconds=time.monotonic() - start_time,\n                server_address=getattr(conn, \"host\", None),\n                server_port=getattr(conn, \"port\", None),\n                db_namespace=str(conn.db),\n            )\n\n            return response\n        except Exception as e:\n            record_error_count(\n                server_address=getattr(conn, \"host\", None),\n                server_port=getattr(conn, \"port\", None),\n                network_peer_address=getattr(conn, \"host\", None),\n                network_peer_port=getattr(conn, \"port\", None),\n                error_type=e,\n                retry_attempts=actual_retry_attempts[0],\n                is_internal=False,\n            )\n            raise\n\n    def pipeline_execute_command(self, *args, **options) -> \"Pipeline\":\n        \"\"\"\n        Stage a command to be executed when execute() is next called\n\n        Returns the current Pipeline object back so commands can be\n        chained together, such as:\n\n        pipe = pipe.set('foo', 'bar').incr('baz').decr('bang')\n\n        At some other point, you can then run: pipe.execute(),\n        which will execute all commands queued in the pipe.\n        \"\"\"\n        self.command_stack.append((args, options))\n        return self\n\n    def _execute_transaction(\n        self, connection: Connection, commands, raise_on_error\n    ) -> List:\n        cmds = chain([((\"MULTI\",), {})], commands, [((\"EXEC\",), {})])\n        all_cmds = connection.pack_commands(\n            [args for args, options in cmds if EMPTY_RESPONSE not in options]\n        )\n        connection.send_packed_command(all_cmds)\n        errors = []\n\n        # parse off the response for MULTI\n        # NOTE: we need to handle ResponseErrors here and continue\n        # so that we read all the additional command messages from\n        # the socket\n        try:\n            self.parse_response(connection, \"_\")\n        except ResponseError as e:\n            errors.append((0, e))\n\n        # and all the other commands\n        for i, command in enumerate(commands):\n            if EMPTY_RESPONSE in command[1]:\n                errors.append((i, command[1][EMPTY_RESPONSE]))\n            else:\n                try:\n                    self.parse_response(connection, \"_\")\n                except ResponseError as e:\n                    self.annotate_exception(e, i + 1, command[0])\n                    errors.append((i, e))\n\n        # parse the EXEC.\n        try:\n            response = self.parse_response(connection, \"_\")\n        except ExecAbortError:\n            if errors:\n                raise errors[0][1]\n            raise\n\n        # EXEC clears any watched keys\n        self.watching = False\n\n        if response is None:\n            raise WatchError(\"Watched variable changed.\")\n\n        # put any parse errors into the response\n        for i, e in errors:\n            response.insert(i, e)\n\n        if len(response) != len(commands):\n            self.connection.disconnect()\n            raise ResponseError(\n                \"Wrong number of response items from pipeline execution\"\n            )\n\n        # find any errors in the response and raise if necessary\n        if raise_on_error:\n            self.raise_first_error(commands, response)\n\n        # We have to run response callbacks manually\n        data = []\n        for r, cmd in zip(response, commands):\n            if not isinstance(r, Exception):\n                args, options = cmd\n                # Remove keys entry, it needs only for cache.\n                options.pop(\"keys\", None)\n                command_name = args[0]\n                if command_name in self.response_callbacks:\n                    r = self.response_callbacks[command_name](r, **options)\n            data.append(r)\n\n        return data\n\n    def _execute_pipeline(self, connection, commands, raise_on_error):\n        # build up all commands into a single request to increase network perf\n        all_cmds = connection.pack_commands([args for args, _ in commands])\n        connection.send_packed_command(all_cmds)\n\n        responses = []\n        for args, options in commands:\n            try:\n                responses.append(self.parse_response(connection, args[0], **options))\n            except ResponseError as e:\n                responses.append(e)\n\n        if raise_on_error:\n            self.raise_first_error(commands, responses)\n\n        return responses\n\n    def raise_first_error(self, commands, response):\n        for i, r in enumerate(response):\n            if isinstance(r, ResponseError):\n                self.annotate_exception(r, i + 1, commands[i][0])\n                raise r\n\n    def annotate_exception(self, exception, number, command):\n        cmd = \" \".join(map(safe_str, command))\n        msg = (\n            f\"Command # {number} ({truncate_text(cmd)}) of pipeline \"\n            f\"caused error: {exception.args[0]}\"\n        )\n        exception.args = (msg,) + exception.args[1:]\n\n    def parse_response(self, connection, command_name, **options):\n        result = Redis.parse_response(self, connection, command_name, **options)\n        if command_name in self.UNWATCH_COMMANDS:\n            self.watching = False\n        elif command_name == \"WATCH\":\n            self.watching = True\n        return result\n\n    def load_scripts(self):\n        # make sure all scripts that are about to be run on this pipeline exist\n        scripts = list(self.scripts)\n        immediate = self.immediate_execute_command\n        shas = [s.sha for s in scripts]\n        # we can't use the normal script_* methods because they would just\n        # get buffered in the pipeline.\n        exists = immediate(\"SCRIPT EXISTS\", *shas)\n        if not all(exists):\n            for s, exist in zip(scripts, exists):\n                if not exist:\n                    s.sha = immediate(\"SCRIPT LOAD\", s.script)\n\n    def _disconnect_raise_on_watching(\n        self,\n        conn: AbstractConnection,\n        error: Exception,\n        failure_count: Optional[int] = None,\n        start_time: Optional[float] = None,\n        command_name: Optional[str] = None,\n    ) -> None:\n        \"\"\"\n        Close the connection, raise an exception if we were watching.\n\n        The supported exceptions are already checked in the\n        retry object so we don't need to do it here.\n\n        After we disconnect the connection, it will try to reconnect and\n        do a health check as part of the send_command logic(on connection level).\n        \"\"\"\n        if error and failure_count <= conn.retry.get_retries():\n            record_operation_duration(\n                command_name=command_name,\n                duration_seconds=time.monotonic() - start_time,\n                server_address=getattr(conn, \"host\", None),\n                server_port=getattr(conn, \"port\", None),\n                db_namespace=str(conn.db),\n                error=error,\n                retry_attempts=failure_count,\n            )\n        conn.disconnect()\n        # if we were watching a variable, the watch is no longer valid\n        # since this connection has died. raise a WatchError, which\n        # indicates the user should retry this transaction.\n        if self.watching:\n            raise WatchError(\n                f\"A {type(error).__name__} occurred while watching one or more keys\"\n            )\n\n    def execute(self, raise_on_error: bool = True) -> List[Any]:\n        \"\"\"Execute all the commands in the current pipeline\"\"\"\n        stack = self.command_stack\n        if not stack and not self.watching:\n            return []\n        if self.scripts:\n            self.load_scripts()\n        if self.transaction or self.explicit_transaction:\n            execute = self._execute_transaction\n            operation_name = \"MULTI\"\n        else:\n            execute = self._execute_pipeline\n            operation_name = \"PIPELINE\"\n\n        conn = self.connection\n        if not conn:\n            conn = self.connection_pool.get_connection()\n            # assign to self.connection so reset() releases the connection\n            # back to the pool after we're done\n            self.connection = conn\n\n        # Start timing for observability\n        start_time = time.monotonic()\n        # Track actual retry attempts for error reporting\n        actual_retry_attempts = [0]\n\n        def failure_callback(error, failure_count):\n            if is_debug_log_enabled():\n                add_debug_log_for_operation_failure(conn)\n            actual_retry_attempts[0] = failure_count\n            self._disconnect_raise_on_watching(\n                conn, error, failure_count, start_time, operation_name\n            )\n\n        try:\n            response = conn.retry.call_with_retry(\n                lambda: execute(conn, stack, raise_on_error),\n                failure_callback,\n                with_failure_count=True,\n            )\n\n            record_operation_duration(\n                command_name=operation_name,\n                duration_seconds=time.monotonic() - start_time,\n                server_address=getattr(conn, \"host\", None),\n                server_port=getattr(conn, \"port\", None),\n                db_namespace=str(conn.db),\n            )\n            return response\n        except Exception as e:\n            record_error_count(\n                server_address=getattr(conn, \"host\", None),\n                server_port=getattr(conn, \"port\", None),\n                network_peer_address=getattr(conn, \"host\", None),\n                network_peer_port=getattr(conn, \"port\", None),\n                error_type=e,\n                retry_attempts=actual_retry_attempts[0],\n                is_internal=False,\n            )\n            raise\n\n        finally:\n            # in reset() the connection is disconnected before returned to the pool if\n            # it is marked for reconnect.\n            self.reset()\n\n    def discard(self):\n        \"\"\"\n        Flushes all previously queued commands\n        See: https://redis.io/commands/DISCARD\n        \"\"\"\n        self.execute_command(\"DISCARD\")\n\n    def watch(self, *names):\n        \"\"\"Watches the values at keys ``names``\"\"\"\n        if self.explicit_transaction:\n            raise RedisError(\"Cannot issue a WATCH after a MULTI\")\n        return self.execute_command(\"WATCH\", *names)\n\n    def unwatch(self) -> bool:\n        \"\"\"Unwatches all previously specified keys\"\"\"\n        return self.watching and self.execute_command(\"UNWATCH\") or True\n"
  },
  {
    "path": "redis/cluster.py",
    "content": "import logging\nimport random\nimport socket\nimport sys\nimport threading\nimport time\nfrom abc import ABC, abstractmethod\nfrom collections import OrderedDict\nfrom copy import copy\nfrom enum import Enum\nfrom itertools import chain\nfrom typing import (\n    Any,\n    Callable,\n    Dict,\n    List,\n    Literal,\n    Optional,\n    Set,\n    Tuple,\n    Union,\n)\n\nfrom redis._parsers import CommandsParser, Encoder\nfrom redis._parsers.commands import CommandPolicies, RequestPolicy, ResponsePolicy\nfrom redis._parsers.helpers import parse_scan\nfrom redis.backoff import ExponentialWithJitterBackoff, NoBackoff\nfrom redis.cache import CacheConfig, CacheFactory, CacheFactoryInterface, CacheInterface\nfrom redis.client import EMPTY_RESPONSE, CaseInsensitiveDict, PubSub, Redis\nfrom redis.commands import READ_COMMANDS, RedisClusterCommands\nfrom redis.commands.helpers import list_or_args\nfrom redis.commands.policies import PolicyResolver, StaticPolicyResolver\nfrom redis.connection import (\n    Connection,\n    ConnectionPool,\n    parse_url,\n)\nfrom redis.crc import REDIS_CLUSTER_HASH_SLOTS, key_slot\nfrom redis.event import (\n    AfterPooledConnectionsInstantiationEvent,\n    AfterPubSubConnectionInstantiationEvent,\n    ClientType,\n    EventDispatcher,\n)\nfrom redis.exceptions import (\n    AskError,\n    AuthenticationError,\n    ClusterDownError,\n    ClusterError,\n    ConnectionError,\n    CrossSlotTransactionError,\n    DataError,\n    ExecAbortError,\n    InvalidPipelineStack,\n    MaxConnectionsError,\n    MovedError,\n    RedisClusterException,\n    RedisError,\n    ResponseError,\n    SlotNotCoveredError,\n    TimeoutError,\n    TryAgainError,\n    WatchError,\n)\nfrom redis.lock import Lock\nfrom redis.maint_notifications import (\n    MaintNotificationsConfig,\n    OSSMaintNotificationsHandler,\n)\nfrom redis.observability.recorder import (\n    record_error_count,\n    record_operation_duration,\n)\nfrom redis.retry import Retry\nfrom redis.utils import (\n    check_protocol_version,\n    deprecated_args,\n    deprecated_function,\n    dict_merge,\n    list_keys_to_dict,\n    merge_result,\n    safe_str,\n    str_if_bytes,\n    truncate_text,\n)\n\nlogger = logging.getLogger(__name__)\n\n\ndef is_debug_log_enabled():\n    return logger.isEnabledFor(logging.DEBUG)\n\n\ndef get_node_name(host: str, port: Union[str, int]) -> str:\n    return f\"{host}:{port}\"\n\n\n@deprecated_args(\n    allowed_args=[\"redis_node\"],\n    reason=\"Use get_connection(redis_node) instead\",\n    version=\"5.3.0\",\n)\ndef get_connection(redis_node: Redis, *args, **options) -> Connection:\n    return redis_node.connection or redis_node.connection_pool.get_connection()\n\n\ndef parse_scan_result(command, res, **options):\n    cursors = {}\n    ret = []\n    for node_name, response in res.items():\n        cursor, r = parse_scan(response, **options)\n        cursors[node_name] = cursor\n        ret += r\n\n    return cursors, ret\n\n\ndef parse_pubsub_numsub(command, res, **options):\n    numsub_d = OrderedDict()\n    for numsub_tups in res.values():\n        for channel, numsubbed in numsub_tups:\n            try:\n                numsub_d[channel] += numsubbed\n            except KeyError:\n                numsub_d[channel] = numsubbed\n\n    ret_numsub = [(channel, numsub) for channel, numsub in numsub_d.items()]\n    return ret_numsub\n\n\ndef parse_cluster_slots(\n    resp: Any, **options: Any\n) -> Dict[Tuple[int, int], Dict[str, Any]]:\n    current_host = options.get(\"current_host\", \"\")\n\n    def fix_server(*args: Any) -> Tuple[str, Any]:\n        return str_if_bytes(args[0]) or current_host, args[1]\n\n    slots = {}\n    for slot in resp:\n        start, end, primary = slot[:3]\n        replicas = slot[3:]\n        slots[start, end] = {\n            \"primary\": fix_server(*primary),\n            \"replicas\": [fix_server(*replica) for replica in replicas],\n        }\n\n    return slots\n\n\ndef parse_cluster_shards(resp, **options):\n    \"\"\"\n    Parse CLUSTER SHARDS response.\n    \"\"\"\n    if isinstance(resp[0], dict):\n        return resp\n    shards = []\n    for x in resp:\n        shard = {\"slots\": [], \"nodes\": []}\n        for i in range(0, len(x[1]), 2):\n            shard[\"slots\"].append((x[1][i], (x[1][i + 1])))\n        nodes = x[3]\n        for node in nodes:\n            dict_node = {}\n            for i in range(0, len(node), 2):\n                dict_node[node[i]] = node[i + 1]\n            shard[\"nodes\"].append(dict_node)\n        shards.append(shard)\n\n    return shards\n\n\ndef parse_cluster_myshardid(resp, **options):\n    \"\"\"\n    Parse CLUSTER MYSHARDID response.\n    \"\"\"\n    return resp.decode(\"utf-8\")\n\n\nPRIMARY = \"primary\"\nREPLICA = \"replica\"\nSLOT_ID = \"slot-id\"\n\nREDIS_ALLOWED_KEYS = (\n    \"connection_class\",\n    \"connection_pool\",\n    \"connection_pool_class\",\n    \"client_name\",\n    \"credential_provider\",\n    \"db\",\n    \"decode_responses\",\n    \"encoding\",\n    \"encoding_errors\",\n    \"host\",\n    \"lib_name\",\n    \"lib_version\",\n    \"max_connections\",\n    \"nodes_flag\",\n    \"redis_connect_func\",\n    \"password\",\n    \"port\",\n    \"timeout\",\n    \"queue_class\",\n    \"retry\",\n    \"retry_on_timeout\",\n    \"protocol\",\n    \"socket_connect_timeout\",\n    \"socket_keepalive\",\n    \"socket_keepalive_options\",\n    \"socket_timeout\",\n    \"ssl\",\n    \"ssl_ca_certs\",\n    \"ssl_ca_data\",\n    \"ssl_ca_path\",\n    \"ssl_certfile\",\n    \"ssl_cert_reqs\",\n    \"ssl_include_verify_flags\",\n    \"ssl_exclude_verify_flags\",\n    \"ssl_keyfile\",\n    \"ssl_password\",\n    \"ssl_check_hostname\",\n    \"unix_socket_path\",\n    \"username\",\n    \"cache\",\n    \"cache_config\",\n    \"maint_notifications_config\",\n)\nKWARGS_DISABLED_KEYS = (\"host\", \"port\", \"retry\")\n\n\ndef cleanup_kwargs(**kwargs):\n    \"\"\"\n    Remove unsupported or disabled keys from kwargs\n    \"\"\"\n    connection_kwargs = {\n        k: v\n        for k, v in kwargs.items()\n        if k in REDIS_ALLOWED_KEYS and k not in KWARGS_DISABLED_KEYS\n    }\n\n    return connection_kwargs\n\n\nclass MaintNotificationsAbstractRedisCluster:\n    \"\"\"\n    Abstract class for handling maintenance notifications logic.\n    This class is expected to be used as base class together with RedisCluster.\n\n    This class is intended to be used with multiple inheritance!\n\n    All logic related to maintenance notifications is encapsulated in this class.\n    \"\"\"\n\n    def __init__(\n        self,\n        maint_notifications_config: Optional[MaintNotificationsConfig],\n        **kwargs,\n    ):\n        # Initialize maintenance notifications\n        is_protocol_supported = check_protocol_version(kwargs.get(\"protocol\"), 3)\n\n        if (\n            maint_notifications_config\n            and maint_notifications_config.enabled\n            and not is_protocol_supported\n        ):\n            raise RedisError(\n                \"Maintenance notifications handlers on connection are only supported with RESP version 3\"\n            )\n        if maint_notifications_config is None and is_protocol_supported:\n            maint_notifications_config = MaintNotificationsConfig()\n\n        self.maint_notifications_config = maint_notifications_config\n\n        if self.maint_notifications_config and self.maint_notifications_config.enabled:\n            self._oss_cluster_maint_notifications_handler = (\n                OSSMaintNotificationsHandler(self, self.maint_notifications_config)\n            )\n            # Update connection kwargs for all future nodes connections\n            self._update_connection_kwargs_for_maint_notifications(\n                self._oss_cluster_maint_notifications_handler\n            )\n            # Update existing nodes connections - they are created as part of the RedisCluster constructor\n            for node in self.get_nodes():\n                if node.redis_connection is None:\n                    continue\n                node.redis_connection.connection_pool.update_maint_notifications_config(\n                    self.maint_notifications_config,\n                    oss_cluster_maint_notifications_handler=self._oss_cluster_maint_notifications_handler,\n                )\n        else:\n            self._oss_cluster_maint_notifications_handler = None\n\n    def _update_connection_kwargs_for_maint_notifications(\n        self, oss_cluster_maint_notifications_handler: OSSMaintNotificationsHandler\n    ):\n        \"\"\"\n        Update the connection kwargs for all future connections.\n        \"\"\"\n        self.nodes_manager.connection_kwargs.update(\n            {\n                \"oss_cluster_maint_notifications_handler\": oss_cluster_maint_notifications_handler,\n            }\n        )\n\n\nclass AbstractRedisCluster:\n    RedisClusterRequestTTL = 16\n\n    PRIMARIES = \"primaries\"\n    REPLICAS = \"replicas\"\n    ALL_NODES = \"all\"\n    RANDOM = \"random\"\n    DEFAULT_NODE = \"default-node\"\n\n    NODE_FLAGS = {PRIMARIES, REPLICAS, ALL_NODES, RANDOM, DEFAULT_NODE}\n\n    COMMAND_FLAGS = dict_merge(\n        list_keys_to_dict(\n            [\n                \"ACL CAT\",\n                \"ACL DELUSER\",\n                \"ACL DRYRUN\",\n                \"ACL GENPASS\",\n                \"ACL GETUSER\",\n                \"ACL HELP\",\n                \"ACL LIST\",\n                \"ACL LOG\",\n                \"ACL LOAD\",\n                \"ACL SAVE\",\n                \"ACL SETUSER\",\n                \"ACL USERS\",\n                \"ACL WHOAMI\",\n                \"AUTH\",\n                \"CLIENT LIST\",\n                \"CLIENT SETINFO\",\n                \"CLIENT SETNAME\",\n                \"CLIENT GETNAME\",\n                \"CONFIG SET\",\n                \"CONFIG REWRITE\",\n                \"CONFIG RESETSTAT\",\n                \"TIME\",\n                \"PUBSUB CHANNELS\",\n                \"PUBSUB NUMPAT\",\n                \"PUBSUB NUMSUB\",\n                \"PUBSUB SHARDCHANNELS\",\n                \"PUBSUB SHARDNUMSUB\",\n                \"PING\",\n                \"INFO\",\n                \"SHUTDOWN\",\n                \"KEYS\",\n                \"DBSIZE\",\n                \"BGSAVE\",\n                \"SLOWLOG GET\",\n                \"SLOWLOG LEN\",\n                \"SLOWLOG RESET\",\n                \"WAIT\",\n                \"WAITAOF\",\n                \"SAVE\",\n                \"MEMORY PURGE\",\n                \"MEMORY MALLOC-STATS\",\n                \"MEMORY STATS\",\n                \"LASTSAVE\",\n                \"CLIENT TRACKINGINFO\",\n                \"CLIENT PAUSE\",\n                \"CLIENT UNPAUSE\",\n                \"CLIENT UNBLOCK\",\n                \"CLIENT ID\",\n                \"CLIENT REPLY\",\n                \"CLIENT GETREDIR\",\n                \"CLIENT INFO\",\n                \"CLIENT KILL\",\n                \"READONLY\",\n                \"CLUSTER INFO\",\n                \"CLUSTER MEET\",\n                \"CLUSTER MYSHARDID\",\n                \"CLUSTER NODES\",\n                \"CLUSTER REPLICAS\",\n                \"CLUSTER RESET\",\n                \"CLUSTER SET-CONFIG-EPOCH\",\n                \"CLUSTER SLOTS\",\n                \"CLUSTER SHARDS\",\n                \"CLUSTER COUNT-FAILURE-REPORTS\",\n                \"CLUSTER KEYSLOT\",\n                \"COMMAND\",\n                \"COMMAND COUNT\",\n                \"COMMAND LIST\",\n                \"COMMAND GETKEYS\",\n                \"CONFIG GET\",\n                \"DEBUG\",\n                \"RANDOMKEY\",\n                \"READONLY\",\n                \"READWRITE\",\n                \"TIME\",\n                \"TFUNCTION LOAD\",\n                \"TFUNCTION DELETE\",\n                \"TFUNCTION LIST\",\n                \"TFCALL\",\n                \"TFCALLASYNC\",\n                \"LATENCY HISTORY\",\n                \"LATENCY LATEST\",\n                \"LATENCY RESET\",\n                \"MODULE LIST\",\n                \"MODULE LOAD\",\n                \"MODULE UNLOAD\",\n                \"MODULE LOADEX\",\n            ],\n            DEFAULT_NODE,\n        ),\n        list_keys_to_dict(\n            [\n                \"FLUSHALL\",\n                \"FLUSHDB\",\n                \"FUNCTION DELETE\",\n                \"FUNCTION FLUSH\",\n                \"FUNCTION LIST\",\n                \"FUNCTION LOAD\",\n                \"FUNCTION RESTORE\",\n                \"SCAN\",\n                \"SCRIPT EXISTS\",\n                \"SCRIPT FLUSH\",\n                \"SCRIPT LOAD\",\n            ],\n            PRIMARIES,\n        ),\n        list_keys_to_dict([\"FUNCTION DUMP\"], RANDOM),\n        list_keys_to_dict(\n            [\n                \"CLUSTER COUNTKEYSINSLOT\",\n                \"CLUSTER DELSLOTS\",\n                \"CLUSTER DELSLOTSRANGE\",\n                \"CLUSTER GETKEYSINSLOT\",\n                \"CLUSTER SETSLOT\",\n            ],\n            SLOT_ID,\n        ),\n    )\n\n    SEARCH_COMMANDS = (\n        [\n            \"FT.CREATE\",\n            \"FT.SEARCH\",\n            \"FT.AGGREGATE\",\n            \"FT.EXPLAIN\",\n            \"FT.EXPLAINCLI\",\n            \"FT,PROFILE\",\n            \"FT.ALTER\",\n            \"FT.DROPINDEX\",\n            \"FT.ALIASADD\",\n            \"FT.ALIASUPDATE\",\n            \"FT.ALIASDEL\",\n            \"FT.TAGVALS\",\n            \"FT.SUGADD\",\n            \"FT.SUGGET\",\n            \"FT.SUGDEL\",\n            \"FT.SUGLEN\",\n            \"FT.SYNUPDATE\",\n            \"FT.SYNDUMP\",\n            \"FT.SPELLCHECK\",\n            \"FT.DICTADD\",\n            \"FT.DICTDEL\",\n            \"FT.DICTDUMP\",\n            \"FT.INFO\",\n            \"FT._LIST\",\n            \"FT.CONFIG\",\n            \"FT.ADD\",\n            \"FT.DEL\",\n            \"FT.DROP\",\n            \"FT.GET\",\n            \"FT.MGET\",\n            \"FT.SYNADD\",\n        ],\n    )\n\n    CLUSTER_COMMANDS_RESPONSE_CALLBACKS = {\n        \"CLUSTER SLOTS\": parse_cluster_slots,\n        \"CLUSTER SHARDS\": parse_cluster_shards,\n        \"CLUSTER MYSHARDID\": parse_cluster_myshardid,\n    }\n\n    RESULT_CALLBACKS = dict_merge(\n        list_keys_to_dict([\"PUBSUB NUMSUB\", \"PUBSUB SHARDNUMSUB\"], parse_pubsub_numsub),\n        list_keys_to_dict(\n            [\"PUBSUB NUMPAT\"], lambda command, res: sum(list(res.values()))\n        ),\n        list_keys_to_dict(\n            [\"KEYS\", \"PUBSUB CHANNELS\", \"PUBSUB SHARDCHANNELS\"], merge_result\n        ),\n        list_keys_to_dict(\n            [\n                \"PING\",\n                \"CONFIG SET\",\n                \"CONFIG REWRITE\",\n                \"CONFIG RESETSTAT\",\n                \"CLIENT SETNAME\",\n                \"BGSAVE\",\n                \"SLOWLOG RESET\",\n                \"SAVE\",\n                \"MEMORY PURGE\",\n                \"CLIENT PAUSE\",\n                \"CLIENT UNPAUSE\",\n            ],\n            lambda command, res: all(res.values()) if isinstance(res, dict) else res,\n        ),\n        list_keys_to_dict(\n            [\"DBSIZE\", \"WAIT\"],\n            lambda command, res: sum(res.values()) if isinstance(res, dict) else res,\n        ),\n        list_keys_to_dict(\n            [\"CLIENT UNBLOCK\"], lambda command, res: 1 if sum(res.values()) > 0 else 0\n        ),\n        list_keys_to_dict([\"SCAN\"], parse_scan_result),\n        list_keys_to_dict(\n            [\"SCRIPT LOAD\"], lambda command, res: list(res.values()).pop()\n        ),\n        list_keys_to_dict(\n            [\"SCRIPT EXISTS\"], lambda command, res: [all(k) for k in zip(*res.values())]\n        ),\n        list_keys_to_dict([\"SCRIPT FLUSH\"], lambda command, res: all(res.values())),\n    )\n\n    ERRORS_ALLOW_RETRY = (\n        ConnectionError,\n        TimeoutError,\n        ClusterDownError,\n        SlotNotCoveredError,\n    )\n\n    def replace_default_node(self, target_node: \"ClusterNode\" = None) -> None:\n        \"\"\"Replace the default cluster node.\n        A random cluster node will be chosen if target_node isn't passed, and primaries\n        will be prioritized. The default node will not be changed if there are no other\n        nodes in the cluster.\n\n        Args:\n            target_node (ClusterNode, optional): Target node to replace the default\n            node. Defaults to None.\n        \"\"\"\n        if target_node:\n            self.nodes_manager.default_node = target_node\n        else:\n            curr_node = self.get_default_node()\n            primaries = [node for node in self.get_primaries() if node != curr_node]\n            if primaries:\n                # Choose a primary if the cluster contains different primaries\n                self.nodes_manager.default_node = random.choice(primaries)\n            else:\n                # Otherwise, choose a primary if the cluster contains different primaries\n                replicas = [node for node in self.get_replicas() if node != curr_node]\n                if replicas:\n                    self.nodes_manager.default_node = random.choice(replicas)\n\n\nclass RedisCluster(\n    AbstractRedisCluster, MaintNotificationsAbstractRedisCluster, RedisClusterCommands\n):\n    # Type discrimination marker for @overload self-type pattern\n    _is_async_client: Literal[False] = False\n\n    @classmethod\n    def from_url(cls, url: str, **kwargs: Any) -> \"RedisCluster\":\n        \"\"\"\n        Return a Redis client object configured from the given URL\n\n        For example::\n\n            redis://[[username]:[password]]@localhost:6379/0\n            rediss://[[username]:[password]]@localhost:6379/0\n            unix://[username@]/path/to/socket.sock?db=0[&password=password]\n\n        Three URL schemes are supported:\n\n        - `redis://` creates a TCP socket connection. See more at:\n          <https://www.iana.org/assignments/uri-schemes/prov/redis>\n        - `rediss://` creates a SSL wrapped TCP socket connection. See more at:\n          <https://www.iana.org/assignments/uri-schemes/prov/rediss>\n        - ``unix://``: creates a Unix Domain Socket connection.\n\n        The username, password, hostname, path and all querystring values\n        are passed through urllib.parse.unquote in order to replace any\n        percent-encoded values with their corresponding characters.\n\n        There are several ways to specify a database number. The first value\n        found will be used:\n\n            1. A ``db`` querystring option, e.g. redis://localhost?db=0\n            2. If using the redis:// or rediss:// schemes, the path argument\n               of the url, e.g. redis://localhost/0\n            3. A ``db`` keyword argument to this function.\n\n        If none of these options are specified, the default db=0 is used.\n\n        All querystring options are cast to their appropriate Python types.\n        Boolean arguments can be specified with string values \"True\"/\"False\"\n        or \"Yes\"/\"No\". Values that cannot be properly cast cause a\n        ``ValueError`` to be raised. Once parsed, the querystring arguments\n        and keyword arguments are passed to the ``ConnectionPool``'s\n        class initializer. In the case of conflicting arguments, querystring\n        arguments always win.\n\n        \"\"\"\n        return cls(url=url, **kwargs)\n\n    @deprecated_args(\n        args_to_warn=[\"read_from_replicas\"],\n        reason=\"Please configure the 'load_balancing_strategy' instead\",\n        version=\"5.3.0\",\n    )\n    @deprecated_args(\n        args_to_warn=[\n            \"cluster_error_retry_attempts\",\n        ],\n        reason=\"Please configure the 'retry' object instead\",\n        version=\"6.0.0\",\n    )\n    def __init__(\n        self,\n        host: Optional[str] = None,\n        port: int = 6379,\n        startup_nodes: Optional[List[\"ClusterNode\"]] = None,\n        cluster_error_retry_attempts: int = 3,\n        retry: Optional[\"Retry\"] = None,\n        require_full_coverage: bool = True,\n        reinitialize_steps: int = 5,\n        read_from_replicas: bool = False,\n        load_balancing_strategy: Optional[\"LoadBalancingStrategy\"] = None,\n        dynamic_startup_nodes: bool = True,\n        url: Optional[str] = None,\n        address_remap: Optional[Callable[[Tuple[str, int]], Tuple[str, int]]] = None,\n        cache: Optional[CacheInterface] = None,\n        cache_config: Optional[CacheConfig] = None,\n        event_dispatcher: Optional[EventDispatcher] = None,\n        policy_resolver: PolicyResolver = StaticPolicyResolver(),\n        maint_notifications_config: Optional[MaintNotificationsConfig] = None,\n        **kwargs,\n    ):\n        \"\"\"\n        Initialize a new RedisCluster client.\n\n        :param startup_nodes:\n            List of nodes from which initial bootstrapping can be done\n        :param host:\n            Can be used to point to a startup node\n        :param port:\n            Can be used to point to a startup node\n        :param require_full_coverage:\n            When set to False (default value): the client will not require a\n            full coverage of the slots. However, if not all slots are covered,\n            and at least one node has 'cluster-require-full-coverage' set to\n            'yes,' the server will throw a ClusterDownError for some key-based\n            commands. See -\n            https://redis.io/topics/cluster-tutorial#redis-cluster-configuration-parameters\n            When set to True: all slots must be covered to construct the\n            cluster client. If not all slots are covered, RedisClusterException\n            will be thrown.\n        :param read_from_replicas:\n            @deprecated - please use load_balancing_strategy instead\n            Enable read from replicas in READONLY mode. You can read possibly\n            stale data.\n            When set to true, read commands will be assigned between the\n            primary and its replications in a Round-Robin manner.\n        :param load_balancing_strategy:\n            Enable read from replicas in READONLY mode and defines the load balancing\n            strategy that will be used for cluster node selection.\n            The data read from replicas is eventually consistent with the data in primary nodes.\n        :param dynamic_startup_nodes:\n            Set the RedisCluster's startup nodes to all of the discovered nodes.\n            If true (default value), the cluster's discovered nodes will be used to\n            determine the cluster nodes-slots mapping in the next topology refresh.\n            It will remove the initial passed startup nodes if their endpoints aren't\n            listed in the CLUSTER SLOTS output.\n            If you use dynamic DNS endpoints for startup nodes but CLUSTER SLOTS lists\n            specific IP addresses, it is best to set it to false.\n        :param cluster_error_retry_attempts:\n            @deprecated - Please configure the 'retry' object instead\n            In case 'retry' object is set - this argument is ignored!\n\n            Number of times to retry before raising an error when\n            :class:`~.TimeoutError` or :class:`~.ConnectionError`, :class:`~.SlotNotCoveredError` or\n            :class:`~.ClusterDownError` are encountered\n        :param retry:\n            A retry object that defines the retry strategy and the number of\n            retries for the cluster client.\n            In current implementation for the cluster client (starting form redis-py version 6.0.0)\n            the retry object is not yet fully utilized, instead it is used just to determine\n            the number of retries for the cluster client.\n            In the future releases the retry object will be used to handle the cluster client retries!\n        :param reinitialize_steps:\n            Specifies the number of MOVED errors that need to occur before\n            reinitializing the whole cluster topology. If a MOVED error occurs\n            and the cluster does not need to be reinitialized on this current\n            error handling, only the MOVED slot will be patched with the\n            redirected node.\n            To reinitialize the cluster on every MOVED error, set\n            reinitialize_steps to 1.\n            To avoid reinitializing the cluster on moved errors, set\n            reinitialize_steps to 0.\n        :param address_remap:\n            An optional callable which, when provided with an internal network\n            address of a node, e.g. a `(host, port)` tuple, will return the address\n            where the node is reachable.  This can be used to map the addresses at\n            which the nodes _think_ they are, to addresses at which a client may\n            reach them, such as when they sit behind a proxy.\n\n        :param maint_notifications_config:\n            Configures the nodes connections to support maintenance notifications - see\n            `redis.maint_notifications.MaintNotificationsConfig` for details.\n            Only supported with RESP3.\n            If not provided and protocol is RESP3, the maintenance notifications\n            will be enabled by default (logic is included in the NodesManager\n            initialization).\n        :**kwargs:\n            Extra arguments that will be sent into Redis instance when created\n            (See Official redis-py doc for supported kwargs - the only limitation\n            is that you can't provide 'retry' object as part of kwargs.\n            [https://github.com/andymccurdy/redis-py/blob/master/redis/client.py])\n            Some kwargs are not supported and will raise a\n            RedisClusterException:\n                - db (Redis do not support database SELECT in cluster mode)\n\n        \"\"\"\n        if startup_nodes is None:\n            startup_nodes = []\n\n        if \"db\" in kwargs:\n            # Argument 'db' is not possible to use in cluster mode\n            raise RedisClusterException(\n                \"Argument 'db' is not possible to use in cluster mode\"\n            )\n\n        if \"retry\" in kwargs:\n            # Argument 'retry' is not possible to be used in kwargs when in cluster mode\n            # the kwargs are set to the lower level connections to the cluster nodes\n            # and there we provide retry configuration without retries allowed.\n            # The retries should be handled on cluster client level.\n            raise RedisClusterException(\n                \"The 'retry' argument cannot be used in kwargs when running in cluster mode.\"\n            )\n\n        # Get the startup node/s\n        from_url = False\n        if url is not None:\n            from_url = True\n            url_options = parse_url(url)\n            if \"path\" in url_options:\n                raise RedisClusterException(\n                    \"RedisCluster does not currently support Unix Domain \"\n                    \"Socket connections\"\n                )\n            if \"db\" in url_options and url_options[\"db\"] != 0:\n                # Argument 'db' is not possible to use in cluster mode\n                raise RedisClusterException(\n                    \"A ``db`` querystring option can only be 0 in cluster mode\"\n                )\n            kwargs.update(url_options)\n            host = kwargs.get(\"host\")\n            port = kwargs.get(\"port\", port)\n            startup_nodes.append(ClusterNode(host, port))\n        elif host is not None and port is not None:\n            startup_nodes.append(ClusterNode(host, port))\n        elif len(startup_nodes) == 0:\n            # No startup node was provided\n            raise RedisClusterException(\n                \"RedisCluster requires at least one node to discover the \"\n                \"cluster. Please provide one of the followings:\\n\"\n                \"1. host and port, for example:\\n\"\n                \" RedisCluster(host='localhost', port=6379)\\n\"\n                \"2. list of startup nodes, for example:\\n\"\n                \" RedisCluster(startup_nodes=[ClusterNode('localhost', 6379),\"\n                \" ClusterNode('localhost', 6378)])\"\n            )\n        # Update the connection arguments\n        # Whenever a new connection is established, RedisCluster's on_connect\n        # method should be run\n        # If the user passed on_connect function we'll save it and run it\n        # inside the RedisCluster.on_connect() function\n        self.user_on_connect_func = kwargs.pop(\"redis_connect_func\", None)\n        kwargs.update({\"redis_connect_func\": self.on_connect})\n        kwargs = cleanup_kwargs(**kwargs)\n        if retry:\n            self.retry = retry\n        else:\n            self.retry = Retry(\n                backoff=ExponentialWithJitterBackoff(base=1, cap=10),\n                retries=cluster_error_retry_attempts,\n            )\n\n        self.encoder = Encoder(\n            kwargs.get(\"encoding\", \"utf-8\"),\n            kwargs.get(\"encoding_errors\", \"strict\"),\n            kwargs.get(\"decode_responses\", False),\n        )\n        protocol = kwargs.get(\"protocol\", None)\n        if (cache_config or cache) and not check_protocol_version(protocol, 3):\n            raise RedisError(\"Client caching is only supported with RESP version 3\")\n\n        if maint_notifications_config and not check_protocol_version(protocol, 3):\n            raise RedisError(\n                \"Maintenance notifications are only supported with RESP version 3\"\n            )\n        if check_protocol_version(protocol, 3) and maint_notifications_config is None:\n            maint_notifications_config = MaintNotificationsConfig()\n\n        self.command_flags = self.__class__.COMMAND_FLAGS.copy()\n        self.node_flags = self.__class__.NODE_FLAGS.copy()\n        self.read_from_replicas = read_from_replicas\n        self.load_balancing_strategy = load_balancing_strategy\n        self.reinitialize_counter = 0\n        self.reinitialize_steps = reinitialize_steps\n        if event_dispatcher is None:\n            self._event_dispatcher = EventDispatcher()\n        else:\n            self._event_dispatcher = event_dispatcher\n        self.startup_nodes = startup_nodes\n\n        self.nodes_manager = NodesManager(\n            startup_nodes=startup_nodes,\n            from_url=from_url,\n            require_full_coverage=require_full_coverage,\n            dynamic_startup_nodes=dynamic_startup_nodes,\n            address_remap=address_remap,\n            cache=cache,\n            cache_config=cache_config,\n            event_dispatcher=self._event_dispatcher,\n            maint_notifications_config=maint_notifications_config,\n            **kwargs,\n        )\n\n        self.cluster_response_callbacks = CaseInsensitiveDict(\n            self.__class__.CLUSTER_COMMANDS_RESPONSE_CALLBACKS\n        )\n        self.result_callbacks = CaseInsensitiveDict(self.__class__.RESULT_CALLBACKS)\n\n        # For backward compatibility, mapping from existing policies to new one\n        self._command_flags_mapping: dict[str, Union[RequestPolicy, ResponsePolicy]] = {\n            self.__class__.RANDOM: RequestPolicy.DEFAULT_KEYLESS,\n            self.__class__.PRIMARIES: RequestPolicy.ALL_SHARDS,\n            self.__class__.ALL_NODES: RequestPolicy.ALL_NODES,\n            self.__class__.REPLICAS: RequestPolicy.ALL_REPLICAS,\n            self.__class__.DEFAULT_NODE: RequestPolicy.DEFAULT_NODE,\n            SLOT_ID: RequestPolicy.DEFAULT_KEYED,\n        }\n\n        self._policies_callback_mapping: dict[\n            Union[RequestPolicy, ResponsePolicy], Callable\n        ] = {\n            RequestPolicy.DEFAULT_KEYLESS: lambda command_name: [\n                self.get_random_primary_or_all_nodes(command_name)\n            ],\n            RequestPolicy.DEFAULT_KEYED: lambda command,\n            *args: self.get_nodes_from_slot(command, *args),\n            RequestPolicy.DEFAULT_NODE: lambda: [self.get_default_node()],\n            RequestPolicy.ALL_SHARDS: self.get_primaries,\n            RequestPolicy.ALL_NODES: self.get_nodes,\n            RequestPolicy.ALL_REPLICAS: self.get_replicas,\n            RequestPolicy.MULTI_SHARD: lambda *args,\n            **kwargs: self._split_multi_shard_command(*args, **kwargs),\n            RequestPolicy.SPECIAL: self.get_special_nodes,\n            ResponsePolicy.DEFAULT_KEYLESS: lambda res: res,\n            ResponsePolicy.DEFAULT_KEYED: lambda res: res,\n        }\n\n        self._policy_resolver = policy_resolver\n        self.commands_parser = CommandsParser(self)\n\n        # Node where FT.AGGREGATE command is executed.\n        self._aggregate_nodes = None\n        self._lock = threading.RLock()\n\n        MaintNotificationsAbstractRedisCluster.__init__(\n            self, maint_notifications_config, **kwargs\n        )\n\n    def __enter__(self):\n        return self\n\n    def __exit__(self, exc_type, exc_value, traceback):\n        self.close()\n\n    def __del__(self):\n        try:\n            self.close()\n        except Exception:\n            pass\n\n    def disconnect_connection_pools(self):\n        for node in self.get_nodes():\n            if node.redis_connection:\n                try:\n                    node.redis_connection.connection_pool.disconnect()\n                except OSError:\n                    # Client was already disconnected. do nothing\n                    pass\n\n    def on_connect(self, connection):\n        \"\"\"\n        Initialize the connection, authenticate and select a database and send\n         READONLY if it is set during object initialization.\n        \"\"\"\n        connection.on_connect()\n\n        if self.read_from_replicas or self.load_balancing_strategy:\n            # Sending READONLY command to server to configure connection as\n            # readonly. Since each cluster node may change its server type due\n            # to a failover, we should establish a READONLY connection\n            # regardless of the server type. If this is a primary connection,\n            # READONLY would not affect executing write commands.\n            connection.send_command(\"READONLY\")\n            if str_if_bytes(connection.read_response()) != \"OK\":\n                raise ConnectionError(\"READONLY command failed\")\n\n        if self.user_on_connect_func is not None:\n            self.user_on_connect_func(connection)\n\n    def get_redis_connection(self, node: \"ClusterNode\") -> Redis:\n        if not node.redis_connection:\n            with self._lock:\n                if not node.redis_connection:\n                    self.nodes_manager.create_redis_connections([node])\n        return node.redis_connection\n\n    def get_node(self, host=None, port=None, node_name=None):\n        return self.nodes_manager.get_node(host, port, node_name)\n\n    def get_primaries(self):\n        return self.nodes_manager.get_nodes_by_server_type(PRIMARY)\n\n    def get_replicas(self):\n        return self.nodes_manager.get_nodes_by_server_type(REPLICA)\n\n    def get_random_node(self):\n        return random.choice(list(self.nodes_manager.nodes_cache.values()))\n\n    def get_random_primary_or_all_nodes(self, command_name):\n        \"\"\"\n        Returns random primary or all nodes depends on READONLY mode.\n        \"\"\"\n        if self.read_from_replicas and command_name in READ_COMMANDS:\n            return self.get_random_node()\n\n        return self.get_random_primary_node()\n\n    def get_nodes(self):\n        return list(self.nodes_manager.nodes_cache.values())\n\n    def get_node_from_key(self, key, replica=False):\n        \"\"\"\n        Get the node that holds the key's slot.\n        If replica set to True but the slot doesn't have any replicas, None is\n        returned.\n        \"\"\"\n        slot = self.keyslot(key)\n        slot_cache = self.nodes_manager.slots_cache.get(slot)\n        if slot_cache is None or len(slot_cache) == 0:\n            raise SlotNotCoveredError(f'Slot \"{slot}\" is not covered by the cluster.')\n        if replica and len(self.nodes_manager.slots_cache[slot]) < 2:\n            return None\n        elif replica:\n            node_idx = 1\n        else:\n            # primary\n            node_idx = 0\n\n        return slot_cache[node_idx]\n\n    def get_default_node(self):\n        \"\"\"\n        Get the cluster's default node\n        \"\"\"\n        return self.nodes_manager.default_node\n\n    def get_nodes_from_slot(self, command: str, *args):\n        \"\"\"\n        Returns a list of nodes that hold the specified keys' slots.\n        \"\"\"\n        # get the node that holds the key's slot\n        slot = self.determine_slot(*args)\n        node = self.nodes_manager.get_node_from_slot(\n            slot,\n            self.read_from_replicas and command in READ_COMMANDS,\n            self.load_balancing_strategy if command in READ_COMMANDS else None,\n        )\n        return [node]\n\n    def _split_multi_shard_command(self, *args, **kwargs) -> list[dict]:\n        \"\"\"\n        Splits the command with Multi-Shard policy, to the multiple commands\n        \"\"\"\n        keys = self._get_command_keys(*args)\n        commands = []\n\n        for key in keys:\n            commands.append(\n                {\n                    \"args\": (args[0], key),\n                    \"kwargs\": kwargs,\n                }\n            )\n\n        return commands\n\n    def get_special_nodes(self) -> Optional[list[\"ClusterNode\"]]:\n        \"\"\"\n        Returns a list of nodes for commands with a special policy.\n        \"\"\"\n        if not self._aggregate_nodes:\n            raise RedisClusterException(\n                \"Cannot execute FT.CURSOR commands without FT.AGGREGATE\"\n            )\n\n        return self._aggregate_nodes\n\n    def get_random_primary_node(self) -> \"ClusterNode\":\n        \"\"\"\n        Returns a random primary node\n        \"\"\"\n        return random.choice(self.get_primaries())\n\n    def _evaluate_all_succeeded(self, res):\n        \"\"\"\n        Evaluate the result of a command with ResponsePolicy.ALL_SUCCEEDED\n        \"\"\"\n        first_successful_response = None\n\n        if isinstance(res, dict):\n            for key, value in res.items():\n                if value:\n                    if first_successful_response is None:\n                        first_successful_response = {key: value}\n                else:\n                    return {key: False}\n        else:\n            for response in res:\n                if response:\n                    if first_successful_response is None:\n                        # Dynamically resolve type\n                        first_successful_response = type(response)(response)\n                else:\n                    return type(response)(False)\n\n        return first_successful_response\n\n    def set_default_node(self, node):\n        \"\"\"\n        Set the default node of the cluster.\n        :param node: 'ClusterNode'\n        :return True if the default node was set, else False\n        \"\"\"\n        if node is None or self.get_node(node_name=node.name) is None:\n            return False\n        self.nodes_manager.default_node = node\n        return True\n\n    def set_retry(self, retry: Retry) -> None:\n        self.retry = retry\n\n    def monitor(self, target_node=None):\n        \"\"\"\n        Returns a Monitor object for the specified target node.\n        The default cluster node will be selected if no target node was\n        specified.\n        Monitor is useful for handling the MONITOR command to the redis server.\n        next_command() method returns one command from monitor\n        listen() method yields commands from monitor.\n        \"\"\"\n        if target_node is None:\n            target_node = self.get_default_node()\n        if target_node.redis_connection is None:\n            raise RedisClusterException(\n                f\"Cluster Node {target_node.name} has no redis_connection\"\n            )\n        return target_node.redis_connection.monitor()\n\n    def pubsub(self, node=None, host=None, port=None, **kwargs):\n        \"\"\"\n        Allows passing a ClusterNode, or host&port, to get a pubsub instance\n        connected to the specified node\n        \"\"\"\n        return ClusterPubSub(self, node=node, host=host, port=port, **kwargs)\n\n    def pipeline(self, transaction=None, shard_hint=None):\n        \"\"\"\n        Cluster impl:\n            Pipelines do not work in cluster mode the same way they\n            do in normal mode. Create a clone of this object so\n            that simulating pipelines will work correctly. Each\n            command will be called directly when used and\n            when calling execute() will only return the result stack.\n        \"\"\"\n        if shard_hint:\n            raise RedisClusterException(\"shard_hint is deprecated in cluster mode\")\n\n        return ClusterPipeline(\n            nodes_manager=self.nodes_manager,\n            commands_parser=self.commands_parser,\n            startup_nodes=self.nodes_manager.startup_nodes,\n            result_callbacks=self.result_callbacks,\n            cluster_response_callbacks=self.cluster_response_callbacks,\n            read_from_replicas=self.read_from_replicas,\n            load_balancing_strategy=self.load_balancing_strategy,\n            reinitialize_steps=self.reinitialize_steps,\n            retry=self.retry,\n            lock=self._lock,\n            transaction=transaction,\n            event_dispatcher=self._event_dispatcher,\n        )\n\n    def lock(\n        self,\n        name,\n        timeout=None,\n        sleep=0.1,\n        blocking=True,\n        blocking_timeout=None,\n        lock_class=None,\n        thread_local=True,\n        raise_on_release_error: bool = True,\n    ):\n        \"\"\"\n        Return a new Lock object using key ``name`` that mimics\n        the behavior of threading.Lock.\n\n        If specified, ``timeout`` indicates a maximum life for the lock.\n        By default, it will remain locked until release() is called.\n\n        ``sleep`` indicates the amount of time to sleep per loop iteration\n        when the lock is in blocking mode and another client is currently\n        holding the lock.\n\n        ``blocking`` indicates whether calling ``acquire`` should block until\n        the lock has been acquired or to fail immediately, causing ``acquire``\n        to return False and the lock not being acquired. Defaults to True.\n        Note this value can be overridden by passing a ``blocking``\n        argument to ``acquire``.\n\n        ``blocking_timeout`` indicates the maximum amount of time in seconds to\n        spend trying to acquire the lock. A value of ``None`` indicates\n        continue trying forever. ``blocking_timeout`` can be specified as a\n        float or integer, both representing the number of seconds to wait.\n\n        ``lock_class`` forces the specified lock implementation. Note that as\n        of redis-py 3.0, the only lock class we implement is ``Lock`` (which is\n        a Lua-based lock). So, it's unlikely you'll need this parameter, unless\n        you have created your own custom lock class.\n\n        ``thread_local`` indicates whether the lock token is placed in\n        thread-local storage. By default, the token is placed in thread local\n        storage so that a thread only sees its token, not a token set by\n        another thread. Consider the following timeline:\n\n            time: 0, thread-1 acquires `my-lock`, with a timeout of 5 seconds.\n                     thread-1 sets the token to \"abc\"\n            time: 1, thread-2 blocks trying to acquire `my-lock` using the\n                     Lock instance.\n            time: 5, thread-1 has not yet completed. redis expires the lock\n                     key.\n            time: 5, thread-2 acquired `my-lock` now that it's available.\n                     thread-2 sets the token to \"xyz\"\n            time: 6, thread-1 finishes its work and calls release(). if the\n                     token is *not* stored in thread local storage, then\n                     thread-1 would see the token value as \"xyz\" and would be\n                     able to successfully release the thread-2's lock.\n\n        ``raise_on_release_error`` indicates whether to raise an exception when\n        the lock is no longer owned when exiting the context manager. By default,\n        this is True, meaning an exception will be raised. If False, the warning\n        will be logged and the exception will be suppressed.\n\n        In some use cases it's necessary to disable thread local storage. For\n        example, if you have code where one thread acquires a lock and passes\n        that lock instance to a worker thread to release later. If thread\n        local storage isn't disabled in this case, the worker thread won't see\n        the token set by the thread that acquired the lock. Our assumption\n        is that these cases aren't common and as such default to using\n        thread local storage.\"\"\"\n        if lock_class is None:\n            lock_class = Lock\n        return lock_class(\n            self,\n            name,\n            timeout=timeout,\n            sleep=sleep,\n            blocking=blocking,\n            blocking_timeout=blocking_timeout,\n            thread_local=thread_local,\n            raise_on_release_error=raise_on_release_error,\n        )\n\n    def set_response_callback(self, command, callback):\n        \"\"\"Set a custom Response Callback\"\"\"\n        self.cluster_response_callbacks[command] = callback\n\n    def _determine_nodes(\n        self, *args, request_policy: RequestPolicy, **kwargs\n    ) -> List[\"ClusterNode\"]:\n        \"\"\"\n        Determines a nodes the command should be executed on.\n        \"\"\"\n        command = args[0].upper()\n        if len(args) >= 2 and f\"{args[0]} {args[1]}\".upper() in self.command_flags:\n            command = f\"{args[0]} {args[1]}\".upper()\n\n        nodes_flag = kwargs.pop(\"nodes_flag\", None)\n        if nodes_flag is not None:\n            # nodes flag passed by the user\n            command_flag = nodes_flag\n        else:\n            # get the nodes group for this command if it was predefined\n            command_flag = self.command_flags.get(command)\n\n        if command_flag in self._command_flags_mapping:\n            request_policy = self._command_flags_mapping[command_flag]\n\n        policy_callback = self._policies_callback_mapping[request_policy]\n\n        if request_policy == RequestPolicy.DEFAULT_KEYED:\n            nodes = policy_callback(command, *args)\n        elif request_policy == RequestPolicy.MULTI_SHARD:\n            nodes = policy_callback(*args, **kwargs)\n        elif request_policy == RequestPolicy.DEFAULT_KEYLESS:\n            nodes = policy_callback(args[0])\n        else:\n            nodes = policy_callback()\n\n        if args[0].lower() == \"ft.aggregate\":\n            self._aggregate_nodes = nodes\n\n        return nodes\n\n    def _should_reinitialized(self):\n        # To reinitialize the cluster on every MOVED error,\n        # set reinitialize_steps to 1.\n        # To avoid reinitializing the cluster on moved errors, set\n        # reinitialize_steps to 0.\n        if self.reinitialize_steps == 0:\n            return False\n        else:\n            return self.reinitialize_counter % self.reinitialize_steps == 0\n\n    def keyslot(self, key):\n        \"\"\"\n        Calculate keyslot for a given key.\n        See Keys distribution model in https://redis.io/topics/cluster-spec\n        \"\"\"\n        k = self.encoder.encode(key)\n        return key_slot(k)\n\n    def _get_command_keys(self, *args):\n        \"\"\"\n        Get the keys in the command. If the command has no keys in in, None is\n        returned.\n\n        NOTE: Due to a bug in redis<7.0, this function does not work properly\n        for EVAL or EVALSHA when the `numkeys` arg is 0.\n         - issue: https://github.com/redis/redis/issues/9493\n         - fix: https://github.com/redis/redis/pull/9733\n\n        So, don't use this function with EVAL or EVALSHA.\n        \"\"\"\n        redis_conn = self.get_default_node().redis_connection\n        return self.commands_parser.get_keys(redis_conn, *args)\n\n    def determine_slot(self, *args) -> Optional[int]:\n        \"\"\"\n        Figure out what slot to use based on args.\n\n        Raises a RedisClusterException if there's a missing key and we can't\n            determine what slots to map the command to; or, if the keys don't\n            all map to the same key slot.\n        \"\"\"\n        command = args[0]\n        if self.command_flags.get(command) == SLOT_ID:\n            # The command contains the slot ID\n            return args[1]\n\n        # Get the keys in the command\n\n        # CLIENT TRACKING is a special case.\n        # It doesn't have any keys, it needs to be sent to the provided nodes\n        # By default it will be sent to all nodes.\n        if command.upper() == \"CLIENT TRACKING\":\n            return None\n\n        # EVAL and EVALSHA are common enough that it's wasteful to go to the\n        # redis server to parse the keys. Besides, there is a bug in redis<7.0\n        # where `self._get_command_keys()` fails anyway. So, we special case\n        # EVAL/EVALSHA.\n        if command.upper() in (\"EVAL\", \"EVALSHA\"):\n            # command syntax: EVAL \"script body\" num_keys ...\n            if len(args) <= 2:\n                raise RedisClusterException(f\"Invalid args in command: {args}\")\n            num_actual_keys = int(args[2])\n            eval_keys = args[3 : 3 + num_actual_keys]\n            # if there are 0 keys, that means the script can be run on any node\n            # so we can just return a random slot\n            if len(eval_keys) == 0:\n                return random.randrange(0, REDIS_CLUSTER_HASH_SLOTS)\n            keys = eval_keys\n        else:\n            keys = self._get_command_keys(*args)\n            if keys is None or len(keys) == 0:\n                # FCALL can call a function with 0 keys, that means the function\n                #  can be run on any node so we can just return a random slot\n                if command.upper() in (\"FCALL\", \"FCALL_RO\"):\n                    return random.randrange(0, REDIS_CLUSTER_HASH_SLOTS)\n                raise RedisClusterException(\n                    \"No way to dispatch this command to Redis Cluster. \"\n                    \"Missing key.\\nYou can execute the command by specifying \"\n                    f\"target nodes.\\nCommand: {args}\"\n                )\n\n        # single key command\n        if len(keys) == 1:\n            return self.keyslot(keys[0])\n\n        # multi-key command; we need to make sure all keys are mapped to\n        # the same slot\n        slots = {self.keyslot(key) for key in keys}\n        if len(slots) != 1:\n            raise RedisClusterException(\n                f\"{command} - all keys must map to the same key slot\"\n            )\n\n        return slots.pop()\n\n    def get_encoder(self):\n        \"\"\"\n        Get the connections' encoder\n        \"\"\"\n        return self.encoder\n\n    def get_connection_kwargs(self):\n        \"\"\"\n        Get the connections' key-word arguments\n        \"\"\"\n        return self.nodes_manager.connection_kwargs\n\n    def _is_nodes_flag(self, target_nodes):\n        return isinstance(target_nodes, str) and target_nodes in self.node_flags\n\n    def _parse_target_nodes(self, target_nodes):\n        if isinstance(target_nodes, list):\n            nodes = target_nodes\n        elif isinstance(target_nodes, ClusterNode):\n            # Supports passing a single ClusterNode as a variable\n            nodes = [target_nodes]\n        elif isinstance(target_nodes, dict):\n            # Supports dictionaries of the format {node_name: node}.\n            # It enables to execute commands with multi nodes as follows:\n            # rc.cluster_save_config(rc.get_primaries())\n            nodes = target_nodes.values()\n        else:\n            raise TypeError(\n                \"target_nodes type can be one of the following: \"\n                \"node_flag (PRIMARIES, REPLICAS, RANDOM, ALL_NODES),\"\n                \"ClusterNode, list<ClusterNode>, or dict<any, ClusterNode>. \"\n                f\"The passed type is {type(target_nodes)}\"\n            )\n        return nodes\n\n    def execute_command(self, *args, **kwargs):\n        return self._internal_execute_command(*args, **kwargs)\n\n    def _internal_execute_command(self, *args, **kwargs):\n        \"\"\"\n        Wrapper for ERRORS_ALLOW_RETRY error handling.\n\n        It will try the number of times specified by the retries property from\n        config option \"self.retry\" which defaults to 3 unless manually\n        configured.\n\n        If it reaches the number of times, the command will raise the exception\n\n        Key argument :target_nodes: can be passed with the following types:\n            nodes_flag: PRIMARIES, REPLICAS, ALL_NODES, RANDOM\n            ClusterNode\n            list<ClusterNode>\n            dict<Any, ClusterNode>\n        \"\"\"\n        target_nodes_specified = False\n        is_default_node = False\n        target_nodes = None\n        passed_targets = kwargs.pop(\"target_nodes\", None)\n        command_policies = self._policy_resolver.resolve(args[0].lower())\n\n        if passed_targets is not None and not self._is_nodes_flag(passed_targets):\n            target_nodes = self._parse_target_nodes(passed_targets)\n            target_nodes_specified = True\n\n        if not command_policies and not target_nodes_specified:\n            command = args[0].upper()\n            if len(args) >= 2 and f\"{args[0]} {args[1]}\".upper() in self.command_flags:\n                command = f\"{args[0]} {args[1]}\".upper()\n\n            # We only could resolve key properties if command is not\n            # in a list of pre-defined request policies\n            command_flag = self.command_flags.get(command)\n            if not command_flag:\n                # Fallback to default policy\n                if not self.get_default_node():\n                    slot = None\n                else:\n                    slot = self.determine_slot(*args)\n                if slot is None:\n                    command_policies = CommandPolicies()\n                else:\n                    command_policies = CommandPolicies(\n                        request_policy=RequestPolicy.DEFAULT_KEYED,\n                        response_policy=ResponsePolicy.DEFAULT_KEYED,\n                    )\n            else:\n                if command_flag in self._command_flags_mapping:\n                    command_policies = CommandPolicies(\n                        request_policy=self._command_flags_mapping[command_flag]\n                    )\n                else:\n                    command_policies = CommandPolicies()\n        elif not command_policies and target_nodes_specified:\n            command_policies = CommandPolicies()\n\n        # If an error that allows retrying was thrown, the nodes and slots\n        # cache were reinitialized. We will retry executing the command with\n        # the updated cluster setup only when the target nodes can be\n        # determined again with the new cache tables. Therefore, when target\n        # nodes were passed to this function, we cannot retry the command\n        # execution since the nodes may not be valid anymore after the tables\n        # were reinitialized. So in case of passed target nodes,\n        # retry_attempts will be set to 0.\n        retry_attempts = 0 if target_nodes_specified else self.retry.get_retries()\n        # Add one for the first execution\n        execute_attempts = 1 + retry_attempts\n        failure_count = 0\n\n        # Start timing for observability\n        start_time = time.monotonic()\n\n        for _ in range(execute_attempts):\n            try:\n                res = {}\n                if not target_nodes_specified:\n                    # Determine the nodes to execute the command on\n                    target_nodes = self._determine_nodes(\n                        *args,\n                        request_policy=command_policies.request_policy,\n                        nodes_flag=passed_targets,\n                    )\n\n                    if not target_nodes:\n                        raise RedisClusterException(\n                            f\"No targets were found to execute {args} command on\"\n                        )\n                    if (\n                        len(target_nodes) == 1\n                        and target_nodes[0] == self.get_default_node()\n                    ):\n                        is_default_node = True\n                for node in target_nodes:\n                    res[node.name] = self._execute_command(node, *args, **kwargs)\n\n                    if command_policies.response_policy == ResponsePolicy.ONE_SUCCEEDED:\n                        break\n\n                # Return the processed result\n                return self._process_result(\n                    args[0],\n                    res,\n                    response_policy=command_policies.response_policy,\n                    **kwargs,\n                )\n            except Exception as e:\n                if retry_attempts > 0 and type(e) in self.__class__.ERRORS_ALLOW_RETRY:\n                    if is_default_node:\n                        # Replace the default cluster node\n                        self.replace_default_node()\n                    # The nodes and slots cache were reinitialized.\n                    # Try again with the new cluster setup.\n                    retry_attempts -= 1\n                    failure_count += 1\n\n                    if hasattr(e, \"connection\"):\n                        self._record_command_metric(\n                            command_name=args[0],\n                            duration_seconds=time.monotonic() - start_time,\n                            connection=e.connection,\n                            error=e,\n                        )\n\n                        self._record_error_metric(\n                            error=e,\n                            connection=e.connection,\n                            retry_attempts=failure_count,\n                        )\n                    continue\n                else:\n                    # raise the exception\n                    if hasattr(e, \"connection\"):\n                        self._record_error_metric(\n                            error=e,\n                            connection=e.connection,\n                            retry_attempts=failure_count,\n                            is_internal=False,\n                        )\n                    raise e\n\n    def _execute_command(self, target_node, *args, **kwargs):\n        \"\"\"\n        Send a command to a node in the cluster\n        \"\"\"\n        command = args[0]\n        redis_node = None\n        connection = None\n        redirect_addr = None\n        asking = False\n        moved = False\n        ttl = int(self.RedisClusterRequestTTL)\n\n        # Start timing for observability\n        start_time = time.monotonic()\n\n        while ttl > 0:\n            ttl -= 1\n            try:\n                if asking:\n                    target_node = self.get_node(node_name=redirect_addr)\n                elif moved:\n                    # MOVED occurred and the slots cache was updated,\n                    # refresh the target node\n                    slot = self.determine_slot(*args)\n                    target_node = self.nodes_manager.get_node_from_slot(\n                        slot,\n                        self.read_from_replicas and command in READ_COMMANDS,\n                        self.load_balancing_strategy\n                        if command in READ_COMMANDS\n                        else None,\n                    )\n                    moved = False\n\n                redis_node = self.get_redis_connection(target_node)\n                connection = get_connection(redis_node)\n                if asking:\n                    connection.send_command(\"ASKING\")\n                    redis_node.parse_response(connection, \"ASKING\", **kwargs)\n                    asking = False\n                connection.send_command(*args, **kwargs)\n                response = redis_node.parse_response(connection, command, **kwargs)\n\n                # Remove keys entry, it needs only for cache.\n                kwargs.pop(\"keys\", None)\n\n                if command in self.cluster_response_callbacks:\n                    response = self.cluster_response_callbacks[command](\n                        response, **kwargs\n                    )\n\n                self._record_command_metric(\n                    command_name=command,\n                    duration_seconds=time.monotonic() - start_time,\n                    connection=connection,\n                )\n                return response\n            except AuthenticationError as e:\n                e.connection = connection if connection is not None else target_node\n                self._record_command_metric(\n                    command_name=command,\n                    duration_seconds=time.monotonic() - start_time,\n                    connection=e.connection,\n                    error=e,\n                )\n                raise\n            except MaxConnectionsError as e:\n                # MaxConnectionsError indicates client-side resource exhaustion\n                # (too many connections in the pool), not a node failure.\n                # Don't treat this as a node failure - just re-raise the error\n                # without reinitializing the cluster.\n                # The connection in the error is used to report the metrics based on host and port info\n                # so we use the target node object which contains the host and port info\n                # because we did not get the connection yet\n                e.connection = target_node\n                self._record_command_metric(\n                    command_name=command,\n                    duration_seconds=time.monotonic() - start_time,\n                    connection=e.connection,\n                    error=e,\n                )\n                raise\n            except (ConnectionError, TimeoutError) as e:\n                if is_debug_log_enabled():\n                    socket_address = self._extracts_socket_address(connection)\n                    args_log_str = truncate_text(\" \".join(map(safe_str, args)))\n                    logger.debug(\n                        f\"{type(e).__name__} received for command {args_log_str}, on node {target_node.name}, \"\n                        f\"and connection: {connection} using local socket address: {socket_address}, error: {e}\"\n                    )\n                # this is used to report the metrics based on host and port info\n                e.connection = connection if connection else target_node\n\n                # ConnectionError can also be raised if we couldn't get a\n                # connection from the pool before timing out, so check that\n                # this is an actual connection before attempting to disconnect.\n                if connection is not None:\n                    connection.disconnect()\n\n                # Instead of setting to None, properly handle the pool\n                # Get the pool safely - redis_connection could be set to None\n                # by another thread between the check and access\n                redis_conn = target_node.redis_connection\n                if redis_conn is not None:\n                    pool = redis_conn.connection_pool\n                    if pool is not None:\n                        with pool._lock:\n                            # take care for the active connections in the pool\n                            pool.update_active_connections_for_reconnect()\n                            # disconnect all free connections\n                            pool.disconnect_free_connections()\n\n                # Move the failed node to the end of the cached nodes list\n                self.nodes_manager.move_node_to_end_of_cached_nodes(target_node.name)\n\n                # DON'T set redis_connection = None - keep the pool for reuse\n                self.nodes_manager.initialize()\n                self._record_command_metric(\n                    command_name=command,\n                    duration_seconds=time.monotonic() - start_time,\n                    connection=e.connection,\n                    error=e,\n                )\n                raise e\n            except MovedError as e:\n                if is_debug_log_enabled():\n                    socket_address = self._extracts_socket_address(connection)\n                    args_log_str = truncate_text(\" \".join(map(safe_str, args)))\n                    logger.debug(\n                        f\"MOVED error received for command {args_log_str}, on node {target_node.name}, \"\n                        f\"and connection: {connection} using local socket address: {socket_address}, error: {e}\"\n                    )\n                # First, we will try to patch the slots/nodes cache with the\n                # redirected node output and try again. If MovedError exceeds\n                # 'reinitialize_steps' number of times, we will force\n                # reinitializing the tables, and then try again.\n                # 'reinitialize_steps' counter will increase faster when\n                # the same client object is shared between multiple threads. To\n                # reduce the frequency you can set this variable in the\n                # RedisCluster constructor.\n                self.reinitialize_counter += 1\n                if self._should_reinitialized():\n                    # during this call all connections are closed or marked for disconnect,\n                    # so we don't need to disconnect the changed node's connections\n                    self.nodes_manager.initialize(\n                        additional_startup_nodes_info=[(e.host, e.port)]\n                    )\n                    # Reset the counter\n                    self.reinitialize_counter = 0\n                else:\n                    self.nodes_manager.move_slot(e)\n                moved = True\n                self._record_command_metric(\n                    command_name=command,\n                    duration_seconds=time.monotonic() - start_time,\n                    connection=connection,\n                    error=e,\n                )\n                self._record_error_metric(\n                    error=e,\n                    connection=connection,\n                )\n            except TryAgainError as e:\n                if is_debug_log_enabled():\n                    socket_address = self._extracts_socket_address(connection)\n                    args_log_str = truncate_text(\" \".join(map(safe_str, args)))\n                    logger.debug(\n                        f\"TRYAGAIN error received for command {args_log_str}, on node {target_node.name}, \"\n                        f\"and connection: {connection} using local socket address: {socket_address}\"\n                    )\n                if ttl < self.RedisClusterRequestTTL / 2:\n                    time.sleep(0.05)\n\n                self._record_command_metric(\n                    command_name=command,\n                    duration_seconds=time.monotonic() - start_time,\n                    connection=connection,\n                    error=e,\n                )\n                self._record_error_metric(\n                    error=e,\n                    connection=connection,\n                )\n            except AskError as e:\n                if is_debug_log_enabled():\n                    socket_address = self._extracts_socket_address(connection)\n                    args_log_str = truncate_text(\" \".join(map(safe_str, args)))\n                    logger.debug(\n                        f\"ASK error received for command {args_log_str}, on node {target_node.name}, \"\n                        f\"and connection: {connection} using local socket address: {socket_address}, error: {e}\"\n                    )\n                redirect_addr = get_node_name(host=e.host, port=e.port)\n                asking = True\n\n                self._record_command_metric(\n                    command_name=command,\n                    duration_seconds=time.monotonic() - start_time,\n                    connection=connection,\n                    error=e,\n                )\n                self._record_error_metric(\n                    error=e,\n                    connection=connection,\n                )\n            except (ClusterDownError, SlotNotCoveredError) as e:\n                # ClusterDownError can occur during a failover and to get\n                # self-healed, we will try to reinitialize the cluster layout\n                # and retry executing the command\n\n                # SlotNotCoveredError can occur when the cluster is not fully\n                # initialized or can be temporary issue.\n                # We will try to reinitialize the cluster topology\n                # and retry executing the command\n\n                time.sleep(0.25)\n                self.nodes_manager.initialize()\n\n                # if we have a connection, use it, otherwise use the target node\n                # object which contains the host and port info\n                # this is used to report the metrics based on host and port info\n                e.connection = connection if connection else target_node\n                self._record_command_metric(\n                    command_name=command,\n                    duration_seconds=time.monotonic() - start_time,\n                    connection=e.connection,\n                    error=e,\n                )\n                raise\n            except ResponseError as e:\n                # this is used to report the metrics based on host and port info\n                # ResponseError typically happens after get_connection() succeeds,\n                # so connection should be available\n                e.connection = connection if connection else target_node\n                self._record_command_metric(\n                    command_name=command,\n                    duration_seconds=time.monotonic() - start_time,\n                    connection=e.connection,\n                    error=e,\n                )\n                raise\n            except Exception as e:\n                if connection:\n                    connection.disconnect()\n\n                # if we have a connection, use it, otherwise use the target node\n                # object which contains the host and port info\n                # this is used to report the metrics based on host and port info\n                e.connection = connection if connection else target_node\n                self._record_command_metric(\n                    command_name=command,\n                    duration_seconds=time.monotonic() - start_time,\n                    connection=e.connection,\n                    error=e,\n                )\n                raise e\n            finally:\n                if connection is not None:\n                    redis_node.connection_pool.release(connection)\n\n        e = ClusterError(\"TTL exhausted.\")\n        # In this case we should have an active connection.\n        # If we are here, we have received many MOVED or ASK errors and finally exhausted the TTL.\n        # This means that we used an active connection to read from the socket.\n        # This is used to report metrics based on the host and port information.\n        e.connection = connection\n        self._record_command_metric(\n            command_name=command,\n            duration_seconds=time.monotonic() - start_time,\n            connection=connection,\n            error=e,\n        )\n        raise e\n\n    def _record_command_metric(\n        self,\n        command_name: str,\n        duration_seconds: float,\n        connection: Connection,\n        error=None,\n    ):\n        \"\"\"\n        Records operation duration metric directly.\n        \"\"\"\n        host = connection.host if connection else \"unknown\"\n        port = connection.port if connection else 0\n        db = str(connection.db) if connection and hasattr(connection, \"db\") else \"0\"\n\n        record_operation_duration(\n            command_name=command_name,\n            duration_seconds=duration_seconds,\n            server_address=host,\n            server_port=port,\n            db_namespace=db,\n            error=error,\n        )\n\n    def _record_error_metric(\n        self,\n        error: Exception,\n        connection: Connection,\n        is_internal: bool = True,\n        retry_attempts: Optional[int] = None,\n    ):\n        \"\"\"\n        Records error count metric directly.\n        \"\"\"\n        record_error_count(\n            server_address=connection.host,\n            server_port=connection.port,\n            network_peer_address=connection.host,\n            network_peer_port=connection.port,\n            error_type=error,\n            retry_attempts=retry_attempts if retry_attempts is not None else 0,\n            is_internal=is_internal,\n        )\n\n    def _extracts_socket_address(\n        self, connection: Optional[Connection]\n    ) -> Optional[int]:\n        if connection is None:\n            return None\n        try:\n            socket_address = (\n                connection._sock.getsockname() if connection._sock else None\n            )\n            socket_address = socket_address[1] if socket_address else None\n        except (AttributeError, OSError):\n            pass\n        return socket_address\n\n    def close(self) -> None:\n        try:\n            with self._lock:\n                if self.nodes_manager:\n                    self.nodes_manager.close()\n        except AttributeError:\n            # RedisCluster's __init__ can fail before nodes_manager is set\n            pass\n\n    def _process_result(self, command, res, response_policy: ResponsePolicy, **kwargs):\n        \"\"\"\n        Process the result of the executed command.\n        The function would return a dict or a single value.\n\n        :type command: str\n        :type res: dict\n\n        `res` should be in the following format:\n            Dict<node_name, command_result>\n        \"\"\"\n        if command in self.result_callbacks:\n            res = self.result_callbacks[command](command, res, **kwargs)\n        elif len(res) == 1:\n            # When we execute the command on a single node, we can\n            # remove the dictionary and return a single response\n            res = list(res.values())[0]\n\n        return self._policies_callback_mapping[response_policy](res)\n\n    def load_external_module(self, funcname, func):\n        \"\"\"\n        This function can be used to add externally defined redis modules,\n        and their namespaces to the redis client.\n\n        ``funcname`` - A string containing the name of the function to create\n        ``func`` - The function, being added to this class.\n        \"\"\"\n        setattr(self, funcname, func)\n\n    def transaction(self, func, *watches, **kwargs):\n        \"\"\"\n        Convenience method for executing the callable `func` as a transaction\n        while watching all keys specified in `watches`. The 'func' callable\n        should expect a single argument which is a Pipeline object.\n        \"\"\"\n        shard_hint = kwargs.pop(\"shard_hint\", None)\n        value_from_callable = kwargs.pop(\"value_from_callable\", False)\n        watch_delay = kwargs.pop(\"watch_delay\", None)\n        with self.pipeline(True, shard_hint) as pipe:\n            while True:\n                try:\n                    if watches:\n                        pipe.watch(*watches)\n                    func_value = func(pipe)\n                    exec_value = pipe.execute()\n                    return func_value if value_from_callable else exec_value\n                except WatchError:\n                    if watch_delay is not None and watch_delay > 0:\n                        time.sleep(watch_delay)\n                    continue\n\n\nclass ClusterNode:\n    def __init__(self, host, port, server_type=None, redis_connection=None):\n        if host == \"localhost\":\n            host = socket.gethostbyname(host)\n\n        self.host = host\n        self.port = port\n        self.name = get_node_name(host, port)\n        self.server_type = server_type\n        self.redis_connection = redis_connection\n\n    def __repr__(self):\n        return (\n            f\"[host={self.host},\"\n            f\"port={self.port},\"\n            f\"name={self.name},\"\n            f\"server_type={self.server_type},\"\n            f\"redis_connection={self.redis_connection}]\"\n        )\n\n    def __eq__(self, obj):\n        return isinstance(obj, ClusterNode) and obj.name == self.name\n\n    def __hash__(self):\n        return hash(self.name)\n\n\nclass LoadBalancingStrategy(Enum):\n    ROUND_ROBIN = \"round_robin\"\n    ROUND_ROBIN_REPLICAS = \"round_robin_replicas\"\n    RANDOM_REPLICA = \"random_replica\"\n\n\nclass LoadBalancer:\n    \"\"\"\n    Round-Robin Load Balancing\n    \"\"\"\n\n    def __init__(self, start_index: int = 0) -> None:\n        self.primary_to_idx: dict[str, int] = {}\n        self.start_index: int = start_index\n        self._lock: threading.Lock = threading.Lock()\n\n    def get_server_index(\n        self,\n        primary: str,\n        list_size: int,\n        load_balancing_strategy: LoadBalancingStrategy = LoadBalancingStrategy.ROUND_ROBIN,\n    ) -> int:\n        if load_balancing_strategy == LoadBalancingStrategy.RANDOM_REPLICA:\n            return self._get_random_replica_index(list_size)\n        else:\n            return self._get_round_robin_index(\n                primary,\n                list_size,\n                load_balancing_strategy == LoadBalancingStrategy.ROUND_ROBIN_REPLICAS,\n            )\n\n    def reset(self) -> None:\n        with self._lock:\n            self.primary_to_idx.clear()\n\n    def _get_random_replica_index(self, list_size: int) -> int:\n        return random.randint(1, list_size - 1)\n\n    def _get_round_robin_index(\n        self, primary: str, list_size: int, replicas_only: bool\n    ) -> int:\n        with self._lock:\n            server_index = self.primary_to_idx.setdefault(primary, self.start_index)\n            if replicas_only and server_index == 0:\n                # skip the primary node index\n                server_index = 1\n            # Update the index for the next round\n            self.primary_to_idx[primary] = (server_index + 1) % list_size\n            return server_index\n\n\nclass NodesManager:\n    def __init__(\n        self,\n        startup_nodes: list[ClusterNode],\n        from_url=False,\n        require_full_coverage=False,\n        lock: Optional[threading.RLock] = None,\n        dynamic_startup_nodes=True,\n        connection_pool_class=ConnectionPool,\n        address_remap: Optional[Callable[[Tuple[str, int]], Tuple[str, int]]] = None,\n        cache: Optional[CacheInterface] = None,\n        cache_config: Optional[CacheConfig] = None,\n        cache_factory: Optional[CacheFactoryInterface] = None,\n        event_dispatcher: Optional[EventDispatcher] = None,\n        maint_notifications_config: Optional[MaintNotificationsConfig] = None,\n        **kwargs,\n    ):\n        self.nodes_cache: dict[str, ClusterNode] = {}\n        self.slots_cache: dict[int, list[ClusterNode]] = {}\n        self.startup_nodes: dict[str, ClusterNode] = {n.name: n for n in startup_nodes}\n        self.default_node: Optional[ClusterNode] = None\n        self._epoch: int = 0\n        self.from_url = from_url\n        self._require_full_coverage = require_full_coverage\n        self._dynamic_startup_nodes = dynamic_startup_nodes\n        self.connection_pool_class = connection_pool_class\n        self.address_remap = address_remap\n        self._cache: Optional[CacheInterface] = None\n        if cache:\n            self._cache = cache\n        elif cache_factory is not None:\n            self._cache = cache_factory.get_cache()\n        elif cache_config is not None:\n            self._cache = CacheFactory(cache_config).get_cache()\n        self.connection_kwargs = kwargs\n        self.read_load_balancer = LoadBalancer()\n\n        # nodes_cache / slots_cache / startup_nodes / default_node are protected by _lock\n        if lock is None:\n            self._lock = threading.RLock()\n        else:\n            self._lock = lock\n\n        # initialize holds _initialization_lock to dedup multiple calls to reinitialize;\n        # note that if we hold both _lock and _initialization_lock, we _must_ acquire\n        # _initialization_lock first (ie: to have a consistent order) to avoid deadlock.\n        self._initialization_lock: threading.RLock = threading.RLock()\n\n        if event_dispatcher is None:\n            self._event_dispatcher = EventDispatcher()\n        else:\n            self._event_dispatcher = event_dispatcher\n        self._credential_provider = self.connection_kwargs.get(\n            \"credential_provider\", None\n        )\n        self.maint_notifications_config = maint_notifications_config\n\n        self.initialize()\n\n    def get_node(\n        self,\n        host: Optional[str] = None,\n        port: Optional[int] = None,\n        node_name: Optional[str] = None,\n    ) -> Optional[ClusterNode]:\n        \"\"\"\n        Get the requested node from the cluster's nodes.\n        nodes.\n        :return: ClusterNode if the node exists, else None\n        \"\"\"\n        if host and port:\n            # the user passed host and port\n            if host == \"localhost\":\n                host = socket.gethostbyname(host)\n            with self._lock:\n                return self.nodes_cache.get(get_node_name(host=host, port=port))\n        elif node_name:\n            with self._lock:\n                return self.nodes_cache.get(node_name)\n        else:\n            return None\n\n    def move_slot(self, e: Union[AskError, MovedError]):\n        \"\"\"\n        Update the slot's node with the redirected one\n        \"\"\"\n        with self._lock:\n            redirected_node = self.get_node(host=e.host, port=e.port)\n            if redirected_node is not None:\n                # The node already exists\n                if redirected_node.server_type is not PRIMARY:\n                    # Update the node's server type\n                    redirected_node.server_type = PRIMARY\n            else:\n                # This is a new node, we will add it to the nodes cache\n                redirected_node = ClusterNode(e.host, e.port, PRIMARY)\n                self.nodes_cache[redirected_node.name] = redirected_node\n\n            slot_nodes = self.slots_cache[e.slot_id]\n            if redirected_node not in slot_nodes:\n                # The new slot owner is a new server, or a server from a different\n                # shard. We need to remove all current nodes from the slot's list\n                # (including replications) and add just the new node.\n                self.slots_cache[e.slot_id] = [redirected_node]\n            elif redirected_node is not slot_nodes[0]:\n                # The MOVED error resulted from a failover, and the new slot owner\n                # had previously been a replica.\n                old_primary = slot_nodes[0]\n                # Update the old primary to be a replica and add it to the end of\n                # the slot's node list\n                old_primary.server_type = REPLICA\n                slot_nodes.append(old_primary)\n                # Remove the old replica, which is now a primary, from the slot's\n                # node list\n                slot_nodes.remove(redirected_node)\n                # Override the old primary with the new one\n                slot_nodes[0] = redirected_node\n                if self.default_node == old_primary:\n                    # Update the default node with the new primary\n                    self.default_node = redirected_node\n            # else: circular MOVED to current primary -> no-op\n\n    @deprecated_args(\n        args_to_warn=[\"server_type\"],\n        reason=(\n            \"In case you need select some load balancing strategy \"\n            \"that will use replicas, please set it through 'load_balancing_strategy'\"\n        ),\n        version=\"5.3.0\",\n    )\n    def get_node_from_slot(\n        self,\n        slot: int,\n        read_from_replicas: bool = False,\n        load_balancing_strategy: Optional[LoadBalancingStrategy] = None,\n        server_type: Optional[Literal[\"primary\", \"replica\"]] = None,\n    ) -> ClusterNode:\n        \"\"\"\n        Gets a node that servers this hash slot\n        \"\"\"\n\n        if read_from_replicas is True and load_balancing_strategy is None:\n            load_balancing_strategy = LoadBalancingStrategy.ROUND_ROBIN\n\n        with self._lock:\n            if self.slots_cache.get(slot) is None or len(self.slots_cache[slot]) == 0:\n                raise SlotNotCoveredError(\n                    f'Slot \"{slot}\" not covered by the cluster. '\n                    + f'\"require_full_coverage={self._require_full_coverage}\"'\n                )\n\n            if len(self.slots_cache[slot]) > 1 and load_balancing_strategy:\n                # get the server index using the strategy defined in load_balancing_strategy\n                primary_name = self.slots_cache[slot][0].name\n                node_idx = self.read_load_balancer.get_server_index(\n                    primary_name, len(self.slots_cache[slot]), load_balancing_strategy\n                )\n            elif (\n                server_type is None\n                or server_type == PRIMARY\n                or len(self.slots_cache[slot]) == 1\n            ):\n                # return a primary\n                node_idx = 0\n            else:\n                # return a replica\n                # randomly choose one of the replicas\n                node_idx = random.randint(1, len(self.slots_cache[slot]) - 1)\n\n            return self.slots_cache[slot][node_idx]\n\n    def get_nodes_by_server_type(self, server_type: Literal[\"primary\", \"replica\"]):\n        \"\"\"\n        Get all nodes with the specified server type\n        :param server_type: 'primary' or 'replica'\n        :return: list of ClusterNode\n        \"\"\"\n        with self._lock:\n            return [\n                node\n                for node in self.nodes_cache.values()\n                if node.server_type == server_type\n            ]\n\n    @deprecated_function(\n        reason=\"This method is not used anymore internally. The startup nodes are populated automatically.\",\n        version=\"7.0.2\",\n    )\n    def populate_startup_nodes(self, nodes):\n        \"\"\"\n        Populate all startup nodes and filters out any duplicates\n        \"\"\"\n        with self._lock:\n            for n in nodes:\n                self.startup_nodes[n.name] = n\n\n    def move_node_to_end_of_cached_nodes(self, node_name: str) -> None:\n        \"\"\"\n        Move a failing node to the end of startup_nodes and nodes_cache so it's\n        tried last during reinitialization and when selecting the default node.\n        If the node is not in the respective list, nothing is done.\n        \"\"\"\n        # Move in startup_nodes\n        if node_name in self.startup_nodes and len(self.startup_nodes) > 1:\n            node = self.startup_nodes.pop(node_name)\n            self.startup_nodes[node_name] = node  # Re-insert at end\n\n        # Move in nodes_cache - this affects get_nodes_by_server_type ordering\n        # which is used to select the default_node during initialize()\n        if node_name in self.nodes_cache and len(self.nodes_cache) > 1:\n            node = self.nodes_cache.pop(node_name)\n            self.nodes_cache[node_name] = node  # Re-insert at end\n\n    def check_slots_coverage(self, slots_cache):\n        # Validate if all slots are covered or if we should try next\n        # startup node\n        for i in range(0, REDIS_CLUSTER_HASH_SLOTS):\n            if i not in slots_cache:\n                return False\n        return True\n\n    def create_redis_connections(self, nodes):\n        \"\"\"\n        This function will create a redis connection to all nodes in :nodes:\n        \"\"\"\n        connection_pools = []\n        for node in nodes:\n            if node.redis_connection is None:\n                node.redis_connection = self.create_redis_node(\n                    host=node.host,\n                    port=node.port,\n                    maint_notifications_config=self.maint_notifications_config,\n                    **self.connection_kwargs,\n                )\n                connection_pools.append(node.redis_connection.connection_pool)\n\n        self._event_dispatcher.dispatch(\n            AfterPooledConnectionsInstantiationEvent(\n                connection_pools, ClientType.SYNC, self._credential_provider\n            )\n        )\n\n    def create_redis_node(\n        self,\n        host,\n        port,\n        **kwargs,\n    ):\n        # We are configuring the connection pool not to retry\n        # connections on lower level clients to avoid retrying\n        # connections to nodes that are not reachable\n        # and to avoid blocking the connection pool.\n        # The only error that will have some handling in the lower\n        # level clients is ConnectionError which will trigger disconnection\n        # of the socket.\n        # The retries will be handled on cluster client level\n        # where we will have proper handling of the cluster topology\n        node_retry_config = Retry(\n            backoff=NoBackoff(), retries=0, supported_errors=(ConnectionError,)\n        )\n\n        if self.from_url:\n            # Create a redis node with a custom connection pool\n            kwargs.update({\"host\": host})\n            kwargs.update({\"port\": port})\n            kwargs.update({\"cache\": self._cache})\n            kwargs.update({\"retry\": node_retry_config})\n            r = Redis(connection_pool=self.connection_pool_class(**kwargs))\n        else:\n            r = Redis(\n                host=host,\n                port=port,\n                cache=self._cache,\n                retry=node_retry_config,\n                **kwargs,\n            )\n        return r\n\n    def _get_or_create_cluster_node(self, host, port, role, tmp_nodes_cache):\n        node_name = get_node_name(host, port)\n        # check if we already have this node in the tmp_nodes_cache\n        target_node = tmp_nodes_cache.get(node_name)\n        if target_node is None:\n            # before creating a new cluster node, check if the cluster node already\n            # exists in the current nodes cache and has a valid connection so we can\n            # reuse it\n            redis_connection: Optional[Redis] = None\n            with self._lock:\n                previous_node = self.nodes_cache.get(node_name)\n                if previous_node:\n                    redis_connection = previous_node.redis_connection\n            # don't update the old ClusterNode, so we don't update its role\n            # outside of the lock\n            target_node = ClusterNode(host, port, role, redis_connection)\n            # add this node to the nodes cache\n            tmp_nodes_cache[target_node.name] = target_node\n\n        return target_node\n\n    def _get_epoch(self) -> int:\n        \"\"\"\n        Get the current epoch value. This method exists primarily to allow\n        tests to mock the epoch fetch and control race condition timing.\n        \"\"\"\n        with self._lock:\n            return self._epoch\n\n    def initialize(\n        self,\n        additional_startup_nodes_info: Optional[List[Tuple[str, int]]] = None,\n        disconnect_startup_nodes_pools: bool = True,\n    ):\n        \"\"\"\n        Initializes the nodes cache, slots cache and redis connections.\n        :startup_nodes:\n            Responsible for discovering other nodes in the cluster\n        :disconnect_startup_nodes_pools:\n            Whether to disconnect the connection pool of the startup nodes\n            after the initialization is complete. This is useful when the\n            startup nodes are not part of the cluster and we want to avoid\n            keeping the connection open.\n        :additional_startup_nodes_info:\n            Additional nodes to add temporarily to the startup nodes.\n            The additional nodes will be used just in the process of extraction of the slots\n            and nodes information from the cluster.\n            This is useful when we want to add new nodes to the cluster\n            and initialize the client\n            with them.\n            The format of the list is a list of tuples, where each tuple contains\n            the host and port of the node.\n        \"\"\"\n        self.reset()\n        tmp_nodes_cache = {}\n        tmp_slots = {}\n        disagreements = []\n        startup_nodes_reachable = False\n        fully_covered = False\n        kwargs = self.connection_kwargs\n        exception = None\n        epoch = self._get_epoch()\n        if additional_startup_nodes_info is None:\n            additional_startup_nodes_info = []\n\n        with self._initialization_lock:\n            with self._lock:\n                if epoch != self._epoch:\n                    # another thread has already re-initialized the nodes; don't\n                    # bother running again\n                    return\n\n            with self._lock:\n                startup_nodes = tuple(self.startup_nodes.values())\n\n            additional_startup_nodes = [\n                ClusterNode(host, port) for host, port in additional_startup_nodes_info\n            ]\n            if is_debug_log_enabled():\n                logger.debug(\n                    f\"Topology refresh: using additional nodes: {[node.name for node in additional_startup_nodes]}; \"\n                    f\"and startup nodes: {[node.name for node in startup_nodes]}\"\n                )\n\n            for startup_node in (*startup_nodes, *additional_startup_nodes):\n                try:\n                    if startup_node.redis_connection:\n                        r = startup_node.redis_connection\n\n                    else:\n                        # Create a new Redis connection\n                        if is_debug_log_enabled():\n                            socket_timeout = kwargs.get(\"socket_timeout\", \"not set\")\n                            socket_connect_timeout = kwargs.get(\n                                \"socket_connect_timeout\", \"not set\"\n                            )\n                            maint_enabled = (\n                                self.maint_notifications_config.enabled\n                                if self.maint_notifications_config\n                                else False\n                            )\n                            logger.debug(\n                                \"Topology refresh: Creating new Redis connection to \"\n                                f\"{startup_node.host}:{startup_node.port}; \"\n                                f\"with socket_timeout: {socket_timeout}, and \"\n                                f\"socket_connect_timeout: {socket_connect_timeout}, \"\n                                \"and maint_notifications enabled: \"\n                                f\"{maint_enabled}\"\n                            )\n                        r = self.create_redis_node(\n                            startup_node.host,\n                            startup_node.port,\n                            maint_notifications_config=self.maint_notifications_config,\n                            **kwargs,\n                        )\n                        if startup_node in self.startup_nodes.values():\n                            self.startup_nodes[startup_node.name].redis_connection = r\n                        else:\n                            startup_node.redis_connection = r\n                    try:\n                        # Make sure cluster mode is enabled on this node\n                        cluster_slots = str_if_bytes(r.execute_command(\"CLUSTER SLOTS\"))\n                        if disconnect_startup_nodes_pools:\n                            with r.connection_pool._lock:\n                                # take care to clear connections before we move on\n                                # mark all active connections for reconnect - they will be\n                                # reconnected on next use, but will allow current in flight commands to complete first\n                                r.connection_pool.update_active_connections_for_reconnect()\n                                # Needed to clear READONLY state when it is no longer applicable\n                                r.connection_pool.disconnect_free_connections()\n                    except ResponseError:\n                        raise RedisClusterException(\n                            \"Cluster mode is not enabled on this node\"\n                        )\n                    startup_nodes_reachable = True\n                except Exception as e:\n                    # Try the next startup node.\n                    # The exception is saved and raised only if we have no more nodes.\n                    exception = e\n                    continue\n\n                # CLUSTER SLOTS command results in the following output:\n                # [[slot_section[from_slot,to_slot,master,replica1,...,replicaN]]]\n                # where each node contains the following list: [IP, port, node_id]\n                # Therefore, cluster_slots[0][2][0] will be the IP address of the\n                # primary node of the first slot section.\n                # If there's only one server in the cluster, its ``host`` is ''\n                # Fix it to the host in startup_nodes\n                if (\n                    len(cluster_slots) == 1\n                    and len(cluster_slots[0][2][0]) == 0\n                    and len(self.startup_nodes) == 1\n                ):\n                    cluster_slots[0][2][0] = startup_node.host\n\n                for slot in cluster_slots:\n                    primary_node = slot[2]\n                    host = str_if_bytes(primary_node[0])\n                    if host == \"\":\n                        host = startup_node.host\n                    port = int(primary_node[1])\n                    host, port = self.remap_host_port(host, port)\n\n                    nodes_for_slot = []\n\n                    target_node = self._get_or_create_cluster_node(\n                        host, port, PRIMARY, tmp_nodes_cache\n                    )\n                    nodes_for_slot.append(target_node)\n\n                    replica_nodes = slot[3:]\n                    for replica_node in replica_nodes:\n                        host = str_if_bytes(replica_node[0])\n                        port = int(replica_node[1])\n                        host, port = self.remap_host_port(host, port)\n                        target_replica_node = self._get_or_create_cluster_node(\n                            host, port, REPLICA, tmp_nodes_cache\n                        )\n                        nodes_for_slot.append(target_replica_node)\n\n                    for i in range(int(slot[0]), int(slot[1]) + 1):\n                        if i not in tmp_slots:\n                            tmp_slots[i] = nodes_for_slot\n                        else:\n                            # Validate that 2 nodes want to use the same slot cache\n                            # setup\n                            tmp_slot = tmp_slots[i][0]\n                            if tmp_slot.name != target_node.name:\n                                disagreements.append(\n                                    f\"{tmp_slot.name} vs {target_node.name} on slot: {i}\"\n                                )\n\n                                if len(disagreements) > 5:\n                                    raise RedisClusterException(\n                                        f\"startup_nodes could not agree on a valid \"\n                                        f\"slots cache: {', '.join(disagreements)}\"\n                                    )\n\n                fully_covered = self.check_slots_coverage(tmp_slots)\n                if fully_covered:\n                    # Don't need to continue to the next startup node if all\n                    # slots are covered\n                    break\n\n            if not startup_nodes_reachable:\n                raise RedisClusterException(\n                    f\"Redis Cluster cannot be connected. Please provide at least \"\n                    f\"one reachable node: {str(exception)}\"\n                ) from exception\n\n            # Create Redis connections to all nodes\n            self.create_redis_connections(list(tmp_nodes_cache.values()))\n\n            # Check if the slots are not fully covered\n            if not fully_covered and self._require_full_coverage:\n                # Despite the requirement that the slots be covered, there\n                # isn't a full coverage\n                raise RedisClusterException(\n                    f\"All slots are not covered after query all startup_nodes. \"\n                    f\"{len(tmp_slots)} of {REDIS_CLUSTER_HASH_SLOTS} \"\n                    f\"covered...\"\n                )\n\n            # Set the tmp variables to the real variables\n            with self._lock:\n                self.nodes_cache = tmp_nodes_cache\n                self.slots_cache = tmp_slots\n                # Set the default node\n                self.default_node = self.get_nodes_by_server_type(PRIMARY)[0]\n                if self._dynamic_startup_nodes:\n                    # Populate the startup nodes with all discovered nodes\n                    self.startup_nodes = tmp_nodes_cache\n                # Increment the epoch to signal that initialization has completed\n                self._epoch += 1\n\n    def close(self) -> None:\n        with self._lock:\n            self.default_node = None\n            nodes = tuple(self.nodes_cache.values())\n        for node in nodes:\n            if node.redis_connection:\n                node.redis_connection.close()\n\n    def reset(self):\n        try:\n            self.read_load_balancer.reset()\n        except TypeError:\n            # The read_load_balancer is None, do nothing\n            pass\n\n    def remap_host_port(self, host: str, port: int) -> Tuple[str, int]:\n        \"\"\"\n        Remap the host and port returned from the cluster to a different\n        internal value.  Useful if the client is not connecting directly\n        to the cluster.\n        \"\"\"\n        if self.address_remap:\n            return self.address_remap((host, port))\n        return host, port\n\n    def find_connection_owner(self, connection: Connection) -> Optional[ClusterNode]:\n        node_name = get_node_name(connection.host, connection.port)\n        with self._lock:\n            for node in tuple(self.nodes_cache.values()):\n                if node.redis_connection:\n                    conn_args = node.redis_connection.connection_pool.connection_kwargs\n                    if node_name == get_node_name(\n                        conn_args.get(\"host\"), conn_args.get(\"port\")\n                    ):\n                        return node\n        return None\n\n\nclass ClusterPubSub(PubSub):\n    \"\"\"\n    Wrapper for PubSub class.\n\n    IMPORTANT: before using ClusterPubSub, read about the known limitations\n    with pubsub in Cluster mode and learn how to workaround them:\n    https://redis-py-cluster.readthedocs.io/en/stable/pubsub.html\n    \"\"\"\n\n    def __init__(\n        self,\n        redis_cluster,\n        node=None,\n        host=None,\n        port=None,\n        push_handler_func=None,\n        event_dispatcher: Optional[\"EventDispatcher\"] = None,\n        **kwargs,\n    ):\n        \"\"\"\n        When a pubsub instance is created without specifying a node, a single\n        node will be transparently chosen for the pubsub connection on the\n        first command execution. The node will be determined by:\n         1. Hashing the channel name in the request to find its keyslot\n         2. Selecting a node that handles the keyslot: If read_from_replicas is\n            set to true or load_balancing_strategy is set, a replica can be selected.\n\n        :type redis_cluster: RedisCluster\n        :type node: ClusterNode\n        :type host: str\n        :type port: int\n        \"\"\"\n        self.node = None\n        self.set_pubsub_node(redis_cluster, node, host, port)\n        connection_pool = (\n            None\n            if self.node is None\n            else redis_cluster.get_redis_connection(self.node).connection_pool\n        )\n        self.cluster = redis_cluster\n        self.node_pubsub_mapping = {}\n        self._pubsubs_generator = self._pubsubs_generator()\n        if event_dispatcher is None:\n            self._event_dispatcher = EventDispatcher()\n        else:\n            self._event_dispatcher = event_dispatcher\n        super().__init__(\n            connection_pool=connection_pool,\n            encoder=redis_cluster.encoder,\n            push_handler_func=push_handler_func,\n            event_dispatcher=self._event_dispatcher,\n            **kwargs,\n        )\n\n    def set_pubsub_node(self, cluster, node=None, host=None, port=None):\n        \"\"\"\n        The pubsub node will be set according to the passed node, host and port\n        When none of the node, host, or port are specified - the node is set\n        to None and will be determined by the keyslot of the channel in the\n        first command to be executed.\n        RedisClusterException will be thrown if the passed node does not exist\n        in the cluster.\n        If host is passed without port, or vice versa, a DataError will be\n        thrown.\n        :type cluster: RedisCluster\n        :type node: ClusterNode\n        :type host: str\n        :type port: int\n        \"\"\"\n        if node is not None:\n            # node is passed by the user\n            self._raise_on_invalid_node(cluster, node, node.host, node.port)\n            pubsub_node = node\n        elif host is not None and port is not None:\n            # host and port passed by the user\n            node = cluster.get_node(host=host, port=port)\n            self._raise_on_invalid_node(cluster, node, host, port)\n            pubsub_node = node\n        elif any([host, port]) is True:\n            # only 'host' or 'port' passed\n            raise DataError(\"Passing a host requires passing a port, and vice versa\")\n        else:\n            # nothing passed by the user. set node to None\n            pubsub_node = None\n\n        self.node = pubsub_node\n\n    def get_pubsub_node(self):\n        \"\"\"\n        Get the node that is being used as the pubsub connection\n        \"\"\"\n        return self.node\n\n    def _raise_on_invalid_node(self, redis_cluster, node, host, port):\n        \"\"\"\n        Raise a RedisClusterException if the node is None or doesn't exist in\n        the cluster.\n        \"\"\"\n        if node is None or redis_cluster.get_node(node_name=node.name) is None:\n            raise RedisClusterException(\n                f\"Node {host}:{port} doesn't exist in the cluster\"\n            )\n\n    def execute_command(self, *args):\n        \"\"\"\n        Execute a subscribe/unsubscribe command.\n\n        Taken code from redis-py and tweak to make it work within a cluster.\n        \"\"\"\n        # NOTE: don't parse the response in this function -- it could pull a\n        # legitimate message off the stack if the connection is already\n        # subscribed to one or more channels\n\n        if self.connection is None:\n            if self.connection_pool is None:\n                if len(args) > 1:\n                    # Hash the first channel and get one of the nodes holding\n                    # this slot\n                    channel = args[1]\n                    slot = self.cluster.keyslot(channel)\n                    node = self.cluster.nodes_manager.get_node_from_slot(\n                        slot,\n                        self.cluster.read_from_replicas,\n                        self.cluster.load_balancing_strategy,\n                    )\n                else:\n                    # Get a random node\n                    node = self.cluster.get_random_node()\n                self.node = node\n                redis_connection = self.cluster.get_redis_connection(node)\n                self.connection_pool = redis_connection.connection_pool\n            self.connection = self.connection_pool.get_connection()\n            # register a callback that re-subscribes to any channels we\n            # were listening to when we were disconnected\n            self.connection.register_connect_callback(self.on_connect)\n            if self.push_handler_func is not None:\n                self.connection._parser.set_pubsub_push_handler(self.push_handler_func)\n            self._event_dispatcher.dispatch(\n                AfterPubSubConnectionInstantiationEvent(\n                    self.connection, self.connection_pool, ClientType.SYNC, self._lock\n                )\n            )\n        connection = self.connection\n        self._execute(connection, connection.send_command, *args)\n\n    def _get_node_pubsub(self, node):\n        try:\n            return self.node_pubsub_mapping[node.name]\n        except KeyError:\n            pubsub = node.redis_connection.pubsub(\n                push_handler_func=self.push_handler_func\n            )\n            self.node_pubsub_mapping[node.name] = pubsub\n            return pubsub\n\n    def _sharded_message_generator(self, timeout=0.0):\n        for _ in range(len(self.node_pubsub_mapping)):\n            pubsub = next(self._pubsubs_generator)\n            # Don't pass ignore_subscribe_messages here - let get_sharded_message\n            # handle the filtering after processing subscription state changes\n            message = pubsub.get_message(\n                ignore_subscribe_messages=False, timeout=timeout\n            )\n            if message is not None:\n                return message\n        return None\n\n    def _pubsubs_generator(self):\n        while True:\n            current_nodes = list(self.node_pubsub_mapping.values())\n            yield from current_nodes\n\n    def get_sharded_message(\n        self, ignore_subscribe_messages=False, timeout=0.0, target_node=None\n    ):\n        if target_node:\n            message = self.node_pubsub_mapping[target_node.name].get_message(\n                ignore_subscribe_messages=ignore_subscribe_messages, timeout=timeout\n            )\n        else:\n            message = self._sharded_message_generator(timeout=timeout)\n        if message is None:\n            return None\n        elif str_if_bytes(message[\"type\"]) == \"sunsubscribe\":\n            if message[\"channel\"] in self.pending_unsubscribe_shard_channels:\n                self.pending_unsubscribe_shard_channels.remove(message[\"channel\"])\n                self.shard_channels.pop(message[\"channel\"], None)\n                node = self.cluster.get_node_from_key(message[\"channel\"])\n                if self.node_pubsub_mapping[node.name].subscribed is False:\n                    self.node_pubsub_mapping.pop(node.name)\n        if not self.channels and not self.patterns and not self.shard_channels:\n            # There are no subscriptions anymore, set subscribed_event flag\n            # to false\n            self.subscribed_event.clear()\n        if self.ignore_subscribe_messages or ignore_subscribe_messages:\n            return None\n        return message\n\n    def ssubscribe(self, *args, **kwargs):\n        if args:\n            args = list_or_args(args[0], args[1:])\n        s_channels = dict.fromkeys(args)\n        s_channels.update(kwargs)\n        for s_channel, handler in s_channels.items():\n            node = self.cluster.get_node_from_key(s_channel)\n            pubsub = self._get_node_pubsub(node)\n            if handler:\n                pubsub.ssubscribe(**{s_channel: handler})\n            else:\n                pubsub.ssubscribe(s_channel)\n            self.shard_channels.update(pubsub.shard_channels)\n            self.pending_unsubscribe_shard_channels.difference_update(\n                self._normalize_keys({s_channel: None})\n            )\n            if pubsub.subscribed and not self.subscribed:\n                self.subscribed_event.set()\n                self.health_check_response_counter = 0\n\n    def sunsubscribe(self, *args):\n        if args:\n            args = list_or_args(args[0], args[1:])\n        else:\n            args = self.shard_channels\n\n        for s_channel in args:\n            node = self.cluster.get_node_from_key(s_channel)\n            p = self._get_node_pubsub(node)\n            p.sunsubscribe(s_channel)\n            self.pending_unsubscribe_shard_channels.update(\n                p.pending_unsubscribe_shard_channels\n            )\n\n    def get_redis_connection(self):\n        \"\"\"\n        Get the Redis connection of the pubsub connected node.\n        \"\"\"\n        if self.node is not None:\n            return self.node.redis_connection\n\n    def disconnect(self):\n        \"\"\"\n        Disconnect the pubsub connection.\n        \"\"\"\n        if self.connection:\n            self.connection.disconnect()\n        for pubsub in self.node_pubsub_mapping.values():\n            pubsub.connection.disconnect()\n\n\nclass ClusterPipeline(RedisCluster):\n    \"\"\"\n    Support for Redis pipeline\n    in cluster mode\n    \"\"\"\n\n    ERRORS_ALLOW_RETRY = (\n        ConnectionError,\n        TimeoutError,\n        MovedError,\n        AskError,\n        TryAgainError,\n    )\n\n    NO_SLOTS_COMMANDS = {\"UNWATCH\"}\n    IMMEDIATE_EXECUTE_COMMANDS = {\"WATCH\", \"UNWATCH\"}\n    UNWATCH_COMMANDS = {\"DISCARD\", \"EXEC\", \"UNWATCH\"}\n\n    @deprecated_args(\n        args_to_warn=[\n            \"cluster_error_retry_attempts\",\n        ],\n        reason=\"Please configure the 'retry' object instead\",\n        version=\"6.0.0\",\n    )\n    def __init__(\n        self,\n        nodes_manager: \"NodesManager\",\n        commands_parser: \"CommandsParser\",\n        result_callbacks: Optional[Dict[str, Callable]] = None,\n        cluster_response_callbacks: Optional[Dict[str, Callable]] = None,\n        startup_nodes: Optional[List[\"ClusterNode\"]] = None,\n        read_from_replicas: bool = False,\n        load_balancing_strategy: Optional[LoadBalancingStrategy] = None,\n        cluster_error_retry_attempts: int = 3,\n        reinitialize_steps: int = 5,\n        retry: Optional[Retry] = None,\n        lock=None,\n        transaction=False,\n        policy_resolver: PolicyResolver = StaticPolicyResolver(),\n        event_dispatcher: Optional[\"EventDispatcher\"] = None,\n        **kwargs,\n    ):\n        \"\"\" \"\"\"\n        self.command_stack = []\n        self.nodes_manager = nodes_manager\n        self.commands_parser = commands_parser\n        self.refresh_table_asap = False\n        self.result_callbacks = (\n            result_callbacks or self.__class__.RESULT_CALLBACKS.copy()\n        )\n        self.startup_nodes = startup_nodes if startup_nodes else []\n        self.read_from_replicas = read_from_replicas\n        self.load_balancing_strategy = load_balancing_strategy\n        self.command_flags = self.__class__.COMMAND_FLAGS.copy()\n        self.cluster_response_callbacks = cluster_response_callbacks\n        self.reinitialize_counter = 0\n        self.reinitialize_steps = reinitialize_steps\n        if retry is not None:\n            self.retry = retry\n        else:\n            self.retry = Retry(\n                backoff=ExponentialWithJitterBackoff(base=1, cap=10),\n                retries=cluster_error_retry_attempts,\n            )\n\n        self.encoder = Encoder(\n            kwargs.get(\"encoding\", \"utf-8\"),\n            kwargs.get(\"encoding_errors\", \"strict\"),\n            kwargs.get(\"decode_responses\", False),\n        )\n        if lock is None:\n            lock = threading.RLock()\n        self._lock = lock\n        self.parent_execute_command = super().execute_command\n        self._execution_strategy: ExecutionStrategy = (\n            PipelineStrategy(self) if not transaction else TransactionStrategy(self)\n        )\n\n        # For backward compatibility, mapping from existing policies to new one\n        self._command_flags_mapping: dict[str, Union[RequestPolicy, ResponsePolicy]] = {\n            self.__class__.RANDOM: RequestPolicy.DEFAULT_KEYLESS,\n            self.__class__.PRIMARIES: RequestPolicy.ALL_SHARDS,\n            self.__class__.ALL_NODES: RequestPolicy.ALL_NODES,\n            self.__class__.REPLICAS: RequestPolicy.ALL_REPLICAS,\n            self.__class__.DEFAULT_NODE: RequestPolicy.DEFAULT_NODE,\n            SLOT_ID: RequestPolicy.DEFAULT_KEYED,\n        }\n\n        self._policies_callback_mapping: dict[\n            Union[RequestPolicy, ResponsePolicy], Callable\n        ] = {\n            RequestPolicy.DEFAULT_KEYLESS: lambda command_name: [\n                self.get_random_primary_or_all_nodes(command_name)\n            ],\n            RequestPolicy.DEFAULT_KEYED: lambda command,\n            *args: self.get_nodes_from_slot(command, *args),\n            RequestPolicy.DEFAULT_NODE: lambda: [self.get_default_node()],\n            RequestPolicy.ALL_SHARDS: self.get_primaries,\n            RequestPolicy.ALL_NODES: self.get_nodes,\n            RequestPolicy.ALL_REPLICAS: self.get_replicas,\n            RequestPolicy.MULTI_SHARD: lambda *args,\n            **kwargs: self._split_multi_shard_command(*args, **kwargs),\n            RequestPolicy.SPECIAL: self.get_special_nodes,\n            ResponsePolicy.DEFAULT_KEYLESS: lambda res: res,\n            ResponsePolicy.DEFAULT_KEYED: lambda res: res,\n        }\n\n        self._policy_resolver = policy_resolver\n\n        if event_dispatcher is None:\n            self._event_dispatcher = EventDispatcher()\n        else:\n            self._event_dispatcher = event_dispatcher\n\n    def __repr__(self):\n        \"\"\" \"\"\"\n        return f\"{type(self).__name__}\"\n\n    def __enter__(self):\n        \"\"\" \"\"\"\n        return self\n\n    def __exit__(self, exc_type, exc_value, traceback):\n        \"\"\" \"\"\"\n        self.reset()\n\n    def __del__(self):\n        try:\n            self.reset()\n        except Exception:\n            pass\n\n    def __len__(self):\n        \"\"\" \"\"\"\n        return len(self._execution_strategy.command_queue)\n\n    def __bool__(self):\n        \"Pipeline instances should  always evaluate to True on Python 3+\"\n        return True\n\n    def execute_command(self, *args, **kwargs):\n        \"\"\"\n        Wrapper function for pipeline_execute_command\n        \"\"\"\n        return self._execution_strategy.execute_command(*args, **kwargs)\n\n    def pipeline_execute_command(self, *args, **options):\n        \"\"\"\n        Stage a command to be executed when execute() is next called\n\n        Returns the current Pipeline object back so commands can be\n        chained together, such as:\n\n        pipe = pipe.set('foo', 'bar').incr('baz').decr('bang')\n\n        At some other point, you can then run: pipe.execute(),\n        which will execute all commands queued in the pipe.\n        \"\"\"\n        return self._execution_strategy.execute_command(*args, **options)\n\n    def annotate_exception(self, exception, number, command):\n        \"\"\"\n        Provides extra context to the exception prior to it being handled\n        \"\"\"\n        self._execution_strategy.annotate_exception(exception, number, command)\n\n    def execute(self, raise_on_error: bool = True) -> List[Any]:\n        \"\"\"\n        Execute all the commands in the current pipeline\n        \"\"\"\n\n        try:\n            return self._execution_strategy.execute(raise_on_error)\n        finally:\n            self.reset()\n\n    def reset(self):\n        \"\"\"\n        Reset back to empty pipeline.\n        \"\"\"\n        self._execution_strategy.reset()\n\n    def send_cluster_commands(\n        self, stack, raise_on_error=True, allow_redirections=True\n    ):\n        return self._execution_strategy.send_cluster_commands(\n            stack, raise_on_error=raise_on_error, allow_redirections=allow_redirections\n        )\n\n    def exists(self, *keys):\n        return self._execution_strategy.exists(*keys)\n\n    def eval(self):\n        \"\"\" \"\"\"\n        return self._execution_strategy.eval()\n\n    def multi(self):\n        \"\"\"\n        Start a transactional block of the pipeline after WATCH commands\n        are issued. End the transactional block with `execute`.\n        \"\"\"\n        self._execution_strategy.multi()\n\n    def load_scripts(self):\n        \"\"\" \"\"\"\n        self._execution_strategy.load_scripts()\n\n    def discard(self):\n        \"\"\" \"\"\"\n        self._execution_strategy.discard()\n\n    def watch(self, *names):\n        \"\"\"Watches the values at keys ``names``\"\"\"\n        self._execution_strategy.watch(*names)\n\n    def unwatch(self):\n        \"\"\"Unwatches all previously specified keys\"\"\"\n        self._execution_strategy.unwatch()\n\n    def script_load_for_pipeline(self, *args, **kwargs):\n        self._execution_strategy.script_load_for_pipeline(*args, **kwargs)\n\n    def delete(self, *names):\n        self._execution_strategy.delete(*names)\n\n    def unlink(self, *names):\n        self._execution_strategy.unlink(*names)\n\n\ndef block_pipeline_command(name: str) -> Callable[..., Any]:\n    \"\"\"\n    Prints error because some pipelined commands should\n    be blocked when running in cluster-mode\n    \"\"\"\n\n    def inner(*args, **kwargs):\n        raise RedisClusterException(\n            f\"ERROR: Calling pipelined function {name} is blocked \"\n            f\"when running redis in cluster mode...\"\n        )\n\n    return inner\n\n\n# Blocked pipeline commands\nPIPELINE_BLOCKED_COMMANDS = (\n    \"BGREWRITEAOF\",\n    \"BGSAVE\",\n    \"BITOP\",\n    \"BRPOPLPUSH\",\n    \"CLIENT GETNAME\",\n    \"CLIENT KILL\",\n    \"CLIENT LIST\",\n    \"CLIENT SETNAME\",\n    \"CLIENT\",\n    \"CONFIG GET\",\n    \"CONFIG RESETSTAT\",\n    \"CONFIG REWRITE\",\n    \"CONFIG SET\",\n    \"CONFIG\",\n    \"DBSIZE\",\n    \"ECHO\",\n    \"EVALSHA\",\n    \"FLUSHALL\",\n    \"FLUSHDB\",\n    \"INFO\",\n    \"KEYS\",\n    \"LASTSAVE\",\n    \"MGET\",\n    \"MGET NONATOMIC\",\n    \"MOVE\",\n    \"MSET\",\n    \"MSETEX\",\n    \"MSET NONATOMIC\",\n    \"MSETNX\",\n    \"PFCOUNT\",\n    \"PFMERGE\",\n    \"PING\",\n    \"PUBLISH\",\n    \"RANDOMKEY\",\n    \"READONLY\",\n    \"READWRITE\",\n    \"RENAME\",\n    \"RENAMENX\",\n    \"RPOPLPUSH\",\n    \"SAVE\",\n    \"SCAN\",\n    \"SCRIPT EXISTS\",\n    \"SCRIPT FLUSH\",\n    \"SCRIPT KILL\",\n    \"SCRIPT LOAD\",\n    \"SCRIPT\",\n    \"SDIFF\",\n    \"SDIFFSTORE\",\n    \"SENTINEL GET MASTER ADDR BY NAME\",\n    \"SENTINEL MASTER\",\n    \"SENTINEL MASTERS\",\n    \"SENTINEL MONITOR\",\n    \"SENTINEL REMOVE\",\n    \"SENTINEL SENTINELS\",\n    \"SENTINEL SET\",\n    \"SENTINEL SLAVES\",\n    \"SENTINEL\",\n    \"SHUTDOWN\",\n    \"SINTER\",\n    \"SINTERSTORE\",\n    \"SLAVEOF\",\n    \"SLOWLOG GET\",\n    \"SLOWLOG LEN\",\n    \"SLOWLOG RESET\",\n    \"SLOWLOG\",\n    \"SMOVE\",\n    \"SORT\",\n    \"SUNION\",\n    \"SUNIONSTORE\",\n    \"TIME\",\n)\nfor command in PIPELINE_BLOCKED_COMMANDS:\n    command = command.replace(\" \", \"_\").lower()\n\n    setattr(ClusterPipeline, command, block_pipeline_command(command))\n\n\nclass PipelineCommand:\n    \"\"\" \"\"\"\n\n    def __init__(self, args, options=None, position=None):\n        self.args = args\n        if options is None:\n            options = {}\n        self.options = options\n        self.position = position\n        self.result = None\n        self.node = None\n        self.asking = False\n        self.command_policies: Optional[CommandPolicies] = None\n\n\nclass NodeCommands:\n    \"\"\" \"\"\"\n\n    def __init__(\n        self, parse_response, connection_pool: ConnectionPool, connection: Connection\n    ):\n        \"\"\" \"\"\"\n        self.parse_response = parse_response\n        self.connection_pool = connection_pool\n        self.connection = connection\n        self.commands = []\n\n    def append(self, c):\n        \"\"\" \"\"\"\n        self.commands.append(c)\n\n    def write(self):\n        \"\"\"\n        Code borrowed from Redis so it can be fixed\n        \"\"\"\n        connection = self.connection\n        commands = self.commands\n\n        # We are going to clobber the commands with the write, so go ahead\n        # and ensure that nothing is sitting there from a previous run.\n        for c in commands:\n            c.result = None\n\n        # build up all commands into a single request to increase network perf\n        # send all the commands and catch connection and timeout errors.\n        try:\n            connection.send_packed_command(\n                connection.pack_commands([c.args for c in commands])\n            )\n        except (ConnectionError, TimeoutError) as e:\n            for c in commands:\n                c.result = e\n\n    def read(self):\n        \"\"\" \"\"\"\n        connection = self.connection\n        for c in self.commands:\n            # if there is a result on this command,\n            # it means we ran into an exception\n            # like a connection error. Trying to parse\n            # a response on a connection that\n            # is no longer open will result in a\n            # connection error raised by redis-py.\n            # but redis-py doesn't check in parse_response\n            # that the sock object is\n            # still set and if you try to\n            # read from a closed connection, it will\n            # result in an AttributeError because\n            # it will do a readline() call on None.\n            # This can have all kinds of nasty side-effects.\n            # Treating this case as a connection error\n            # is fine because it will dump\n            # the connection object back into the\n            # pool and on the next write, it will\n            # explicitly open the connection and all will be well.\n            if c.result is None:\n                try:\n                    c.result = self.parse_response(connection, c.args[0], **c.options)\n                except (ConnectionError, TimeoutError) as e:\n                    for c in self.commands:\n                        c.result = e\n                    return\n                except RedisError:\n                    c.result = sys.exc_info()[1]\n\n\nclass ExecutionStrategy(ABC):\n    @property\n    @abstractmethod\n    def command_queue(self):\n        pass\n\n    @abstractmethod\n    def execute_command(self, *args, **kwargs):\n        \"\"\"\n        Execution flow for current execution strategy.\n\n        See: ClusterPipeline.execute_command()\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def annotate_exception(self, exception, number, command):\n        \"\"\"\n        Annotate exception according to current execution strategy.\n\n        See: ClusterPipeline.annotate_exception()\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def pipeline_execute_command(self, *args, **options):\n        \"\"\"\n        Pipeline execution flow for current execution strategy.\n\n        See: ClusterPipeline.pipeline_execute_command()\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def execute(self, raise_on_error: bool = True) -> List[Any]:\n        \"\"\"\n        Executes current execution strategy.\n\n        See: ClusterPipeline.execute()\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def send_cluster_commands(\n        self, stack, raise_on_error=True, allow_redirections=True\n    ):\n        \"\"\"\n        Sends commands according to current execution strategy.\n\n        See: ClusterPipeline.send_cluster_commands()\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def reset(self):\n        \"\"\"\n        Resets current execution strategy.\n\n        See: ClusterPipeline.reset()\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def exists(self, *keys):\n        pass\n\n    @abstractmethod\n    def eval(self):\n        pass\n\n    @abstractmethod\n    def multi(self):\n        \"\"\"\n        Starts transactional context.\n\n        See: ClusterPipeline.multi()\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def load_scripts(self):\n        pass\n\n    @abstractmethod\n    def watch(self, *names):\n        pass\n\n    @abstractmethod\n    def unwatch(self):\n        \"\"\"\n        Unwatches all previously specified keys\n\n        See: ClusterPipeline.unwatch()\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def script_load_for_pipeline(self, *args, **kwargs):\n        pass\n\n    @abstractmethod\n    def delete(self, *names):\n        \"\"\"\n        \"Delete a key specified by ``names``\"\n\n        See: ClusterPipeline.delete()\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def unlink(self, *names):\n        \"\"\"\n        \"Unlink a key specified by ``names``\"\n\n        See: ClusterPipeline.unlink()\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def discard(self):\n        pass\n\n\nclass AbstractStrategy(ExecutionStrategy):\n    def __init__(\n        self,\n        pipe: ClusterPipeline,\n    ):\n        self._command_queue: List[PipelineCommand] = []\n        self._pipe = pipe\n        self._nodes_manager = self._pipe.nodes_manager\n\n    @property\n    def command_queue(self):\n        return self._command_queue\n\n    @command_queue.setter\n    def command_queue(self, queue: List[PipelineCommand]):\n        self._command_queue = queue\n\n    @abstractmethod\n    def execute_command(self, *args, **kwargs):\n        pass\n\n    def pipeline_execute_command(self, *args, **options):\n        self._command_queue.append(\n            PipelineCommand(args, options, len(self._command_queue))\n        )\n        return self._pipe\n\n    @abstractmethod\n    def execute(self, raise_on_error: bool = True) -> List[Any]:\n        pass\n\n    @abstractmethod\n    def send_cluster_commands(\n        self, stack, raise_on_error=True, allow_redirections=True\n    ):\n        pass\n\n    @abstractmethod\n    def reset(self):\n        pass\n\n    def exists(self, *keys):\n        return self.execute_command(\"EXISTS\", *keys)\n\n    def eval(self):\n        \"\"\" \"\"\"\n        raise RedisClusterException(\"method eval() is not implemented\")\n\n    def load_scripts(self):\n        \"\"\" \"\"\"\n        raise RedisClusterException(\"method load_scripts() is not implemented\")\n\n    def script_load_for_pipeline(self, *args, **kwargs):\n        \"\"\" \"\"\"\n        raise RedisClusterException(\n            \"method script_load_for_pipeline() is not implemented\"\n        )\n\n    def annotate_exception(self, exception, number, command):\n        \"\"\"\n        Provides extra context to the exception prior to it being handled\n        \"\"\"\n        cmd = \" \".join(map(safe_str, command))\n        msg = (\n            f\"Command # {number} ({truncate_text(cmd)}) of pipeline \"\n            f\"caused error: {exception.args[0]}\"\n        )\n        exception.args = (msg,) + exception.args[1:]\n\n\nclass PipelineStrategy(AbstractStrategy):\n    def __init__(self, pipe: ClusterPipeline):\n        super().__init__(pipe)\n        self.command_flags = pipe.command_flags\n\n    def execute_command(self, *args, **kwargs):\n        return self.pipeline_execute_command(*args, **kwargs)\n\n    def _raise_first_error(self, stack, start_time):\n        \"\"\"\n        Raise the first exception on the stack\n        \"\"\"\n        for c in stack:\n            r = c.result\n            if isinstance(r, Exception):\n                self.annotate_exception(r, c.position + 1, c.args)\n\n                record_operation_duration(\n                    command_name=\"PIPELINE\",\n                    duration_seconds=time.monotonic() - start_time,\n                    error=r,\n                )\n\n                raise r\n\n    def execute(self, raise_on_error: bool = True) -> List[Any]:\n        stack = self._command_queue\n        if not stack:\n            return []\n\n        try:\n            return self.send_cluster_commands(stack, raise_on_error)\n        finally:\n            self.reset()\n\n    def reset(self):\n        \"\"\"\n        Reset back to empty pipeline.\n        \"\"\"\n        self._command_queue = []\n\n    def send_cluster_commands(\n        self, stack, raise_on_error=True, allow_redirections=True\n    ):\n        \"\"\"\n        Wrapper for RedisCluster.ERRORS_ALLOW_RETRY errors handling.\n\n        If one of the retryable exceptions has been thrown we assume that:\n         - connection_pool was disconnected\n         - connection_pool was reset\n         - refresh_table_asap set to True\n\n        It will try the number of times specified by\n        the retries in config option \"self.retry\"\n        which defaults to 3 unless manually configured.\n\n        If it reaches the number of times, the command will\n        raises ClusterDownException.\n        \"\"\"\n        if not stack:\n            return []\n        retry_attempts = self._pipe.retry.get_retries()\n        while True:\n            try:\n                return self._send_cluster_commands(\n                    stack,\n                    raise_on_error=raise_on_error,\n                    allow_redirections=allow_redirections,\n                )\n            except RedisCluster.ERRORS_ALLOW_RETRY as e:\n                if retry_attempts > 0:\n                    # Try again with the new cluster setup. All other errors\n                    # should be raised.\n                    retry_attempts -= 1\n                    pass\n                else:\n                    raise e\n\n    def _send_cluster_commands(\n        self, stack, raise_on_error=True, allow_redirections=True\n    ):\n        \"\"\"\n        Send a bunch of cluster commands to the redis cluster.\n\n        `allow_redirections` If the pipeline should follow\n        `ASK` & `MOVED` responses automatically. If set\n        to false it will raise RedisClusterException.\n        \"\"\"\n        # the first time sending the commands we send all of\n        # the commands that were queued up.\n        # if we have to run through it again, we only retry\n        # the commands that failed.\n        attempt = sorted(stack, key=lambda x: x.position)\n        is_default_node = False\n        # build a list of node objects based on node names we need to\n        nodes: dict[str, NodeCommands] = {}\n        nodes_written = 0\n        nodes_read = 0\n\n        try:\n            # as we move through each command that still needs to be processed,\n            # we figure out the slot number that command maps to, then from\n            # the slot determine the node.\n            for c in attempt:\n                command_policies = self._pipe._policy_resolver.resolve(\n                    c.args[0].lower()\n                )\n                # refer to our internal node -> slot table that\n                # tells us where a given command should route to.\n                # (it might be possible we have a cached node that no longer\n                # exists in the cluster, which is why we do this in a loop)\n                passed_targets = c.options.pop(\"target_nodes\", None)\n                if passed_targets and not self._is_nodes_flag(passed_targets):\n                    target_nodes = self._parse_target_nodes(passed_targets)\n\n                    if not command_policies:\n                        command_policies = CommandPolicies()\n                else:\n                    if not command_policies:\n                        command = c.args[0].upper()\n                        if (\n                            len(c.args) >= 2\n                            and f\"{c.args[0]} {c.args[1]}\".upper()\n                            in self._pipe.command_flags\n                        ):\n                            command = f\"{c.args[0]} {c.args[1]}\".upper()\n\n                        # We only could resolve key properties if command is not\n                        # in a list of pre-defined request policies\n                        command_flag = self.command_flags.get(command)\n                        if not command_flag:\n                            # Fallback to default policy\n                            if not self._pipe.get_default_node():\n                                keys = None\n                            else:\n                                keys = self._pipe._get_command_keys(*c.args)\n                            if not keys or len(keys) == 0:\n                                command_policies = CommandPolicies()\n                            else:\n                                command_policies = CommandPolicies(\n                                    request_policy=RequestPolicy.DEFAULT_KEYED,\n                                    response_policy=ResponsePolicy.DEFAULT_KEYED,\n                                )\n                        else:\n                            if command_flag in self._pipe._command_flags_mapping:\n                                command_policies = CommandPolicies(\n                                    request_policy=self._pipe._command_flags_mapping[\n                                        command_flag\n                                    ]\n                                )\n                            else:\n                                command_policies = CommandPolicies()\n\n                    target_nodes = self._determine_nodes(\n                        *c.args,\n                        request_policy=command_policies.request_policy,\n                        node_flag=passed_targets,\n                    )\n                    if not target_nodes:\n                        raise RedisClusterException(\n                            f\"No targets were found to execute {c.args} command on\"\n                        )\n                c.command_policies = command_policies\n                if len(target_nodes) > 1:\n                    raise RedisClusterException(\n                        f\"Too many targets for command {c.args}\"\n                    )\n\n                node = target_nodes[0]\n                if node == self._pipe.get_default_node():\n                    is_default_node = True\n\n                # now that we know the name of the node\n                # ( it's just a string in the form of host:port )\n                # we can build a list of commands for each node.\n                node_name = node.name\n                if node_name not in nodes:\n                    redis_node = self._pipe.get_redis_connection(node)\n                    try:\n                        connection = get_connection(redis_node)\n                    except (ConnectionError, TimeoutError):\n                        # Release any connections we've already acquired before clearing nodes\n                        for n in nodes.values():\n                            n.connection_pool.release(n.connection)\n                        # Connection retries are being handled in the node's\n                        # Retry object. Reinitialize the node -> slot table.\n                        self._nodes_manager.initialize()\n                        if is_default_node:\n                            self._pipe.replace_default_node()\n                        nodes = {}\n                        raise\n                    nodes[node_name] = NodeCommands(\n                        redis_node.parse_response,\n                        redis_node.connection_pool,\n                        connection,\n                    )\n                nodes[node_name].append(c)\n\n            # send the commands in sequence.\n            # we  write to all the open sockets for each node first,\n            # before reading anything\n            # this allows us to flush all the requests out across the\n            # network\n            # so that we can read them from different sockets as they come back.\n            # we don't multiplex on the sockets as they come available,\n            # but that shouldn't make too much difference.\n\n            # Start timing for observability\n            start_time = time.monotonic()\n\n            node_commands = nodes.values()\n            for n in node_commands:\n                nodes_written += 1\n                n.write()\n\n            for n in node_commands:\n                n.read()\n\n                # Find the first error in this node's commands, if any\n                node_error = None\n                for cmd in n.commands:\n                    if isinstance(cmd.result, Exception):\n                        node_error = cmd.result\n                        break\n\n                record_operation_duration(\n                    command_name=\"PIPELINE\",\n                    duration_seconds=time.monotonic() - start_time,\n                    server_address=n.connection.host,\n                    server_port=n.connection.port,\n                    db_namespace=str(n.connection.db),\n                    error=node_error,\n                )\n                nodes_read += 1\n        finally:\n            # release all the redis connections we allocated earlier\n            # back into the connection pool.\n            # if the connection is dirty (that is: we've written\n            # commands to it, but haven't read the responses), we need\n            # to close the connection before returning it to the pool.\n            # otherwise, the next caller to use this connection will\n            # read the response from _this_ request, not its own request.\n            # disconnecting discards the dirty state & forces the next\n            # caller to reconnect.\n            # NOTE: dicts have a consistent ordering; we're iterating\n            # through nodes.values() in the same order as we are when\n            # reading / writing to the connections above, which is critical\n            # for how we're using the nodes_written/nodes_read offsets.\n            for i, n in enumerate(nodes.values()):\n                if i < nodes_written and i >= nodes_read:\n                    n.connection.disconnect()\n                n.connection_pool.release(n.connection)\n\n        # if the response isn't an exception it is a\n        # valid response from the node\n        # we're all done with that command, YAY!\n        # if we have more commands to attempt, we've run into problems.\n        # collect all the commands we are allowed to retry.\n        # (MOVED, ASK, or connection errors or timeout errors)\n        attempt = sorted(\n            (\n                c\n                for c in attempt\n                if isinstance(c.result, ClusterPipeline.ERRORS_ALLOW_RETRY)\n            ),\n            key=lambda x: x.position,\n        )\n        if attempt and allow_redirections:\n            # RETRY MAGIC HAPPENS HERE!\n            # send these remaining commands one at a time using `execute_command`\n            # in the main client. This keeps our retry logic\n            # in one place mostly,\n            # and allows us to be more confident in correctness of behavior.\n            # at this point any speed gains from pipelining have been lost\n            # anyway, so we might as well make the best\n            # attempt to get the correct behavior.\n            #\n            # The client command will handle retries for each\n            # individual command sequentially as we pass each\n            # one into `execute_command`. Any exceptions\n            # that bubble out should only appear once all\n            # retries have been exhausted.\n            #\n            # If a lot of commands have failed, we'll be setting the\n            # flag to rebuild the slots table from scratch.\n            # So MOVED errors should correct themselves fairly quickly.\n            self._pipe.reinitialize_counter += 1\n            if self._pipe._should_reinitialized():\n                self._nodes_manager.initialize()\n                if is_default_node:\n                    self._pipe.replace_default_node()\n            for c in attempt:\n                try:\n                    # send each command individually like we\n                    # do in the main client.\n                    c.result = self._pipe.parent_execute_command(*c.args, **c.options)\n                except RedisError as e:\n                    c.result = e\n\n        # turn the response back into a simple flat array that corresponds\n        # to the sequence of commands issued in the stack in pipeline.execute()\n        response = []\n        for c in sorted(stack, key=lambda x: x.position):\n            if c.args[0] in self._pipe.cluster_response_callbacks:\n                # Remove keys entry, it needs only for cache.\n                c.options.pop(\"keys\", None)\n                c.result = self._pipe._policies_callback_mapping[\n                    c.command_policies.response_policy\n                ](\n                    self._pipe.cluster_response_callbacks[c.args[0]](\n                        c.result, **c.options\n                    )\n                )\n            response.append(c.result)\n\n        if raise_on_error:\n            self._raise_first_error(stack, start_time)\n\n        return response\n\n    def _is_nodes_flag(self, target_nodes):\n        return isinstance(target_nodes, str) and target_nodes in self._pipe.node_flags\n\n    def _parse_target_nodes(self, target_nodes):\n        if isinstance(target_nodes, list):\n            nodes = target_nodes\n        elif isinstance(target_nodes, ClusterNode):\n            # Supports passing a single ClusterNode as a variable\n            nodes = [target_nodes]\n        elif isinstance(target_nodes, dict):\n            # Supports dictionaries of the format {node_name: node}.\n            # It enables to execute commands with multi nodes as follows:\n            # rc.cluster_save_config(rc.get_primaries())\n            nodes = target_nodes.values()\n        else:\n            raise TypeError(\n                \"target_nodes type can be one of the following: \"\n                \"node_flag (PRIMARIES, REPLICAS, RANDOM, ALL_NODES),\"\n                \"ClusterNode, list<ClusterNode>, or dict<any, ClusterNode>. \"\n                f\"The passed type is {type(target_nodes)}\"\n            )\n        return nodes\n\n    def _determine_nodes(\n        self, *args, request_policy: RequestPolicy, **kwargs\n    ) -> List[\"ClusterNode\"]:\n        # Determine which nodes should be executed the command on.\n        # Returns a list of target nodes.\n        command = args[0].upper()\n        if (\n            len(args) >= 2\n            and f\"{args[0]} {args[1]}\".upper() in self._pipe.command_flags\n        ):\n            command = f\"{args[0]} {args[1]}\".upper()\n\n        nodes_flag = kwargs.pop(\"nodes_flag\", None)\n        if nodes_flag is not None:\n            # nodes flag passed by the user\n            command_flag = nodes_flag\n        else:\n            # get the nodes group for this command if it was predefined\n            command_flag = self._pipe.command_flags.get(command)\n\n        if command_flag in self._pipe._command_flags_mapping:\n            request_policy = self._pipe._command_flags_mapping[command_flag]\n\n        policy_callback = self._pipe._policies_callback_mapping[request_policy]\n\n        if request_policy == RequestPolicy.DEFAULT_KEYED:\n            nodes = policy_callback(command, *args)\n        elif request_policy == RequestPolicy.MULTI_SHARD:\n            nodes = policy_callback(*args, **kwargs)\n        elif request_policy == RequestPolicy.DEFAULT_KEYLESS:\n            nodes = policy_callback(args[0])\n        else:\n            nodes = policy_callback()\n\n        if args[0].lower() == \"ft.aggregate\":\n            self._aggregate_nodes = nodes\n\n        return nodes\n\n    def multi(self):\n        raise RedisClusterException(\n            \"method multi() is not supported outside of transactional context\"\n        )\n\n    def discard(self):\n        raise RedisClusterException(\n            \"method discard() is not supported outside of transactional context\"\n        )\n\n    def watch(self, *names):\n        raise RedisClusterException(\n            \"method watch() is not supported outside of transactional context\"\n        )\n\n    def unwatch(self, *names):\n        raise RedisClusterException(\n            \"method unwatch() is not supported outside of transactional context\"\n        )\n\n    def delete(self, *names):\n        if len(names) != 1:\n            raise RedisClusterException(\n                \"deleting multiple keys is not implemented in pipeline command\"\n            )\n\n        return self.execute_command(\"DEL\", names[0])\n\n    def unlink(self, *names):\n        if len(names) != 1:\n            raise RedisClusterException(\n                \"unlinking multiple keys is not implemented in pipeline command\"\n            )\n\n        return self.execute_command(\"UNLINK\", names[0])\n\n\nclass TransactionStrategy(AbstractStrategy):\n    NO_SLOTS_COMMANDS = {\"UNWATCH\"}\n    IMMEDIATE_EXECUTE_COMMANDS = {\"WATCH\", \"UNWATCH\"}\n    UNWATCH_COMMANDS = {\"DISCARD\", \"EXEC\", \"UNWATCH\"}\n    SLOT_REDIRECT_ERRORS = (AskError, MovedError)\n    CONNECTION_ERRORS = (\n        ConnectionError,\n        OSError,\n        ClusterDownError,\n        SlotNotCoveredError,\n    )\n\n    def __init__(self, pipe: ClusterPipeline):\n        super().__init__(pipe)\n        self._explicit_transaction = False\n        self._watching = False\n        self._pipeline_slots: Set[int] = set()\n        self._transaction_connection: Optional[Connection] = None\n        self._executing = False\n        self._retry = copy(self._pipe.retry)\n        self._retry.update_supported_errors(\n            RedisCluster.ERRORS_ALLOW_RETRY + self.SLOT_REDIRECT_ERRORS\n        )\n\n    def _get_client_and_connection_for_transaction(self) -> Tuple[Redis, Connection]:\n        \"\"\"\n        Find a connection for a pipeline transaction.\n\n        For running an atomic transaction, watch keys ensure that contents have not been\n        altered as long as the watch commands for those keys were sent over the same\n        connection. So once we start watching a key, we fetch a connection to the\n        node that owns that slot and reuse it.\n        \"\"\"\n        if not self._pipeline_slots:\n            raise RedisClusterException(\n                \"At least a command with a key is needed to identify a node\"\n            )\n\n        node: ClusterNode = self._nodes_manager.get_node_from_slot(\n            list(self._pipeline_slots)[0], False\n        )\n        redis_node: Redis = self._pipe.get_redis_connection(node)\n        if self._transaction_connection:\n            if not redis_node.connection_pool.owns_connection(\n                self._transaction_connection\n            ):\n                previous_node = self._nodes_manager.find_connection_owner(\n                    self._transaction_connection\n                )\n                previous_node.connection_pool.release(self._transaction_connection)\n                self._transaction_connection = None\n\n        if not self._transaction_connection:\n            self._transaction_connection = get_connection(redis_node)\n\n        return redis_node, self._transaction_connection\n\n    def execute_command(self, *args, **kwargs):\n        slot_number: Optional[int] = None\n        if args[0] not in ClusterPipeline.NO_SLOTS_COMMANDS:\n            slot_number = self._pipe.determine_slot(*args)\n\n        if (\n            self._watching or args[0] in self.IMMEDIATE_EXECUTE_COMMANDS\n        ) and not self._explicit_transaction:\n            if args[0] == \"WATCH\":\n                self._validate_watch()\n\n            if slot_number is not None:\n                if self._pipeline_slots and slot_number not in self._pipeline_slots:\n                    raise CrossSlotTransactionError(\n                        \"Cannot watch or send commands on different slots\"\n                    )\n\n                self._pipeline_slots.add(slot_number)\n            elif args[0] not in self.NO_SLOTS_COMMANDS:\n                raise RedisClusterException(\n                    f\"Cannot identify slot number for command: {args[0]},\"\n                    \"it cannot be triggered in a transaction\"\n                )\n\n            return self._immediate_execute_command(*args, **kwargs)\n        else:\n            if slot_number is not None:\n                self._pipeline_slots.add(slot_number)\n\n            return self.pipeline_execute_command(*args, **kwargs)\n\n    def _validate_watch(self):\n        if self._explicit_transaction:\n            raise RedisError(\"Cannot issue a WATCH after a MULTI\")\n\n        self._watching = True\n\n    def _immediate_execute_command(self, *args, **options):\n        return self._retry.call_with_retry(\n            lambda: self._get_connection_and_send_command(*args, **options),\n            self._reinitialize_on_error,\n            with_failure_count=True,\n        )\n\n    def _get_connection_and_send_command(self, *args, **options):\n        redis_node, connection = self._get_client_and_connection_for_transaction()\n\n        # Start timing for observability\n        start_time = time.monotonic()\n\n        try:\n            response = self._send_command_parse_response(\n                connection, redis_node, args[0], *args, **options\n            )\n\n            record_operation_duration(\n                command_name=args[0],\n                duration_seconds=time.monotonic() - start_time,\n                server_address=connection.host,\n                server_port=connection.port,\n                db_namespace=str(connection.db),\n            )\n\n            return response\n        except Exception as e:\n            if connection:\n                # this is used to report the metrics based on host and port info\n                e.connection = connection\n            record_operation_duration(\n                command_name=args[0],\n                duration_seconds=time.monotonic() - start_time,\n                server_address=connection.host,\n                server_port=connection.port,\n                db_namespace=str(connection.db),\n                error=e,\n            )\n            raise\n\n    def _send_command_parse_response(\n        self, conn, redis_node: Redis, command_name, *args, **options\n    ):\n        \"\"\"\n        Send a command and parse the response\n        \"\"\"\n\n        conn.send_command(*args)\n        output = redis_node.parse_response(conn, command_name, **options)\n\n        if command_name in self.UNWATCH_COMMANDS:\n            self._watching = False\n        return output\n\n    def _reinitialize_on_error(self, error, failure_count):\n        if hasattr(error, \"connection\"):\n            record_error_count(\n                server_address=error.connection.host,\n                server_port=error.connection.port,\n                network_peer_address=error.connection.host,\n                network_peer_port=error.connection.port,\n                error_type=error,\n                retry_attempts=failure_count,\n                is_internal=True,\n            )\n\n        if self._watching:\n            if type(error) in self.SLOT_REDIRECT_ERRORS and self._executing:\n                raise WatchError(\"Slot rebalancing occurred while watching keys\")\n\n        if (\n            type(error) in self.SLOT_REDIRECT_ERRORS\n            or type(error) in self.CONNECTION_ERRORS\n        ):\n            if self._transaction_connection:\n                if is_debug_log_enabled():\n                    logger.debug(\n                        f\"Operation failed, \"\n                        f\"with connection: {self._transaction_connection}, \"\n                        f\"details: {self._transaction_connection.extract_connection_details()}\",\n                    )\n                # Disconnect and release back to pool\n                self._transaction_connection.disconnect()\n                node = self._nodes_manager.find_connection_owner(\n                    self._transaction_connection\n                )\n                if node and node.redis_connection:\n                    node.redis_connection.connection_pool.release(\n                        self._transaction_connection\n                    )\n                self._transaction_connection = None\n\n            self._pipe.reinitialize_counter += 1\n            if self._pipe._should_reinitialized():\n                self._nodes_manager.initialize()\n                self.reinitialize_counter = 0\n            else:\n                if isinstance(error, AskError):\n                    self._nodes_manager.move_slot(error)\n\n        self._executing = False\n\n    def _raise_first_error(self, responses, stack, start_time):\n        \"\"\"\n        Raise the first exception on the stack\n        \"\"\"\n        for r, cmd in zip(responses, stack):\n            if isinstance(r, Exception):\n                self.annotate_exception(r, cmd.position + 1, cmd.args)\n\n                record_operation_duration(\n                    command_name=\"TRANSACTION\",\n                    duration_seconds=time.monotonic() - start_time,\n                    server_address=self._transaction_connection.host,\n                    server_port=self._transaction_connection.port,\n                    db_namespace=str(self._transaction_connection.db),\n                )\n\n                raise r\n\n    def execute(self, raise_on_error: bool = True) -> List[Any]:\n        stack = self._command_queue\n        if not stack and (not self._watching or not self._pipeline_slots):\n            return []\n\n        return self._execute_transaction_with_retries(stack, raise_on_error)\n\n    def _execute_transaction_with_retries(\n        self, stack: List[\"PipelineCommand\"], raise_on_error: bool\n    ):\n        return self._retry.call_with_retry(\n            lambda: self._execute_transaction(stack, raise_on_error),\n            lambda error, failure_count: self._reinitialize_on_error(\n                error, failure_count\n            ),\n            with_failure_count=True,\n        )\n\n    def _execute_transaction(\n        self, stack: List[\"PipelineCommand\"], raise_on_error: bool\n    ):\n        if len(self._pipeline_slots) > 1:\n            raise CrossSlotTransactionError(\n                \"All keys involved in a cluster transaction must map to the same slot\"\n            )\n\n        self._executing = True\n\n        redis_node, connection = self._get_client_and_connection_for_transaction()\n\n        stack = chain(\n            [PipelineCommand((\"MULTI\",))],\n            stack,\n            [PipelineCommand((\"EXEC\",))],\n        )\n        commands = [c.args for c in stack if EMPTY_RESPONSE not in c.options]\n        packed_commands = connection.pack_commands(commands)\n\n        # Start timing for observability\n        start_time = time.monotonic()\n\n        connection.send_packed_command(packed_commands)\n        errors = []\n\n        # parse off the response for MULTI\n        # NOTE: we need to handle ResponseErrors here and continue\n        # so that we read all the additional command messages from\n        # the socket\n        try:\n            redis_node.parse_response(connection, \"MULTI\")\n        except ResponseError as e:\n            self.annotate_exception(e, 0, \"MULTI\")\n            errors.append(e)\n        except self.CONNECTION_ERRORS as cluster_error:\n            self.annotate_exception(cluster_error, 0, \"MULTI\")\n            raise\n\n        # and all the other commands\n        for i, command in enumerate(self._command_queue):\n            if EMPTY_RESPONSE in command.options:\n                errors.append((i, command.options[EMPTY_RESPONSE]))\n            else:\n                try:\n                    _ = redis_node.parse_response(connection, \"_\")\n                except self.SLOT_REDIRECT_ERRORS as slot_error:\n                    self.annotate_exception(slot_error, i + 1, command.args)\n                    errors.append(slot_error)\n                except self.CONNECTION_ERRORS as cluster_error:\n                    self.annotate_exception(cluster_error, i + 1, command.args)\n                    raise\n                except ResponseError as e:\n                    self.annotate_exception(e, i + 1, command.args)\n                    errors.append(e)\n\n        response = None\n        # parse the EXEC.\n        try:\n            response = redis_node.parse_response(connection, \"EXEC\")\n        except ExecAbortError:\n            if errors:\n                raise errors[0]\n            raise\n\n        self._executing = False\n\n        record_operation_duration(\n            command_name=\"TRANSACTION\",\n            duration_seconds=time.monotonic() - start_time,\n            server_address=connection.host,\n            server_port=connection.port,\n            db_namespace=str(connection.db),\n        )\n\n        # EXEC clears any watched keys\n        self._watching = False\n\n        if response is None:\n            raise WatchError(\"Watched variable changed.\")\n\n        # put any parse errors into the response\n        for i, e in errors:\n            response.insert(i, e)\n\n        if len(response) != len(self._command_queue):\n            raise InvalidPipelineStack(\n                \"Unexpected response length for cluster pipeline EXEC.\"\n                \" Command stack was {} but response had length {}\".format(\n                    [c.args[0] for c in self._command_queue], len(response)\n                )\n            )\n\n        # find any errors in the response and raise if necessary\n        if raise_on_error or len(errors) > 0:\n            self._raise_first_error(\n                response,\n                self._command_queue,\n                start_time,\n            )\n\n        # We have to run response callbacks manually\n        data = []\n        for r, cmd in zip(response, self._command_queue):\n            if not isinstance(r, Exception):\n                command_name = cmd.args[0]\n                if command_name in self._pipe.cluster_response_callbacks:\n                    r = self._pipe.cluster_response_callbacks[command_name](\n                        r, **cmd.options\n                    )\n            data.append(r)\n        return data\n\n    def reset(self):\n        self._command_queue = []\n\n        # make sure to reset the connection state in the event that we were\n        # watching something\n        if self._transaction_connection:\n            try:\n                if self._watching:\n                    # call this manually since our unwatch or\n                    # immediate_execute_command methods can call reset()\n                    self._transaction_connection.send_command(\"UNWATCH\")\n                    self._transaction_connection.read_response()\n                # we can safely return the connection to the pool here since we're\n                # sure we're no longer WATCHing anything\n                node = self._nodes_manager.find_connection_owner(\n                    self._transaction_connection\n                )\n                if node and node.redis_connection:\n                    node.redis_connection.connection_pool.release(\n                        self._transaction_connection\n                    )\n                self._transaction_connection = None\n            except self.CONNECTION_ERRORS:\n                # disconnect will also remove any previous WATCHes\n                if self._transaction_connection:\n                    self._transaction_connection.disconnect()\n                    node = self._nodes_manager.find_connection_owner(\n                        self._transaction_connection\n                    )\n                    if node and node.redis_connection:\n                        node.redis_connection.connection_pool.release(\n                            self._transaction_connection\n                        )\n                    self._transaction_connection = None\n\n        # clean up the other instance attributes\n        self._watching = False\n        self._explicit_transaction = False\n        self._pipeline_slots = set()\n        self._executing = False\n\n    def send_cluster_commands(\n        self, stack, raise_on_error=True, allow_redirections=True\n    ):\n        raise NotImplementedError(\n            \"send_cluster_commands cannot be executed in transactional context.\"\n        )\n\n    def multi(self):\n        if self._explicit_transaction:\n            raise RedisError(\"Cannot issue nested calls to MULTI\")\n        if self._command_queue:\n            raise RedisError(\n                \"Commands without an initial WATCH have already been issued\"\n            )\n        self._explicit_transaction = True\n\n    def watch(self, *names):\n        if self._explicit_transaction:\n            raise RedisError(\"Cannot issue a WATCH after a MULTI\")\n\n        return self.execute_command(\"WATCH\", *names)\n\n    def unwatch(self):\n        if self._watching:\n            return self.execute_command(\"UNWATCH\")\n\n        return True\n\n    def discard(self):\n        self.reset()\n\n    def delete(self, *names):\n        return self.execute_command(\"DEL\", *names)\n\n    def unlink(self, *names):\n        return self.execute_command(\"UNLINK\", *names)\n"
  },
  {
    "path": "redis/commands/__init__.py",
    "content": "from .cluster import READ_COMMANDS, AsyncRedisClusterCommands, RedisClusterCommands\nfrom .core import AsyncCoreCommands, CoreCommands\nfrom .helpers import list_or_args\nfrom .redismodules import AsyncRedisModuleCommands, RedisModuleCommands\nfrom .sentinel import AsyncSentinelCommands, SentinelCommands\n\n__all__ = [\n    \"AsyncCoreCommands\",\n    \"AsyncRedisClusterCommands\",\n    \"AsyncRedisModuleCommands\",\n    \"AsyncSentinelCommands\",\n    \"CoreCommands\",\n    \"READ_COMMANDS\",\n    \"RedisClusterCommands\",\n    \"RedisModuleCommands\",\n    \"SentinelCommands\",\n    \"list_or_args\",\n]\n"
  },
  {
    "path": "redis/commands/bf/__init__.py",
    "content": "from redis._parsers.helpers import bool_ok\n\nfrom ..helpers import get_protocol_version, parse_to_list\nfrom .commands import *  # noqa\nfrom .info import BFInfo, CFInfo, CMSInfo, TDigestInfo, TopKInfo\n\n\nclass AbstractBloom:\n    \"\"\"\n    The client allows to interact with RedisBloom and use all of\n    it's functionality.\n\n    - BF for Bloom Filter\n    - CF for Cuckoo Filter\n    - CMS for Count-Min Sketch\n    - TOPK for TopK Data Structure\n    - TDIGEST for estimate rank statistics\n    \"\"\"\n\n    @staticmethod\n    def append_items(params, items):\n        \"\"\"Append ITEMS to params.\"\"\"\n        params.extend([\"ITEMS\"])\n        params += items\n\n    @staticmethod\n    def append_error(params, error):\n        \"\"\"Append ERROR to params.\"\"\"\n        if error is not None:\n            params.extend([\"ERROR\", error])\n\n    @staticmethod\n    def append_capacity(params, capacity):\n        \"\"\"Append CAPACITY to params.\"\"\"\n        if capacity is not None:\n            params.extend([\"CAPACITY\", capacity])\n\n    @staticmethod\n    def append_expansion(params, expansion):\n        \"\"\"Append EXPANSION to params.\"\"\"\n        if expansion is not None:\n            params.extend([\"EXPANSION\", expansion])\n\n    @staticmethod\n    def append_no_scale(params, noScale):\n        \"\"\"Append NONSCALING tag to params.\"\"\"\n        if noScale is not None:\n            params.extend([\"NONSCALING\"])\n\n    @staticmethod\n    def append_weights(params, weights):\n        \"\"\"Append WEIGHTS to params.\"\"\"\n        if len(weights) > 0:\n            params.append(\"WEIGHTS\")\n            params += weights\n\n    @staticmethod\n    def append_no_create(params, noCreate):\n        \"\"\"Append NOCREATE tag to params.\"\"\"\n        if noCreate is not None:\n            params.extend([\"NOCREATE\"])\n\n    @staticmethod\n    def append_items_and_increments(params, items, increments):\n        \"\"\"Append pairs of items and increments to params.\"\"\"\n        for i in range(len(items)):\n            params.append(items[i])\n            params.append(increments[i])\n\n    @staticmethod\n    def append_values_and_weights(params, items, weights):\n        \"\"\"Append pairs of items and weights to params.\"\"\"\n        for i in range(len(items)):\n            params.append(items[i])\n            params.append(weights[i])\n\n    @staticmethod\n    def append_max_iterations(params, max_iterations):\n        \"\"\"Append MAXITERATIONS to params.\"\"\"\n        if max_iterations is not None:\n            params.extend([\"MAXITERATIONS\", max_iterations])\n\n    @staticmethod\n    def append_bucket_size(params, bucket_size):\n        \"\"\"Append BUCKETSIZE to params.\"\"\"\n        if bucket_size is not None:\n            params.extend([\"BUCKETSIZE\", bucket_size])\n\n\nclass CMSBloom(CMSCommands, AbstractBloom):\n    def __init__(self, client, **kwargs):\n        \"\"\"Create a new RedisBloom client.\"\"\"\n        # Set the module commands' callbacks\n        _MODULE_CALLBACKS = {\n            CMS_INITBYDIM: bool_ok,\n            CMS_INITBYPROB: bool_ok,\n            # CMS_INCRBY: spaceHolder,\n            # CMS_QUERY: spaceHolder,\n            CMS_MERGE: bool_ok,\n        }\n\n        _RESP2_MODULE_CALLBACKS = {\n            CMS_INFO: CMSInfo,\n        }\n        _RESP3_MODULE_CALLBACKS = {}\n\n        self.client = client\n        self.commandmixin = CMSCommands\n        self.execute_command = client.execute_command\n\n        if get_protocol_version(self.client) in [\"3\", 3]:\n            _MODULE_CALLBACKS.update(_RESP3_MODULE_CALLBACKS)\n        else:\n            _MODULE_CALLBACKS.update(_RESP2_MODULE_CALLBACKS)\n\n        for k, v in _MODULE_CALLBACKS.items():\n            self.client.set_response_callback(k, v)\n\n\nclass TOPKBloom(TOPKCommands, AbstractBloom):\n    def __init__(self, client, **kwargs):\n        \"\"\"Create a new RedisBloom client.\"\"\"\n        # Set the module commands' callbacks\n        _MODULE_CALLBACKS = {\n            TOPK_RESERVE: bool_ok,\n            # TOPK_QUERY: spaceHolder,\n            # TOPK_COUNT: spaceHolder,\n        }\n\n        _RESP2_MODULE_CALLBACKS = {\n            TOPK_ADD: parse_to_list,\n            TOPK_INCRBY: parse_to_list,\n            TOPK_INFO: TopKInfo,\n            TOPK_LIST: parse_to_list,\n        }\n        _RESP3_MODULE_CALLBACKS = {}\n\n        self.client = client\n        self.commandmixin = TOPKCommands\n        self.execute_command = client.execute_command\n\n        if get_protocol_version(self.client) in [\"3\", 3]:\n            _MODULE_CALLBACKS.update(_RESP3_MODULE_CALLBACKS)\n        else:\n            _MODULE_CALLBACKS.update(_RESP2_MODULE_CALLBACKS)\n\n        for k, v in _MODULE_CALLBACKS.items():\n            self.client.set_response_callback(k, v)\n\n\nclass CFBloom(CFCommands, AbstractBloom):\n    def __init__(self, client, **kwargs):\n        \"\"\"Create a new RedisBloom client.\"\"\"\n        # Set the module commands' callbacks\n        _MODULE_CALLBACKS = {\n            CF_RESERVE: bool_ok,\n            # CF_ADD: spaceHolder,\n            # CF_ADDNX: spaceHolder,\n            # CF_INSERT: spaceHolder,\n            # CF_INSERTNX: spaceHolder,\n            # CF_EXISTS: spaceHolder,\n            # CF_DEL: spaceHolder,\n            # CF_COUNT: spaceHolder,\n            # CF_SCANDUMP: spaceHolder,\n            # CF_LOADCHUNK: spaceHolder,\n        }\n\n        _RESP2_MODULE_CALLBACKS = {\n            CF_INFO: CFInfo,\n        }\n        _RESP3_MODULE_CALLBACKS = {}\n\n        self.client = client\n        self.commandmixin = CFCommands\n        self.execute_command = client.execute_command\n\n        if get_protocol_version(self.client) in [\"3\", 3]:\n            _MODULE_CALLBACKS.update(_RESP3_MODULE_CALLBACKS)\n        else:\n            _MODULE_CALLBACKS.update(_RESP2_MODULE_CALLBACKS)\n\n        for k, v in _MODULE_CALLBACKS.items():\n            self.client.set_response_callback(k, v)\n\n\nclass TDigestBloom(TDigestCommands, AbstractBloom):\n    def __init__(self, client, **kwargs):\n        \"\"\"Create a new RedisBloom client.\"\"\"\n        # Set the module commands' callbacks\n        _MODULE_CALLBACKS = {\n            TDIGEST_CREATE: bool_ok,\n            # TDIGEST_RESET: bool_ok,\n            # TDIGEST_ADD: spaceHolder,\n            # TDIGEST_MERGE: spaceHolder,\n        }\n\n        _RESP2_MODULE_CALLBACKS = {\n            TDIGEST_BYRANK: parse_to_list,\n            TDIGEST_BYREVRANK: parse_to_list,\n            TDIGEST_CDF: parse_to_list,\n            TDIGEST_INFO: TDigestInfo,\n            TDIGEST_MIN: float,\n            TDIGEST_MAX: float,\n            TDIGEST_TRIMMED_MEAN: float,\n            TDIGEST_QUANTILE: parse_to_list,\n        }\n        _RESP3_MODULE_CALLBACKS = {}\n\n        self.client = client\n        self.commandmixin = TDigestCommands\n        self.execute_command = client.execute_command\n\n        if get_protocol_version(self.client) in [\"3\", 3]:\n            _MODULE_CALLBACKS.update(_RESP3_MODULE_CALLBACKS)\n        else:\n            _MODULE_CALLBACKS.update(_RESP2_MODULE_CALLBACKS)\n\n        for k, v in _MODULE_CALLBACKS.items():\n            self.client.set_response_callback(k, v)\n\n\nclass BFBloom(BFCommands, AbstractBloom):\n    def __init__(self, client, **kwargs):\n        \"\"\"Create a new RedisBloom client.\"\"\"\n        # Set the module commands' callbacks\n        _MODULE_CALLBACKS = {\n            BF_RESERVE: bool_ok,\n            # BF_ADD: spaceHolder,\n            # BF_MADD: spaceHolder,\n            # BF_INSERT: spaceHolder,\n            # BF_EXISTS: spaceHolder,\n            # BF_MEXISTS: spaceHolder,\n            # BF_SCANDUMP: spaceHolder,\n            # BF_LOADCHUNK: spaceHolder,\n            # BF_CARD: spaceHolder,\n        }\n\n        _RESP2_MODULE_CALLBACKS = {\n            BF_INFO: BFInfo,\n        }\n        _RESP3_MODULE_CALLBACKS = {}\n\n        self.client = client\n        self.commandmixin = BFCommands\n        self.execute_command = client.execute_command\n\n        if get_protocol_version(self.client) in [\"3\", 3]:\n            _MODULE_CALLBACKS.update(_RESP3_MODULE_CALLBACKS)\n        else:\n            _MODULE_CALLBACKS.update(_RESP2_MODULE_CALLBACKS)\n\n        for k, v in _MODULE_CALLBACKS.items():\n            self.client.set_response_callback(k, v)\n"
  },
  {
    "path": "redis/commands/bf/commands.py",
    "content": "from redis.client import NEVER_DECODE\nfrom redis.utils import deprecated_function\n\nBF_RESERVE = \"BF.RESERVE\"\nBF_ADD = \"BF.ADD\"\nBF_MADD = \"BF.MADD\"\nBF_INSERT = \"BF.INSERT\"\nBF_EXISTS = \"BF.EXISTS\"\nBF_MEXISTS = \"BF.MEXISTS\"\nBF_SCANDUMP = \"BF.SCANDUMP\"\nBF_LOADCHUNK = \"BF.LOADCHUNK\"\nBF_INFO = \"BF.INFO\"\nBF_CARD = \"BF.CARD\"\n\nCF_RESERVE = \"CF.RESERVE\"\nCF_ADD = \"CF.ADD\"\nCF_ADDNX = \"CF.ADDNX\"\nCF_INSERT = \"CF.INSERT\"\nCF_INSERTNX = \"CF.INSERTNX\"\nCF_EXISTS = \"CF.EXISTS\"\nCF_MEXISTS = \"CF.MEXISTS\"\nCF_DEL = \"CF.DEL\"\nCF_COUNT = \"CF.COUNT\"\nCF_SCANDUMP = \"CF.SCANDUMP\"\nCF_LOADCHUNK = \"CF.LOADCHUNK\"\nCF_INFO = \"CF.INFO\"\n\nCMS_INITBYDIM = \"CMS.INITBYDIM\"\nCMS_INITBYPROB = \"CMS.INITBYPROB\"\nCMS_INCRBY = \"CMS.INCRBY\"\nCMS_QUERY = \"CMS.QUERY\"\nCMS_MERGE = \"CMS.MERGE\"\nCMS_INFO = \"CMS.INFO\"\n\nTOPK_RESERVE = \"TOPK.RESERVE\"\nTOPK_ADD = \"TOPK.ADD\"\nTOPK_INCRBY = \"TOPK.INCRBY\"\nTOPK_QUERY = \"TOPK.QUERY\"\nTOPK_COUNT = \"TOPK.COUNT\"\nTOPK_LIST = \"TOPK.LIST\"\nTOPK_INFO = \"TOPK.INFO\"\n\nTDIGEST_CREATE = \"TDIGEST.CREATE\"\nTDIGEST_RESET = \"TDIGEST.RESET\"\nTDIGEST_ADD = \"TDIGEST.ADD\"\nTDIGEST_MERGE = \"TDIGEST.MERGE\"\nTDIGEST_CDF = \"TDIGEST.CDF\"\nTDIGEST_QUANTILE = \"TDIGEST.QUANTILE\"\nTDIGEST_MIN = \"TDIGEST.MIN\"\nTDIGEST_MAX = \"TDIGEST.MAX\"\nTDIGEST_INFO = \"TDIGEST.INFO\"\nTDIGEST_TRIMMED_MEAN = \"TDIGEST.TRIMMED_MEAN\"\nTDIGEST_RANK = \"TDIGEST.RANK\"\nTDIGEST_REVRANK = \"TDIGEST.REVRANK\"\nTDIGEST_BYRANK = \"TDIGEST.BYRANK\"\nTDIGEST_BYREVRANK = \"TDIGEST.BYREVRANK\"\n\n\nclass BFCommands:\n    \"\"\"Bloom Filter commands.\"\"\"\n\n    def create(self, key, errorRate, capacity, expansion=None, noScale=None):\n        \"\"\"\n        Create a new Bloom Filter `key` with desired probability of false positives\n        `errorRate` expected entries to be inserted as `capacity`.\n        Default expansion value is 2. By default, filter is auto-scaling.\n        For more information see `BF.RESERVE <https://redis.io/commands/bf.reserve>`_.\n        \"\"\"  # noqa\n        params = [key, errorRate, capacity]\n        self.append_expansion(params, expansion)\n        self.append_no_scale(params, noScale)\n        return self.execute_command(BF_RESERVE, *params)\n\n    reserve = create\n\n    def add(self, key, item):\n        \"\"\"\n        Add to a Bloom Filter `key` an `item`.\n        For more information see `BF.ADD <https://redis.io/commands/bf.add>`_.\n        \"\"\"  # noqa\n        return self.execute_command(BF_ADD, key, item)\n\n    def madd(self, key, *items):\n        \"\"\"\n        Add to a Bloom Filter `key` multiple `items`.\n        For more information see `BF.MADD <https://redis.io/commands/bf.madd>`_.\n        \"\"\"  # noqa\n        return self.execute_command(BF_MADD, key, *items)\n\n    def insert(\n        self,\n        key,\n        items,\n        capacity=None,\n        error=None,\n        noCreate=None,\n        expansion=None,\n        noScale=None,\n    ):\n        \"\"\"\n        Add to a Bloom Filter `key` multiple `items`.\n\n        If `nocreate` remain `None` and `key` does not exist, a new Bloom Filter\n        `key` will be created with desired probability of false positives `errorRate`\n        and expected entries to be inserted as `size`.\n        For more information see `BF.INSERT <https://redis.io/commands/bf.insert>`_.\n        \"\"\"  # noqa\n        params = [key]\n        self.append_capacity(params, capacity)\n        self.append_error(params, error)\n        self.append_expansion(params, expansion)\n        self.append_no_create(params, noCreate)\n        self.append_no_scale(params, noScale)\n        self.append_items(params, items)\n\n        return self.execute_command(BF_INSERT, *params)\n\n    def exists(self, key, item):\n        \"\"\"\n        Check whether an `item` exists in Bloom Filter `key`.\n        For more information see `BF.EXISTS <https://redis.io/commands/bf.exists>`_.\n        \"\"\"  # noqa\n        return self.execute_command(BF_EXISTS, key, item)\n\n    def mexists(self, key, *items):\n        \"\"\"\n        Check whether `items` exist in Bloom Filter `key`.\n        For more information see `BF.MEXISTS <https://redis.io/commands/bf.mexists>`_.\n        \"\"\"  # noqa\n        return self.execute_command(BF_MEXISTS, key, *items)\n\n    def scandump(self, key, iter):\n        \"\"\"\n        Begin an incremental save of the bloom filter `key`.\n\n        This is useful for large bloom filters which cannot fit into the normal SAVE and RESTORE model.\n        The first time this command is called, the value of `iter` should be 0.\n        This command will return successive (iter, data) pairs until (0, NULL) to indicate completion.\n        For more information see `BF.SCANDUMP <https://redis.io/commands/bf.scandump>`_.\n        \"\"\"  # noqa\n        params = [key, iter]\n        options = {}\n        options[NEVER_DECODE] = []\n        return self.execute_command(BF_SCANDUMP, *params, **options)\n\n    def loadchunk(self, key, iter, data):\n        \"\"\"\n        Restore a filter previously saved using SCANDUMP.\n\n        See the SCANDUMP command for example usage.\n        This command will overwrite any bloom filter stored under key.\n        Ensure that the bloom filter will not be modified between invocations.\n        For more information see `BF.LOADCHUNK <https://redis.io/commands/bf.loadchunk>`_.\n        \"\"\"  # noqa\n        return self.execute_command(BF_LOADCHUNK, key, iter, data)\n\n    def info(self, key):\n        \"\"\"\n        Return capacity, size, number of filters, number of items inserted, and expansion rate.\n        For more information see `BF.INFO <https://redis.io/commands/bf.info>`_.\n        \"\"\"  # noqa\n        return self.execute_command(BF_INFO, key)\n\n    def card(self, key):\n        \"\"\"\n        Returns the cardinality of a Bloom filter - number of items that were added to a Bloom filter and detected as unique\n        (items that caused at least one bit to be set in at least one sub-filter).\n        For more information see `BF.CARD <https://redis.io/commands/bf.card>`_.\n        \"\"\"  # noqa\n        return self.execute_command(BF_CARD, key)\n\n\nclass CFCommands:\n    \"\"\"Cuckoo Filter commands.\"\"\"\n\n    def create(\n        self, key, capacity, expansion=None, bucket_size=None, max_iterations=None\n    ):\n        \"\"\"\n        Create a new Cuckoo Filter `key` an initial `capacity` items.\n        For more information see `CF.RESERVE <https://redis.io/commands/cf.reserve>`_.\n        \"\"\"  # noqa\n        params = [key, capacity]\n        self.append_expansion(params, expansion)\n        self.append_bucket_size(params, bucket_size)\n        self.append_max_iterations(params, max_iterations)\n        return self.execute_command(CF_RESERVE, *params)\n\n    reserve = create\n\n    def add(self, key, item):\n        \"\"\"\n        Add an `item` to a Cuckoo Filter `key`.\n        For more information see `CF.ADD <https://redis.io/commands/cf.add>`_.\n        \"\"\"  # noqa\n        return self.execute_command(CF_ADD, key, item)\n\n    def addnx(self, key, item):\n        \"\"\"\n        Add an `item` to a Cuckoo Filter `key` only if item does not yet exist.\n        Command might be slower that `add`.\n        For more information see `CF.ADDNX <https://redis.io/commands/cf.addnx>`_.\n        \"\"\"  # noqa\n        return self.execute_command(CF_ADDNX, key, item)\n\n    def insert(self, key, items, capacity=None, nocreate=None):\n        \"\"\"\n        Add multiple `items` to a Cuckoo Filter `key`, allowing the filter\n        to be created with a custom `capacity` if it does not yet exist.\n        `items` must be provided as a list.\n        For more information see `CF.INSERT <https://redis.io/commands/cf.insert>`_.\n        \"\"\"  # noqa\n        params = [key]\n        self.append_capacity(params, capacity)\n        self.append_no_create(params, nocreate)\n        self.append_items(params, items)\n        return self.execute_command(CF_INSERT, *params)\n\n    def insertnx(self, key, items, capacity=None, nocreate=None):\n        \"\"\"\n        Add multiple `items` to a Cuckoo Filter `key` only if they do not exist yet,\n        allowing the filter to be created with a custom `capacity` if it does not yet exist.\n        `items` must be provided as a list.\n        For more information see `CF.INSERTNX <https://redis.io/commands/cf.insertnx>`_.\n        \"\"\"  # noqa\n        params = [key]\n        self.append_capacity(params, capacity)\n        self.append_no_create(params, nocreate)\n        self.append_items(params, items)\n        return self.execute_command(CF_INSERTNX, *params)\n\n    def exists(self, key, item):\n        \"\"\"\n        Check whether an `item` exists in Cuckoo Filter `key`.\n        For more information see `CF.EXISTS <https://redis.io/commands/cf.exists>`_.\n        \"\"\"  # noqa\n        return self.execute_command(CF_EXISTS, key, item)\n\n    def mexists(self, key, *items):\n        \"\"\"\n        Check whether an `items` exist in Cuckoo Filter `key`.\n        For more information see `CF.MEXISTS <https://redis.io/commands/cf.mexists>`_.\n        \"\"\"  # noqa\n        return self.execute_command(CF_MEXISTS, key, *items)\n\n    def delete(self, key, item):\n        \"\"\"\n        Delete `item` from `key`.\n        For more information see `CF.DEL <https://redis.io/commands/cf.del>`_.\n        \"\"\"  # noqa\n        return self.execute_command(CF_DEL, key, item)\n\n    def count(self, key, item):\n        \"\"\"\n        Return the number of times an `item` may be in the `key`.\n        For more information see `CF.COUNT <https://redis.io/commands/cf.count>`_.\n        \"\"\"  # noqa\n        return self.execute_command(CF_COUNT, key, item)\n\n    def scandump(self, key, iter):\n        \"\"\"\n        Begin an incremental save of the Cuckoo filter `key`.\n\n        This is useful for large Cuckoo filters which cannot fit into the normal\n        SAVE and RESTORE model.\n        The first time this command is called, the value of `iter` should be 0.\n        This command will return successive (iter, data) pairs until\n        (0, NULL) to indicate completion.\n        For more information see `CF.SCANDUMP <https://redis.io/commands/cf.scandump>`_.\n        \"\"\"  # noqa\n        return self.execute_command(CF_SCANDUMP, key, iter)\n\n    def loadchunk(self, key, iter, data):\n        \"\"\"\n        Restore a filter previously saved using SCANDUMP. See the SCANDUMP command for example usage.\n\n        This command will overwrite any Cuckoo filter stored under key.\n        Ensure that the Cuckoo filter will not be modified between invocations.\n        For more information see `CF.LOADCHUNK <https://redis.io/commands/cf.loadchunk>`_.\n        \"\"\"  # noqa\n        return self.execute_command(CF_LOADCHUNK, key, iter, data)\n\n    def info(self, key):\n        \"\"\"\n        Return size, number of buckets, number of filter, number of items inserted,\n        number of items deleted, bucket size, expansion rate, and max iteration.\n        For more information see `CF.INFO <https://redis.io/commands/cf.info>`_.\n        \"\"\"  # noqa\n        return self.execute_command(CF_INFO, key)\n\n\nclass TOPKCommands:\n    \"\"\"TOP-k Filter commands.\"\"\"\n\n    def reserve(self, key, k, width, depth, decay):\n        \"\"\"\n        Create a new Top-K Filter `key` with desired probability of false\n        positives `errorRate` expected entries to be inserted as `size`.\n        For more information see `TOPK.RESERVE <https://redis.io/commands/topk.reserve>`_.\n        \"\"\"  # noqa\n        return self.execute_command(TOPK_RESERVE, key, k, width, depth, decay)\n\n    def add(self, key, *items):\n        \"\"\"\n        Add one `item` or more to a Top-K Filter `key`.\n        For more information see `TOPK.ADD <https://redis.io/commands/topk.add>`_.\n        \"\"\"  # noqa\n        return self.execute_command(TOPK_ADD, key, *items)\n\n    def incrby(self, key, items, increments):\n        \"\"\"\n        Add/increase `items` to a Top-K Sketch `key` by ''increments''.\n        Both `items` and `increments` are lists.\n        For more information see `TOPK.INCRBY <https://redis.io/commands/topk.incrby>`_.\n\n        Example:\n\n        >>> topkincrby('A', ['foo'], [1])\n        \"\"\"  # noqa\n        params = [key]\n        self.append_items_and_increments(params, items, increments)\n        return self.execute_command(TOPK_INCRBY, *params)\n\n    def query(self, key, *items):\n        \"\"\"\n        Check whether one `item` or more is a Top-K item at `key`.\n        For more information see `TOPK.QUERY <https://redis.io/commands/topk.query>`_.\n        \"\"\"  # noqa\n        return self.execute_command(TOPK_QUERY, key, *items)\n\n    @deprecated_function(version=\"4.4.0\", reason=\"deprecated since redisbloom 2.4.0\")\n    def count(self, key, *items):\n        \"\"\"\n        Return count for one `item` or more from `key`.\n        For more information see `TOPK.COUNT <https://redis.io/commands/topk.count>`_.\n        \"\"\"  # noqa\n        return self.execute_command(TOPK_COUNT, key, *items)\n\n    def list(self, key, withcount=False):\n        \"\"\"\n        Return full list of items in Top-K list of `key`.\n        If `withcount` set to True, return full list of items\n        with probabilistic count in Top-K list of `key`.\n        For more information see `TOPK.LIST <https://redis.io/commands/topk.list>`_.\n        \"\"\"  # noqa\n        params = [key]\n        if withcount:\n            params.append(\"WITHCOUNT\")\n        return self.execute_command(TOPK_LIST, *params)\n\n    def info(self, key):\n        \"\"\"\n        Return k, width, depth and decay values of `key`.\n        For more information see `TOPK.INFO <https://redis.io/commands/topk.info>`_.\n        \"\"\"  # noqa\n        return self.execute_command(TOPK_INFO, key)\n\n\nclass TDigestCommands:\n    def create(self, key, compression=100):\n        \"\"\"\n        Allocate the memory and initialize the t-digest.\n        For more information see `TDIGEST.CREATE <https://redis.io/commands/tdigest.create>`_.\n        \"\"\"  # noqa\n        return self.execute_command(TDIGEST_CREATE, key, \"COMPRESSION\", compression)\n\n    def reset(self, key):\n        \"\"\"\n        Reset the sketch `key` to zero - empty out the sketch and re-initialize it.\n        For more information see `TDIGEST.RESET <https://redis.io/commands/tdigest.reset>`_.\n        \"\"\"  # noqa\n        return self.execute_command(TDIGEST_RESET, key)\n\n    def add(self, key, values):\n        \"\"\"\n        Adds one or more observations to a t-digest sketch `key`.\n\n        For more information see `TDIGEST.ADD <https://redis.io/commands/tdigest.add>`_.\n        \"\"\"  # noqa\n        return self.execute_command(TDIGEST_ADD, key, *values)\n\n    def merge(self, destination_key, num_keys, *keys, compression=None, override=False):\n        \"\"\"\n        Merges all of the values from `keys` to 'destination-key' sketch.\n        It is mandatory to provide the `num_keys` before passing the input keys and\n        the other (optional) arguments.\n        If `destination_key` already exists its values are merged with the input keys.\n        If you wish to override the destination key contents use the `OVERRIDE` parameter.\n\n        For more information see `TDIGEST.MERGE <https://redis.io/commands/tdigest.merge>`_.\n        \"\"\"  # noqa\n        params = [destination_key, num_keys, *keys]\n        if compression is not None:\n            params.extend([\"COMPRESSION\", compression])\n        if override:\n            params.append(\"OVERRIDE\")\n        return self.execute_command(TDIGEST_MERGE, *params)\n\n    def min(self, key):\n        \"\"\"\n        Return minimum value from the sketch `key`. Will return DBL_MAX if the sketch is empty.\n        For more information see `TDIGEST.MIN <https://redis.io/commands/tdigest.min>`_.\n        \"\"\"  # noqa\n        return self.execute_command(TDIGEST_MIN, key)\n\n    def max(self, key):\n        \"\"\"\n        Return maximum value from the sketch `key`. Will return DBL_MIN if the sketch is empty.\n        For more information see `TDIGEST.MAX <https://redis.io/commands/tdigest.max>`_.\n        \"\"\"  # noqa\n        return self.execute_command(TDIGEST_MAX, key)\n\n    def quantile(self, key, quantile, *quantiles):\n        \"\"\"\n        Returns estimates of one or more cutoffs such that a specified fraction of the\n        observations added to this t-digest would be less than or equal to each of the\n        specified cutoffs. (Multiple quantiles can be returned with one call)\n        For more information see `TDIGEST.QUANTILE <https://redis.io/commands/tdigest.quantile>`_.\n        \"\"\"  # noqa\n        return self.execute_command(TDIGEST_QUANTILE, key, quantile, *quantiles)\n\n    def cdf(self, key, value, *values):\n        \"\"\"\n        Return double fraction of all points added which are <= value.\n        For more information see `TDIGEST.CDF <https://redis.io/commands/tdigest.cdf>`_.\n        \"\"\"  # noqa\n        return self.execute_command(TDIGEST_CDF, key, value, *values)\n\n    def info(self, key):\n        \"\"\"\n        Return Compression, Capacity, Merged Nodes, Unmerged Nodes, Merged Weight, Unmerged Weight\n        and Total Compressions.\n        For more information see `TDIGEST.INFO <https://redis.io/commands/tdigest.info>`_.\n        \"\"\"  # noqa\n        return self.execute_command(TDIGEST_INFO, key)\n\n    def trimmed_mean(self, key, low_cut_quantile, high_cut_quantile):\n        \"\"\"\n        Return mean value from the sketch, excluding observation values outside\n        the low and high cutoff quantiles.\n        For more information see `TDIGEST.TRIMMED_MEAN <https://redis.io/commands/tdigest.trimmed_mean>`_.\n        \"\"\"  # noqa\n        return self.execute_command(\n            TDIGEST_TRIMMED_MEAN, key, low_cut_quantile, high_cut_quantile\n        )\n\n    def rank(self, key, value, *values):\n        \"\"\"\n        Retrieve the estimated rank of value (the number of observations in the sketch\n        that are smaller than value + half the number of observations that are equal to value).\n\n        For more information see `TDIGEST.RANK <https://redis.io/commands/tdigest.rank>`_.\n        \"\"\"  # noqa\n        return self.execute_command(TDIGEST_RANK, key, value, *values)\n\n    def revrank(self, key, value, *values):\n        \"\"\"\n        Retrieve the estimated rank of value (the number of observations in the sketch\n        that are larger than value + half the number of observations that are equal to value).\n\n        For more information see `TDIGEST.REVRANK <https://redis.io/commands/tdigest.revrank>`_.\n        \"\"\"  # noqa\n        return self.execute_command(TDIGEST_REVRANK, key, value, *values)\n\n    def byrank(self, key, rank, *ranks):\n        \"\"\"\n        Retrieve an estimation of the value with the given rank.\n\n        For more information see `TDIGEST.BY_RANK <https://redis.io/commands/tdigest.by_rank>`_.\n        \"\"\"  # noqa\n        return self.execute_command(TDIGEST_BYRANK, key, rank, *ranks)\n\n    def byrevrank(self, key, rank, *ranks):\n        \"\"\"\n        Retrieve an estimation of the value with the given reverse rank.\n\n        For more information see `TDIGEST.BY_REVRANK <https://redis.io/commands/tdigest.by_revrank>`_.\n        \"\"\"  # noqa\n        return self.execute_command(TDIGEST_BYREVRANK, key, rank, *ranks)\n\n\nclass CMSCommands:\n    \"\"\"Count-Min Sketch Commands\"\"\"\n\n    def initbydim(self, key, width, depth):\n        \"\"\"\n        Initialize a Count-Min Sketch `key` to dimensions (`width`, `depth`) specified by user.\n        For more information see `CMS.INITBYDIM <https://redis.io/commands/cms.initbydim>`_.\n        \"\"\"  # noqa\n        return self.execute_command(CMS_INITBYDIM, key, width, depth)\n\n    def initbyprob(self, key, error, probability):\n        \"\"\"\n        Initialize a Count-Min Sketch `key` to characteristics (`error`, `probability`) specified by user.\n        For more information see `CMS.INITBYPROB <https://redis.io/commands/cms.initbyprob>`_.\n        \"\"\"  # noqa\n        return self.execute_command(CMS_INITBYPROB, key, error, probability)\n\n    def incrby(self, key, items, increments):\n        \"\"\"\n        Add/increase `items` to a Count-Min Sketch `key` by ''increments''.\n        Both `items` and `increments` are lists.\n        For more information see `CMS.INCRBY <https://redis.io/commands/cms.incrby>`_.\n\n        Example:\n\n        >>> cmsincrby('A', ['foo'], [1])\n        \"\"\"  # noqa\n        params = [key]\n        self.append_items_and_increments(params, items, increments)\n        return self.execute_command(CMS_INCRBY, *params)\n\n    def query(self, key, *items):\n        \"\"\"\n        Return count for an `item` from `key`. Multiple items can be queried with one call.\n        For more information see `CMS.QUERY <https://redis.io/commands/cms.query>`_.\n        \"\"\"  # noqa\n        return self.execute_command(CMS_QUERY, key, *items)\n\n    def merge(self, destKey, numKeys, srcKeys, weights=[]):\n        \"\"\"\n        Merge `numKeys` of sketches into `destKey`. Sketches specified in `srcKeys`.\n        All sketches must have identical width and depth.\n        `Weights` can be used to multiply certain sketches. Default weight is 1.\n        Both `srcKeys` and `weights` are lists.\n        For more information see `CMS.MERGE <https://redis.io/commands/cms.merge>`_.\n        \"\"\"  # noqa\n        params = [destKey, numKeys]\n        params += srcKeys\n        self.append_weights(params, weights)\n        return self.execute_command(CMS_MERGE, *params)\n\n    def info(self, key):\n        \"\"\"\n        Return width, depth and total count of the sketch.\n        For more information see `CMS.INFO <https://redis.io/commands/cms.info>`_.\n        \"\"\"  # noqa\n        return self.execute_command(CMS_INFO, key)\n"
  },
  {
    "path": "redis/commands/bf/info.py",
    "content": "from ..helpers import nativestr\n\n\nclass BFInfo:\n    capacity = None\n    size = None\n    filterNum = None\n    insertedNum = None\n    expansionRate = None\n\n    def __init__(self, args):\n        response = dict(zip(map(nativestr, args[::2]), args[1::2]))\n        self.capacity = response[\"Capacity\"]\n        self.size = response[\"Size\"]\n        self.filterNum = response[\"Number of filters\"]\n        self.insertedNum = response[\"Number of items inserted\"]\n        self.expansionRate = response[\"Expansion rate\"]\n\n    def get(self, item):\n        try:\n            return self.__getitem__(item)\n        except AttributeError:\n            return None\n\n    def __getitem__(self, item):\n        return getattr(self, item)\n\n\nclass CFInfo:\n    size = None\n    bucketNum = None\n    filterNum = None\n    insertedNum = None\n    deletedNum = None\n    bucketSize = None\n    expansionRate = None\n    maxIteration = None\n\n    def __init__(self, args):\n        response = dict(zip(map(nativestr, args[::2]), args[1::2]))\n        self.size = response[\"Size\"]\n        self.bucketNum = response[\"Number of buckets\"]\n        self.filterNum = response[\"Number of filters\"]\n        self.insertedNum = response[\"Number of items inserted\"]\n        self.deletedNum = response[\"Number of items deleted\"]\n        self.bucketSize = response[\"Bucket size\"]\n        self.expansionRate = response[\"Expansion rate\"]\n        self.maxIteration = response[\"Max iterations\"]\n\n    def get(self, item):\n        try:\n            return self.__getitem__(item)\n        except AttributeError:\n            return None\n\n    def __getitem__(self, item):\n        return getattr(self, item)\n\n\nclass CMSInfo:\n    width = None\n    depth = None\n    count = None\n\n    def __init__(self, args):\n        response = dict(zip(map(nativestr, args[::2]), args[1::2]))\n        self.width = response[\"width\"]\n        self.depth = response[\"depth\"]\n        self.count = response[\"count\"]\n\n    def __getitem__(self, item):\n        return getattr(self, item)\n\n\nclass TopKInfo:\n    k = None\n    width = None\n    depth = None\n    decay = None\n\n    def __init__(self, args):\n        response = dict(zip(map(nativestr, args[::2]), args[1::2]))\n        self.k = response[\"k\"]\n        self.width = response[\"width\"]\n        self.depth = response[\"depth\"]\n        self.decay = response[\"decay\"]\n\n    def __getitem__(self, item):\n        return getattr(self, item)\n\n\nclass TDigestInfo:\n    compression = None\n    capacity = None\n    merged_nodes = None\n    unmerged_nodes = None\n    merged_weight = None\n    unmerged_weight = None\n    total_compressions = None\n    memory_usage = None\n\n    def __init__(self, args):\n        response = dict(zip(map(nativestr, args[::2]), args[1::2]))\n        self.compression = response[\"Compression\"]\n        self.capacity = response[\"Capacity\"]\n        self.merged_nodes = response[\"Merged nodes\"]\n        self.unmerged_nodes = response[\"Unmerged nodes\"]\n        self.merged_weight = response[\"Merged weight\"]\n        self.unmerged_weight = response[\"Unmerged weight\"]\n        self.total_compressions = response[\"Total compressions\"]\n        self.memory_usage = response[\"Memory usage\"]\n\n    def get(self, item):\n        try:\n            return self.__getitem__(item)\n        except AttributeError:\n            return None\n\n    def __getitem__(self, item):\n        return getattr(self, item)\n"
  },
  {
    "path": "redis/commands/cluster.py",
    "content": "import asyncio\nfrom typing import (\n    TYPE_CHECKING,\n    Any,\n    AsyncIterator,\n    Awaitable,\n    Dict,\n    Iterable,\n    Iterator,\n    List,\n    Literal,\n    Mapping,\n    NoReturn,\n    Optional,\n    Sequence,\n    Union,\n)\n\nfrom redis.crc import key_slot\nfrom redis.exceptions import RedisClusterException, RedisError\nfrom redis.typing import (\n    AnyKeyT,\n    ClusterCommandsProtocol,\n    EncodableT,\n    KeysT,\n    KeyT,\n    PatternT,\n    ResponseT,\n)\nfrom redis.utils import deprecated_function\n\nfrom .core import (\n    ACLCommands,\n    AsyncACLCommands,\n    AsyncDataAccessCommands,\n    AsyncFunctionCommands,\n    AsyncManagementCommands,\n    AsyncModuleCommands,\n    AsyncScriptCommands,\n    DataAccessCommands,\n    FunctionCommands,\n    HotkeysMetricsTypes,\n    ManagementCommands,\n    ModuleCommands,\n    PubSubCommands,\n    ScriptCommands,\n)\nfrom .helpers import list_or_args\nfrom .redismodules import AsyncRedisModuleCommands, RedisModuleCommands\n\nif TYPE_CHECKING:\n    from redis.asyncio.cluster import TargetNodesT\n\n# Not complete, but covers the major ones\n# https://redis.io/commands\nREAD_COMMANDS = frozenset(\n    [\n        # Bit Operations\n        \"BITCOUNT\",\n        \"BITFIELD_RO\",\n        \"BITPOS\",\n        # Scripting\n        \"EVAL_RO\",\n        \"EVALSHA_RO\",\n        \"FCALL_RO\",\n        # Key Operations\n        \"DBSIZE\",\n        \"DIGEST\",\n        \"DUMP\",\n        \"EXISTS\",\n        \"EXPIRETIME\",\n        \"PEXPIRETIME\",\n        \"KEYS\",\n        \"SCAN\",\n        \"PTTL\",\n        \"RANDOMKEY\",\n        \"TTL\",\n        \"TYPE\",\n        # String Operations\n        \"GET\",\n        \"GETBIT\",\n        \"GETRANGE\",\n        \"MGET\",\n        \"STRLEN\",\n        \"LCS\",\n        # Geo Operations\n        \"GEODIST\",\n        \"GEOHASH\",\n        \"GEOPOS\",\n        \"GEOSEARCH\",\n        # Hash Operations\n        \"HEXISTS\",\n        \"HGET\",\n        \"HGETALL\",\n        \"HKEYS\",\n        \"HLEN\",\n        \"HMGET\",\n        \"HSTRLEN\",\n        \"HVALS\",\n        \"HRANDFIELD\",\n        \"HEXPIRETIME\",\n        \"HPEXPIRETIME\",\n        \"HTTL\",\n        \"HPTTL\",\n        \"HSCAN\",\n        # List Operations\n        \"LINDEX\",\n        \"LPOS\",\n        \"LLEN\",\n        \"LRANGE\",\n        # Set Operations\n        \"SCARD\",\n        \"SDIFF\",\n        \"SINTER\",\n        \"SINTERCARD\",\n        \"SISMEMBER\",\n        \"SMISMEMBER\",\n        \"SMEMBERS\",\n        \"SRANDMEMBER\",\n        \"SUNION\",\n        \"SSCAN\",\n        # Sorted Set Operations\n        \"ZCARD\",\n        \"ZCOUNT\",\n        \"ZDIFF\",\n        \"ZINTER\",\n        \"ZINTERCARD\",\n        \"ZLEXCOUNT\",\n        \"ZMSCORE\",\n        \"ZRANDMEMBER\",\n        \"ZRANGE\",\n        \"ZRANGEBYLEX\",\n        \"ZRANGEBYSCORE\",\n        \"ZRANK\",\n        \"ZREVRANGE\",\n        \"ZREVRANGEBYLEX\",\n        \"ZREVRANGEBYSCORE\",\n        \"ZREVRANK\",\n        \"ZSCAN\",\n        \"ZSCORE\",\n        \"ZUNION\",\n        # Stream Operations\n        \"XLEN\",\n        \"XPENDING\",\n        \"XRANGE\",\n        \"XREAD\",\n        \"XREVRANGE\",\n        # JSON Module\n        \"JSON.ARRINDEX\",\n        \"JSON.ARRLEN\",\n        \"JSON.GET\",\n        \"JSON.MGET\",\n        \"JSON.OBJKEYS\",\n        \"JSON.OBJLEN\",\n        \"JSON.RESP\",\n        \"JSON.STRLEN\",\n        \"JSON.TYPE\",\n        # RediSearch Module\n        \"FT.EXPLAIN\",\n        \"FT.INFO\",\n        \"FT.PROFILE\",\n        \"FT.SEARCH\",\n    ]\n)\n\n\nclass ClusterMultiKeyCommands(ClusterCommandsProtocol):\n    \"\"\"\n    A class containing commands that handle more than one key\n    \"\"\"\n\n    def _partition_keys_by_slot(self, keys: Iterable[KeyT]) -> Dict[int, List[KeyT]]:\n        \"\"\"Split keys into a dictionary that maps a slot to a list of keys.\"\"\"\n\n        slots_to_keys = {}\n        for key in keys:\n            slot = key_slot(self.encoder.encode(key))\n            slots_to_keys.setdefault(slot, []).append(key)\n\n        return slots_to_keys\n\n    def _partition_pairs_by_slot(\n        self, mapping: Mapping[AnyKeyT, EncodableT]\n    ) -> Dict[int, List[EncodableT]]:\n        \"\"\"Split pairs into a dictionary that maps a slot to a list of pairs.\"\"\"\n\n        slots_to_pairs = {}\n        for pair in mapping.items():\n            slot = key_slot(self.encoder.encode(pair[0]))\n            slots_to_pairs.setdefault(slot, []).extend(pair)\n\n        return slots_to_pairs\n\n    def _execute_pipeline_by_slot(\n        self, command: str, slots_to_args: Mapping[int, Iterable[EncodableT]]\n    ) -> List[Any]:\n        read_from_replicas = self.read_from_replicas and command in READ_COMMANDS\n        pipe = self.pipeline()\n        [\n            pipe.execute_command(\n                command,\n                *slot_args,\n                target_nodes=[\n                    self.nodes_manager.get_node_from_slot(slot, read_from_replicas)\n                ],\n            )\n            for slot, slot_args in slots_to_args.items()\n        ]\n        return pipe.execute()\n\n    def _reorder_keys_by_command(\n        self,\n        keys: Iterable[KeyT],\n        slots_to_args: Mapping[int, Iterable[EncodableT]],\n        responses: Iterable[Any],\n    ) -> List[Any]:\n        results = {\n            k: v\n            for slot_values, response in zip(slots_to_args.values(), responses)\n            for k, v in zip(slot_values, response)\n        }\n        return [results[key] for key in keys]\n\n    def mget_nonatomic(self, keys: KeysT, *args: KeyT) -> List[Optional[Any]]:\n        \"\"\"\n        Splits the keys into different slots and then calls MGET\n        for the keys of every slot. This operation will not be atomic\n        if keys belong to more than one slot.\n\n        Returns a list of values ordered identically to ``keys``\n\n        For more information see https://redis.io/commands/mget\n        \"\"\"\n\n        # Concatenate all keys into a list\n        keys = list_or_args(keys, args)\n\n        # Split keys into slots\n        slots_to_keys = self._partition_keys_by_slot(keys)\n\n        # Execute commands using a pipeline\n        res = self._execute_pipeline_by_slot(\"MGET\", slots_to_keys)\n\n        # Reorder keys in the order the user provided & return\n        return self._reorder_keys_by_command(keys, slots_to_keys, res)\n\n    def mset_nonatomic(self, mapping: Mapping[AnyKeyT, EncodableT]) -> List[bool]:\n        \"\"\"\n        Sets key/values based on a mapping. Mapping is a dictionary of\n        key/value pairs. Both keys and values should be strings or types that\n        can be cast to a string via str().\n\n        Splits the keys into different slots and then calls MSET\n        for the keys of every slot. This operation will not be atomic\n        if keys belong to more than one slot.\n\n        For more information see https://redis.io/commands/mset\n        \"\"\"\n\n        # Partition the keys by slot\n        slots_to_pairs = self._partition_pairs_by_slot(mapping)\n\n        # Execute commands using a pipeline & return list of replies\n        return self._execute_pipeline_by_slot(\"MSET\", slots_to_pairs)\n\n    def _split_command_across_slots(self, command: str, *keys: KeyT) -> int:\n        \"\"\"\n        Runs the given command once for the keys\n        of each slot. Returns the sum of the return values.\n        \"\"\"\n\n        # Partition the keys by slot\n        slots_to_keys = self._partition_keys_by_slot(keys)\n\n        # Sum up the reply from each command\n        return sum(self._execute_pipeline_by_slot(command, slots_to_keys))\n\n    def exists(self, *keys: KeyT) -> ResponseT:\n        \"\"\"\n        Returns the number of ``names`` that exist in the\n        whole cluster. The keys are first split up into slots\n        and then an EXISTS command is sent for every slot\n\n        For more information see https://redis.io/commands/exists\n        \"\"\"\n        return self._split_command_across_slots(\"EXISTS\", *keys)\n\n    def delete(self, *keys: KeyT) -> ResponseT:\n        \"\"\"\n        Deletes the given keys in the cluster.\n        The keys are first split up into slots\n        and then an DEL command is sent for every slot\n\n        Non-existent keys are ignored.\n        Returns the number of keys that were deleted.\n\n        For more information see https://redis.io/commands/del\n        \"\"\"\n        return self._split_command_across_slots(\"DEL\", *keys)\n\n    def touch(self, *keys: KeyT) -> ResponseT:\n        \"\"\"\n        Updates the last access time of given keys across the\n        cluster.\n\n        The keys are first split up into slots\n        and then an TOUCH command is sent for every slot\n\n        Non-existent keys are ignored.\n        Returns the number of keys that were touched.\n\n        For more information see https://redis.io/commands/touch\n        \"\"\"\n        return self._split_command_across_slots(\"TOUCH\", *keys)\n\n    def unlink(self, *keys: KeyT) -> ResponseT:\n        \"\"\"\n        Remove the specified keys in a different thread.\n\n        The keys are first split up into slots\n        and then an TOUCH command is sent for every slot\n\n        Non-existent keys are ignored.\n        Returns the number of keys that were unlinked.\n\n        For more information see https://redis.io/commands/unlink\n        \"\"\"\n        return self._split_command_across_slots(\"UNLINK\", *keys)\n\n\nclass AsyncClusterMultiKeyCommands(ClusterMultiKeyCommands):\n    \"\"\"\n    A class containing commands that handle more than one key\n    \"\"\"\n\n    async def mget_nonatomic(self, keys: KeysT, *args: KeyT) -> List[Optional[Any]]:\n        \"\"\"\n        Splits the keys into different slots and then calls MGET\n        for the keys of every slot. This operation will not be atomic\n        if keys belong to more than one slot.\n\n        Returns a list of values ordered identically to ``keys``\n\n        For more information see https://redis.io/commands/mget\n        \"\"\"\n\n        # Concatenate all keys into a list\n        keys = list_or_args(keys, args)\n\n        # Split keys into slots\n        slots_to_keys = self._partition_keys_by_slot(keys)\n\n        # Execute commands using a pipeline\n        res = await self._execute_pipeline_by_slot(\"MGET\", slots_to_keys)\n\n        # Reorder keys in the order the user provided & return\n        return self._reorder_keys_by_command(keys, slots_to_keys, res)\n\n    async def mset_nonatomic(self, mapping: Mapping[AnyKeyT, EncodableT]) -> List[bool]:\n        \"\"\"\n        Sets key/values based on a mapping. Mapping is a dictionary of\n        key/value pairs. Both keys and values should be strings or types that\n        can be cast to a string via str().\n\n        Splits the keys into different slots and then calls MSET\n        for the keys of every slot. This operation will not be atomic\n        if keys belong to more than one slot.\n\n        For more information see https://redis.io/commands/mset\n        \"\"\"\n\n        # Partition the keys by slot\n        slots_to_pairs = self._partition_pairs_by_slot(mapping)\n\n        # Execute commands using a pipeline & return list of replies\n        return await self._execute_pipeline_by_slot(\"MSET\", slots_to_pairs)\n\n    async def _split_command_across_slots(self, command: str, *keys: KeyT) -> int:\n        \"\"\"\n        Runs the given command once for the keys\n        of each slot. Returns the sum of the return values.\n        \"\"\"\n\n        # Partition the keys by slot\n        slots_to_keys = self._partition_keys_by_slot(keys)\n\n        # Sum up the reply from each command\n        return sum(await self._execute_pipeline_by_slot(command, slots_to_keys))\n\n    async def _execute_pipeline_by_slot(\n        self, command: str, slots_to_args: Mapping[int, Iterable[EncodableT]]\n    ) -> List[Any]:\n        if self._initialize:\n            await self.initialize()\n        read_from_replicas = self.read_from_replicas and command in READ_COMMANDS\n        pipe = self.pipeline()\n        [\n            pipe.execute_command(\n                command,\n                *slot_args,\n                target_nodes=[\n                    self.nodes_manager.get_node_from_slot(slot, read_from_replicas)\n                ],\n            )\n            for slot, slot_args in slots_to_args.items()\n        ]\n        return await pipe.execute()\n\n\nclass ClusterManagementCommands(ManagementCommands):\n    \"\"\"\n    A class for Redis Cluster management commands\n\n    The class inherits from Redis's core ManagementCommands class and do the\n    required adjustments to work with cluster mode\n    \"\"\"\n\n    def slaveof(self, *args, **kwargs) -> NoReturn:\n        \"\"\"\n        Make the server a replica of another instance, or promote it as master.\n\n        For more information see https://redis.io/commands/slaveof\n        \"\"\"\n        raise RedisClusterException(\"SLAVEOF is not supported in cluster mode\")\n\n    def replicaof(self, *args, **kwargs) -> NoReturn:\n        \"\"\"\n        Make the server a replica of another instance, or promote it as master.\n\n        For more information see https://redis.io/commands/replicaof\n        \"\"\"\n        raise RedisClusterException(\"REPLICAOF is not supported in cluster mode\")\n\n    def swapdb(self, *args, **kwargs) -> NoReturn:\n        \"\"\"\n        Swaps two Redis databases.\n\n        For more information see https://redis.io/commands/swapdb\n        \"\"\"\n        raise RedisClusterException(\"SWAPDB is not supported in cluster mode\")\n\n    def cluster_myid(self, target_node: \"TargetNodesT\") -> ResponseT:\n        \"\"\"\n        Returns the node's id.\n\n        :target_node: 'ClusterNode'\n            The node to execute the command on\n\n        For more information check https://redis.io/commands/cluster-myid/\n        \"\"\"\n        return self.execute_command(\"CLUSTER MYID\", target_nodes=target_node)\n\n    def cluster_addslots(\n        self, target_node: \"TargetNodesT\", *slots: EncodableT\n    ) -> ResponseT:\n        \"\"\"\n        Assign new hash slots to receiving node. Sends to specified node.\n\n        :target_node: 'ClusterNode'\n            The node to execute the command on\n\n        For more information see https://redis.io/commands/cluster-addslots\n        \"\"\"\n        return self.execute_command(\n            \"CLUSTER ADDSLOTS\", *slots, target_nodes=target_node\n        )\n\n    def cluster_addslotsrange(\n        self, target_node: \"TargetNodesT\", *slots: EncodableT\n    ) -> ResponseT:\n        \"\"\"\n        Similar to the CLUSTER ADDSLOTS command.\n        The difference between the two commands is that ADDSLOTS takes a list of slots\n        to assign to the node, while ADDSLOTSRANGE takes a list of slot ranges\n        (specified by start and end slots) to assign to the node.\n\n        :target_node: 'ClusterNode'\n            The node to execute the command on\n\n        For more information see https://redis.io/commands/cluster-addslotsrange\n        \"\"\"\n        return self.execute_command(\n            \"CLUSTER ADDSLOTSRANGE\", *slots, target_nodes=target_node\n        )\n\n    def cluster_countkeysinslot(self, slot_id: int) -> ResponseT:\n        \"\"\"\n        Return the number of local keys in the specified hash slot\n        Send to node based on specified slot_id\n\n        For more information see https://redis.io/commands/cluster-countkeysinslot\n        \"\"\"\n        return self.execute_command(\"CLUSTER COUNTKEYSINSLOT\", slot_id)\n\n    def cluster_count_failure_report(self, node_id: str) -> ResponseT:\n        \"\"\"\n        Return the number of failure reports active for a given node\n        Sends to a random node\n\n        For more information see https://redis.io/commands/cluster-count-failure-reports\n        \"\"\"\n        return self.execute_command(\"CLUSTER COUNT-FAILURE-REPORTS\", node_id)\n\n    def cluster_delslots(self, *slots: EncodableT) -> List[bool]:\n        \"\"\"\n        Set hash slots as unbound in the cluster.\n        It determines by it self what node the slot is in and sends it there\n\n        Returns a list of the results for each processed slot.\n\n        For more information see https://redis.io/commands/cluster-delslots\n        \"\"\"\n        return [self.execute_command(\"CLUSTER DELSLOTS\", slot) for slot in slots]\n\n    def cluster_delslotsrange(self, *slots: EncodableT) -> ResponseT:\n        \"\"\"\n        Similar to the CLUSTER DELSLOTS command.\n        The difference is that CLUSTER DELSLOTS takes a list of hash slots to remove\n        from the node, while CLUSTER DELSLOTSRANGE takes a list of slot ranges to remove\n        from the node.\n\n        For more information see https://redis.io/commands/cluster-delslotsrange\n        \"\"\"\n        return self.execute_command(\"CLUSTER DELSLOTSRANGE\", *slots)\n\n    def cluster_failover(\n        self, target_node: \"TargetNodesT\", option: Optional[str] = None\n    ) -> ResponseT:\n        \"\"\"\n        Forces a slave to perform a manual failover of its master\n        Sends to specified node\n\n        :target_node: 'ClusterNode'\n            The node to execute the command on\n\n        For more information see https://redis.io/commands/cluster-failover\n        \"\"\"\n        if option:\n            if option.upper() not in [\"FORCE\", \"TAKEOVER\"]:\n                raise RedisError(\n                    f\"Invalid option for CLUSTER FAILOVER command: {option}\"\n                )\n            else:\n                return self.execute_command(\n                    \"CLUSTER FAILOVER\", option, target_nodes=target_node\n                )\n        else:\n            return self.execute_command(\"CLUSTER FAILOVER\", target_nodes=target_node)\n\n    def cluster_info(self, target_nodes: Optional[\"TargetNodesT\"] = None) -> ResponseT:\n        \"\"\"\n        Provides info about Redis Cluster node state.\n        The command will be sent to a random node in the cluster if no target\n        node is specified.\n\n        For more information see https://redis.io/commands/cluster-info\n        \"\"\"\n        return self.execute_command(\"CLUSTER INFO\", target_nodes=target_nodes)\n\n    def cluster_keyslot(self, key: str) -> ResponseT:\n        \"\"\"\n        Returns the hash slot of the specified key\n        Sends to random node in the cluster\n\n        For more information see https://redis.io/commands/cluster-keyslot\n        \"\"\"\n        return self.execute_command(\"CLUSTER KEYSLOT\", key)\n\n    def cluster_meet(\n        self, host: str, port: int, target_nodes: Optional[\"TargetNodesT\"] = None\n    ) -> ResponseT:\n        \"\"\"\n        Force a node cluster to handshake with another node.\n        Sends to specified node.\n\n        For more information see https://redis.io/commands/cluster-meet\n        \"\"\"\n        return self.execute_command(\n            \"CLUSTER MEET\", host, port, target_nodes=target_nodes\n        )\n\n    def cluster_nodes(self) -> ResponseT:\n        \"\"\"\n        Get Cluster config for the node.\n        Sends to random node in the cluster\n\n        For more information see https://redis.io/commands/cluster-nodes\n        \"\"\"\n        return self.execute_command(\"CLUSTER NODES\")\n\n    def cluster_replicate(\n        self, target_nodes: \"TargetNodesT\", node_id: str\n    ) -> ResponseT:\n        \"\"\"\n        Reconfigure a node as a slave of the specified master node\n\n        For more information see https://redis.io/commands/cluster-replicate\n        \"\"\"\n        return self.execute_command(\n            \"CLUSTER REPLICATE\", node_id, target_nodes=target_nodes\n        )\n\n    def cluster_reset(\n        self, soft: bool = True, target_nodes: Optional[\"TargetNodesT\"] = None\n    ) -> ResponseT:\n        \"\"\"\n        Reset a Redis Cluster node\n\n        If 'soft' is True then it will send 'SOFT' argument\n        If 'soft' is False then it will send 'HARD' argument\n\n        For more information see https://redis.io/commands/cluster-reset\n        \"\"\"\n        return self.execute_command(\n            \"CLUSTER RESET\", b\"SOFT\" if soft else b\"HARD\", target_nodes=target_nodes\n        )\n\n    def cluster_save_config(\n        self, target_nodes: Optional[\"TargetNodesT\"] = None\n    ) -> ResponseT:\n        \"\"\"\n        Forces the node to save cluster state on disk\n\n        For more information see https://redis.io/commands/cluster-saveconfig\n        \"\"\"\n        return self.execute_command(\"CLUSTER SAVECONFIG\", target_nodes=target_nodes)\n\n    def cluster_get_keys_in_slot(self, slot: int, num_keys: int) -> ResponseT:\n        \"\"\"\n        Returns the number of keys in the specified cluster slot\n\n        For more information see https://redis.io/commands/cluster-getkeysinslot\n        \"\"\"\n        return self.execute_command(\"CLUSTER GETKEYSINSLOT\", slot, num_keys)\n\n    def cluster_set_config_epoch(\n        self, epoch: int, target_nodes: Optional[\"TargetNodesT\"] = None\n    ) -> ResponseT:\n        \"\"\"\n        Set the configuration epoch in a new node\n\n        For more information see https://redis.io/commands/cluster-set-config-epoch\n        \"\"\"\n        return self.execute_command(\n            \"CLUSTER SET-CONFIG-EPOCH\", epoch, target_nodes=target_nodes\n        )\n\n    def cluster_setslot(\n        self, target_node: \"TargetNodesT\", node_id: str, slot_id: int, state: str\n    ) -> ResponseT:\n        \"\"\"\n        Bind an hash slot to a specific node\n\n        :target_node: 'ClusterNode'\n            The node to execute the command on\n\n        For more information see https://redis.io/commands/cluster-setslot\n        \"\"\"\n        if state.upper() in (\"IMPORTING\", \"NODE\", \"MIGRATING\"):\n            return self.execute_command(\n                \"CLUSTER SETSLOT\", slot_id, state, node_id, target_nodes=target_node\n            )\n        elif state.upper() == \"STABLE\":\n            raise RedisError('For \"stable\" state please use cluster_setslot_stable')\n        else:\n            raise RedisError(f\"Invalid slot state: {state}\")\n\n    def cluster_setslot_stable(self, slot_id: int) -> ResponseT:\n        \"\"\"\n        Clears migrating / importing state from the slot.\n        It determines by it self what node the slot is in and sends it there.\n\n        For more information see https://redis.io/commands/cluster-setslot\n        \"\"\"\n        return self.execute_command(\"CLUSTER SETSLOT\", slot_id, \"STABLE\")\n\n    def cluster_replicas(\n        self, node_id: str, target_nodes: Optional[\"TargetNodesT\"] = None\n    ) -> ResponseT:\n        \"\"\"\n        Provides a list of replica nodes replicating from the specified primary\n        target node.\n\n        For more information see https://redis.io/commands/cluster-replicas\n        \"\"\"\n        return self.execute_command(\n            \"CLUSTER REPLICAS\", node_id, target_nodes=target_nodes\n        )\n\n    def cluster_slots(self, target_nodes: Optional[\"TargetNodesT\"] = None) -> ResponseT:\n        \"\"\"\n        Get array of Cluster slot to node mappings\n\n        For more information see https://redis.io/commands/cluster-slots\n        \"\"\"\n        return self.execute_command(\"CLUSTER SLOTS\", target_nodes=target_nodes)\n\n    def cluster_shards(self, target_nodes=None):\n        \"\"\"\n        Returns details about the shards of the cluster.\n\n        For more information see https://redis.io/commands/cluster-shards\n        \"\"\"\n        return self.execute_command(\"CLUSTER SHARDS\", target_nodes=target_nodes)\n\n    def cluster_myshardid(self, target_nodes=None):\n        \"\"\"\n        Returns the shard ID of the node.\n\n        For more information see https://redis.io/commands/cluster-myshardid/\n        \"\"\"\n        return self.execute_command(\"CLUSTER MYSHARDID\", target_nodes=target_nodes)\n\n    def cluster_links(self, target_node: \"TargetNodesT\") -> ResponseT:\n        \"\"\"\n        Each node in a Redis Cluster maintains a pair of long-lived TCP link with each\n        peer in the cluster: One for sending outbound messages towards the peer and one\n        for receiving inbound messages from the peer.\n\n        This command outputs information of all such peer links as an array.\n\n        For more information see https://redis.io/commands/cluster-links\n        \"\"\"\n        return self.execute_command(\"CLUSTER LINKS\", target_nodes=target_node)\n\n    def cluster_flushslots(self, target_nodes: Optional[\"TargetNodesT\"] = None) -> None:\n        raise NotImplementedError(\n            \"CLUSTER FLUSHSLOTS is intentionally not implemented in the client.\"\n        )\n\n    def cluster_bumpepoch(self, target_nodes: Optional[\"TargetNodesT\"] = None) -> None:\n        raise NotImplementedError(\n            \"CLUSTER BUMPEPOCH is intentionally not implemented in the client.\"\n        )\n\n    def readonly(self, target_nodes: Optional[\"TargetNodesT\"] = None) -> ResponseT:\n        \"\"\"\n        Enables read queries.\n        The command will be sent to the default cluster node if target_nodes is\n        not specified.\n\n        For more information see https://redis.io/commands/readonly\n        \"\"\"\n        if target_nodes == \"replicas\" or target_nodes == \"all\":\n            # read_from_replicas will only be enabled if the READONLY command\n            # is sent to all replicas\n            self.read_from_replicas = True\n        return self.execute_command(\"READONLY\", target_nodes=target_nodes)\n\n    def readwrite(self, target_nodes: Optional[\"TargetNodesT\"] = None) -> ResponseT:\n        \"\"\"\n        Disables read queries.\n        The command will be sent to the default cluster node if target_nodes is\n        not specified.\n\n        For more information see https://redis.io/commands/readwrite\n        \"\"\"\n        # Reset read from replicas flag\n        self.read_from_replicas = False\n        return self.execute_command(\"READWRITE\", target_nodes=target_nodes)\n\n    @deprecated_function(\n        version=\"7.2.0\",\n        reason=\"Use client-side caching feature instead.\",\n    )\n    def client_tracking_on(\n        self,\n        clientid: Optional[int] = None,\n        prefix: Sequence[KeyT] = [],\n        bcast: bool = False,\n        optin: bool = False,\n        optout: bool = False,\n        noloop: bool = False,\n        target_nodes: Optional[\"TargetNodesT\"] = \"all\",\n    ) -> ResponseT:\n        \"\"\"\n        Enables the tracking feature of the Redis server, that is used\n        for server assisted client side caching.\n\n        When clientid is provided - in target_nodes only the node that owns the\n        connection with this id should be provided.\n        When clientid is not provided - target_nodes can be any node.\n\n        For more information see https://redis.io/commands/client-tracking\n        \"\"\"\n        return self.client_tracking(\n            True,\n            clientid,\n            prefix,\n            bcast,\n            optin,\n            optout,\n            noloop,\n            target_nodes=target_nodes,\n        )\n\n    @deprecated_function(\n        version=\"7.2.0\",\n        reason=\"Use client-side caching feature instead.\",\n    )\n    def client_tracking_off(\n        self,\n        clientid: Optional[int] = None,\n        prefix: Sequence[KeyT] = [],\n        bcast: bool = False,\n        optin: bool = False,\n        optout: bool = False,\n        noloop: bool = False,\n        target_nodes: Optional[\"TargetNodesT\"] = \"all\",\n    ) -> ResponseT:\n        \"\"\"\n        Disables the tracking feature of the Redis server, that is used\n        for server assisted client side caching.\n\n        When clientid is provided - in target_nodes only the node that owns the\n        connection with this id should be provided.\n        When clientid is not provided - target_nodes can be any node.\n\n        For more information see https://redis.io/commands/client-tracking\n        \"\"\"\n        return self.client_tracking(\n            False,\n            clientid,\n            prefix,\n            bcast,\n            optin,\n            optout,\n            noloop,\n            target_nodes=target_nodes,\n        )\n\n    def hotkeys_start(\n        self,\n        metrics: List[HotkeysMetricsTypes],\n        count: Optional[int] = None,\n        duration: Optional[int] = None,\n        sample_ratio: Optional[int] = None,\n        slots: Optional[List[int]] = None,\n        **kwargs,\n    ) -> Union[str, bytes]:\n        \"\"\"\n        Cluster client does not support hotkeys command. Please use the non-cluster client.\n\n        For more information see https://redis.io/commands/hotkeys-start\n        \"\"\"\n        raise NotImplementedError(\n            \"HOTKEYS commands are not supported in cluster mode. Please use the non-cluster client.\"\n        )\n\n    def hotkeys_stop(self, **kwargs) -> Union[str, bytes]:\n        \"\"\"\n        Cluster client does not support hotkeys command. Please use the non-cluster client.\n\n        For more information see https://redis.io/commands/hotkeys-stop\n        \"\"\"\n        raise NotImplementedError(\n            \"HOTKEYS commands are not supported in cluster mode. Please use the non-cluster client.\"\n        )\n\n    def hotkeys_reset(self, **kwargs) -> Union[str, bytes]:\n        \"\"\"\n        Cluster client does not support hotkeys command. Please use the non-cluster client.\n\n        For more information see https://redis.io/commands/hotkeys-reset\n        \"\"\"\n        raise NotImplementedError(\n            \"HOTKEYS commands are not supported in cluster mode. Please use the non-cluster client.\"\n        )\n\n    def hotkeys_get(self, **kwargs) -> list[dict[Union[str, bytes], Any]]:\n        \"\"\"\n        Cluster client does not support hotkeys command. Please use the non-cluster client.\n\n        For more information see https://redis.io/commands/hotkeys-get\n        \"\"\"\n        raise NotImplementedError(\n            \"HOTKEYS commands are not supported in cluster mode. Please use the non-cluster client.\"\n        )\n\n\nclass AsyncClusterManagementCommands(\n    ClusterManagementCommands, AsyncManagementCommands\n):\n    \"\"\"\n    A class for Redis Cluster management commands\n\n    The class inherits from Redis's core ManagementCommands class and do the\n    required adjustments to work with cluster mode\n    \"\"\"\n\n    async def cluster_delslots(self, *slots: EncodableT) -> List[bool]:\n        \"\"\"\n        Set hash slots as unbound in the cluster.\n        It determines by it self what node the slot is in and sends it there\n\n        Returns a list of the results for each processed slot.\n\n        For more information see https://redis.io/commands/cluster-delslots\n        \"\"\"\n        return await asyncio.gather(\n            *(\n                asyncio.create_task(self.execute_command(\"CLUSTER DELSLOTS\", slot))\n                for slot in slots\n            )\n        )\n\n    @deprecated_function(\n        version=\"7.2.0\",\n        reason=\"Use client-side caching feature instead.\",\n    )\n    async def client_tracking_on(\n        self,\n        clientid: Optional[int] = None,\n        prefix: Sequence[KeyT] = [],\n        bcast: bool = False,\n        optin: bool = False,\n        optout: bool = False,\n        noloop: bool = False,\n        target_nodes: Optional[\"TargetNodesT\"] = \"all\",\n    ) -> ResponseT:\n        \"\"\"\n        Enables the tracking feature of the Redis server, that is used\n        for server assisted client side caching.\n\n        When clientid is provided - in target_nodes only the node that owns the\n        connection with this id should be provided.\n        When clientid is not provided - target_nodes can be any node.\n\n        For more information see https://redis.io/commands/client-tracking\n        \"\"\"\n        return await self.client_tracking(\n            True,\n            clientid,\n            prefix,\n            bcast,\n            optin,\n            optout,\n            noloop,\n            target_nodes=target_nodes,\n        )\n\n    @deprecated_function(\n        version=\"7.2.0\",\n        reason=\"Use client-side caching feature instead.\",\n    )\n    async def client_tracking_off(\n        self,\n        clientid: Optional[int] = None,\n        prefix: Sequence[KeyT] = [],\n        bcast: bool = False,\n        optin: bool = False,\n        optout: bool = False,\n        noloop: bool = False,\n        target_nodes: Optional[\"TargetNodesT\"] = \"all\",\n    ) -> ResponseT:\n        \"\"\"\n        Disables the tracking feature of the Redis server, that is used\n        for server assisted client side caching.\n\n        When clientid is provided - in target_nodes only the node that owns the\n        connection with this id should be provided.\n        When clientid is not provided - target_nodes can be any node.\n\n        For more information see https://redis.io/commands/client-tracking\n        \"\"\"\n        return await self.client_tracking(\n            False,\n            clientid,\n            prefix,\n            bcast,\n            optin,\n            optout,\n            noloop,\n            target_nodes=target_nodes,\n        )\n\n    async def hotkeys_start(\n        self,\n        metrics: List[HotkeysMetricsTypes],\n        count: Optional[int] = None,\n        duration: Optional[int] = None,\n        sample_ratio: Optional[int] = None,\n        slots: Optional[List[int]] = None,\n        **kwargs,\n    ) -> Awaitable[Union[str, bytes]]:\n        \"\"\"\n        Cluster client does not support hotkeys command. Please use the non-cluster client.\n\n        For more information see https://redis.io/commands/hotkeys-start\n        \"\"\"\n        raise NotImplementedError(\n            \"HOTKEYS commands are not supported in cluster mode. Please use the non-cluster client.\"\n        )\n\n    async def hotkeys_stop(self, **kwargs) -> Awaitable[Union[str, bytes]]:\n        \"\"\"\n        Cluster client does not support hotkeys command. Please use the non-cluster client.\n\n        For more information see https://redis.io/commands/hotkeys-stop\n        \"\"\"\n        raise NotImplementedError(\n            \"HOTKEYS commands are not supported in cluster mode. Please use the non-cluster client.\"\n        )\n\n    async def hotkeys_reset(self, **kwargs) -> Awaitable[Union[str, bytes]]:\n        \"\"\"\n        Cluster client does not support hotkeys command. Please use the non-cluster client.\n\n        For more information see https://redis.io/commands/hotkeys-reset\n        \"\"\"\n        raise NotImplementedError(\n            \"HOTKEYS commands are not supported in cluster mode. Please use the non-cluster client.\"\n        )\n\n    async def hotkeys_get(\n        self, **kwargs\n    ) -> Awaitable[list[dict[Union[str, bytes], Any]]]:\n        \"\"\"\n        Cluster client does not support hotkeys command. Please use the non-cluster client.\n\n        For more information see https://redis.io/commands/hotkeys-get\n        \"\"\"\n        raise NotImplementedError(\n            \"HOTKEYS commands are not supported in cluster mode. Please use the non-cluster client.\"\n        )\n\n\nclass ClusterDataAccessCommands(DataAccessCommands):\n    \"\"\"\n    A class for Redis Cluster Data Access Commands\n\n    The class inherits from Redis's core DataAccessCommand class and do the\n    required adjustments to work with cluster mode\n    \"\"\"\n\n    def stralgo(\n        self,\n        algo: Literal[\"LCS\"],\n        value1: KeyT,\n        value2: KeyT,\n        specific_argument: Union[Literal[\"strings\"], Literal[\"keys\"]] = \"strings\",\n        len: bool = False,\n        idx: bool = False,\n        minmatchlen: Optional[int] = None,\n        withmatchlen: bool = False,\n        **kwargs,\n    ) -> ResponseT:\n        \"\"\"\n        Implements complex algorithms that operate on strings.\n        Right now the only algorithm implemented is the LCS algorithm\n        (longest common substring). However new algorithms could be\n        implemented in the future.\n\n        ``algo`` Right now must be LCS\n        ``value1`` and ``value2`` Can be two strings or two keys\n        ``specific_argument`` Specifying if the arguments to the algorithm\n        will be keys or strings. strings is the default.\n        ``len`` Returns just the len of the match.\n        ``idx`` Returns the match positions in each string.\n        ``minmatchlen`` Restrict the list of matches to the ones of a given\n        minimal length. Can be provided only when ``idx`` set to True.\n        ``withmatchlen`` Returns the matches with the len of the match.\n        Can be provided only when ``idx`` set to True.\n\n        For more information see https://redis.io/commands/stralgo\n        \"\"\"\n        target_nodes = kwargs.pop(\"target_nodes\", None)\n        if specific_argument == \"strings\" and target_nodes is None:\n            target_nodes = \"default-node\"\n        kwargs.update({\"target_nodes\": target_nodes})\n        return super().stralgo(\n            algo,\n            value1,\n            value2,\n            specific_argument,\n            len,\n            idx,\n            minmatchlen,\n            withmatchlen,\n            **kwargs,\n        )\n\n    def scan_iter(\n        self,\n        match: Optional[PatternT] = None,\n        count: Optional[int] = None,\n        _type: Optional[str] = None,\n        **kwargs,\n    ) -> Iterator:\n        # Do the first query with cursor=0 for all nodes\n        cursors, data = self.scan(match=match, count=count, _type=_type, **kwargs)\n        yield from data\n\n        cursors = {name: cursor for name, cursor in cursors.items() if cursor != 0}\n        if cursors:\n            # Get nodes by name\n            nodes = {name: self.get_node(node_name=name) for name in cursors.keys()}\n\n            # Iterate over each node till its cursor is 0\n            kwargs.pop(\"target_nodes\", None)\n            while cursors:\n                for name, cursor in cursors.items():\n                    cur, data = self.scan(\n                        cursor=cursor,\n                        match=match,\n                        count=count,\n                        _type=_type,\n                        target_nodes=nodes[name],\n                        **kwargs,\n                    )\n                    yield from data\n                    cursors[name] = cur[name]\n\n                cursors = {\n                    name: cursor for name, cursor in cursors.items() if cursor != 0\n                }\n\n\nclass AsyncClusterDataAccessCommands(\n    ClusterDataAccessCommands, AsyncDataAccessCommands\n):\n    \"\"\"\n    A class for Redis Cluster Data Access Commands\n\n    The class inherits from Redis's core DataAccessCommand class and do the\n    required adjustments to work with cluster mode\n    \"\"\"\n\n    async def scan_iter(\n        self,\n        match: Optional[PatternT] = None,\n        count: Optional[int] = None,\n        _type: Optional[str] = None,\n        **kwargs,\n    ) -> AsyncIterator:\n        # Do the first query with cursor=0 for all nodes\n        cursors, data = await self.scan(match=match, count=count, _type=_type, **kwargs)\n        for value in data:\n            yield value\n\n        cursors = {name: cursor for name, cursor in cursors.items() if cursor != 0}\n        if cursors:\n            # Get nodes by name\n            nodes = {name: self.get_node(node_name=name) for name in cursors.keys()}\n\n            # Iterate over each node till its cursor is 0\n            kwargs.pop(\"target_nodes\", None)\n            while cursors:\n                for name, cursor in cursors.items():\n                    cur, data = await self.scan(\n                        cursor=cursor,\n                        match=match,\n                        count=count,\n                        _type=_type,\n                        target_nodes=nodes[name],\n                        **kwargs,\n                    )\n                    for value in data:\n                        yield value\n                    cursors[name] = cur[name]\n\n                cursors = {\n                    name: cursor for name, cursor in cursors.items() if cursor != 0\n                }\n\n\nclass RedisClusterCommands(\n    ClusterMultiKeyCommands,\n    ClusterManagementCommands,\n    ACLCommands,\n    PubSubCommands,\n    ClusterDataAccessCommands,\n    ScriptCommands,\n    FunctionCommands,\n    ModuleCommands,\n    RedisModuleCommands,\n):\n    \"\"\"\n    A class for all Redis Cluster commands\n\n    For key-based commands, the target node(s) will be internally determined\n    by the keys' hash slot.\n    Non-key-based commands can be executed with the 'target_nodes' argument to\n    target specific nodes. By default, if target_nodes is not specified, the\n    command will be executed on the default cluster node.\n\n    :param :target_nodes: type can be one of the followings:\n        - nodes flag: ALL_NODES, PRIMARIES, REPLICAS, RANDOM\n        - 'ClusterNode'\n        - 'list(ClusterNodes)'\n        - 'dict(any:clusterNodes)'\n\n    for example:\n        r.cluster_info(target_nodes=RedisCluster.ALL_NODES)\n    \"\"\"\n\n\nclass AsyncRedisClusterCommands(\n    AsyncClusterMultiKeyCommands,\n    AsyncClusterManagementCommands,\n    AsyncACLCommands,\n    AsyncClusterDataAccessCommands,\n    AsyncScriptCommands,\n    AsyncFunctionCommands,\n    AsyncModuleCommands,\n    AsyncRedisModuleCommands,\n):\n    \"\"\"\n    A class for all Redis Cluster commands\n\n    For key-based commands, the target node(s) will be internally determined\n    by the keys' hash slot.\n    Non-key-based commands can be executed with the 'target_nodes' argument to\n    target specific nodes. By default, if target_nodes is not specified, the\n    command will be executed on the default cluster node.\n\n    :param :target_nodes: type can be one of the followings:\n        - nodes flag: ALL_NODES, PRIMARIES, REPLICAS, RANDOM\n        - 'ClusterNode'\n        - 'list(ClusterNodes)'\n        - 'dict(any:clusterNodes)'\n\n    for example:\n        r.cluster_info(target_nodes=RedisCluster.ALL_NODES)\n    \"\"\"\n"
  },
  {
    "path": "redis/commands/core.py",
    "content": "from __future__ import annotations\n\nimport datetime\nimport hashlib\nimport inspect\n\n# Try to import the xxhash library as an optional dependency\ntry:\n    import xxhash\n\n    HAS_XXHASH = True\nexcept ImportError:\n    HAS_XXHASH = False\n\nimport warnings\nfrom enum import Enum\nfrom typing import (\n    TYPE_CHECKING,\n    Any,\n    AsyncIterator,\n    Awaitable,\n    Callable,\n    Dict,\n    Iterable,\n    Iterator,\n    List,\n    Literal,\n    Mapping,\n    Optional,\n    Sequence,\n    Tuple,\n    Union,\n    overload,\n)\n\nfrom redis.asyncio.observability.recorder import (\n    record_streaming_lag_from_response as async_record_streaming_lag,\n)\nfrom redis.exceptions import ConnectionError, DataError, NoScriptError, RedisError\nfrom redis.typing import (\n    AbsExpiryT,\n    ACLGetUserData,\n    ACLLogData,\n    AnyKeyT,\n    AsyncClientProtocol,\n    BitfieldOffsetT,\n    BlockingListPopResponse,\n    BlockingZSetPopResponse,\n    ChannelT,\n    CommandGetKeysAndFlagsResponse,\n    CommandsProtocol,\n    ConsumerT,\n    EncodableT,\n    ExpiryT,\n    FieldT,\n    GroupT,\n    HScanResponse,\n    KeysT,\n    KeyT,\n    ListMultiPopResponse,\n    Number,\n    PatternT,\n    ResponseT,\n    ScanResponse,\n    ScriptTextT,\n    SortResponse,\n    StralgoResponse,\n    StreamIdT,\n    StreamRangeResponse,\n    SyncClientProtocol,\n    TimeoutSecT,\n    XClaimResponse,\n    XPendingRangeResponse,\n    XReadResponse,\n    ZMPopResponse,\n    ZRandMemberResponse,\n    ZScanResponse,\n    ZScoreBoundT,\n    ZSetRangeResponse,\n)\nfrom redis.utils import (\n    deprecated_function,\n    experimental_args,\n    experimental_method,\n    extract_expire_flags,\n    str_if_bytes,\n)\n\nfrom ..observability.attributes import PubSubDirection\nfrom ..observability.recorder import (\n    record_pubsub_message,\n    record_streaming_lag_from_response,\n)\nfrom .helpers import at_most_one_value_set, list_or_args\n\nif TYPE_CHECKING:\n    import redis.asyncio.client\n    import redis.client\n\n\nclass ACLCommands(CommandsProtocol):\n    \"\"\"\n    Redis Access Control List (ACL) commands.\n    see: https://redis.io/topics/acl\n    \"\"\"\n\n    @overload\n    def acl_cat(\n        self: SyncClientProtocol, category: str | None = None, **kwargs\n    ) -> list[bytes | str]: ...\n\n    @overload\n    def acl_cat(\n        self: AsyncClientProtocol, category: str | None = None, **kwargs\n    ) -> Awaitable[list[bytes | str]]: ...\n\n    def acl_cat(\n        self, category: str | None = None, **kwargs\n    ) -> list[bytes | str] | Awaitable[list[bytes | str]]:\n        \"\"\"\n        Returns a list of categories or commands within a category.\n\n        If ``category`` is not supplied, returns a list of all categories.\n        If ``category`` is supplied, returns a list of all commands within\n        that category.\n\n        For more information, see https://redis.io/commands/acl-cat\n        \"\"\"\n        pieces: list[EncodableT] = [category] if category else []\n        return self.execute_command(\"ACL CAT\", *pieces, **kwargs)\n\n    @overload\n    def acl_dryrun(\n        self: SyncClientProtocol, username: str, *args: EncodableT, **kwargs\n    ) -> bytes | str: ...\n\n    @overload\n    def acl_dryrun(\n        self: AsyncClientProtocol, username: str, *args: EncodableT, **kwargs\n    ) -> Awaitable[bytes | str]: ...\n\n    def acl_dryrun(self, username: str, *args: EncodableT, **kwargs) -> (\n        bytes | str\n    ) | Awaitable[bytes | str]:\n        \"\"\"\n        Simulate the execution of a given command by a given ``username``.\n\n        For more information, see https://redis.io/commands/acl-dryrun\n        \"\"\"\n        return self.execute_command(\"ACL DRYRUN\", username, *args, **kwargs)\n\n    @overload\n    def acl_deluser(self: SyncClientProtocol, *username: str, **kwargs) -> int: ...\n\n    @overload\n    def acl_deluser(\n        self: AsyncClientProtocol, *username: str, **kwargs\n    ) -> Awaitable[int]: ...\n\n    def acl_deluser(self, *username: str, **kwargs) -> int | Awaitable[int]:\n        \"\"\"\n        Delete the ACL for the specified ``username``\\\\s\n\n        For more information, see https://redis.io/commands/acl-deluser\n        \"\"\"\n        return self.execute_command(\"ACL DELUSER\", *username, **kwargs)\n\n    @overload\n    def acl_genpass(\n        self: SyncClientProtocol, bits: int | None = None, **kwargs\n    ) -> bytes | str: ...\n\n    @overload\n    def acl_genpass(\n        self: AsyncClientProtocol, bits: int | None = None, **kwargs\n    ) -> Awaitable[bytes | str]: ...\n\n    def acl_genpass(self, bits: int | None = None, **kwargs) -> (\n        bytes | str\n    ) | Awaitable[bytes | str]:\n        \"\"\"Generate a random password value.\n        If ``bits`` is supplied then use this number of bits, rounded to\n        the next multiple of 4.\n        See: https://redis.io/commands/acl-genpass\n        \"\"\"\n        pieces = []\n        if bits is not None:\n            try:\n                b = int(bits)\n                if b < 0 or b > 4096:\n                    raise ValueError\n                pieces.append(b)\n            except ValueError:\n                raise DataError(\n                    \"genpass optionally accepts a bits argument, between 0 and 4096.\"\n                )\n        return self.execute_command(\"ACL GENPASS\", *pieces, **kwargs)\n\n    @overload\n    def acl_getuser(\n        self: SyncClientProtocol, username: str, **kwargs\n    ) -> ACLGetUserData: ...\n\n    @overload\n    def acl_getuser(\n        self: AsyncClientProtocol, username: str, **kwargs\n    ) -> Awaitable[ACLGetUserData]: ...\n\n    def acl_getuser(\n        self, username: str, **kwargs\n    ) -> ACLGetUserData | Awaitable[ACLGetUserData]:\n        \"\"\"\n        Get the ACL details for the specified ``username``.\n\n        If ``username`` does not exist, return None\n\n        For more information, see https://redis.io/commands/acl-getuser\n        \"\"\"\n        return self.execute_command(\"ACL GETUSER\", username, **kwargs)\n\n    @overload\n    def acl_help(self: SyncClientProtocol, **kwargs) -> list[bytes | str]: ...\n\n    @overload\n    def acl_help(\n        self: AsyncClientProtocol, **kwargs\n    ) -> Awaitable[list[bytes | str]]: ...\n\n    def acl_help(self, **kwargs) -> list[bytes | str] | Awaitable[list[bytes | str]]:\n        \"\"\"The ACL HELP command returns helpful text describing\n        the different subcommands.\n\n        For more information, see https://redis.io/commands/acl-help\n        \"\"\"\n        return self.execute_command(\"ACL HELP\", **kwargs)\n\n    @overload\n    def acl_list(self: SyncClientProtocol, **kwargs) -> list[bytes | str]: ...\n\n    @overload\n    def acl_list(\n        self: AsyncClientProtocol, **kwargs\n    ) -> Awaitable[list[bytes | str]]: ...\n\n    def acl_list(self, **kwargs) -> list[bytes | str] | Awaitable[list[bytes | str]]:\n        \"\"\"\n        Return a list of all ACLs on the server\n\n        For more information, see https://redis.io/commands/acl-list\n        \"\"\"\n        return self.execute_command(\"ACL LIST\", **kwargs)\n\n    @overload\n    def acl_log(\n        self: SyncClientProtocol, count: int | None = None, **kwargs\n    ) -> ACLLogData: ...\n\n    @overload\n    def acl_log(\n        self: AsyncClientProtocol, count: int | None = None, **kwargs\n    ) -> Awaitable[ACLLogData]: ...\n\n    def acl_log(\n        self, count: int | None = None, **kwargs\n    ) -> ACLLogData | Awaitable[ACLLogData]:\n        \"\"\"\n        Get ACL logs as a list.\n        :param int count: Get logs[0:count].\n        :rtype: List.\n\n        For more information, see https://redis.io/commands/acl-log\n        \"\"\"\n        args = []\n        if count is not None:\n            if not isinstance(count, int):\n                raise DataError(\"ACL LOG count must be an integer\")\n            args.append(count)\n\n        return self.execute_command(\"ACL LOG\", *args, **kwargs)\n\n    @overload\n    def acl_log_reset(self: SyncClientProtocol, **kwargs) -> bool: ...\n\n    @overload\n    def acl_log_reset(self: AsyncClientProtocol, **kwargs) -> Awaitable[bool]: ...\n\n    def acl_log_reset(self, **kwargs) -> bool | Awaitable[bool]:\n        \"\"\"\n        Reset ACL logs.\n        :rtype: Boolean.\n\n        For more information, see https://redis.io/commands/acl-log\n        \"\"\"\n        args = [b\"RESET\"]\n        return self.execute_command(\"ACL LOG\", *args, **kwargs)\n\n    @overload\n    def acl_load(self: SyncClientProtocol, **kwargs) -> bool: ...\n\n    @overload\n    def acl_load(self: AsyncClientProtocol, **kwargs) -> Awaitable[bool]: ...\n\n    def acl_load(self, **kwargs) -> bool | Awaitable[bool]:\n        \"\"\"\n        Load ACL rules from the configured ``aclfile``.\n\n        Note that the server must be configured with the ``aclfile``\n        directive to be able to load ACL rules from an aclfile.\n\n        For more information, see https://redis.io/commands/acl-load\n        \"\"\"\n        return self.execute_command(\"ACL LOAD\", **kwargs)\n\n    @overload\n    def acl_save(self: SyncClientProtocol, **kwargs) -> bool: ...\n\n    @overload\n    def acl_save(self: AsyncClientProtocol, **kwargs) -> Awaitable[bool]: ...\n\n    def acl_save(self, **kwargs) -> bool | Awaitable[bool]:\n        \"\"\"\n        Save ACL rules to the configured ``aclfile``.\n\n        Note that the server must be configured with the ``aclfile``\n        directive to be able to save ACL rules to an aclfile.\n\n        For more information, see https://redis.io/commands/acl-save\n        \"\"\"\n        return self.execute_command(\"ACL SAVE\", **kwargs)\n\n    @overload\n    def acl_setuser(\n        self: SyncClientProtocol,\n        username: str,\n        enabled: bool = False,\n        nopass: bool = False,\n        passwords: str | Iterable[str] | None = None,\n        hashed_passwords: str | Iterable[str] | None = None,\n        categories: Iterable[str] | None = None,\n        commands: Iterable[str] | None = None,\n        keys: Iterable[KeyT] | None = None,\n        channels: Iterable[ChannelT] | None = None,\n        selectors: Iterable[Tuple[str, KeyT]] | None = None,\n        reset: bool = False,\n        reset_keys: bool = False,\n        reset_channels: bool = False,\n        reset_passwords: bool = False,\n        **kwargs,\n    ) -> bool: ...\n\n    @overload\n    def acl_setuser(\n        self: AsyncClientProtocol,\n        username: str,\n        enabled: bool = False,\n        nopass: bool = False,\n        passwords: str | Iterable[str] | None = None,\n        hashed_passwords: str | Iterable[str] | None = None,\n        categories: Iterable[str] | None = None,\n        commands: Iterable[str] | None = None,\n        keys: Iterable[KeyT] | None = None,\n        channels: Iterable[ChannelT] | None = None,\n        selectors: Iterable[Tuple[str, KeyT]] | None = None,\n        reset: bool = False,\n        reset_keys: bool = False,\n        reset_channels: bool = False,\n        reset_passwords: bool = False,\n        **kwargs,\n    ) -> Awaitable[bool]: ...\n\n    def acl_setuser(\n        self,\n        username: str,\n        enabled: bool = False,\n        nopass: bool = False,\n        passwords: str | Iterable[str] | None = None,\n        hashed_passwords: str | Iterable[str] | None = None,\n        categories: Iterable[str] | None = None,\n        commands: Iterable[str] | None = None,\n        keys: Iterable[KeyT] | None = None,\n        channels: Iterable[ChannelT] | None = None,\n        selectors: Iterable[Tuple[str, KeyT]] | None = None,\n        reset: bool = False,\n        reset_keys: bool = False,\n        reset_channels: bool = False,\n        reset_passwords: bool = False,\n        **kwargs,\n    ) -> bool | Awaitable[bool]:\n        \"\"\"\n        Create or update an ACL user.\n\n        Create or update the ACL for `username`. If the user already exists,\n        the existing ACL is completely overwritten and replaced with the\n        specified values.\n\n        For more information, see https://redis.io/commands/acl-setuser\n\n        Args:\n            username: The name of the user whose ACL is to be created or updated.\n            enabled: Indicates whether the user should be allowed to authenticate.\n                     Defaults to `False`.\n            nopass: Indicates whether the user can authenticate without a password.\n                    This cannot be `True` if `passwords` are also specified.\n            passwords: A list of plain text passwords to add to or remove from the user.\n                       Each password must be prefixed with a '+' to add or a '-' to\n                       remove. For convenience, a single prefixed string can be used\n                       when adding or removing a single password.\n            hashed_passwords: A list of SHA-256 hashed passwords to add to or remove\n                              from the user. Each hashed password must be prefixed with\n                              a '+' to add or a '-' to remove. For convenience, a single\n                              prefixed string can be used when adding or removing a\n                              single password.\n            categories: A list of strings representing category permissions. Each string\n                        must be prefixed with either a '+' to add the category\n                        permission or a '-' to remove the category permission.\n            commands: A list of strings representing command permissions. Each string\n                      must be prefixed with either a '+' to add the command permission\n                      or a '-' to remove the command permission.\n            keys: A list of key patterns to grant the user access to. Key patterns allow\n                  ``'*'`` to support wildcard matching. For example, ``'*'`` grants\n                  access to all keys while ``'cache:*'`` grants access to all keys that\n                  are prefixed with ``cache:``.\n                  `keys` should not be prefixed with a ``'~'``.\n            reset: Indicates whether the user should be fully reset prior to applying\n                   the new ACL. Setting this to `True` will remove all existing\n                   passwords, flags, and privileges from the user and then apply the\n                   specified rules. If `False`, the user's existing passwords, flags,\n                   and privileges will be kept and any new specified rules will be\n                   applied on top.\n            reset_keys: Indicates whether the user's key permissions should be reset\n                        prior to applying any new key permissions specified in `keys`.\n                        If `False`, the user's existing key permissions will be kept and\n                        any new specified key permissions will be applied on top.\n            reset_channels: Indicates whether the user's channel permissions should be\n                            reset prior to applying any new channel permissions\n                            specified in `channels`. If `False`, the user's existing\n                            channel permissions will be kept and any new specified\n                            channel permissions will be applied on top.\n            reset_passwords: Indicates whether to remove all existing passwords and the\n                             `nopass` flag from the user prior to applying any new\n                             passwords specified in `passwords` or `hashed_passwords`.\n                             If `False`, the user's existing passwords and `nopass`\n                             status will be kept and any new specified passwords or\n                             hashed passwords will be applied on top.\n        \"\"\"\n        encoder = self.get_encoder()\n        pieces: List[EncodableT] = [username]\n\n        if reset:\n            pieces.append(b\"reset\")\n\n        if reset_keys:\n            pieces.append(b\"resetkeys\")\n\n        if reset_channels:\n            pieces.append(b\"resetchannels\")\n\n        if reset_passwords:\n            pieces.append(b\"resetpass\")\n\n        if enabled:\n            pieces.append(b\"on\")\n        else:\n            pieces.append(b\"off\")\n\n        if (passwords or hashed_passwords) and nopass:\n            raise DataError(\n                \"Cannot set 'nopass' and supply 'passwords' or 'hashed_passwords'\"\n            )\n\n        if passwords:\n            # as most users will have only one password, allow remove_passwords\n            # to be specified as a simple string or a list\n            passwords = list_or_args(passwords, [])\n            for i, password in enumerate(passwords):\n                password = encoder.encode(password)\n                if password.startswith(b\"+\"):\n                    pieces.append(b\">%s\" % password[1:])\n                elif password.startswith(b\"-\"):\n                    pieces.append(b\"<%s\" % password[1:])\n                else:\n                    raise DataError(\n                        f\"Password {i} must be prefixed with a \"\n                        f'\"+\" to add or a \"-\" to remove'\n                    )\n\n        if hashed_passwords:\n            # as most users will have only one password, allow remove_passwords\n            # to be specified as a simple string or a list\n            hashed_passwords = list_or_args(hashed_passwords, [])\n            for i, hashed_password in enumerate(hashed_passwords):\n                hashed_password = encoder.encode(hashed_password)\n                if hashed_password.startswith(b\"+\"):\n                    pieces.append(b\"#%s\" % hashed_password[1:])\n                elif hashed_password.startswith(b\"-\"):\n                    pieces.append(b\"!%s\" % hashed_password[1:])\n                else:\n                    raise DataError(\n                        f\"Hashed password {i} must be prefixed with a \"\n                        f'\"+\" to add or a \"-\" to remove'\n                    )\n\n        if nopass:\n            pieces.append(b\"nopass\")\n\n        if categories:\n            for category in categories:\n                category = encoder.encode(category)\n                # categories can be prefixed with one of (+@, +, -@, -)\n                if category.startswith(b\"+@\"):\n                    pieces.append(category)\n                elif category.startswith(b\"+\"):\n                    pieces.append(b\"+@%s\" % category[1:])\n                elif category.startswith(b\"-@\"):\n                    pieces.append(category)\n                elif category.startswith(b\"-\"):\n                    pieces.append(b\"-@%s\" % category[1:])\n                else:\n                    raise DataError(\n                        f'Category \"{encoder.decode(category, force=True)}\" '\n                        'must be prefixed with \"+\" or \"-\"'\n                    )\n        if commands:\n            for cmd in commands:\n                cmd = encoder.encode(cmd)\n                if not cmd.startswith(b\"+\") and not cmd.startswith(b\"-\"):\n                    raise DataError(\n                        f'Command \"{encoder.decode(cmd, force=True)}\" '\n                        'must be prefixed with \"+\" or \"-\"'\n                    )\n                pieces.append(cmd)\n\n        if keys:\n            for key in keys:\n                key = encoder.encode(key)\n                if not key.startswith(b\"%\") and not key.startswith(b\"~\"):\n                    key = b\"~%s\" % key\n                pieces.append(key)\n\n        if channels:\n            for channel in channels:\n                channel = encoder.encode(channel)\n                pieces.append(b\"&%s\" % channel)\n\n        if selectors:\n            for cmd, key in selectors:\n                cmd = encoder.encode(cmd)\n                if not cmd.startswith(b\"+\") and not cmd.startswith(b\"-\"):\n                    raise DataError(\n                        f'Command \"{encoder.decode(cmd, force=True)}\" '\n                        'must be prefixed with \"+\" or \"-\"'\n                    )\n\n                key = encoder.encode(key)\n                if not key.startswith(b\"%\") and not key.startswith(b\"~\"):\n                    key = b\"~%s\" % key\n\n                pieces.append(b\"(%s %s)\" % (cmd, key))\n\n        return self.execute_command(\"ACL SETUSER\", *pieces, **kwargs)\n\n    @overload\n    def acl_users(self: SyncClientProtocol, **kwargs) -> list[bytes | str]: ...\n\n    @overload\n    def acl_users(\n        self: AsyncClientProtocol, **kwargs\n    ) -> Awaitable[list[bytes | str]]: ...\n\n    def acl_users(self, **kwargs) -> list[bytes | str] | Awaitable[list[bytes | str]]:\n        \"\"\"Returns a list of all registered users on the server.\n\n        For more information, see https://redis.io/commands/acl-users\n        \"\"\"\n        return self.execute_command(\"ACL USERS\", **kwargs)\n\n    @overload\n    def acl_whoami(self: SyncClientProtocol, **kwargs) -> bytes | str: ...\n\n    @overload\n    def acl_whoami(self: AsyncClientProtocol, **kwargs) -> Awaitable[bytes | str]: ...\n\n    def acl_whoami(self, **kwargs) -> (bytes | str) | Awaitable[bytes | str]:\n        \"\"\"Get the username for the current connection\n\n        For more information, see https://redis.io/commands/acl-whoami\n        \"\"\"\n        return self.execute_command(\"ACL WHOAMI\", **kwargs)\n\n\nAsyncACLCommands = ACLCommands\n\n\nclass HotkeysMetricsTypes(Enum):\n    CPU = \"CPU\"\n    NET = \"NET\"\n\n\nclass ManagementCommands(CommandsProtocol):\n    \"\"\"\n    Redis management commands\n    \"\"\"\n\n    @overload\n    def auth(\n        self: SyncClientProtocol,\n        password: str,\n        username: str | None = None,\n        **kwargs,\n    ) -> bool: ...\n\n    @overload\n    def auth(\n        self: AsyncClientProtocol,\n        password: str,\n        username: str | None = None,\n        **kwargs,\n    ) -> Awaitable[bool]: ...\n\n    def auth(\n        self, password: str, username: str | None = None, **kwargs\n    ) -> bool | Awaitable[bool]:\n        \"\"\"\n        Authenticates the user. If you do not pass username, Redis will try to\n        authenticate for the \"default\" user. If you do pass username, it will\n        authenticate for the given user.\n        For more information, see https://redis.io/commands/auth\n        \"\"\"\n        pieces = []\n        if username is not None:\n            pieces.append(username)\n        pieces.append(password)\n        return self.execute_command(\"AUTH\", *pieces, **kwargs)\n\n    @overload\n    def bgrewriteaof(self: SyncClientProtocol, **kwargs) -> bool | bytes | str: ...\n\n    @overload\n    def bgrewriteaof(\n        self: AsyncClientProtocol, **kwargs\n    ) -> Awaitable[bool | bytes | str]: ...\n\n    def bgrewriteaof(self, **kwargs) -> (bool | bytes | str) | Awaitable[\n        bool | bytes | str\n    ]:\n        \"\"\"Tell the Redis server to rewrite the AOF file from data in memory.\n\n        For more information, see https://redis.io/commands/bgrewriteaof\n        \"\"\"\n        return self.execute_command(\"BGREWRITEAOF\", **kwargs)\n\n    @overload\n    def bgsave(\n        self: SyncClientProtocol, schedule: bool = True, **kwargs\n    ) -> bool | bytes | str: ...\n\n    @overload\n    def bgsave(\n        self: AsyncClientProtocol, schedule: bool = True, **kwargs\n    ) -> Awaitable[bool | bytes | str]: ...\n\n    def bgsave(self, schedule: bool = True, **kwargs) -> (\n        bool | bytes | str\n    ) | Awaitable[bool | bytes | str]:\n        \"\"\"\n        Tell the Redis server to save its data to disk.  Unlike save(),\n        this method is asynchronous and returns immediately.\n\n        For more information, see https://redis.io/commands/bgsave\n        \"\"\"\n        pieces = []\n        if schedule:\n            pieces.append(\"SCHEDULE\")\n        return self.execute_command(\"BGSAVE\", *pieces, **kwargs)\n\n    @overload\n    def role(self: SyncClientProtocol) -> list[Any]: ...\n\n    @overload\n    def role(self: AsyncClientProtocol) -> Awaitable[list[Any]]: ...\n\n    def role(self) -> list[Any] | Awaitable[list[Any]]:\n        \"\"\"\n        Provide information on the role of a Redis instance in\n        the context of replication, by returning if the instance\n        is currently a master, slave, or sentinel.\n\n        For more information, see https://redis.io/commands/role\n        \"\"\"\n        return self.execute_command(\"ROLE\")\n\n    @overload\n    def client_kill(self: SyncClientProtocol, address: str, **kwargs) -> bool | int: ...\n\n    @overload\n    def client_kill(\n        self: AsyncClientProtocol, address: str, **kwargs\n    ) -> Awaitable[bool | int]: ...\n\n    def client_kill(self, address: str, **kwargs) -> (bool | int) | Awaitable[\n        bool | int\n    ]:\n        \"\"\"Disconnects the client at ``address`` (ip:port)\n\n        For more information, see https://redis.io/commands/client-kill\n        \"\"\"\n        return self.execute_command(\"CLIENT KILL\", address, **kwargs)\n\n    @overload\n    def client_kill_filter(\n        self: SyncClientProtocol,\n        _id: str | None = None,\n        _type: str | None = None,\n        addr: str | None = None,\n        skipme: bool | None = None,\n        laddr: bool | None = None,\n        user: str | None = None,\n        maxage: int | None = None,\n        **kwargs,\n    ) -> int: ...\n\n    @overload\n    def client_kill_filter(\n        self: AsyncClientProtocol,\n        _id: str | None = None,\n        _type: str | None = None,\n        addr: str | None = None,\n        skipme: bool | None = None,\n        laddr: bool | None = None,\n        user: str | None = None,\n        maxage: int | None = None,\n        **kwargs,\n    ) -> Awaitable[int]: ...\n\n    def client_kill_filter(\n        self,\n        _id: str | None = None,\n        _type: str | None = None,\n        addr: str | None = None,\n        skipme: bool | None = None,\n        laddr: bool | None = None,\n        user: str | None = None,\n        maxage: int | None = None,\n        **kwargs,\n    ) -> int | Awaitable[int]:\n        \"\"\"\n        Disconnects client(s) using a variety of filter options\n        :param _id: Kills a client by its unique ID field\n        :param _type: Kills a client by type where type is one of 'normal',\n        'master', 'slave' or 'pubsub'\n        :param addr: Kills a client by its 'address:port'\n        :param skipme: If True, then the client calling the command\n        will not get killed even if it is identified by one of the filter\n        options. If skipme is not provided, the server defaults to skipme=True\n        :param laddr: Kills a client by its 'local (bind) address:port'\n        :param user: Kills a client for a specific user name\n        :param maxage: Kills clients that are older than the specified age in seconds\n        \"\"\"\n        args = []\n        if _type is not None:\n            client_types = (\"normal\", \"master\", \"slave\", \"pubsub\")\n            if str(_type).lower() not in client_types:\n                raise DataError(f\"CLIENT KILL type must be one of {client_types!r}\")\n            args.extend((b\"TYPE\", _type))\n        if skipme is not None:\n            if not isinstance(skipme, bool):\n                raise DataError(\"CLIENT KILL skipme must be a bool\")\n            if skipme:\n                args.extend((b\"SKIPME\", b\"YES\"))\n            else:\n                args.extend((b\"SKIPME\", b\"NO\"))\n        if _id is not None:\n            args.extend((b\"ID\", _id))\n        if addr is not None:\n            args.extend((b\"ADDR\", addr))\n        if laddr is not None:\n            args.extend((b\"LADDR\", laddr))\n        if user is not None:\n            args.extend((b\"USER\", user))\n        if maxage is not None:\n            args.extend((b\"MAXAGE\", maxage))\n        if not args:\n            raise DataError(\n                \"CLIENT KILL <filter> <value> ... ... <filter> \"\n                \"<value> must specify at least one filter\"\n            )\n        return self.execute_command(\"CLIENT KILL\", *args, **kwargs)\n\n    @overload\n    def client_info(self: SyncClientProtocol, **kwargs) -> dict[str, str | int]: ...\n\n    @overload\n    def client_info(\n        self: AsyncClientProtocol, **kwargs\n    ) -> Awaitable[dict[str, str | int]]: ...\n\n    def client_info(\n        self, **kwargs\n    ) -> dict[str, str | int] | Awaitable[dict[str, str | int]]:\n        \"\"\"\n        Returns information and statistics about the current\n        client connection.\n\n        For more information, see https://redis.io/commands/client-info\n        \"\"\"\n        return self.execute_command(\"CLIENT INFO\", **kwargs)\n\n    @overload\n    def client_list(\n        self: SyncClientProtocol,\n        _type: str | None = None,\n        client_id: List[EncodableT] = [],\n        **kwargs,\n    ) -> list[dict[str, str]]: ...\n\n    @overload\n    def client_list(\n        self: AsyncClientProtocol,\n        _type: str | None = None,\n        client_id: List[EncodableT] = [],\n        **kwargs,\n    ) -> Awaitable[list[dict[str, str]]]: ...\n\n    def client_list(\n        self, _type: str | None = None, client_id: List[EncodableT] = [], **kwargs\n    ) -> list[dict[str, str]] | Awaitable[list[dict[str, str]]]:\n        \"\"\"\n        Returns a list of currently connected clients.\n        If type of client specified, only that type will be returned.\n\n        :param _type: optional. one of the client types (normal, master,\n         replica, pubsub)\n        :param client_id: optional. a list of client ids\n\n        For more information, see https://redis.io/commands/client-list\n        \"\"\"\n        args = []\n        if _type is not None:\n            client_types = (\"normal\", \"master\", \"replica\", \"pubsub\")\n            if str(_type).lower() not in client_types:\n                raise DataError(f\"CLIENT LIST _type must be one of {client_types!r}\")\n            args.append(b\"TYPE\")\n            args.append(_type)\n        if not isinstance(client_id, list):\n            raise DataError(\"client_id must be a list\")\n        if client_id:\n            args.append(b\"ID\")\n            args += client_id\n        return self.execute_command(\"CLIENT LIST\", *args, **kwargs)\n\n    @overload\n    def client_getname(self: SyncClientProtocol, **kwargs) -> bytes | str | None: ...\n\n    @overload\n    def client_getname(\n        self: AsyncClientProtocol, **kwargs\n    ) -> Awaitable[bytes | str | None]: ...\n\n    def client_getname(self, **kwargs) -> (bytes | str | None) | Awaitable[\n        bytes | str | None\n    ]:\n        \"\"\"\n        Returns the current connection name\n\n        For more information, see https://redis.io/commands/client-getname\n        \"\"\"\n        return self.execute_command(\"CLIENT GETNAME\", **kwargs)\n\n    @overload\n    def client_getredir(self: SyncClientProtocol, **kwargs) -> int: ...\n\n    @overload\n    def client_getredir(self: AsyncClientProtocol, **kwargs) -> Awaitable[int]: ...\n\n    def client_getredir(self, **kwargs) -> int | Awaitable[int]:\n        \"\"\"\n        Returns the ID (an integer) of the client to whom we are\n        redirecting tracking notifications.\n\n        see: https://redis.io/commands/client-getredir\n        \"\"\"\n        return self.execute_command(\"CLIENT GETREDIR\", **kwargs)\n\n    @overload\n    def client_reply(\n        self: SyncClientProtocol,\n        reply: Literal[\"ON\", \"OFF\", \"SKIP\"],\n        **kwargs,\n    ) -> bytes | str: ...\n\n    @overload\n    def client_reply(\n        self: AsyncClientProtocol,\n        reply: Literal[\"ON\", \"OFF\", \"SKIP\"],\n        **kwargs,\n    ) -> Awaitable[bytes | str]: ...\n\n    def client_reply(self, reply: Literal[\"ON\", \"OFF\", \"SKIP\"], **kwargs) -> (\n        bytes | str\n    ) | Awaitable[bytes | str]:\n        \"\"\"\n        Enable and disable redis server replies.\n\n        ``reply`` Must be ON OFF or SKIP,\n        ON - The default most with server replies to commands\n        OFF - Disable server responses to commands\n        SKIP - Skip the response of the immediately following command.\n\n        Note: When setting OFF or SKIP replies, you will need a client object\n        with a timeout specified in seconds, and will need to catch the\n        TimeoutError.\n        The test_client_reply unit test illustrates this, and\n        conftest.py has a client with a timeout.\n\n        See https://redis.io/commands/client-reply\n        \"\"\"\n        replies = [\"ON\", \"OFF\", \"SKIP\"]\n        if reply not in replies:\n            raise DataError(f\"CLIENT REPLY must be one of {replies!r}\")\n        return self.execute_command(\"CLIENT REPLY\", reply, **kwargs)\n\n    @overload\n    def client_id(self: SyncClientProtocol, **kwargs) -> int: ...\n\n    @overload\n    def client_id(self: AsyncClientProtocol, **kwargs) -> Awaitable[int]: ...\n\n    def client_id(self, **kwargs) -> int | Awaitable[int]:\n        \"\"\"\n        Returns the current connection id\n\n        For more information, see https://redis.io/commands/client-id\n        \"\"\"\n        return self.execute_command(\"CLIENT ID\", **kwargs)\n\n    @overload\n    def client_tracking_on(\n        self: SyncClientProtocol,\n        clientid: int | None = None,\n        prefix: Sequence[KeyT] = [],\n        bcast: bool = False,\n        optin: bool = False,\n        optout: bool = False,\n        noloop: bool = False,\n    ) -> bytes | str: ...\n\n    @overload\n    def client_tracking_on(\n        self: AsyncClientProtocol,\n        clientid: int | None = None,\n        prefix: Sequence[KeyT] = [],\n        bcast: bool = False,\n        optin: bool = False,\n        optout: bool = False,\n        noloop: bool = False,\n    ) -> Awaitable[bytes | str]: ...\n\n    def client_tracking_on(\n        self,\n        clientid: int | None = None,\n        prefix: Sequence[KeyT] = [],\n        bcast: bool = False,\n        optin: bool = False,\n        optout: bool = False,\n        noloop: bool = False,\n    ) -> (bytes | str) | Awaitable[bytes | str]:\n        \"\"\"\n        Turn on the tracking mode.\n        For more information, about the options look at client_tracking func.\n\n        See https://redis.io/commands/client-tracking\n        \"\"\"\n        return self.client_tracking(\n            True, clientid, prefix, bcast, optin, optout, noloop\n        )\n\n    @overload\n    def client_tracking_off(\n        self: SyncClientProtocol,\n        clientid: int | None = None,\n        prefix: Sequence[KeyT] = [],\n        bcast: bool = False,\n        optin: bool = False,\n        optout: bool = False,\n        noloop: bool = False,\n    ) -> bytes | str: ...\n\n    @overload\n    def client_tracking_off(\n        self: AsyncClientProtocol,\n        clientid: int | None = None,\n        prefix: Sequence[KeyT] = [],\n        bcast: bool = False,\n        optin: bool = False,\n        optout: bool = False,\n        noloop: bool = False,\n    ) -> Awaitable[bytes | str]: ...\n\n    def client_tracking_off(\n        self,\n        clientid: int | None = None,\n        prefix: Sequence[KeyT] = [],\n        bcast: bool = False,\n        optin: bool = False,\n        optout: bool = False,\n        noloop: bool = False,\n    ) -> (bytes | str) | Awaitable[bytes | str]:\n        \"\"\"\n        Turn off the tracking mode.\n        For more information, about the options look at client_tracking func.\n\n        See https://redis.io/commands/client-tracking\n        \"\"\"\n        return self.client_tracking(\n            False, clientid, prefix, bcast, optin, optout, noloop\n        )\n\n    @overload\n    def client_tracking(\n        self: SyncClientProtocol,\n        on: bool = True,\n        clientid: int | None = None,\n        prefix: Sequence[KeyT] = [],\n        bcast: bool = False,\n        optin: bool = False,\n        optout: bool = False,\n        noloop: bool = False,\n        **kwargs,\n    ) -> bytes | str: ...\n\n    @overload\n    def client_tracking(\n        self: AsyncClientProtocol,\n        on: bool = True,\n        clientid: int | None = None,\n        prefix: Sequence[KeyT] = [],\n        bcast: bool = False,\n        optin: bool = False,\n        optout: bool = False,\n        noloop: bool = False,\n        **kwargs,\n    ) -> Awaitable[bytes | str]: ...\n\n    def client_tracking(\n        self,\n        on: bool = True,\n        clientid: int | None = None,\n        prefix: Sequence[KeyT] = [],\n        bcast: bool = False,\n        optin: bool = False,\n        optout: bool = False,\n        noloop: bool = False,\n        **kwargs,\n    ) -> (bytes | str) | Awaitable[bytes | str]:\n        \"\"\"\n        Enables the tracking feature of the Redis server, that is used\n        for server assisted client side caching.\n\n        ``on`` indicate for tracking on or tracking off. The default is on.\n\n        ``clientid`` send invalidation messages to the connection with\n        the specified ID.\n\n        ``bcast`` enable tracking in broadcasting mode. In this mode\n        invalidation messages are reported for all the prefixes\n        specified, regardless of the keys requested by the connection.\n\n        ``optin``  when broadcasting is NOT active, normally don't track\n        keys in read only commands, unless they are called immediately\n        after a CLIENT CACHING yes command.\n\n        ``optout`` when broadcasting is NOT active, normally track keys in\n        read only commands, unless they are called immediately after a\n        CLIENT CACHING no command.\n\n        ``noloop`` don't send notifications about keys modified by this\n        connection itself.\n\n        ``prefix``  for broadcasting, register a given key prefix, so that\n        notifications will be provided only for keys starting with this string.\n\n        See https://redis.io/commands/client-tracking\n        \"\"\"\n\n        if len(prefix) != 0 and bcast is False:\n            raise DataError(\"Prefix can only be used with bcast\")\n\n        pieces = [\"ON\"] if on else [\"OFF\"]\n        if clientid is not None:\n            pieces.extend([\"REDIRECT\", clientid])\n        for p in prefix:\n            pieces.extend([\"PREFIX\", p])\n        if bcast:\n            pieces.append(\"BCAST\")\n        if optin:\n            pieces.append(\"OPTIN\")\n        if optout:\n            pieces.append(\"OPTOUT\")\n        if noloop:\n            pieces.append(\"NOLOOP\")\n\n        return self.execute_command(\"CLIENT TRACKING\", *pieces, **kwargs)\n\n    @overload\n    def client_trackinginfo(\n        self: SyncClientProtocol, **kwargs\n    ) -> list[bytes | str]: ...\n\n    @overload\n    def client_trackinginfo(\n        self: AsyncClientProtocol, **kwargs\n    ) -> Awaitable[list[bytes | str]]: ...\n\n    def client_trackinginfo(\n        self, **kwargs\n    ) -> list[bytes | str] | Awaitable[list[bytes | str]]:\n        \"\"\"\n        Returns the information about the current client connection's\n        use of the server assisted client side cache.\n\n        See https://redis.io/commands/client-trackinginfo\n        \"\"\"\n        return self.execute_command(\"CLIENT TRACKINGINFO\", **kwargs)\n\n    @overload\n    def client_setname(self: SyncClientProtocol, name: str, **kwargs) -> bool: ...\n\n    @overload\n    def client_setname(\n        self: AsyncClientProtocol, name: str, **kwargs\n    ) -> Awaitable[bool]: ...\n\n    def client_setname(self, name: str, **kwargs) -> bool | Awaitable[bool]:\n        \"\"\"\n        Sets the current connection name\n\n        For more information, see https://redis.io/commands/client-setname\n\n        .. note::\n           This method sets client name only for **current** connection.\n\n           If you want to set a common name for all connections managed\n           by this client, use ``client_name`` constructor argument.\n        \"\"\"\n        return self.execute_command(\"CLIENT SETNAME\", name, **kwargs)\n\n    @overload\n    def client_setinfo(\n        self: SyncClientProtocol, attr: str, value: str, **kwargs\n    ) -> bool: ...\n\n    @overload\n    def client_setinfo(\n        self: AsyncClientProtocol, attr: str, value: str, **kwargs\n    ) -> Awaitable[bool]: ...\n\n    def client_setinfo(self, attr: str, value: str, **kwargs) -> bool | Awaitable[bool]:\n        \"\"\"\n        Sets the current connection library name or version\n        For mor information see https://redis.io/commands/client-setinfo\n        \"\"\"\n        return self.execute_command(\"CLIENT SETINFO\", attr, value, **kwargs)\n\n    @overload\n    def client_unblock(\n        self: SyncClientProtocol, client_id: int, error: bool = False, **kwargs\n    ) -> bool: ...\n\n    @overload\n    def client_unblock(\n        self: AsyncClientProtocol, client_id: int, error: bool = False, **kwargs\n    ) -> Awaitable[bool]: ...\n\n    def client_unblock(\n        self, client_id: int, error: bool = False, **kwargs\n    ) -> bool | Awaitable[bool]:\n        \"\"\"\n        Unblocks a connection by its client id.\n        If ``error`` is True, unblocks the client with a special error message.\n        If ``error`` is False (default), the client is unblocked using the\n        regular timeout mechanism.\n\n        For more information, see https://redis.io/commands/client-unblock\n        \"\"\"\n        args = [\"CLIENT UNBLOCK\", int(client_id)]\n        if error:\n            args.append(b\"ERROR\")\n        return self.execute_command(*args, **kwargs)\n\n    @overload\n    def client_pause(\n        self: SyncClientProtocol, timeout: int, all: bool = True, **kwargs\n    ) -> bool: ...\n\n    @overload\n    def client_pause(\n        self: AsyncClientProtocol, timeout: int, all: bool = True, **kwargs\n    ) -> Awaitable[bool]: ...\n\n    def client_pause(\n        self, timeout: int, all: bool = True, **kwargs\n    ) -> bool | Awaitable[bool]:\n        \"\"\"\n        Suspend all the Redis clients for the specified amount of time.\n\n\n        For more information, see https://redis.io/commands/client-pause\n\n        Args:\n            timeout: milliseconds to pause clients\n            all: If true (default) all client commands are blocked.\n                 otherwise, clients are only blocked if they attempt to execute\n                 a write command.\n\n        For the WRITE mode, some commands have special behavior:\n\n        * EVAL/EVALSHA: Will block client for all scripts.\n        * PUBLISH: Will block client.\n        * PFCOUNT: Will block client.\n        * WAIT: Acknowledgments will be delayed, so this command will\n            appear blocked.\n        \"\"\"\n        args = [\"CLIENT PAUSE\", str(timeout)]\n        if not isinstance(timeout, int):\n            raise DataError(\"CLIENT PAUSE timeout must be an integer\")\n        if not all:\n            args.append(\"WRITE\")\n        return self.execute_command(*args, **kwargs)\n\n    @overload\n    def client_unpause(self: SyncClientProtocol, **kwargs) -> bytes | str: ...\n\n    @overload\n    def client_unpause(\n        self: AsyncClientProtocol, **kwargs\n    ) -> Awaitable[bytes | str]: ...\n\n    def client_unpause(self, **kwargs) -> (bytes | str) | Awaitable[bytes | str]:\n        \"\"\"\n        Unpause all redis clients\n\n        For more information, see https://redis.io/commands/client-unpause\n        \"\"\"\n        return self.execute_command(\"CLIENT UNPAUSE\", **kwargs)\n\n    @overload\n    def client_no_evict(self: SyncClientProtocol, mode: str) -> bytes | str: ...\n\n    @overload\n    def client_no_evict(\n        self: AsyncClientProtocol, mode: str\n    ) -> Awaitable[bytes | str]: ...\n\n    def client_no_evict(self, mode: str) -> (bytes | str) | Awaitable[bytes | str]:\n        \"\"\"\n        Sets the client eviction mode for the current connection.\n\n        For more information, see https://redis.io/commands/client-no-evict\n        \"\"\"\n        return self.execute_command(\"CLIENT NO-EVICT\", mode)\n\n    @overload\n    def client_no_touch(self: SyncClientProtocol, mode: str) -> bytes | str: ...\n\n    @overload\n    def client_no_touch(\n        self: AsyncClientProtocol, mode: str\n    ) -> Awaitable[bytes | str]: ...\n\n    def client_no_touch(self, mode: str) -> (bytes | str) | Awaitable[bytes | str]:\n        \"\"\"\n        # The command controls whether commands sent by the client will alter\n        # the LRU/LFU of the keys they access.\n        # When turned on, the current client will not change LFU/LRU stats,\n        # unless it sends the TOUCH command.\n\n        For more information, see https://redis.io/commands/client-no-touch\n        \"\"\"\n        return self.execute_command(\"CLIENT NO-TOUCH\", mode)\n\n    @overload\n    def command(self: SyncClientProtocol, **kwargs) -> dict[str, dict[str, Any]]: ...\n\n    @overload\n    def command(\n        self: AsyncClientProtocol, **kwargs\n    ) -> Awaitable[dict[str, dict[str, Any]]]: ...\n\n    def command(\n        self, **kwargs\n    ) -> dict[str, dict[str, Any]] | Awaitable[dict[str, dict[str, Any]]]:\n        \"\"\"\n        Returns dict reply of details about all Redis commands.\n\n        For more information, see https://redis.io/commands/command\n        \"\"\"\n        return self.execute_command(\"COMMAND\", **kwargs)\n\n    def command_info(self, **kwargs) -> None:\n        raise NotImplementedError(\n            \"COMMAND INFO is intentionally not implemented in the client.\"\n        )\n\n    @overload\n    def command_count(self: SyncClientProtocol, **kwargs) -> int: ...\n\n    @overload\n    def command_count(self: AsyncClientProtocol, **kwargs) -> Awaitable[int]: ...\n\n    def command_count(self, **kwargs) -> int | Awaitable[int]:\n        return self.execute_command(\"COMMAND COUNT\", **kwargs)\n\n    @overload\n    def command_list(\n        self: SyncClientProtocol,\n        module: str | None = None,\n        category: str | None = None,\n        pattern: str | None = None,\n    ) -> list[bytes | str]: ...\n\n    @overload\n    def command_list(\n        self: AsyncClientProtocol,\n        module: str | None = None,\n        category: str | None = None,\n        pattern: str | None = None,\n    ) -> Awaitable[list[bytes | str]]: ...\n\n    def command_list(\n        self,\n        module: str | None = None,\n        category: str | None = None,\n        pattern: str | None = None,\n    ) -> list[bytes | str] | Awaitable[list[bytes | str]]:\n        \"\"\"\n        Return an array of the server's command names.\n        You can use one of the following filters:\n        ``module``: get the commands that belong to the module\n        ``category``: get the commands in the ACL category\n        ``pattern``: get the commands that match the given pattern\n\n        For more information, see https://redis.io/commands/command-list/\n        \"\"\"\n        pieces = []\n        if module is not None:\n            pieces.extend([\"MODULE\", module])\n        if category is not None:\n            pieces.extend([\"ACLCAT\", category])\n        if pattern is not None:\n            pieces.extend([\"PATTERN\", pattern])\n\n        if pieces:\n            pieces.insert(0, \"FILTERBY\")\n\n        return self.execute_command(\"COMMAND LIST\", *pieces)\n\n    @overload\n    def command_getkeysandflags(\n        self: SyncClientProtocol, *args: str\n    ) -> CommandGetKeysAndFlagsResponse: ...\n\n    @overload\n    def command_getkeysandflags(\n        self: AsyncClientProtocol, *args: str\n    ) -> Awaitable[CommandGetKeysAndFlagsResponse]: ...\n\n    def command_getkeysandflags(\n        self, *args: str\n    ) -> CommandGetKeysAndFlagsResponse | Awaitable[CommandGetKeysAndFlagsResponse]:\n        \"\"\"\n        Returns array of keys from a full Redis command and their usage flags.\n\n        For more information, see https://redis.io/commands/command-getkeysandflags\n        \"\"\"\n        return self.execute_command(\"COMMAND GETKEYSANDFLAGS\", *args)\n\n    def command_docs(self, *args):\n        \"\"\"\n        This function throws a NotImplementedError since it is intentionally\n        not supported.\n        \"\"\"\n        raise NotImplementedError(\n            \"COMMAND DOCS is intentionally not implemented in the client.\"\n        )\n\n    @overload\n    def config_get(\n        self: SyncClientProtocol, pattern: PatternT = \"*\", *args: PatternT, **kwargs\n    ) -> dict[str | None, str | None]: ...\n\n    @overload\n    def config_get(\n        self: AsyncClientProtocol, pattern: PatternT = \"*\", *args: PatternT, **kwargs\n    ) -> Awaitable[dict[str | None, str | None]]: ...\n\n    def config_get(\n        self, pattern: PatternT = \"*\", *args: PatternT, **kwargs\n    ) -> dict[str | None, str | None] | Awaitable[dict[str | None, str | None]]:\n        \"\"\"\n        Return a dictionary of configuration based on the ``pattern``\n\n        For more information, see https://redis.io/commands/config-get\n        \"\"\"\n        return self.execute_command(\"CONFIG GET\", pattern, *args, **kwargs)\n\n    @overload\n    def config_set(\n        self: SyncClientProtocol,\n        name: KeyT,\n        value: EncodableT,\n        *args: Union[KeyT, EncodableT],\n        **kwargs,\n    ) -> bool: ...\n\n    @overload\n    def config_set(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        value: EncodableT,\n        *args: Union[KeyT, EncodableT],\n        **kwargs,\n    ) -> Awaitable[bool]: ...\n\n    def config_set(\n        self,\n        name: KeyT,\n        value: EncodableT,\n        *args: Union[KeyT, EncodableT],\n        **kwargs,\n    ) -> bool | Awaitable[bool]:\n        \"\"\"Set config item ``name`` with ``value``\n\n        For more information, see https://redis.io/commands/config-set\n        \"\"\"\n        return self.execute_command(\"CONFIG SET\", name, value, *args, **kwargs)\n\n    @overload\n    def config_resetstat(self: SyncClientProtocol, **kwargs) -> bool: ...\n\n    @overload\n    def config_resetstat(self: AsyncClientProtocol, **kwargs) -> Awaitable[bool]: ...\n\n    def config_resetstat(self, **kwargs) -> bool | Awaitable[bool]:\n        \"\"\"\n        Reset runtime statistics\n\n        For more information, see https://redis.io/commands/config-resetstat\n        \"\"\"\n        return self.execute_command(\"CONFIG RESETSTAT\", **kwargs)\n\n    @overload\n    def config_rewrite(self: SyncClientProtocol, **kwargs) -> bytes | str: ...\n\n    @overload\n    def config_rewrite(\n        self: AsyncClientProtocol, **kwargs\n    ) -> Awaitable[bytes | str]: ...\n\n    def config_rewrite(self, **kwargs) -> (bytes | str) | Awaitable[bytes | str]:\n        \"\"\"\n        Rewrite config file with the minimal change to reflect running config.\n\n        For more information, see https://redis.io/commands/config-rewrite\n        \"\"\"\n        return self.execute_command(\"CONFIG REWRITE\", **kwargs)\n\n    @overload\n    def dbsize(self: SyncClientProtocol, **kwargs) -> int: ...\n\n    @overload\n    def dbsize(self: AsyncClientProtocol, **kwargs) -> Awaitable[int]: ...\n\n    def dbsize(self, **kwargs) -> int | Awaitable[int]:\n        \"\"\"\n        Returns the number of keys in the current database\n\n        For more information, see https://redis.io/commands/dbsize\n        \"\"\"\n        return self.execute_command(\"DBSIZE\", **kwargs)\n\n    @overload\n    def debug_object(\n        self: SyncClientProtocol, key: KeyT, **kwargs\n    ) -> dict[str, str | int] | bytes | str: ...\n\n    @overload\n    def debug_object(\n        self: AsyncClientProtocol, key: KeyT, **kwargs\n    ) -> Awaitable[dict[str, str | int] | bytes | str]: ...\n\n    def debug_object(self, key: KeyT, **kwargs) -> (\n        dict[str, str | int] | bytes | str\n    ) | Awaitable[dict[str, str | int] | bytes | str]:\n        \"\"\"\n        Returns version specific meta information about a given key\n\n        For more information, see https://redis.io/commands/debug-object\n        \"\"\"\n        return self.execute_command(\"DEBUG OBJECT\", key, **kwargs)\n\n    def debug_segfault(self, **kwargs) -> None:\n        raise NotImplementedError(\n            \"\"\"\n            DEBUG SEGFAULT is intentionally not implemented in the client.\n\n            For more information, see https://redis.io/commands/debug-segfault\n            \"\"\"\n        )\n\n    @overload\n    def echo(self: SyncClientProtocol, value: EncodableT, **kwargs) -> bytes | str: ...\n\n    @overload\n    def echo(\n        self: AsyncClientProtocol, value: EncodableT, **kwargs\n    ) -> Awaitable[bytes | str]: ...\n\n    def echo(self, value: EncodableT, **kwargs) -> (bytes | str) | Awaitable[\n        bytes | str\n    ]:\n        \"\"\"\n        Echo the string back from the server\n\n        For more information, see https://redis.io/commands/echo\n        \"\"\"\n        return self.execute_command(\"ECHO\", value, **kwargs)\n\n    @overload\n    def flushall(\n        self: SyncClientProtocol, asynchronous: bool = False, **kwargs\n    ) -> bool: ...\n\n    @overload\n    def flushall(\n        self: AsyncClientProtocol, asynchronous: bool = False, **kwargs\n    ) -> Awaitable[bool]: ...\n\n    def flushall(self, asynchronous: bool = False, **kwargs) -> bool | Awaitable[bool]:\n        \"\"\"\n        Delete all keys in all databases on the current host.\n\n        ``asynchronous`` indicates whether the operation is\n        executed asynchronously by the server.\n\n        For more information, see https://redis.io/commands/flushall\n        \"\"\"\n        args = []\n        if asynchronous:\n            args.append(b\"ASYNC\")\n        return self.execute_command(\"FLUSHALL\", *args, **kwargs)\n\n    @overload\n    def flushdb(\n        self: SyncClientProtocol, asynchronous: bool = False, **kwargs\n    ) -> bool: ...\n\n    @overload\n    def flushdb(\n        self: AsyncClientProtocol, asynchronous: bool = False, **kwargs\n    ) -> Awaitable[bool]: ...\n\n    def flushdb(self, asynchronous: bool = False, **kwargs) -> bool | Awaitable[bool]:\n        \"\"\"\n        Delete all keys in the current database.\n\n        ``asynchronous`` indicates whether the operation is\n        executed asynchronously by the server.\n\n        For more information, see https://redis.io/commands/flushdb\n        \"\"\"\n        args = []\n        if asynchronous:\n            args.append(b\"ASYNC\")\n        return self.execute_command(\"FLUSHDB\", *args, **kwargs)\n\n    @overload\n    def sync(self: SyncClientProtocol) -> bytes: ...\n\n    @overload\n    def sync(self: AsyncClientProtocol) -> Awaitable[bytes]: ...\n\n    def sync(self) -> bytes | Awaitable[bytes]:\n        \"\"\"\n        Initiates a replication stream from the master.\n\n        For more information, see https://redis.io/commands/sync\n        \"\"\"\n        from redis.client import NEVER_DECODE\n\n        options = {}\n        options[NEVER_DECODE] = []\n        return self.execute_command(\"SYNC\", **options)\n\n    @overload\n    def psync(self: SyncClientProtocol, replicationid: str, offset: int) -> bytes: ...\n\n    @overload\n    def psync(\n        self: AsyncClientProtocol, replicationid: str, offset: int\n    ) -> Awaitable[bytes]: ...\n\n    def psync(self, replicationid: str, offset: int) -> bytes | Awaitable[bytes]:\n        \"\"\"\n        Initiates a replication stream from the master.\n        Newer version for `sync`.\n\n        For more information, see https://redis.io/commands/sync\n        \"\"\"\n        from redis.client import NEVER_DECODE\n\n        options = {}\n        options[NEVER_DECODE] = []\n        return self.execute_command(\"PSYNC\", replicationid, offset, **options)\n\n    @overload\n    def swapdb(self: SyncClientProtocol, first: int, second: int, **kwargs) -> bool: ...\n\n    @overload\n    def swapdb(\n        self: AsyncClientProtocol, first: int, second: int, **kwargs\n    ) -> Awaitable[bool]: ...\n\n    def swapdb(self, first: int, second: int, **kwargs) -> bool | Awaitable[bool]:\n        \"\"\"\n        Swap two databases\n\n        For more information, see https://redis.io/commands/swapdb\n        \"\"\"\n        return self.execute_command(\"SWAPDB\", first, second, **kwargs)\n\n    @overload\n    def select(self: SyncClientProtocol, index: int, **kwargs) -> bool: ...\n\n    @overload\n    def select(self: AsyncClientProtocol, index: int, **kwargs) -> Awaitable[bool]: ...\n\n    def select(self, index: int, **kwargs) -> bool | Awaitable[bool]:\n        \"\"\"Select the Redis logical database at index.\n\n        See: https://redis.io/commands/select\n        \"\"\"\n        return self.execute_command(\"SELECT\", index, **kwargs)\n\n    @overload\n    def info(\n        self: SyncClientProtocol,\n        section: Optional[str] = None,\n        *args: str,\n        **kwargs,\n    ) -> dict[str, Any]: ...\n\n    @overload\n    def info(\n        self: AsyncClientProtocol,\n        section: Optional[str] = None,\n        *args: str,\n        **kwargs,\n    ) -> Awaitable[dict[str, Any]]: ...\n\n    def info(\n        self, section: Optional[str] = None, *args: str, **kwargs\n    ) -> dict[str, Any] | Awaitable[dict[str, Any]]:\n        \"\"\"\n        Returns a dictionary containing information about the Redis server\n\n        The ``section`` option can be used to select a specific section\n        of information\n\n        The section option is not supported by older versions of Redis Server,\n        and will generate ResponseError\n\n        For more information, see https://redis.io/commands/info\n        \"\"\"\n        if section is None:\n            return self.execute_command(\"INFO\", **kwargs)\n        else:\n            return self.execute_command(\"INFO\", section, *args, **kwargs)\n\n    @overload\n    def lastsave(self: SyncClientProtocol, **kwargs) -> datetime.datetime: ...\n\n    @overload\n    def lastsave(\n        self: AsyncClientProtocol, **kwargs\n    ) -> Awaitable[datetime.datetime]: ...\n\n    def lastsave(self, **kwargs) -> datetime.datetime | Awaitable[datetime.datetime]:\n        \"\"\"\n        Return a Python datetime object representing the last time the\n        Redis database was saved to disk\n\n        For more information, see https://redis.io/commands/lastsave\n        \"\"\"\n        return self.execute_command(\"LASTSAVE\", **kwargs)\n\n    def latency_doctor(self):\n        \"\"\"Raise a NotImplementedError, as the client will not support LATENCY DOCTOR.\n        This function is best used within the redis-cli.\n\n        For more information, see https://redis.io/commands/latency-doctor\n        \"\"\"\n        raise NotImplementedError(\n            \"\"\"\n            LATENCY DOCTOR is intentionally not implemented in the client.\n\n            For more information, see https://redis.io/commands/latency-doctor\n            \"\"\"\n        )\n\n    def latency_graph(self):\n        \"\"\"Raise a NotImplementedError, as the client will not support LATENCY GRAPH.\n        This function is best used within the redis-cli.\n\n        For more information, see https://redis.io/commands/latency-graph.\n        \"\"\"\n        raise NotImplementedError(\n            \"\"\"\n            LATENCY GRAPH is intentionally not implemented in the client.\n\n            For more information, see https://redis.io/commands/latency-graph\n            \"\"\"\n        )\n\n    @overload\n    def lolwut(\n        self: SyncClientProtocol, *version_numbers: Union[str, float], **kwargs\n    ) -> bytes | str: ...\n\n    @overload\n    def lolwut(\n        self: AsyncClientProtocol, *version_numbers: Union[str, float], **kwargs\n    ) -> Awaitable[bytes | str]: ...\n\n    def lolwut(self, *version_numbers: Union[str, float], **kwargs) -> (\n        bytes | str\n    ) | Awaitable[bytes | str]:\n        \"\"\"\n        Get the Redis version and a piece of generative computer art\n\n        See: https://redis.io/commands/lolwut\n        \"\"\"\n        if version_numbers:\n            return self.execute_command(\"LOLWUT VERSION\", *version_numbers, **kwargs)\n        else:\n            return self.execute_command(\"LOLWUT\", **kwargs)\n\n    @overload\n    def reset(self: SyncClientProtocol) -> bytes | str: ...\n\n    @overload\n    def reset(self: AsyncClientProtocol) -> Awaitable[bytes | str]: ...\n\n    def reset(self) -> (bytes | str) | Awaitable[bytes | str]:\n        \"\"\"Perform a full reset on the connection's server-side context.\n\n        See: https://redis.io/commands/reset\n        \"\"\"\n        return self.execute_command(\"RESET\")\n\n    @overload\n    def migrate(\n        self: SyncClientProtocol,\n        host: str,\n        port: int,\n        keys: KeysT,\n        destination_db: int,\n        timeout: int,\n        copy: bool = False,\n        replace: bool = False,\n        auth: str | None = None,\n        **kwargs,\n    ) -> bytes | str: ...\n\n    @overload\n    def migrate(\n        self: AsyncClientProtocol,\n        host: str,\n        port: int,\n        keys: KeysT,\n        destination_db: int,\n        timeout: int,\n        copy: bool = False,\n        replace: bool = False,\n        auth: str | None = None,\n        **kwargs,\n    ) -> Awaitable[bytes | str]: ...\n\n    def migrate(\n        self,\n        host: str,\n        port: int,\n        keys: KeysT,\n        destination_db: int,\n        timeout: int,\n        copy: bool = False,\n        replace: bool = False,\n        auth: str | None = None,\n        **kwargs,\n    ) -> (bytes | str) | Awaitable[bytes | str]:\n        \"\"\"\n        Migrate 1 or more keys from the current Redis server to a different\n        server specified by the ``host``, ``port`` and ``destination_db``.\n\n        The ``timeout``, specified in milliseconds, indicates the maximum\n        time the connection between the two servers can be idle before the\n        command is interrupted.\n\n        If ``copy`` is True, the specified ``keys`` are NOT deleted from\n        the source server.\n\n        If ``replace`` is True, this operation will overwrite the keys\n        on the destination server if they exist.\n\n        If ``auth`` is specified, authenticate to the destination server with\n        the password provided.\n\n        For more information, see https://redis.io/commands/migrate\n        \"\"\"\n        keys = list_or_args(keys, [])\n        if not keys:\n            raise DataError(\"MIGRATE requires at least one key\")\n        pieces = []\n        if copy:\n            pieces.append(b\"COPY\")\n        if replace:\n            pieces.append(b\"REPLACE\")\n        if auth:\n            pieces.append(b\"AUTH\")\n            pieces.append(auth)\n        pieces.append(b\"KEYS\")\n        pieces.extend(keys)\n        return self.execute_command(\n            \"MIGRATE\", host, port, \"\", destination_db, timeout, *pieces, **kwargs\n        )\n\n    @overload\n    def object(self: SyncClientProtocol, infotype: str, key: KeyT, **kwargs) -> Any: ...\n\n    @overload\n    def object(\n        self: AsyncClientProtocol, infotype: str, key: KeyT, **kwargs\n    ) -> Awaitable[Any]: ...\n\n    def object(self, infotype: str, key: KeyT, **kwargs) -> Any | Awaitable[Any]:\n        \"\"\"\n        Return the encoding, idletime, or refcount about the key\n        \"\"\"\n        return self.execute_command(\n            \"OBJECT\", infotype, key, infotype=infotype, **kwargs\n        )\n\n    def memory_doctor(self, **kwargs) -> None:\n        raise NotImplementedError(\n            \"\"\"\n            MEMORY DOCTOR is intentionally not implemented in the client.\n\n            For more information, see https://redis.io/commands/memory-doctor\n            \"\"\"\n        )\n\n    def memory_help(self, **kwargs) -> None:\n        raise NotImplementedError(\n            \"\"\"\n            MEMORY HELP is intentionally not implemented in the client.\n\n            For more information, see https://redis.io/commands/memory-help\n            \"\"\"\n        )\n\n    @overload\n    def memory_stats(self: SyncClientProtocol, **kwargs) -> dict[str, Any]: ...\n\n    @overload\n    def memory_stats(\n        self: AsyncClientProtocol, **kwargs\n    ) -> Awaitable[dict[str, Any]]: ...\n\n    def memory_stats(self, **kwargs) -> dict[str, Any] | Awaitable[dict[str, Any]]:\n        \"\"\"\n        Return a dictionary of memory stats\n\n        For more information, see https://redis.io/commands/memory-stats\n        \"\"\"\n        return self.execute_command(\"MEMORY STATS\", **kwargs)\n\n    @overload\n    def memory_malloc_stats(self: SyncClientProtocol, **kwargs) -> bytes | str: ...\n\n    @overload\n    def memory_malloc_stats(\n        self: AsyncClientProtocol, **kwargs\n    ) -> Awaitable[bytes | str]: ...\n\n    def memory_malloc_stats(self, **kwargs) -> (bytes | str) | Awaitable[bytes | str]:\n        \"\"\"\n        Return an internal statistics report from the memory allocator.\n\n        See: https://redis.io/commands/memory-malloc-stats\n        \"\"\"\n        return self.execute_command(\"MEMORY MALLOC-STATS\", **kwargs)\n\n    @overload\n    def memory_usage(\n        self: SyncClientProtocol, key: KeyT, samples: int | None = None, **kwargs\n    ) -> int | None: ...\n\n    @overload\n    def memory_usage(\n        self: AsyncClientProtocol, key: KeyT, samples: int | None = None, **kwargs\n    ) -> Awaitable[int | None]: ...\n\n    def memory_usage(self, key: KeyT, samples: int | None = None, **kwargs) -> (\n        int | None\n    ) | Awaitable[int | None]:\n        \"\"\"\n        Return the total memory usage for key, its value and associated\n        administrative overheads.\n\n        For nested data structures, ``samples`` is the number of elements to\n        sample. If left unspecified, the server's default is 5. Use 0 to sample\n        all elements.\n\n        For more information, see https://redis.io/commands/memory-usage\n        \"\"\"\n        args = []\n        if isinstance(samples, int):\n            args.extend([b\"SAMPLES\", samples])\n        return self.execute_command(\"MEMORY USAGE\", key, *args, **kwargs)\n\n    @overload\n    def memory_purge(self: SyncClientProtocol, **kwargs) -> bool: ...\n\n    @overload\n    def memory_purge(self: AsyncClientProtocol, **kwargs) -> Awaitable[bool]: ...\n\n    def memory_purge(self, **kwargs) -> bool | Awaitable[bool]:\n        \"\"\"\n        Attempts to purge dirty pages for reclamation by allocator\n\n        For more information, see https://redis.io/commands/memory-purge\n        \"\"\"\n        return self.execute_command(\"MEMORY PURGE\", **kwargs)\n\n    def latency_histogram(self, *args):\n        \"\"\"\n        This function throws a NotImplementedError since it is intentionally\n        not supported.\n        \"\"\"\n        raise NotImplementedError(\n            \"LATENCY HISTOGRAM is intentionally not implemented in the client.\"\n        )\n\n    @overload\n    def latency_history(self: SyncClientProtocol, event: str) -> list[list[int]]: ...\n\n    @overload\n    def latency_history(\n        self: AsyncClientProtocol, event: str\n    ) -> Awaitable[list[list[int]]]: ...\n\n    def latency_history(\n        self, event: str\n    ) -> list[list[int]] | Awaitable[list[list[int]]]:\n        \"\"\"\n        Returns the raw data of the ``event``'s latency spikes time series.\n\n        For more information, see https://redis.io/commands/latency-history\n        \"\"\"\n        return self.execute_command(\"LATENCY HISTORY\", event)\n\n    @overload\n    def latency_latest(self: SyncClientProtocol) -> list[list[bytes | str | int]]: ...\n\n    @overload\n    def latency_latest(\n        self: AsyncClientProtocol,\n    ) -> Awaitable[list[list[bytes | str | int]]]: ...\n\n    def latency_latest(\n        self,\n    ) -> list[list[bytes | str | int]] | Awaitable[list[list[bytes | str | int]]]:\n        \"\"\"\n        Reports the latest latency events logged.\n\n        For more information, see https://redis.io/commands/latency-latest\n        \"\"\"\n        return self.execute_command(\"LATENCY LATEST\")\n\n    @overload\n    def latency_reset(self: SyncClientProtocol, *events: str) -> int: ...\n\n    @overload\n    def latency_reset(self: AsyncClientProtocol, *events: str) -> Awaitable[int]: ...\n\n    def latency_reset(self, *events: str) -> int | Awaitable[int]:\n        \"\"\"\n        Resets the latency spikes time series of all, or only some, events.\n\n        For more information, see https://redis.io/commands/latency-reset\n        \"\"\"\n        return self.execute_command(\"LATENCY RESET\", *events)\n\n    @overload\n    def ping(self: SyncClientProtocol, **kwargs) -> bool: ...\n\n    @overload\n    def ping(self: AsyncClientProtocol, **kwargs) -> Awaitable[bool]: ...\n\n    def ping(self, **kwargs) -> Union[Awaitable[bool], bool]:\n        \"\"\"\n        Ping the Redis server to test connectivity.\n\n        Sends a PING command to the Redis server and returns True if the server\n        responds with \"PONG\".\n\n        This command is useful for:\n        - Testing whether a connection is still alive\n        - Verifying the server's ability to serve data\n\n        For more information on the underlying ping command see https://redis.io/commands/ping\n        \"\"\"\n        return self.execute_command(\"PING\", **kwargs)\n\n    @overload\n    def quit(self: SyncClientProtocol, **kwargs) -> bool: ...\n\n    @overload\n    def quit(self: AsyncClientProtocol, **kwargs) -> Awaitable[bool]: ...\n\n    def quit(self, **kwargs) -> bool | Awaitable[bool]:\n        \"\"\"\n        Ask the server to close the connection.\n\n        For more information, see https://redis.io/commands/quit\n        \"\"\"\n        return self.execute_command(\"QUIT\", **kwargs)\n\n    @overload\n    def replicaof(self: SyncClientProtocol, *args, **kwargs) -> bytes | str: ...\n\n    @overload\n    def replicaof(\n        self: AsyncClientProtocol, *args, **kwargs\n    ) -> Awaitable[bytes | str]: ...\n\n    def replicaof(self, *args, **kwargs) -> (bytes | str) | Awaitable[bytes | str]:\n        \"\"\"\n        Update the replication settings of a redis replica, on the fly.\n\n        Examples of valid arguments include:\n\n        NO ONE (set no replication)\n        host port (set to the host and port of a redis server)\n\n        For more information, see  https://redis.io/commands/replicaof\n        \"\"\"\n        return self.execute_command(\"REPLICAOF\", *args, **kwargs)\n\n    @overload\n    def save(self: SyncClientProtocol, **kwargs) -> bool: ...\n\n    @overload\n    def save(self: AsyncClientProtocol, **kwargs) -> Awaitable[bool]: ...\n\n    def save(self, **kwargs) -> bool | Awaitable[bool]:\n        \"\"\"\n        Tell the Redis server to save its data to disk,\n        blocking until the save is complete\n\n        For more information, see https://redis.io/commands/save\n        \"\"\"\n        return self.execute_command(\"SAVE\", **kwargs)\n\n    def shutdown(\n        self,\n        save: bool = False,\n        nosave: bool = False,\n        now: bool = False,\n        force: bool = False,\n        abort: bool = False,\n        **kwargs,\n    ) -> None:\n        \"\"\"Shutdown the Redis server.  If Redis has persistence configured,\n        data will be flushed before shutdown.\n        It is possible to specify modifiers to alter the behavior of the command:\n        ``save`` will force a DB saving operation even if no save points are configured.\n        ``nosave`` will prevent a DB saving operation even if one or more save points\n        are configured.\n        ``now`` skips waiting for lagging replicas, i.e. it bypasses the first step in\n        the shutdown sequence.\n        ``force`` ignores any errors that would normally prevent the server from exiting\n        ``abort`` cancels an ongoing shutdown and cannot be combined with other flags.\n\n        For more information, see https://redis.io/commands/shutdown\n        \"\"\"\n        if save and nosave:\n            raise DataError(\"SHUTDOWN save and nosave cannot both be set\")\n        args = [\"SHUTDOWN\"]\n        if save:\n            args.append(\"SAVE\")\n        if nosave:\n            args.append(\"NOSAVE\")\n        if now:\n            args.append(\"NOW\")\n        if force:\n            args.append(\"FORCE\")\n        if abort:\n            args.append(\"ABORT\")\n        try:\n            self.execute_command(*args, **kwargs)\n        except ConnectionError:\n            # a ConnectionError here is expected\n            return\n        raise RedisError(\"SHUTDOWN seems to have failed.\")\n\n    @overload\n    def slaveof(\n        self: SyncClientProtocol,\n        host: str | None = None,\n        port: int | None = None,\n        **kwargs,\n    ) -> bool: ...\n\n    @overload\n    def slaveof(\n        self: AsyncClientProtocol,\n        host: str | None = None,\n        port: int | None = None,\n        **kwargs,\n    ) -> Awaitable[bool]: ...\n\n    def slaveof(\n        self, host: str | None = None, port: int | None = None, **kwargs\n    ) -> bool | Awaitable[bool]:\n        \"\"\"\n        Set the server to be a replicated slave of the instance identified\n        by the ``host`` and ``port``. If called without arguments, the\n        instance is promoted to a master instead.\n\n        For more information, see https://redis.io/commands/slaveof\n        \"\"\"\n        if host is None and port is None:\n            return self.execute_command(\"SLAVEOF\", b\"NO\", b\"ONE\", **kwargs)\n        return self.execute_command(\"SLAVEOF\", host, port, **kwargs)\n\n    @overload\n    def slowlog_get(\n        self: SyncClientProtocol, num: int | None = None, **kwargs\n    ) -> list[dict[str, Any]]: ...\n\n    @overload\n    def slowlog_get(\n        self: AsyncClientProtocol, num: int | None = None, **kwargs\n    ) -> Awaitable[list[dict[str, Any]]]: ...\n\n    def slowlog_get(\n        self, num: int | None = None, **kwargs\n    ) -> list[dict[str, Any]] | Awaitable[list[dict[str, Any]]]:\n        \"\"\"\n        Get the entries from the slowlog. If ``num`` is specified, get the\n        most recent ``num`` items.\n\n        For more information, see https://redis.io/commands/slowlog-get\n        \"\"\"\n        from redis.client import NEVER_DECODE\n\n        args = [\"SLOWLOG GET\"]\n        if num is not None:\n            args.append(num)\n        decode_responses = self.get_connection_kwargs().get(\"decode_responses\", False)\n        if decode_responses is True:\n            kwargs[NEVER_DECODE] = []\n        return self.execute_command(*args, **kwargs)\n\n    @overload\n    def slowlog_len(self: SyncClientProtocol, **kwargs) -> int: ...\n\n    @overload\n    def slowlog_len(self: AsyncClientProtocol, **kwargs) -> Awaitable[int]: ...\n\n    def slowlog_len(self, **kwargs) -> int | Awaitable[int]:\n        \"\"\"\n        Get the number of items in the slowlog\n\n        For more information, see https://redis.io/commands/slowlog-len\n        \"\"\"\n        return self.execute_command(\"SLOWLOG LEN\", **kwargs)\n\n    @overload\n    def slowlog_reset(self: SyncClientProtocol, **kwargs) -> bool: ...\n\n    @overload\n    def slowlog_reset(self: AsyncClientProtocol, **kwargs) -> Awaitable[bool]: ...\n\n    def slowlog_reset(self, **kwargs) -> bool | Awaitable[bool]:\n        \"\"\"\n        Remove all items in the slowlog\n\n        For more information, see https://redis.io/commands/slowlog-reset\n        \"\"\"\n        return self.execute_command(\"SLOWLOG RESET\", **kwargs)\n\n    @overload\n    def time(self: SyncClientProtocol, **kwargs) -> tuple[int, int]: ...\n\n    @overload\n    def time(self: AsyncClientProtocol, **kwargs) -> Awaitable[tuple[int, int]]: ...\n\n    def time(self, **kwargs) -> tuple[int, int] | Awaitable[tuple[int, int]]:\n        \"\"\"\n        Returns the server time as a 2-item tuple of ints:\n        (seconds since epoch, microseconds into this second).\n\n        For more information, see https://redis.io/commands/time\n        \"\"\"\n        return self.execute_command(\"TIME\", **kwargs)\n\n    @overload\n    def wait(\n        self: SyncClientProtocol, num_replicas: int, timeout: int, **kwargs\n    ) -> int: ...\n\n    @overload\n    def wait(\n        self: AsyncClientProtocol, num_replicas: int, timeout: int, **kwargs\n    ) -> Awaitable[int]: ...\n\n    def wait(self, num_replicas: int, timeout: int, **kwargs) -> int | Awaitable[int]:\n        \"\"\"\n        Redis synchronous replication\n        That returns the number of replicas that processed the query when\n        we finally have at least ``num_replicas``, or when the ``timeout`` was\n        reached.\n\n        For more information, see https://redis.io/commands/wait\n        \"\"\"\n        return self.execute_command(\"WAIT\", num_replicas, timeout, **kwargs)\n\n    @overload\n    def waitaof(\n        self: SyncClientProtocol,\n        num_local: int,\n        num_replicas: int,\n        timeout: int,\n        **kwargs,\n    ) -> list[int]: ...\n\n    @overload\n    def waitaof(\n        self: AsyncClientProtocol,\n        num_local: int,\n        num_replicas: int,\n        timeout: int,\n        **kwargs,\n    ) -> Awaitable[list[int]]: ...\n\n    def waitaof(\n        self, num_local: int, num_replicas: int, timeout: int, **kwargs\n    ) -> list[int] | Awaitable[list[int]]:\n        \"\"\"\n        This command blocks the current client until all previous write\n        commands by that client are acknowledged as having been fsynced\n        to the AOF of the local Redis and/or at least the specified number\n        of replicas.\n\n        For more information, see https://redis.io/commands/waitaof\n        \"\"\"\n        return self.execute_command(\n            \"WAITAOF\", num_local, num_replicas, timeout, **kwargs\n        )\n\n    def hello(self):\n        \"\"\"\n        This function throws a NotImplementedError since it is intentionally\n        not supported.\n        \"\"\"\n        raise NotImplementedError(\n            \"HELLO is intentionally not implemented in the client.\"\n        )\n\n    def failover(self):\n        \"\"\"\n        This function throws a NotImplementedError since it is intentionally\n        not supported.\n        \"\"\"\n        raise NotImplementedError(\n            \"FAILOVER is intentionally not implemented in the client.\"\n        )\n\n    @overload\n    def hotkeys_start(\n        self: SyncClientProtocol,\n        metrics: List[HotkeysMetricsTypes],\n        count: int | None = None,\n        duration: int | None = None,\n        sample_ratio: int | None = None,\n        slots: List[int] | None = None,\n        **kwargs,\n    ) -> bytes | str: ...\n\n    @overload\n    def hotkeys_start(\n        self: AsyncClientProtocol,\n        metrics: List[HotkeysMetricsTypes],\n        count: int | None = None,\n        duration: int | None = None,\n        sample_ratio: int | None = None,\n        slots: List[int] | None = None,\n        **kwargs,\n    ) -> Awaitable[bytes | str]: ...\n\n    def hotkeys_start(\n        self,\n        metrics: List[HotkeysMetricsTypes],\n        count: int | None = None,\n        duration: int | None = None,\n        sample_ratio: int | None = None,\n        slots: List[int] | None = None,\n        **kwargs,\n    ) -> (bytes | str) | Awaitable[bytes | str]:\n        \"\"\"\n        Start collecting hotkeys data.\n        Returns an error if there is an ongoing collection session.\n\n        Args:\n            count: The number of keys to collect in each criteria (CPU and network consumption)\n            metrics: List of metrics to track. Supported values: [HotkeysMetricsTypes.CPU, HotkeysMetricsTypes.NET]\n            duration: Automatically stop the collection after `duration` seconds\n            sample_ratio: Commands are sampled with probability 1/ratio (1 means no sampling)\n            slots: Only track keys on the specified hash slots\n\n        For more information, see https://redis.io/commands/hotkeys-start\n        \"\"\"\n        args: List[Union[str, int]] = [\"HOTKEYS\", \"START\"]\n\n        # Add METRICS\n        args.extend([\"METRICS\", len(metrics)])\n        args.extend([str(m.value) for m in metrics])\n\n        # Add COUNT\n        if count is not None:\n            args.extend([\"COUNT\", count])\n\n        # Add optional DURATION\n        if duration is not None:\n            args.extend([\"DURATION\", duration])\n\n        # Add optional SAMPLE ratio\n        if sample_ratio is not None:\n            args.extend([\"SAMPLE\", sample_ratio])\n\n        # Add optional SLOTS\n        if slots is not None:\n            args.append(\"SLOTS\")\n            args.append(len(slots))\n            args.extend(slots)\n\n        return self.execute_command(*args, **kwargs)\n\n    @overload\n    def hotkeys_stop(self: SyncClientProtocol, **kwargs) -> bytes | str: ...\n\n    @overload\n    def hotkeys_stop(self: AsyncClientProtocol, **kwargs) -> Awaitable[bytes | str]: ...\n\n    def hotkeys_stop(self, **kwargs) -> (bytes | str) | Awaitable[bytes | str]:\n        \"\"\"\n        Stop the ongoing hotkeys collection session (if any).\n        The results of the last collection session are kept for consumption with HOTKEYS GET.\n\n        For more information, see https://redis.io/commands/hotkeys-stop\n        \"\"\"\n        return self.execute_command(\"HOTKEYS STOP\", **kwargs)\n\n    @overload\n    def hotkeys_reset(self: SyncClientProtocol, **kwargs) -> bytes | str: ...\n\n    @overload\n    def hotkeys_reset(\n        self: AsyncClientProtocol, **kwargs\n    ) -> Awaitable[bytes | str]: ...\n\n    def hotkeys_reset(self, **kwargs) -> (bytes | str) | Awaitable[bytes | str]:\n        \"\"\"\n        Discard the last hotkeys collection session results (in order to save memory).\n        Error if there is an ongoing collection session.\n\n        For more information, see https://redis.io/commands/hotkeys-reset\n        \"\"\"\n        return self.execute_command(\"HOTKEYS RESET\", **kwargs)\n\n    @overload\n    def hotkeys_get(\n        self: SyncClientProtocol, **kwargs\n    ) -> list[dict[str | bytes, Any]]: ...\n\n    @overload\n    def hotkeys_get(\n        self: AsyncClientProtocol, **kwargs\n    ) -> Awaitable[list[dict[str | bytes, Any]]]: ...\n\n    def hotkeys_get(\n        self, **kwargs\n    ) -> Union[\n        Awaitable[list[dict[str | bytes, Any]]],\n        list[dict[str | bytes, Any]],\n    ]:\n        \"\"\"\n        Retrieve the result of the ongoing collection session (if any),\n        or the last collection session (if any).\n\n        HOTKEYS GET response is wrapped in an array for aggregation support.\n        Each node returns a single-element array, allowing multiple node\n        responses to be concatenated by DMC or other aggregators.\n\n        For more information, see https://redis.io/commands/hotkeys-get\n        \"\"\"\n        return self.execute_command(\"HOTKEYS GET\", **kwargs)\n\n\nclass AsyncManagementCommands(ManagementCommands):\n    async def command_info(self, **kwargs) -> None:\n        return super().command_info(**kwargs)\n\n    async def debug_segfault(self, **kwargs) -> None:\n        return super().debug_segfault(**kwargs)\n\n    async def memory_doctor(self, **kwargs) -> None:\n        return super().memory_doctor(**kwargs)\n\n    async def memory_help(self, **kwargs) -> None:\n        return super().memory_help(**kwargs)\n\n    async def shutdown(\n        self,\n        save: bool = False,\n        nosave: bool = False,\n        now: bool = False,\n        force: bool = False,\n        abort: bool = False,\n        **kwargs,\n    ) -> None:\n        \"\"\"Shutdown the Redis server.  If Redis has persistence configured,\n        data will be flushed before shutdown.  If the \"save\" option is set,\n        a data flush will be attempted even if there is no persistence\n        configured.  If the \"nosave\" option is set, no data flush will be\n        attempted.  The \"save\" and \"nosave\" options cannot both be set.\n\n        For more information, see https://redis.io/commands/shutdown\n        \"\"\"\n        if save and nosave:\n            raise DataError(\"SHUTDOWN save and nosave cannot both be set\")\n        args = [\"SHUTDOWN\"]\n        if save:\n            args.append(\"SAVE\")\n        if nosave:\n            args.append(\"NOSAVE\")\n        if now:\n            args.append(\"NOW\")\n        if force:\n            args.append(\"FORCE\")\n        if abort:\n            args.append(\"ABORT\")\n        try:\n            await self.execute_command(*args, **kwargs)\n        except ConnectionError:\n            # a ConnectionError here is expected\n            return\n        raise RedisError(\"SHUTDOWN seems to have failed.\")\n\n\nclass BitFieldOperation:\n    \"\"\"\n    Command builder for BITFIELD commands.\n    \"\"\"\n\n    def __init__(\n        self,\n        client: Union[\"redis.client.Redis\", \"redis.asyncio.client.Redis\"],\n        key: str,\n        default_overflow: Optional[str] = None,\n    ):\n        self.client = client\n        self.key = key\n        self._default_overflow = default_overflow\n        # for typing purposes, run the following in constructor and in reset()\n        self.operations: list[tuple[EncodableT, ...]] = []\n        self._last_overflow = \"WRAP\"\n        self.reset()\n\n    def reset(self):\n        \"\"\"\n        Reset the state of the instance to when it was constructed\n        \"\"\"\n        self.operations = []\n        self._last_overflow = \"WRAP\"\n        self.overflow(self._default_overflow or self._last_overflow)\n\n    def overflow(self, overflow: str):\n        \"\"\"\n        Update the overflow algorithm of successive INCRBY operations\n        :param overflow: Overflow algorithm, one of WRAP, SAT, FAIL. See the\n            Redis docs for descriptions of these algorithmsself.\n        :returns: a :py:class:`BitFieldOperation` instance.\n        \"\"\"\n        overflow = overflow.upper()\n        if overflow != self._last_overflow:\n            self._last_overflow = overflow\n            self.operations.append((\"OVERFLOW\", overflow))\n        return self\n\n    def incrby(\n        self,\n        fmt: str,\n        offset: BitfieldOffsetT,\n        increment: int,\n        overflow: Optional[str] = None,\n    ):\n        \"\"\"\n        Increment a bitfield by a given amount.\n        :param fmt: format-string for the bitfield being updated, e.g. 'u8'\n            for an unsigned 8-bit integer.\n        :param offset: offset (in number of bits). If prefixed with a\n            '#', this is an offset multiplier, e.g. given the arguments\n            fmt='u8', offset='#2', the offset will be 16.\n        :param int increment: value to increment the bitfield by.\n        :param str overflow: overflow algorithm. Defaults to WRAP, but other\n            acceptable values are SAT and FAIL. See the Redis docs for\n            descriptions of these algorithms.\n        :returns: a :py:class:`BitFieldOperation` instance.\n        \"\"\"\n        if overflow is not None:\n            self.overflow(overflow)\n\n        self.operations.append((\"INCRBY\", fmt, offset, increment))\n        return self\n\n    def get(self, fmt: str, offset: BitfieldOffsetT):\n        \"\"\"\n        Get the value of a given bitfield.\n        :param fmt: format-string for the bitfield being read, e.g. 'u8' for\n            an unsigned 8-bit integer.\n        :param offset: offset (in number of bits). If prefixed with a\n            '#', this is an offset multiplier, e.g. given the arguments\n            fmt='u8', offset='#2', the offset will be 16.\n        :returns: a :py:class:`BitFieldOperation` instance.\n        \"\"\"\n        self.operations.append((\"GET\", fmt, offset))\n        return self\n\n    def set(self, fmt: str, offset: BitfieldOffsetT, value: int):\n        \"\"\"\n        Set the value of a given bitfield.\n        :param fmt: format-string for the bitfield being read, e.g. 'u8' for\n            an unsigned 8-bit integer.\n        :param offset: offset (in number of bits). If prefixed with a\n            '#', this is an offset multiplier, e.g. given the arguments\n            fmt='u8', offset='#2', the offset will be 16.\n        :param int value: value to set at the given position.\n        :returns: a :py:class:`BitFieldOperation` instance.\n        \"\"\"\n        self.operations.append((\"SET\", fmt, offset, value))\n        return self\n\n    @property\n    def command(self):\n        cmd = [\"BITFIELD\", self.key]\n        for ops in self.operations:\n            cmd.extend(ops)\n        return cmd\n\n    def execute(self) -> ResponseT:\n        \"\"\"\n        Execute the operation(s) in a single BITFIELD command. The return value\n        is a list of values corresponding to each operation. If the client\n        used to create this instance was a pipeline, the list of values\n        will be present within the pipeline's execute.\n        \"\"\"\n        command = self.command\n        self.reset()\n        return self.client.execute_command(*command)\n\n\nclass DataPersistOptions(Enum):\n    # set the value for each provided key to each\n    # provided value only if all do not already exist.\n    NX = \"NX\"\n\n    # set the value for each provided key to each\n    # provided value only if all already exist.\n    XX = \"XX\"\n\n\nclass BasicKeyCommands(CommandsProtocol):\n    \"\"\"\n    Redis basic key-based commands\n    \"\"\"\n\n    @overload\n    def append(self: SyncClientProtocol, key: KeyT, value: EncodableT) -> int: ...\n\n    @overload\n    def append(\n        self: AsyncClientProtocol, key: KeyT, value: EncodableT\n    ) -> Awaitable[int]: ...\n\n    def append(self, key: KeyT, value: EncodableT) -> int | Awaitable[int]:\n        \"\"\"\n        Appends the string ``value`` to the value at ``key``. If ``key``\n        doesn't already exist, create it with a value of ``value``.\n        Returns the new length of the value at ``key``.\n\n        For more information, see https://redis.io/commands/append\n        \"\"\"\n        return self.execute_command(\"APPEND\", key, value)\n\n    @overload\n    def bitcount(\n        self: SyncClientProtocol,\n        key: KeyT,\n        start: int | None = None,\n        end: int | None = None,\n        mode: str | None = None,\n    ) -> int: ...\n\n    @overload\n    def bitcount(\n        self: AsyncClientProtocol,\n        key: KeyT,\n        start: int | None = None,\n        end: int | None = None,\n        mode: str | None = None,\n    ) -> Awaitable[int]: ...\n\n    def bitcount(\n        self,\n        key: KeyT,\n        start: int | None = None,\n        end: int | None = None,\n        mode: str | None = None,\n    ) -> int | Awaitable[int]:\n        \"\"\"\n        Returns the count of set bits in the value of ``key``.  Optional\n        ``start`` and ``end`` parameters indicate which bytes to consider\n\n        For more information, see https://redis.io/commands/bitcount\n        \"\"\"\n        params = [key]\n        if start is not None and end is not None:\n            params.append(start)\n            params.append(end)\n        elif (start is not None and end is None) or (end is not None and start is None):\n            raise DataError(\"Both start and end must be specified\")\n        if mode is not None:\n            params.append(mode)\n        return self.execute_command(\"BITCOUNT\", *params, keys=[key])\n\n    def bitfield(\n        self: Union[\"redis.client.Redis\", \"redis.asyncio.client.Redis\"],\n        key: KeyT,\n        default_overflow: str | None = None,\n    ) -> BitFieldOperation:\n        \"\"\"\n        Return a BitFieldOperation instance to conveniently construct one or\n        more bitfield operations on ``key``.\n\n        For more information, see https://redis.io/commands/bitfield\n        \"\"\"\n        return BitFieldOperation(self, key, default_overflow=default_overflow)\n\n    @overload\n    def bitfield_ro(\n        self: SyncClientProtocol,\n        key: KeyT,\n        encoding: str,\n        offset: BitfieldOffsetT,\n        items: list[tuple[str, BitfieldOffsetT]] | None = None,\n    ) -> list[int]: ...\n\n    @overload\n    def bitfield_ro(\n        self: AsyncClientProtocol,\n        key: KeyT,\n        encoding: str,\n        offset: BitfieldOffsetT,\n        items: list[tuple[str, BitfieldOffsetT]] | None = None,\n    ) -> Awaitable[list[int]]: ...\n\n    def bitfield_ro(\n        self: Union[\"redis.client.Redis\", \"redis.asyncio.client.Redis\"],\n        key: KeyT,\n        encoding: str,\n        offset: BitfieldOffsetT,\n        items: list[tuple[str, BitfieldOffsetT]] | None = None,\n    ) -> list[int] | Awaitable[list[int]]:\n        \"\"\"\n        Return an array of the specified bitfield values\n        where the first value is found using ``encoding`` and ``offset``\n        parameters and remaining values are result of corresponding\n        encoding/offset pairs in optional list ``items``\n        Read-only variant of the BITFIELD command.\n\n        For more information, see https://redis.io/commands/bitfield_ro\n        \"\"\"\n        params = [key, \"GET\", encoding, offset]\n\n        items = items or []\n        for encoding, offset in items:\n            params.extend([\"GET\", encoding, offset])\n        return self.execute_command(\"BITFIELD_RO\", *params, keys=[key])\n\n    @overload\n    def bitop(\n        self: SyncClientProtocol, operation: str, dest: KeyT, *keys: KeyT\n    ) -> int: ...\n\n    @overload\n    def bitop(\n        self: AsyncClientProtocol, operation: str, dest: KeyT, *keys: KeyT\n    ) -> Awaitable[int]: ...\n\n    def bitop(self, operation: str, dest: KeyT, *keys: KeyT) -> int | Awaitable[int]:\n        \"\"\"\n        Perform a bitwise operation using ``operation`` between ``keys`` and\n        store the result in ``dest``.\n\n        For more information, see https://redis.io/commands/bitop\n        \"\"\"\n        return self.execute_command(\"BITOP\", operation, dest, *keys)\n\n    @overload\n    def bitpos(\n        self: SyncClientProtocol,\n        key: KeyT,\n        bit: int,\n        start: int | None = None,\n        end: int | None = None,\n        mode: str | None = None,\n    ) -> int: ...\n\n    @overload\n    def bitpos(\n        self: AsyncClientProtocol,\n        key: KeyT,\n        bit: int,\n        start: int | None = None,\n        end: int | None = None,\n        mode: str | None = None,\n    ) -> Awaitable[int]: ...\n\n    def bitpos(\n        self,\n        key: KeyT,\n        bit: int,\n        start: int | None = None,\n        end: int | None = None,\n        mode: str | None = None,\n    ) -> int | Awaitable[int]:\n        \"\"\"\n        Return the position of the first bit set to 1 or 0 in a string.\n        ``start`` and ``end`` defines search range. The range is interpreted\n        as a range of bytes and not a range of bits, so start=0 and end=2\n        means to look at the first three bytes.\n\n        For more information, see https://redis.io/commands/bitpos\n        \"\"\"\n        if bit not in (0, 1):\n            raise DataError(\"bit must be 0 or 1\")\n        params = [key, bit]\n\n        start is not None and params.append(start)\n\n        if start is not None and end is not None:\n            params.append(end)\n        elif start is None and end is not None:\n            raise DataError(\"start argument is not set, when end is specified\")\n\n        if mode is not None:\n            params.append(mode)\n        return self.execute_command(\"BITPOS\", *params, keys=[key])\n\n    @overload\n    def copy(\n        self: SyncClientProtocol,\n        source: str,\n        destination: str,\n        destination_db: str | None = None,\n        replace: bool = False,\n    ) -> bool: ...\n\n    @overload\n    def copy(\n        self: AsyncClientProtocol,\n        source: str,\n        destination: str,\n        destination_db: str | None = None,\n        replace: bool = False,\n    ) -> Awaitable[bool]: ...\n\n    def copy(\n        self,\n        source: str,\n        destination: str,\n        destination_db: str | None = None,\n        replace: bool = False,\n    ) -> bool | Awaitable[bool]:\n        \"\"\"\n        Copy the value stored in the ``source`` key to the ``destination`` key.\n\n        ``destination_db`` an alternative destination database. By default,\n        the ``destination`` key is created in the source Redis database.\n\n        ``replace`` whether the ``destination`` key should be removed before\n        copying the value to it. By default, the value is not copied if\n        the ``destination`` key already exists.\n\n        For more information, see https://redis.io/commands/copy\n        \"\"\"\n        params = [source, destination]\n        if destination_db is not None:\n            params.extend([\"DB\", destination_db])\n        if replace:\n            params.append(\"REPLACE\")\n        return self.execute_command(\"COPY\", *params)\n\n    @overload\n    def decrby(self: SyncClientProtocol, name: KeyT, amount: int = 1) -> int: ...\n\n    @overload\n    def decrby(\n        self: AsyncClientProtocol, name: KeyT, amount: int = 1\n    ) -> Awaitable[int]: ...\n\n    def decrby(self, name: KeyT, amount: int = 1) -> int | Awaitable[int]:\n        \"\"\"\n        Decrements the value of ``key`` by ``amount``.  If no key exists,\n        the value will be initialized as 0 - ``amount``\n\n        For more information, see https://redis.io/commands/decrby\n        \"\"\"\n        return self.execute_command(\"DECRBY\", name, amount)\n\n    decr = decrby\n\n    @overload\n    def delete(self: SyncClientProtocol, *names: KeyT) -> int: ...\n\n    @overload\n    def delete(self: AsyncClientProtocol, *names: KeyT) -> Awaitable[int]: ...\n\n    def delete(self, *names: KeyT) -> int | Awaitable[int]:\n        \"\"\"\n        Delete one or more keys specified by ``names``\n        \"\"\"\n        return self.execute_command(\"DEL\", *names)\n\n    def __delitem__(self, name: KeyT):\n        self.delete(name)\n\n    @overload\n    def delex(\n        self: SyncClientProtocol,\n        name: KeyT,\n        ifeq: bytes | str | None = None,\n        ifne: bytes | str | None = None,\n        ifdeq: str | None = None,\n        ifdne: str | None = None,\n    ) -> int: ...\n\n    @overload\n    def delex(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        ifeq: bytes | str | None = None,\n        ifne: bytes | str | None = None,\n        ifdeq: str | None = None,\n        ifdne: str | None = None,\n    ) -> Awaitable[int]: ...\n\n    @experimental_method()\n    def delex(\n        self,\n        name: KeyT,\n        ifeq: bytes | str | None = None,\n        ifne: bytes | str | None = None,\n        ifdeq: str | None = None,  # hex digest\n        ifdne: str | None = None,  # hex digest\n    ) -> int | Awaitable[int]:\n        \"\"\"\n        Conditionally removes the specified key.\n\n        Warning:\n        **Experimental** since 7.1.\n        This API may change or be removed without notice.\n        The API may change based on feedback.\n\n        Arguments:\n            name: KeyT - the key to delete\n            ifeq match-valu: Optional[Union[bytes, str]] - Delete the key only if its value is equal to match-value\n            ifne match-value: Optional[Union[bytes, str]] - Delete the key only if its value is not equal to match-value\n            ifdeq match-digest: Optional[str] - Delete the key only if the digest of its value is equal to match-digest\n            ifdne match-digest: Optional[str] - Delete the key only if the digest of its value is not equal to match-digest\n\n        Returns:\n            int: 1 if the key was deleted, 0 otherwise.\n        Raises:\n            redis.exceptions.ResponseError: if key exists but is not a string\n                                            and a condition is specified.\n            ValueError: if more than one condition is provided.\n\n\n        Requires Redis 8.4 or greater.\n        For more information, see https://redis.io/commands/delex\n        \"\"\"\n        conds = [x is not None for x in (ifeq, ifne, ifdeq, ifdne)]\n        if sum(conds) > 1:\n            raise ValueError(\"Only one of IFEQ/IFNE/IFDEQ/IFDNE may be specified\")\n\n        pieces = [\"DELEX\", name]\n        if ifeq is not None:\n            pieces += [\"IFEQ\", ifeq]\n        elif ifne is not None:\n            pieces += [\"IFNE\", ifne]\n        elif ifdeq is not None:\n            pieces += [\"IFDEQ\", ifdeq]\n        elif ifdne is not None:\n            pieces += [\"IFDNE\", ifdne]\n\n        return self.execute_command(*pieces)\n\n    @overload\n    def dump(self: SyncClientProtocol, name: KeyT) -> bytes | None: ...\n\n    @overload\n    def dump(self: AsyncClientProtocol, name: KeyT) -> Awaitable[bytes | None]: ...\n\n    def dump(self, name: KeyT) -> (bytes | None) | Awaitable[bytes | None]:\n        \"\"\"\n        Return a serialized version of the value stored at the specified key.\n        If key does not exist a nil bulk reply is returned.\n\n        For more information, see https://redis.io/commands/dump\n        \"\"\"\n        from redis.client import NEVER_DECODE\n\n        options = {}\n        options[NEVER_DECODE] = []\n        return self.execute_command(\"DUMP\", name, **options)\n\n    @overload\n    def exists(self: SyncClientProtocol, *names: KeyT) -> int: ...\n\n    @overload\n    def exists(self: AsyncClientProtocol, *names: KeyT) -> Awaitable[int]: ...\n\n    def exists(self, *names: KeyT) -> int | Awaitable[int]:\n        \"\"\"\n        Returns the number of ``names`` that exist\n\n        For more information, see https://redis.io/commands/exists\n        \"\"\"\n        return self.execute_command(\"EXISTS\", *names, keys=names)\n\n    __contains__ = exists\n\n    @overload\n    def expire(\n        self: SyncClientProtocol,\n        name: KeyT,\n        time: ExpiryT,\n        nx: bool = False,\n        xx: bool = False,\n        gt: bool = False,\n        lt: bool = False,\n    ) -> bool: ...\n\n    @overload\n    def expire(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        time: ExpiryT,\n        nx: bool = False,\n        xx: bool = False,\n        gt: bool = False,\n        lt: bool = False,\n    ) -> Awaitable[bool]: ...\n\n    def expire(\n        self,\n        name: KeyT,\n        time: ExpiryT,\n        nx: bool = False,\n        xx: bool = False,\n        gt: bool = False,\n        lt: bool = False,\n    ) -> bool | Awaitable[bool]:\n        \"\"\"\n        Set an expire flag on key ``name`` for ``time`` seconds with given\n        ``option``. ``time`` can be represented by an integer or a Python timedelta\n        object.\n\n        Valid options are:\n            NX -> Set expiry only when the key has no expiry\n            XX -> Set expiry only when the key has an existing expiry\n            GT -> Set expiry only when the new expiry is greater than current one\n            LT -> Set expiry only when the new expiry is less than current one\n\n        For more information, see https://redis.io/commands/expire\n        \"\"\"\n        if isinstance(time, datetime.timedelta):\n            time = int(time.total_seconds())\n\n        exp_option = list()\n        if nx:\n            exp_option.append(\"NX\")\n        if xx:\n            exp_option.append(\"XX\")\n        if gt:\n            exp_option.append(\"GT\")\n        if lt:\n            exp_option.append(\"LT\")\n\n        return self.execute_command(\"EXPIRE\", name, time, *exp_option)\n\n    @overload\n    def expireat(\n        self: SyncClientProtocol,\n        name: KeyT,\n        when: AbsExpiryT,\n        nx: bool = False,\n        xx: bool = False,\n        gt: bool = False,\n        lt: bool = False,\n    ) -> bool: ...\n\n    @overload\n    def expireat(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        when: AbsExpiryT,\n        nx: bool = False,\n        xx: bool = False,\n        gt: bool = False,\n        lt: bool = False,\n    ) -> Awaitable[bool]: ...\n\n    def expireat(\n        self,\n        name: KeyT,\n        when: AbsExpiryT,\n        nx: bool = False,\n        xx: bool = False,\n        gt: bool = False,\n        lt: bool = False,\n    ) -> bool | Awaitable[bool]:\n        \"\"\"\n        Set an expire flag on key ``name`` with given ``option``. ``when``\n        can be represented as an integer indicating unix time or a Python\n        datetime object.\n\n        Valid options are:\n            -> NX -- Set expiry only when the key has no expiry\n            -> XX -- Set expiry only when the key has an existing expiry\n            -> GT -- Set expiry only when the new expiry is greater than current one\n            -> LT -- Set expiry only when the new expiry is less than current one\n\n        For more information, see https://redis.io/commands/expireat\n        \"\"\"\n        if isinstance(when, datetime.datetime):\n            when = int(when.timestamp())\n\n        exp_option = list()\n        if nx:\n            exp_option.append(\"NX\")\n        if xx:\n            exp_option.append(\"XX\")\n        if gt:\n            exp_option.append(\"GT\")\n        if lt:\n            exp_option.append(\"LT\")\n\n        return self.execute_command(\"EXPIREAT\", name, when, *exp_option)\n\n    @overload\n    def expiretime(self: SyncClientProtocol, key: str) -> int: ...\n\n    @overload\n    def expiretime(self: AsyncClientProtocol, key: str) -> Awaitable[int]: ...\n\n    def expiretime(self, key: str) -> int | Awaitable[int]:\n        \"\"\"\n        Returns the absolute Unix timestamp (since January 1, 1970) in seconds\n        at which the given key will expire.\n\n        For more information, see https://redis.io/commands/expiretime\n        \"\"\"\n        return self.execute_command(\"EXPIRETIME\", key)\n\n    @experimental_method()\n    def digest_local(self, value: bytes | str) -> bytes | str:\n        \"\"\"\n        Compute the hexadecimal digest of the value locally, without sending it to the server.\n\n        This is useful for conditional operations like IFDEQ/IFDNE where you need to\n        compute the digest client-side before sending a command.\n\n        Warning:\n        **Experimental** - This API may change or be removed without notice.\n\n        Arguments:\n          - value: Union[bytes, str] - the value to compute the digest of.\n            If a string is provided, it will be encoded using UTF-8 before hashing,\n            which matches Redis's default encoding behavior.\n\n        Returns:\n          - (str | bytes) the XXH3 digest of the value as a hex string (16 hex characters).\n            Returns bytes if decode_responses is False, otherwise returns str.\n\n        For more information, see https://redis.io/commands/digest\n        \"\"\"\n        if not HAS_XXHASH:\n            raise NotImplementedError(\n                \"XXHASH support requires the optional 'xxhash' library. \"\n                \"Install it with 'pip install xxhash' or use this package's extra with \"\n                \"'pip install redis[xxhash]' to enable this feature.\"\n            )\n\n        local_digest = xxhash.xxh3_64(value).hexdigest()\n\n        # To align with digest, we want to return bytes if decode_responses is False.\n        # The following works because of Python's mixin-based client class hierarchy.\n        if not self.get_encoder().decode_responses:\n            local_digest = local_digest.encode()\n\n        return local_digest\n\n    @overload\n    def digest(self: SyncClientProtocol, name: KeyT) -> str | bytes | None: ...\n\n    @overload\n    def digest(\n        self: AsyncClientProtocol, name: KeyT\n    ) -> Awaitable[str | bytes | None]: ...\n\n    @experimental_method()\n    def digest(self, name: KeyT) -> (str | bytes | None) | Awaitable[\n        str | bytes | None\n    ]:\n        \"\"\"\n        Return the digest of the value stored at the specified key.\n\n        Warning:\n        **Experimental** since 7.1.\n        This API may change or be removed without notice.\n        The API may change based on feedback.\n\n        Arguments:\n          - name: KeyT - the key to get the digest of\n\n        Returns:\n          - None if the key does not exist\n          - (bulk string) the XXH3 digest of the value as a hex string\n        Raises:\n          - ResponseError if key exists but is not a string\n\n\n        Requires Redis 8.4 or greater.\n        For more information, see https://redis.io/commands/digest\n        \"\"\"\n        # Bulk string response is already handled (bytes/str based on decode_responses)\n        return self.execute_command(\"DIGEST\", name)\n\n    @overload\n    def get(self: SyncClientProtocol, name: KeyT) -> bytes | str | None: ...\n\n    @overload\n    def get(self: AsyncClientProtocol, name: KeyT) -> Awaitable[bytes | str | None]: ...\n\n    def get(self, name: KeyT) -> (bytes | str | None) | Awaitable[bytes | str | None]:\n        \"\"\"\n        Return the value at key ``name``, or None if the key doesn't exist\n\n        For more information, see https://redis.io/commands/get\n        \"\"\"\n        return self.execute_command(\"GET\", name, keys=[name])\n\n    @overload\n    def getdel(self: SyncClientProtocol, name: KeyT) -> bytes | str | None: ...\n\n    @overload\n    def getdel(\n        self: AsyncClientProtocol, name: KeyT\n    ) -> Awaitable[bytes | str | None]: ...\n\n    def getdel(self, name: KeyT) -> (bytes | str | None) | Awaitable[\n        bytes | str | None\n    ]:\n        \"\"\"\n        Get the value at key ``name`` and delete the key. This command\n        is similar to GET, except for the fact that it also deletes\n        the key on success (if and only if the key's value type\n        is a string).\n\n        For more information, see https://redis.io/commands/getdel\n        \"\"\"\n        return self.execute_command(\"GETDEL\", name)\n\n    @overload\n    def getex(\n        self: SyncClientProtocol,\n        name: KeyT,\n        ex: ExpiryT | None = None,\n        px: ExpiryT | None = None,\n        exat: AbsExpiryT | None = None,\n        pxat: AbsExpiryT | None = None,\n        persist: bool = False,\n    ) -> bytes | str | None: ...\n\n    @overload\n    def getex(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        ex: ExpiryT | None = None,\n        px: ExpiryT | None = None,\n        exat: AbsExpiryT | None = None,\n        pxat: AbsExpiryT | None = None,\n        persist: bool = False,\n    ) -> Awaitable[bytes | str | None]: ...\n\n    def getex(\n        self,\n        name: KeyT,\n        ex: ExpiryT | None = None,\n        px: ExpiryT | None = None,\n        exat: AbsExpiryT | None = None,\n        pxat: AbsExpiryT | None = None,\n        persist: bool = False,\n    ) -> (bytes | str | None) | Awaitable[bytes | str | None]:\n        \"\"\"\n        Get the value of key and optionally set its expiration.\n        GETEX is similar to GET, but is a write command with\n        additional options. All time parameters can be given as\n        datetime.timedelta or integers.\n\n        ``ex`` sets an expire flag on key ``name`` for ``ex`` seconds.\n\n        ``px`` sets an expire flag on key ``name`` for ``px`` milliseconds.\n\n        ``exat`` sets an expire flag on key ``name`` for ``ex`` seconds,\n        specified in unix time.\n\n        ``pxat`` sets an expire flag on key ``name`` for ``ex`` milliseconds,\n        specified in unix time.\n\n        ``persist`` remove the time to live associated with ``name``.\n\n        For more information, see https://redis.io/commands/getex\n        \"\"\"\n        if not at_most_one_value_set((ex, px, exat, pxat, persist)):\n            raise DataError(\n                \"``ex``, ``px``, ``exat``, ``pxat``, \"\n                \"and ``persist`` are mutually exclusive.\"\n            )\n\n        exp_options: list[EncodableT] = extract_expire_flags(ex, px, exat, pxat)\n\n        if persist:\n            exp_options.append(\"PERSIST\")\n\n        return self.execute_command(\"GETEX\", name, *exp_options)\n\n    def __getitem__(self, name: KeyT):\n        \"\"\"\n        Return the value at key ``name``, raises a KeyError if the key\n        doesn't exist.\n        \"\"\"\n        value = self.get(name)\n        if value is not None:\n            return value\n        raise KeyError(name)\n\n    @overload\n    def getbit(self: SyncClientProtocol, name: KeyT, offset: int) -> int: ...\n\n    @overload\n    def getbit(\n        self: AsyncClientProtocol, name: KeyT, offset: int\n    ) -> Awaitable[int]: ...\n\n    def getbit(self, name: KeyT, offset: int) -> int | Awaitable[int]:\n        \"\"\"\n        Returns an integer indicating the value of ``offset`` in ``name``\n\n        For more information, see https://redis.io/commands/getbit\n        \"\"\"\n        return self.execute_command(\"GETBIT\", name, offset, keys=[name])\n\n    @overload\n    def getrange(\n        self: SyncClientProtocol, key: KeyT, start: int, end: int\n    ) -> bytes | str: ...\n\n    @overload\n    def getrange(\n        self: AsyncClientProtocol, key: KeyT, start: int, end: int\n    ) -> Awaitable[bytes | str]: ...\n\n    def getrange(self, key: KeyT, start: int, end: int) -> (bytes | str) | Awaitable[\n        bytes | str\n    ]:\n        \"\"\"\n        Returns the substring of the string value stored at ``key``,\n        determined by the offsets ``start`` and ``end`` (both are inclusive)\n\n        For more information, see https://redis.io/commands/getrange\n        \"\"\"\n        return self.execute_command(\"GETRANGE\", key, start, end, keys=[key])\n\n    @overload\n    def getset(\n        self: SyncClientProtocol, name: KeyT, value: EncodableT\n    ) -> bytes | str | None: ...\n\n    @overload\n    def getset(\n        self: AsyncClientProtocol, name: KeyT, value: EncodableT\n    ) -> Awaitable[bytes | str | None]: ...\n\n    def getset(self, name: KeyT, value: EncodableT) -> (bytes | str | None) | Awaitable[\n        bytes | str | None\n    ]:\n        \"\"\"\n        Sets the value at key ``name`` to ``value``\n        and returns the old value at key ``name`` atomically.\n\n        As per Redis 6.2, GETSET is considered deprecated.\n        Please use SET with GET parameter in new code.\n\n        For more information, see https://redis.io/commands/getset\n        \"\"\"\n        return self.execute_command(\"GETSET\", name, value)\n\n    @overload\n    def incrby(self: SyncClientProtocol, name: KeyT, amount: int = 1) -> int: ...\n\n    @overload\n    def incrby(\n        self: AsyncClientProtocol, name: KeyT, amount: int = 1\n    ) -> Awaitable[int]: ...\n\n    def incrby(self, name: KeyT, amount: int = 1) -> int | Awaitable[int]:\n        \"\"\"\n        Increments the value of ``key`` by ``amount``.  If no key exists,\n        the value will be initialized as ``amount``\n\n        For more information, see https://redis.io/commands/incrby\n        \"\"\"\n        return self.execute_command(\"INCRBY\", name, amount)\n\n    incr = incrby\n\n    @overload\n    def incrbyfloat(\n        self: SyncClientProtocol, name: KeyT, amount: float = 1.0\n    ) -> float: ...\n\n    @overload\n    def incrbyfloat(\n        self: AsyncClientProtocol, name: KeyT, amount: float = 1.0\n    ) -> Awaitable[float]: ...\n\n    def incrbyfloat(self, name: KeyT, amount: float = 1.0) -> float | Awaitable[float]:\n        \"\"\"\n        Increments the value at key ``name`` by floating ``amount``.\n        If no key exists, the value will be initialized as ``amount``\n\n        For more information, see https://redis.io/commands/incrbyfloat\n        \"\"\"\n        return self.execute_command(\"INCRBYFLOAT\", name, amount)\n\n    @overload\n    def keys(\n        self: SyncClientProtocol, pattern: PatternT = \"*\", **kwargs\n    ) -> list[bytes | str]: ...\n\n    @overload\n    def keys(\n        self: AsyncClientProtocol, pattern: PatternT = \"*\", **kwargs\n    ) -> Awaitable[list[bytes | str]]: ...\n\n    def keys(\n        self, pattern: PatternT = \"*\", **kwargs\n    ) -> list[bytes | str] | Awaitable[list[bytes | str]]:\n        \"\"\"\n        Returns a list of keys matching ``pattern``\n\n        For more information, see https://redis.io/commands/keys\n        \"\"\"\n        return self.execute_command(\"KEYS\", pattern, **kwargs)\n\n    @overload\n    def lmove(\n        self: SyncClientProtocol,\n        first_list: str,\n        second_list: str,\n        src: str = \"LEFT\",\n        dest: str = \"RIGHT\",\n    ) -> bytes | str | None: ...\n\n    @overload\n    def lmove(\n        self: AsyncClientProtocol,\n        first_list: str,\n        second_list: str,\n        src: str = \"LEFT\",\n        dest: str = \"RIGHT\",\n    ) -> Awaitable[bytes | str | None]: ...\n\n    def lmove(\n        self, first_list: str, second_list: str, src: str = \"LEFT\", dest: str = \"RIGHT\"\n    ) -> (bytes | str | None) | Awaitable[bytes | str | None]:\n        \"\"\"\n        Atomically returns and removes the first/last element of a list,\n        pushing it as the first/last element on the destination list.\n        Returns the element being popped and pushed.\n\n        For more information, see https://redis.io/commands/lmove\n        \"\"\"\n        params = [first_list, second_list, src, dest]\n        return self.execute_command(\"LMOVE\", *params)\n\n    @overload\n    def blmove(\n        self: SyncClientProtocol,\n        first_list: str,\n        second_list: str,\n        timeout: int,\n        src: str = \"LEFT\",\n        dest: str = \"RIGHT\",\n    ) -> bytes | str | None: ...\n\n    @overload\n    def blmove(\n        self: AsyncClientProtocol,\n        first_list: str,\n        second_list: str,\n        timeout: int,\n        src: str = \"LEFT\",\n        dest: str = \"RIGHT\",\n    ) -> Awaitable[bytes | str | None]: ...\n\n    def blmove(\n        self,\n        first_list: str,\n        second_list: str,\n        timeout: int,\n        src: str = \"LEFT\",\n        dest: str = \"RIGHT\",\n    ) -> (bytes | str | None) | Awaitable[bytes | str | None]:\n        \"\"\"\n        Blocking version of lmove.\n\n        For more information, see https://redis.io/commands/blmove\n        \"\"\"\n        params = [first_list, second_list, src, dest, timeout]\n        return self.execute_command(\"BLMOVE\", *params)\n\n    @overload\n    def mget(\n        self: SyncClientProtocol, keys: KeysT, *args: EncodableT\n    ) -> list[bytes | str | None]: ...\n\n    @overload\n    def mget(\n        self: AsyncClientProtocol, keys: KeysT, *args: EncodableT\n    ) -> Awaitable[list[bytes | str | None]]: ...\n\n    def mget(\n        self, keys: KeysT, *args: EncodableT\n    ) -> list[bytes | str | None] | Awaitable[list[bytes | str | None]]:\n        \"\"\"\n        Returns a list of values ordered identically to ``keys``\n\n        ** Important ** When this method is used with Cluster clients, all keys\n                must be in the same hash slot, otherwise a RedisClusterException\n                will be raised.\n\n        For more information, see https://redis.io/commands/mget\n        \"\"\"\n        from redis.client import EMPTY_RESPONSE\n\n        args = list_or_args(keys, args)\n        options = {}\n        if not args:\n            options[EMPTY_RESPONSE] = []\n        options[\"keys\"] = args\n        return self.execute_command(\"MGET\", *args, **options)\n\n    @overload\n    def mset(\n        self: SyncClientProtocol, mapping: Mapping[AnyKeyT, EncodableT]\n    ) -> bool: ...\n\n    @overload\n    def mset(\n        self: AsyncClientProtocol, mapping: Mapping[AnyKeyT, EncodableT]\n    ) -> Awaitable[bool]: ...\n\n    def mset(self, mapping: Mapping[AnyKeyT, EncodableT]) -> bool | Awaitable[bool]:\n        \"\"\"\n        Sets key/values based on a mapping. Mapping is a dictionary of\n        key/value pairs. Both keys and values should be strings or types that\n        can be cast to a string via str().\n\n        ** Important ** When this method is used with Cluster clients, all keys\n                must be in the same hash slot, otherwise a RedisClusterException\n                will be raised.\n\n        For more information, see https://redis.io/commands/mset\n        \"\"\"\n        items = []\n        for pair in mapping.items():\n            items.extend(pair)\n        return self.execute_command(\"MSET\", *items)\n\n    @overload\n    def msetex(\n        self: SyncClientProtocol,\n        mapping: Mapping[AnyKeyT, EncodableT],\n        data_persist_option: DataPersistOptions | None = None,\n        ex: ExpiryT | None = None,\n        px: ExpiryT | None = None,\n        exat: AbsExpiryT | None = None,\n        pxat: AbsExpiryT | None = None,\n        keepttl: bool = False,\n    ) -> int: ...\n\n    @overload\n    def msetex(\n        self: AsyncClientProtocol,\n        mapping: Mapping[AnyKeyT, EncodableT],\n        data_persist_option: DataPersistOptions | None = None,\n        ex: ExpiryT | None = None,\n        px: ExpiryT | None = None,\n        exat: AbsExpiryT | None = None,\n        pxat: AbsExpiryT | None = None,\n        keepttl: bool = False,\n    ) -> Awaitable[int]: ...\n\n    def msetex(\n        self,\n        mapping: Mapping[AnyKeyT, EncodableT],\n        data_persist_option: DataPersistOptions | None = None,\n        ex: ExpiryT | None = None,\n        px: ExpiryT | None = None,\n        exat: AbsExpiryT | None = None,\n        pxat: AbsExpiryT | None = None,\n        keepttl: bool = False,\n    ) -> int | Awaitable[int]:\n        \"\"\"\n        Sets key/values based on the provided ``mapping`` items.\n\n        ** Important ** When this method is used with Cluster clients, all keys\n                        must be in the same hash slot, otherwise a RedisClusterException\n                        will be raised.\n\n        ``mapping`` accepts a dict of key/value pairs that will be added to the database.\n\n        ``data_persist_option`` can be set to ``NX`` or ``XX`` to control the\n            behavior of the command.\n            ``NX`` will set the value for each provided key to each\n                provided value only if all do not already exist.\n            ``XX`` will set the value for each provided key to each\n                provided value only if all already exist.\n\n        ``ex`` sets an expire flag on the keys in ``mapping`` for ``ex`` seconds.\n\n        ``px`` sets an expire flag on the keys in ``mapping`` for ``px`` milliseconds.\n\n        ``exat`` sets an expire flag on the keys in ``mapping`` for ``exat`` seconds,\n            specified in unix time.\n\n        ``pxat`` sets an expire flag on the keys in ``mapping`` for ``pxat`` milliseconds,\n            specified in unix time.\n\n        ``keepttl`` if True, retain the time to live associated with the keys.\n\n        Returns the number of fields that were added.\n\n        Available since Redis 8.4\n        For more information, see https://redis.io/commands/msetex\n        \"\"\"\n        if not at_most_one_value_set((ex, px, exat, pxat, keepttl)):\n            raise DataError(\n                \"``ex``, ``px``, ``exat``, ``pxat``, \"\n                \"and ``keepttl`` are mutually exclusive.\"\n            )\n\n        exp_options: list[EncodableT] = []\n        if data_persist_option:\n            exp_options.append(data_persist_option.value)\n\n        exp_options.extend(extract_expire_flags(ex, px, exat, pxat))\n\n        if keepttl:\n            exp_options.append(\"KEEPTTL\")\n\n        pieces = [\"MSETEX\", len(mapping)]\n\n        for pair in mapping.items():\n            pieces.extend(pair)\n\n        return self.execute_command(*pieces, *exp_options)\n\n    @overload\n    def msetnx(\n        self: SyncClientProtocol, mapping: Mapping[AnyKeyT, EncodableT]\n    ) -> bool: ...\n\n    @overload\n    def msetnx(\n        self: AsyncClientProtocol, mapping: Mapping[AnyKeyT, EncodableT]\n    ) -> Awaitable[bool]: ...\n\n    def msetnx(self, mapping: Mapping[AnyKeyT, EncodableT]) -> bool | Awaitable[bool]:\n        \"\"\"\n        Sets key/values based on a mapping if none of the keys are already set.\n        Mapping is a dictionary of key/value pairs. Both keys and values\n        should be strings or types that can be cast to a string via str().\n        Returns a boolean indicating if the operation was successful.\n\n        ** Important ** When this method is used with Cluster clients, all keys\n                        must be in the same hash slot, otherwise a RedisClusterException\n                        will be raised.\n\n        For more information, see https://redis.io/commands/msetnx\n        \"\"\"\n        items = []\n        for pair in mapping.items():\n            items.extend(pair)\n        return self.execute_command(\"MSETNX\", *items)\n\n    @overload\n    def move(self: SyncClientProtocol, name: KeyT, db: int) -> bool: ...\n\n    @overload\n    def move(self: AsyncClientProtocol, name: KeyT, db: int) -> Awaitable[bool]: ...\n\n    def move(self, name: KeyT, db: int) -> bool | Awaitable[bool]:\n        \"\"\"\n        Moves the key ``name`` to a different Redis database ``db``\n\n        For more information, see https://redis.io/commands/move\n        \"\"\"\n        return self.execute_command(\"MOVE\", name, db)\n\n    @overload\n    def persist(self: SyncClientProtocol, name: KeyT) -> bool: ...\n\n    @overload\n    def persist(self: AsyncClientProtocol, name: KeyT) -> Awaitable[bool]: ...\n\n    def persist(self, name: KeyT) -> bool | Awaitable[bool]:\n        \"\"\"\n        Removes an expiration on ``name``\n\n        For more information, see https://redis.io/commands/persist\n        \"\"\"\n        return self.execute_command(\"PERSIST\", name)\n\n    @overload\n    def pexpire(\n        self: SyncClientProtocol,\n        name: KeyT,\n        time: ExpiryT,\n        nx: bool = False,\n        xx: bool = False,\n        gt: bool = False,\n        lt: bool = False,\n    ) -> bool: ...\n\n    @overload\n    def pexpire(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        time: ExpiryT,\n        nx: bool = False,\n        xx: bool = False,\n        gt: bool = False,\n        lt: bool = False,\n    ) -> Awaitable[bool]: ...\n\n    def pexpire(\n        self,\n        name: KeyT,\n        time: ExpiryT,\n        nx: bool = False,\n        xx: bool = False,\n        gt: bool = False,\n        lt: bool = False,\n    ) -> bool | Awaitable[bool]:\n        \"\"\"\n        Set an expire flag on key ``name`` for ``time`` milliseconds\n        with given ``option``. ``time`` can be represented by an\n        integer or a Python timedelta object.\n\n        Valid options are:\n            NX -> Set expiry only when the key has no expiry\n            XX -> Set expiry only when the key has an existing expiry\n            GT -> Set expiry only when the new expiry is greater than current one\n            LT -> Set expiry only when the new expiry is less than current one\n\n        For more information, see https://redis.io/commands/pexpire\n        \"\"\"\n        if isinstance(time, datetime.timedelta):\n            time = int(time.total_seconds() * 1000)\n\n        exp_option = list()\n        if nx:\n            exp_option.append(\"NX\")\n        if xx:\n            exp_option.append(\"XX\")\n        if gt:\n            exp_option.append(\"GT\")\n        if lt:\n            exp_option.append(\"LT\")\n        return self.execute_command(\"PEXPIRE\", name, time, *exp_option)\n\n    @overload\n    def pexpireat(\n        self: SyncClientProtocol,\n        name: KeyT,\n        when: AbsExpiryT,\n        nx: bool = False,\n        xx: bool = False,\n        gt: bool = False,\n        lt: bool = False,\n    ) -> bool: ...\n\n    @overload\n    def pexpireat(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        when: AbsExpiryT,\n        nx: bool = False,\n        xx: bool = False,\n        gt: bool = False,\n        lt: bool = False,\n    ) -> Awaitable[bool]: ...\n\n    def pexpireat(\n        self,\n        name: KeyT,\n        when: AbsExpiryT,\n        nx: bool = False,\n        xx: bool = False,\n        gt: bool = False,\n        lt: bool = False,\n    ) -> bool | Awaitable[bool]:\n        \"\"\"\n        Set an expire flag on key ``name`` with given ``option``. ``when``\n        can be represented as an integer representing unix time in\n        milliseconds (unix time * 1000) or a Python datetime object.\n\n        Valid options are:\n            NX -> Set expiry only when the key has no expiry\n            XX -> Set expiry only when the key has an existing expiry\n            GT -> Set expiry only when the new expiry is greater than current one\n            LT -> Set expiry only when the new expiry is less than current one\n\n        For more information, see https://redis.io/commands/pexpireat\n        \"\"\"\n        if isinstance(when, datetime.datetime):\n            when = int(when.timestamp() * 1000)\n        exp_option = list()\n        if nx:\n            exp_option.append(\"NX\")\n        if xx:\n            exp_option.append(\"XX\")\n        if gt:\n            exp_option.append(\"GT\")\n        if lt:\n            exp_option.append(\"LT\")\n        return self.execute_command(\"PEXPIREAT\", name, when, *exp_option)\n\n    @overload\n    def pexpiretime(self: SyncClientProtocol, key: str) -> int: ...\n\n    @overload\n    def pexpiretime(self: AsyncClientProtocol, key: str) -> Awaitable[int]: ...\n\n    def pexpiretime(self, key: str) -> int | Awaitable[int]:\n        \"\"\"\n        Returns the absolute Unix timestamp (since January 1, 1970) in milliseconds\n        at which the given key will expire.\n\n        For more information, see https://redis.io/commands/pexpiretime\n        \"\"\"\n        return self.execute_command(\"PEXPIRETIME\", key)\n\n    @overload\n    def psetex(\n        self: SyncClientProtocol, name: KeyT, time_ms: ExpiryT, value: EncodableT\n    ) -> bool: ...\n\n    @overload\n    def psetex(\n        self: AsyncClientProtocol, name: KeyT, time_ms: ExpiryT, value: EncodableT\n    ) -> Awaitable[bool]: ...\n\n    def psetex(\n        self, name: KeyT, time_ms: ExpiryT, value: EncodableT\n    ) -> bool | Awaitable[bool]:\n        \"\"\"\n        Set the value of key ``name`` to ``value`` that expires in ``time_ms``\n        milliseconds. ``time_ms`` can be represented by an integer or a Python\n        timedelta object\n\n        For more information, see https://redis.io/commands/psetex\n        \"\"\"\n        if isinstance(time_ms, datetime.timedelta):\n            time_ms = int(time_ms.total_seconds() * 1000)\n        return self.execute_command(\"PSETEX\", name, time_ms, value)\n\n    @overload\n    def pttl(self: SyncClientProtocol, name: KeyT) -> int: ...\n\n    @overload\n    def pttl(self: AsyncClientProtocol, name: KeyT) -> Awaitable[int]: ...\n\n    def pttl(self, name: KeyT) -> int | Awaitable[int]:\n        \"\"\"\n        Returns the number of milliseconds until the key ``name`` will expire\n\n        For more information, see https://redis.io/commands/pttl\n        \"\"\"\n        return self.execute_command(\"PTTL\", name)\n\n    @overload\n    def hrandfield(\n        self: SyncClientProtocol,\n        key: str,\n        count: int | None = None,\n        withvalues: bool = False,\n    ) -> bytes | str | list[bytes | str] | None: ...\n\n    @overload\n    def hrandfield(\n        self: AsyncClientProtocol,\n        key: str,\n        count: int | None = None,\n        withvalues: bool = False,\n    ) -> Awaitable[bytes | str | list[bytes | str] | None]: ...\n\n    def hrandfield(\n        self, key: str, count: int | None = None, withvalues: bool = False\n    ) -> (bytes | str | list[bytes | str] | None) | Awaitable[\n        bytes | str | list[bytes | str] | None\n    ]:\n        \"\"\"\n        Return a random field from the hash value stored at key.\n\n        count: if the argument is positive, return an array of distinct fields.\n        If called with a negative count, the behavior changes and the command\n        is allowed to return the same field multiple times. In this case,\n        the number of returned fields is the absolute value of the\n        specified count.\n        withvalues: The optional WITHVALUES modifier changes the reply so it\n        includes the respective values of the randomly selected hash fields.\n\n        For more information, see https://redis.io/commands/hrandfield\n        \"\"\"\n        params = []\n        if count is not None:\n            params.append(count)\n        if withvalues:\n            params.append(\"WITHVALUES\")\n\n        return self.execute_command(\"HRANDFIELD\", key, *params)\n\n    @overload\n    def randomkey(self: SyncClientProtocol, **kwargs) -> bytes | str | None: ...\n\n    @overload\n    def randomkey(\n        self: AsyncClientProtocol, **kwargs\n    ) -> Awaitable[bytes | str | None]: ...\n\n    def randomkey(self, **kwargs) -> (bytes | str | None) | Awaitable[\n        bytes | str | None\n    ]:\n        \"\"\"\n        Returns the name of a random key\n\n        For more information, see https://redis.io/commands/randomkey\n        \"\"\"\n        return self.execute_command(\"RANDOMKEY\", **kwargs)\n\n    @overload\n    def rename(self: SyncClientProtocol, src: KeyT, dst: KeyT) -> bool: ...\n\n    @overload\n    def rename(self: AsyncClientProtocol, src: KeyT, dst: KeyT) -> Awaitable[bool]: ...\n\n    def rename(self, src: KeyT, dst: KeyT) -> bool | Awaitable[bool]:\n        \"\"\"\n        Rename key ``src`` to ``dst``\n\n        For more information, see https://redis.io/commands/rename\n        \"\"\"\n        return self.execute_command(\"RENAME\", src, dst)\n\n    @overload\n    def renamenx(self: SyncClientProtocol, src: KeyT, dst: KeyT) -> bool: ...\n\n    @overload\n    def renamenx(\n        self: AsyncClientProtocol, src: KeyT, dst: KeyT\n    ) -> Awaitable[bool]: ...\n\n    def renamenx(self, src: KeyT, dst: KeyT) -> bool | Awaitable[bool]:\n        \"\"\"\n        Rename key ``src`` to ``dst`` if ``dst`` doesn't already exist\n\n        For more information, see https://redis.io/commands/renamenx\n        \"\"\"\n        return self.execute_command(\"RENAMENX\", src, dst)\n\n    @overload\n    def restore(\n        self: SyncClientProtocol,\n        name: KeyT,\n        ttl: float,\n        value: EncodableT,\n        replace: bool = False,\n        absttl: bool = False,\n        idletime: int | None = None,\n        frequency: int | None = None,\n    ) -> bytes | str: ...\n\n    @overload\n    def restore(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        ttl: float,\n        value: EncodableT,\n        replace: bool = False,\n        absttl: bool = False,\n        idletime: int | None = None,\n        frequency: int | None = None,\n    ) -> Awaitable[bytes | str]: ...\n\n    def restore(\n        self,\n        name: KeyT,\n        ttl: float,\n        value: EncodableT,\n        replace: bool = False,\n        absttl: bool = False,\n        idletime: int | None = None,\n        frequency: int | None = None,\n    ) -> (bytes | str) | Awaitable[bytes | str]:\n        \"\"\"\n        Create a key using the provided serialized value, previously obtained\n        using DUMP.\n\n        ``replace`` allows an existing key on ``name`` to be overridden. If\n        it's not specified an error is raised on collision.\n\n        ``absttl`` if True, specified ``ttl`` should represent an absolute Unix\n        timestamp in milliseconds in which the key will expire. (Redis 5.0 or\n        greater).\n\n        ``idletime`` Used for eviction, this is the number of seconds the\n        key must be idle, prior to execution.\n\n        ``frequency`` Used for eviction, this is the frequency counter of\n        the object stored at the key, prior to execution.\n\n        For more information, see https://redis.io/commands/restore\n        \"\"\"\n        params = [name, ttl, value]\n        if replace:\n            params.append(\"REPLACE\")\n        if absttl:\n            params.append(\"ABSTTL\")\n        if idletime is not None:\n            params.append(\"IDLETIME\")\n            try:\n                params.append(int(idletime))\n            except ValueError:\n                raise DataError(\"idletimemust be an integer\")\n\n        if frequency is not None:\n            params.append(\"FREQ\")\n            try:\n                params.append(int(frequency))\n            except ValueError:\n                raise DataError(\"frequency must be an integer\")\n\n        return self.execute_command(\"RESTORE\", *params)\n\n    @overload\n    def set(\n        self: SyncClientProtocol,\n        name: KeyT,\n        value: EncodableT,\n        ex: ExpiryT | None = ...,\n        px: ExpiryT | None = ...,\n        nx: bool = ...,\n        xx: bool = ...,\n        keepttl: bool = ...,\n        get: bool = ...,\n        exat: AbsExpiryT | None = ...,\n        pxat: AbsExpiryT | None = ...,\n        ifeq: bytes | str | None = ...,\n        ifne: bytes | str | None = ...,\n        ifdeq: str | None = ...,\n        ifdne: str | None = ...,\n    ) -> bool | str | bytes | None: ...\n\n    @overload\n    def set(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        value: EncodableT,\n        ex: ExpiryT | None = ...,\n        px: ExpiryT | None = ...,\n        nx: bool = ...,\n        xx: bool = ...,\n        keepttl: bool = ...,\n        get: bool = ...,\n        exat: AbsExpiryT | None = ...,\n        pxat: AbsExpiryT | None = ...,\n        ifeq: bytes | str | None = ...,\n        ifne: bytes | str | None = ...,\n        ifdeq: str | None = ...,\n        ifdne: str | None = ...,\n    ) -> Awaitable[bool | str | bytes | None]: ...\n\n    @experimental_args([\"ifeq\", \"ifne\", \"ifdeq\", \"ifdne\"])\n    def set(\n        self,\n        name: KeyT,\n        value: EncodableT,\n        ex: ExpiryT | None = None,\n        px: ExpiryT | None = None,\n        nx: bool = False,\n        xx: bool = False,\n        keepttl: bool = False,\n        get: bool = False,\n        exat: AbsExpiryT | None = None,\n        pxat: AbsExpiryT | None = None,\n        ifeq: bytes | str | None = None,\n        ifne: bytes | str | None = None,\n        ifdeq: str | None = None,  # hex digest of current value\n        ifdne: str | None = None,  # hex digest of current value\n    ) -> (bool | str | bytes | None) | Awaitable[bool | str | bytes | None]:\n        \"\"\"\n        Set the value at key ``name`` to ``value``\n\n        Warning:\n        **Experimental** since 7.1.\n        The usage of the arguments ``ifeq``, ``ifne``, ``ifdeq``, and ``ifdne``\n        is experimental. The API or returned results when those parameters are used\n        may change based on feedback.\n\n        ``ex`` sets an expire flag on key ``name`` for ``ex`` seconds.\n\n        ``px`` sets an expire flag on key ``name`` for ``px`` milliseconds.\n\n        ``nx`` if set to True, set the value at key ``name`` to ``value`` only\n            if it does not exist.\n\n        ``xx`` if set to True, set the value at key ``name`` to ``value`` only\n            if it already exists.\n\n        ``keepttl`` if True, retain the time to live associated with the key.\n            (Available since Redis 6.0)\n\n        ``get`` if True, set the value at key ``name`` to ``value`` and return\n            the old value stored at key, or None if the key did not exist.\n            (Available since Redis 6.2)\n\n        ``exat`` sets an expire flag on key ``name`` for ``ex`` seconds,\n            specified in unix time.\n\n        ``pxat`` sets an expire flag on key ``name`` for ``ex`` milliseconds,\n            specified in unix time.\n\n        ``ifeq`` set the value at key ``name`` to ``value`` only if the current\n            value exactly matches the argument.\n            If key doesn’t exist - it won’t be created.\n            (Requires Redis 8.4 or greater)\n\n        ``ifne`` set the value at key ``name`` to ``value`` only if the current\n            value does not exactly match the argument.\n            If key doesn’t exist - it will be created.\n            (Requires Redis 8.4 or greater)\n\n        ``ifdeq`` set the value at key ``name`` to ``value`` only if the current\n            value XXH3 hex digest exactly matches the argument.\n            If key doesn’t exist - it won’t be created.\n            (Requires Redis 8.4 or greater)\n\n        ``ifdne`` set the value at key ``name`` to ``value`` only if the current\n            value XXH3 hex digest does not exactly match the argument.\n            If key doesn’t exist - it will be created.\n            (Requires Redis 8.4 or greater)\n\n        For more information, see https://redis.io/commands/set\n        \"\"\"\n\n        if not at_most_one_value_set((ex, px, exat, pxat, keepttl)):\n            raise DataError(\n                \"``ex``, ``px``, ``exat``, ``pxat``, \"\n                \"and ``keepttl`` are mutually exclusive.\"\n            )\n\n        # Enforce mutual exclusivity among all conditional switches.\n        if not at_most_one_value_set((nx, xx, ifeq, ifne, ifdeq, ifdne)):\n            raise DataError(\n                \"``nx``, ``xx``, ``ifeq``, ``ifne``, ``ifdeq``, ``ifdne`` are mutually exclusive.\"\n            )\n\n        pieces: list[EncodableT] = [name, value]\n        options = {}\n\n        # Conditional modifier (exactly one at most)\n        if nx:\n            pieces.append(\"NX\")\n        elif xx:\n            pieces.append(\"XX\")\n        elif ifeq is not None:\n            pieces.extend((\"IFEQ\", ifeq))\n        elif ifne is not None:\n            pieces.extend((\"IFNE\", ifne))\n        elif ifdeq is not None:\n            pieces.extend((\"IFDEQ\", ifdeq))\n        elif ifdne is not None:\n            pieces.extend((\"IFDNE\", ifdne))\n\n        if get:\n            pieces.append(\"GET\")\n            options[\"get\"] = True\n\n        pieces.extend(extract_expire_flags(ex, px, exat, pxat))\n\n        if keepttl:\n            pieces.append(\"KEEPTTL\")\n\n        return self.execute_command(\"SET\", *pieces, **options)\n\n    def __setitem__(self, name: KeyT, value: EncodableT):\n        self.set(name, value)\n\n    @overload\n    def setbit(\n        self: SyncClientProtocol, name: KeyT, offset: int, value: int\n    ) -> int: ...\n\n    @overload\n    def setbit(\n        self: AsyncClientProtocol, name: KeyT, offset: int, value: int\n    ) -> Awaitable[int]: ...\n\n    def setbit(self, name: KeyT, offset: int, value: int) -> int | Awaitable[int]:\n        \"\"\"\n        Flag the ``offset`` in ``name`` as ``value``. Returns an integer\n        indicating the previous value of ``offset``.\n\n        For more information, see https://redis.io/commands/setbit\n        \"\"\"\n        value = value and 1 or 0\n        return self.execute_command(\"SETBIT\", name, offset, value)\n\n    @overload\n    def setex(\n        self: SyncClientProtocol, name: KeyT, time: ExpiryT, value: EncodableT\n    ) -> bool: ...\n\n    @overload\n    def setex(\n        self: AsyncClientProtocol, name: KeyT, time: ExpiryT, value: EncodableT\n    ) -> Awaitable[bool]: ...\n\n    def setex(\n        self, name: KeyT, time: ExpiryT, value: EncodableT\n    ) -> bool | Awaitable[bool]:\n        \"\"\"\n        Set the value of key ``name`` to ``value`` that expires in ``time``\n        seconds. ``time`` can be represented by an integer or a Python\n        timedelta object.\n\n        For more information, see https://redis.io/commands/setex\n        \"\"\"\n        if isinstance(time, datetime.timedelta):\n            time = int(time.total_seconds())\n        return self.execute_command(\"SETEX\", name, time, value)\n\n    @overload\n    def setnx(self: SyncClientProtocol, name: KeyT, value: EncodableT) -> bool: ...\n\n    @overload\n    def setnx(\n        self: AsyncClientProtocol, name: KeyT, value: EncodableT\n    ) -> Awaitable[bool]: ...\n\n    def setnx(self, name: KeyT, value: EncodableT) -> bool | Awaitable[bool]:\n        \"\"\"\n        Set the value of key ``name`` to ``value`` if key doesn't exist\n\n        For more information, see https://redis.io/commands/setnx\n        \"\"\"\n        return self.execute_command(\"SETNX\", name, value)\n\n    @overload\n    def setrange(\n        self: SyncClientProtocol, name: KeyT, offset: int, value: EncodableT\n    ) -> int: ...\n\n    @overload\n    def setrange(\n        self: AsyncClientProtocol, name: KeyT, offset: int, value: EncodableT\n    ) -> Awaitable[int]: ...\n\n    def setrange(\n        self, name: KeyT, offset: int, value: EncodableT\n    ) -> int | Awaitable[int]:\n        \"\"\"\n        Overwrite bytes in the value of ``name`` starting at ``offset`` with\n        ``value``. If ``offset`` plus the length of ``value`` exceeds the\n        length of the original value, the new value will be larger than before.\n        If ``offset`` exceeds the length of the original value, null bytes\n        will be used to pad between the end of the previous value and the start\n        of what's being injected.\n\n        Returns the length of the new string.\n\n        For more information, see https://redis.io/commands/setrange\n        \"\"\"\n        return self.execute_command(\"SETRANGE\", name, offset, value)\n\n    @overload\n    def stralgo(\n        self: SyncClientProtocol,\n        algo: Literal[\"LCS\"],\n        value1: KeyT,\n        value2: KeyT,\n        specific_argument: Literal[\"strings\"] | Literal[\"keys\"] = \"strings\",\n        len: bool = False,\n        idx: bool = False,\n        minmatchlen: int | None = None,\n        withmatchlen: bool = False,\n        **kwargs,\n    ) -> StralgoResponse: ...\n\n    @overload\n    def stralgo(\n        self: AsyncClientProtocol,\n        algo: Literal[\"LCS\"],\n        value1: KeyT,\n        value2: KeyT,\n        specific_argument: Literal[\"strings\"] | Literal[\"keys\"] = \"strings\",\n        len: bool = False,\n        idx: bool = False,\n        minmatchlen: int | None = None,\n        withmatchlen: bool = False,\n        **kwargs,\n    ) -> Awaitable[StralgoResponse]: ...\n\n    def stralgo(\n        self,\n        algo: Literal[\"LCS\"],\n        value1: KeyT,\n        value2: KeyT,\n        specific_argument: Literal[\"strings\"] | Literal[\"keys\"] = \"strings\",\n        len: bool = False,\n        idx: bool = False,\n        minmatchlen: int | None = None,\n        withmatchlen: bool = False,\n        **kwargs,\n    ) -> StralgoResponse | Awaitable[StralgoResponse]:\n        \"\"\"\n        Implements complex algorithms that operate on strings.\n        Right now the only algorithm implemented is the LCS algorithm\n        (longest common substring). However new algorithms could be\n        implemented in the future.\n\n        ``algo`` Right now must be LCS\n        ``value1`` and ``value2`` Can be two strings or two keys\n        ``specific_argument`` Specifying if the arguments to the algorithm\n        will be keys or strings. strings is the default.\n        ``len`` Returns just the len of the match.\n        ``idx`` Returns the match positions in each string.\n        ``minmatchlen`` Restrict the list of matches to the ones of a given\n        minimal length. Can be provided only when ``idx`` set to True.\n        ``withmatchlen`` Returns the matches with the len of the match.\n        Can be provided only when ``idx`` set to True.\n\n        For more information, see https://redis.io/commands/stralgo\n        \"\"\"\n        # check validity\n        supported_algo = [\"LCS\"]\n        if algo not in supported_algo:\n            supported_algos_str = \", \".join(supported_algo)\n            raise DataError(f\"The supported algorithms are: {supported_algos_str}\")\n        if specific_argument not in [\"keys\", \"strings\"]:\n            raise DataError(\"specific_argument can be only keys or strings\")\n        if len and idx:\n            raise DataError(\"len and idx cannot be provided together.\")\n\n        pieces: list[EncodableT] = [algo, specific_argument.upper(), value1, value2]\n        if len:\n            pieces.append(b\"LEN\")\n        if idx:\n            pieces.append(b\"IDX\")\n        try:\n            int(minmatchlen)\n            pieces.extend([b\"MINMATCHLEN\", minmatchlen])\n        except TypeError:\n            pass\n        if withmatchlen:\n            pieces.append(b\"WITHMATCHLEN\")\n\n        return self.execute_command(\n            \"STRALGO\",\n            *pieces,\n            len=len,\n            idx=idx,\n            minmatchlen=minmatchlen,\n            withmatchlen=withmatchlen,\n            **kwargs,\n        )\n\n    @overload\n    def strlen(self: SyncClientProtocol, name: KeyT) -> int: ...\n\n    @overload\n    def strlen(self: AsyncClientProtocol, name: KeyT) -> Awaitable[int]: ...\n\n    def strlen(self, name: KeyT) -> int | Awaitable[int]:\n        \"\"\"\n        Return the number of bytes stored in the value of ``name``\n\n        For more information, see https://redis.io/commands/strlen\n        \"\"\"\n        return self.execute_command(\"STRLEN\", name, keys=[name])\n\n    @overload\n    def substr(\n        self: SyncClientProtocol, name: KeyT, start: int, end: int = -1\n    ) -> bytes | str: ...\n\n    @overload\n    def substr(\n        self: AsyncClientProtocol, name: KeyT, start: int, end: int = -1\n    ) -> Awaitable[bytes | str]: ...\n\n    def substr(self, name: KeyT, start: int, end: int = -1) -> (\n        bytes | str\n    ) | Awaitable[bytes | str]:\n        \"\"\"\n        Return a substring of the string at key ``name``. ``start`` and ``end``\n        are 0-based integers specifying the portion of the string to return.\n        \"\"\"\n        return self.execute_command(\"SUBSTR\", name, start, end, keys=[name])\n\n    @overload\n    def touch(self: SyncClientProtocol, *args: KeyT) -> int: ...\n\n    @overload\n    def touch(self: AsyncClientProtocol, *args: KeyT) -> Awaitable[int]: ...\n\n    def touch(self, *args: KeyT) -> int | Awaitable[int]:\n        \"\"\"\n        Alters the last access time of a key(s) ``*args``. A key is ignored\n        if it does not exist.\n\n        For more information, see https://redis.io/commands/touch\n        \"\"\"\n        return self.execute_command(\"TOUCH\", *args)\n\n    @overload\n    def ttl(self: SyncClientProtocol, name: KeyT) -> int: ...\n\n    @overload\n    def ttl(self: AsyncClientProtocol, name: KeyT) -> Awaitable[int]: ...\n\n    def ttl(self, name: KeyT) -> int | Awaitable[int]:\n        \"\"\"\n        Returns the number of seconds until the key ``name`` will expire\n\n        For more information, see https://redis.io/commands/ttl\n        \"\"\"\n        return self.execute_command(\"TTL\", name)\n\n    @overload\n    def type(self: SyncClientProtocol, name: KeyT) -> bytes | str: ...\n\n    @overload\n    def type(self: AsyncClientProtocol, name: KeyT) -> Awaitable[bytes | str]: ...\n\n    def type(self, name: KeyT) -> (bytes | str) | Awaitable[bytes | str]:\n        \"\"\"\n        Returns the type of key ``name``\n\n        For more information, see https://redis.io/commands/type\n        \"\"\"\n        return self.execute_command(\"TYPE\", name, keys=[name])\n\n    def watch(self, *names: KeyT) -> None:\n        \"\"\"\n        Watches the values at keys ``names``, or None if the key doesn't exist\n\n        For more information, see https://redis.io/commands/watch\n        \"\"\"\n        warnings.warn(DeprecationWarning(\"Call WATCH from a Pipeline object\"))\n\n    def unwatch(self) -> None:\n        \"\"\"\n        Unwatches all previously watched keys for a transaction\n\n        For more information, see https://redis.io/commands/unwatch\n        \"\"\"\n        warnings.warn(DeprecationWarning(\"Call UNWATCH from a Pipeline object\"))\n\n    @overload\n    def unlink(self: SyncClientProtocol, *names: KeyT) -> int: ...\n\n    @overload\n    def unlink(self: AsyncClientProtocol, *names: KeyT) -> Awaitable[int]: ...\n\n    def unlink(self, *names: KeyT) -> int | Awaitable[int]:\n        \"\"\"\n        Unlink one or more keys specified by ``names``\n\n        For more information, see https://redis.io/commands/unlink\n        \"\"\"\n        return self.execute_command(\"UNLINK\", *names)\n\n    @overload\n    def lcs(\n        self: SyncClientProtocol,\n        key1: str,\n        key2: str,\n        len: bool | None = False,\n        idx: bool | None = False,\n        minmatchlen: int | None = 0,\n        withmatchlen: bool | None = False,\n    ) -> bytes | str | int | list[Any] | dict[Any, Any]: ...\n\n    @overload\n    def lcs(\n        self: AsyncClientProtocol,\n        key1: str,\n        key2: str,\n        len: bool | None = False,\n        idx: bool | None = False,\n        minmatchlen: int | None = 0,\n        withmatchlen: bool | None = False,\n    ) -> Awaitable[bytes | str | int | list[Any] | dict[Any, Any]]: ...\n\n    def lcs(\n        self,\n        key1: str,\n        key2: str,\n        len: bool | None = False,\n        idx: bool | None = False,\n        minmatchlen: int | None = 0,\n        withmatchlen: bool | None = False,\n    ) -> (bytes | str | int | list[Any] | dict[Any, Any]) | Awaitable[\n        bytes | str | int | list[Any] | dict[Any, Any]\n    ]:\n        \"\"\"\n        Find the longest common subsequence between ``key1`` and ``key2``.\n        If ``len`` is true the length of the match will be returned.\n        If ``idx`` is true the match position in each strings will be returned.\n        ``minmatchlen`` restrict the list of matches to the ones of\n        the given ``minmatchlen``.\n        If ``withmatchlen`` the length of the match also will be returned.\n        For more information, see https://redis.io/commands/lcs\n        \"\"\"\n        pieces: list[str | int] = [key1, key2]\n        if len:\n            pieces.append(\"LEN\")\n        if idx:\n            pieces.append(\"IDX\")\n        if minmatchlen is not None and minmatchlen != 0:\n            pieces.extend([\"MINMATCHLEN\", minmatchlen])\n        if withmatchlen:\n            pieces.append(\"WITHMATCHLEN\")\n        return self.execute_command(\"LCS\", *pieces, keys=[key1, key2])\n\n\nclass AsyncBasicKeyCommands(BasicKeyCommands):\n    def __delitem__(self, name: KeyT):\n        raise TypeError(\"Async Redis client does not support class deletion\")\n\n    def __contains__(self, name: KeyT):\n        raise TypeError(\"Async Redis client does not support class inclusion\")\n\n    def __getitem__(self, name: KeyT):\n        raise TypeError(\"Async Redis client does not support class retrieval\")\n\n    def __setitem__(self, name: KeyT, value: EncodableT):\n        raise TypeError(\"Async Redis client does not support class assignment\")\n\n    async def watch(self, *names: KeyT) -> None:\n        return super().watch(*names)\n\n    async def unwatch(self) -> None:\n        return super().unwatch()\n\n\nclass ListCommands(CommandsProtocol):\n    \"\"\"\n    Redis commands for List data type.\n    see: https://redis.io/topics/data-types#lists\n    \"\"\"\n\n    @overload\n    def blpop(\n        self: SyncClientProtocol, keys: KeysT, timeout: Number | None = 0\n    ) -> BlockingListPopResponse: ...\n\n    @overload\n    def blpop(\n        self: AsyncClientProtocol, keys: KeysT, timeout: Number | None = 0\n    ) -> Awaitable[BlockingListPopResponse]: ...\n\n    def blpop(\n        self, keys: KeysT, timeout: Number | None = 0\n    ) -> BlockingListPopResponse | Awaitable[BlockingListPopResponse]:\n        \"\"\"\n        LPOP a value off of the first non-empty list\n        named in ``keys``.\n\n        If none of the lists in ``keys`` has a value to LPOP, then block\n        for ``timeout`` seconds, or until a value gets pushed on to one\n        of the lists.\n\n        If timeout is 0, then block indefinitely.\n\n        For more information, see https://redis.io/commands/blpop\n        \"\"\"\n        if timeout is None:\n            timeout = 0\n        keys = list_or_args(keys, None)\n        keys.append(timeout)\n        return self.execute_command(\"BLPOP\", *keys)\n\n    @overload\n    def brpop(\n        self: SyncClientProtocol, keys: KeysT, timeout: Number | None = 0\n    ) -> BlockingListPopResponse: ...\n\n    @overload\n    def brpop(\n        self: AsyncClientProtocol, keys: KeysT, timeout: Number | None = 0\n    ) -> Awaitable[BlockingListPopResponse]: ...\n\n    def brpop(\n        self, keys: KeysT, timeout: Number | None = 0\n    ) -> BlockingListPopResponse | Awaitable[BlockingListPopResponse]:\n        \"\"\"\n        RPOP a value off of the first non-empty list\n        named in ``keys``.\n\n        If none of the lists in ``keys`` has a value to RPOP, then block\n        for ``timeout`` seconds, or until a value gets pushed on to one\n        of the lists.\n\n        If timeout is 0, then block indefinitely.\n\n        For more information, see https://redis.io/commands/brpop\n        \"\"\"\n        if timeout is None:\n            timeout = 0\n        keys = list_or_args(keys, None)\n        keys.append(timeout)\n        return self.execute_command(\"BRPOP\", *keys)\n\n    @overload\n    def brpoplpush(\n        self: SyncClientProtocol, src: KeyT, dst: KeyT, timeout: Number | None = 0\n    ) -> bytes | str | None: ...\n\n    @overload\n    def brpoplpush(\n        self: AsyncClientProtocol, src: KeyT, dst: KeyT, timeout: Number | None = 0\n    ) -> Awaitable[bytes | str | None]: ...\n\n    def brpoplpush(self, src: KeyT, dst: KeyT, timeout: Number | None = 0) -> (\n        bytes | str | None\n    ) | Awaitable[bytes | str | None]:\n        \"\"\"\n        Pop a value off the tail of ``src``, push it on the head of ``dst``\n        and then return it.\n\n        This command blocks until a value is in ``src`` or until ``timeout``\n        seconds elapse, whichever is first. A ``timeout`` value of 0 blocks\n        forever.\n\n        For more information, see https://redis.io/commands/brpoplpush\n        \"\"\"\n        if timeout is None:\n            timeout = 0\n        return self.execute_command(\"BRPOPLPUSH\", src, dst, timeout)\n\n    @overload\n    def blmpop(\n        self: SyncClientProtocol,\n        timeout: float,\n        numkeys: int,\n        *args: str,\n        direction: str,\n        count: int | None = 1,\n    ) -> ListMultiPopResponse: ...\n\n    @overload\n    def blmpop(\n        self: AsyncClientProtocol,\n        timeout: float,\n        numkeys: int,\n        *args: str,\n        direction: str,\n        count: int | None = 1,\n    ) -> Awaitable[ListMultiPopResponse]: ...\n\n    def blmpop(\n        self,\n        timeout: float,\n        numkeys: int,\n        *args: str,\n        direction: str,\n        count: int | None = 1,\n    ) -> ListMultiPopResponse | Awaitable[ListMultiPopResponse]:\n        \"\"\"\n        Pop ``count`` values (default 1) from first non-empty in the list\n        of provided key names.\n\n        When all lists are empty this command blocks the connection until another\n        client pushes to it or until the timeout, timeout of 0 blocks indefinitely\n\n        For more information, see https://redis.io/commands/blmpop\n        \"\"\"\n        cmd_args = [timeout, numkeys, *args, direction, \"COUNT\", count]\n\n        return self.execute_command(\"BLMPOP\", *cmd_args)\n\n    @overload\n    def lmpop(\n        self: SyncClientProtocol,\n        num_keys: int,\n        *args: str,\n        direction: str,\n        count: int | None = 1,\n    ) -> ListMultiPopResponse: ...\n\n    @overload\n    def lmpop(\n        self: AsyncClientProtocol,\n        num_keys: int,\n        *args: str,\n        direction: str,\n        count: int | None = 1,\n    ) -> Awaitable[ListMultiPopResponse]: ...\n\n    def lmpop(\n        self,\n        num_keys: int,\n        *args: str,\n        direction: str,\n        count: int | None = 1,\n    ) -> ListMultiPopResponse | Awaitable[ListMultiPopResponse]:\n        \"\"\"\n        Pop ``count`` values (default 1) first non-empty list key from the list\n        of args provided key names.\n\n        For more information, see https://redis.io/commands/lmpop\n        \"\"\"\n        cmd_args = [num_keys] + list(args) + [direction]\n        if count != 1:\n            cmd_args.extend([\"COUNT\", count])\n\n        return self.execute_command(\"LMPOP\", *cmd_args)\n\n    @overload\n    def lindex(\n        self: SyncClientProtocol, name: KeyT, index: int\n    ) -> bytes | str | None: ...\n\n    @overload\n    def lindex(\n        self: AsyncClientProtocol, name: KeyT, index: int\n    ) -> Awaitable[bytes | str | None]: ...\n\n    def lindex(self, name: KeyT, index: int) -> (bytes | str | None) | Awaitable[\n        bytes | str | None\n    ]:\n        \"\"\"\n        Return the item from list ``name`` at position ``index``\n\n        Negative indexes are supported and will return an item at the\n        end of the list\n\n        For more information, see https://redis.io/commands/lindex\n        \"\"\"\n        return self.execute_command(\"LINDEX\", name, index, keys=[name])\n\n    @overload\n    def linsert(\n        self: SyncClientProtocol, name: KeyT, where: str, refvalue: str, value: str\n    ) -> int: ...\n\n    @overload\n    def linsert(\n        self: AsyncClientProtocol, name: KeyT, where: str, refvalue: str, value: str\n    ) -> Awaitable[int]: ...\n\n    def linsert(\n        self, name: KeyT, where: str, refvalue: str, value: str\n    ) -> int | Awaitable[int]:\n        \"\"\"\n        Insert ``value`` in list ``name`` either immediately before or after\n        [``where``] ``refvalue``\n\n        Returns the new length of the list on success or -1 if ``refvalue``\n        is not in the list.\n\n        For more information, see https://redis.io/commands/linsert\n        \"\"\"\n        return self.execute_command(\"LINSERT\", name, where, refvalue, value)\n\n    @overload\n    def llen(self: SyncClientProtocol, name: KeyT) -> int: ...\n\n    @overload\n    def llen(self: AsyncClientProtocol, name: KeyT) -> Awaitable[int]: ...\n\n    def llen(self, name: KeyT) -> int | Awaitable[int]:\n        \"\"\"\n        Return the length of the list ``name``\n\n        For more information, see https://redis.io/commands/llen\n        \"\"\"\n        return self.execute_command(\"LLEN\", name, keys=[name])\n\n    @overload\n    def lpop(\n        self: SyncClientProtocol,\n        name: KeyT,\n        count: int | None = None,\n    ) -> bytes | str | list[bytes | str] | None: ...\n\n    @overload\n    def lpop(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        count: int | None = None,\n    ) -> Awaitable[bytes | str | list[bytes | str] | None]: ...\n\n    def lpop(\n        self,\n        name: KeyT,\n        count: int | None = None,\n    ) -> (bytes | str | list[bytes | str] | None) | Awaitable[\n        bytes | str | list[bytes | str] | None\n    ]:\n        \"\"\"\n        Removes and returns the first elements of the list ``name``.\n\n        By default, the command pops a single element from the beginning of\n        the list. When provided with the optional ``count`` argument, the reply\n        will consist of up to count elements, depending on the list's length.\n\n        For more information, see https://redis.io/commands/lpop\n        \"\"\"\n        if count is not None:\n            return self.execute_command(\"LPOP\", name, count)\n        else:\n            return self.execute_command(\"LPOP\", name)\n\n    @overload\n    def lpush(self: SyncClientProtocol, name: KeyT, *values: FieldT) -> int: ...\n\n    @overload\n    def lpush(\n        self: AsyncClientProtocol, name: KeyT, *values: FieldT\n    ) -> Awaitable[int]: ...\n\n    def lpush(self, name: KeyT, *values: FieldT) -> int | Awaitable[int]:\n        \"\"\"\n        Push ``values`` onto the head of the list ``name``\n\n        For more information, see https://redis.io/commands/lpush\n        \"\"\"\n        return self.execute_command(\"LPUSH\", name, *values)\n\n    @overload\n    def lpushx(self: SyncClientProtocol, name: KeyT, *values: FieldT) -> int: ...\n\n    @overload\n    def lpushx(\n        self: AsyncClientProtocol, name: KeyT, *values: FieldT\n    ) -> Awaitable[int]: ...\n\n    def lpushx(self, name: KeyT, *values: FieldT) -> int | Awaitable[int]:\n        \"\"\"\n        Push ``value`` onto the head of the list ``name`` if ``name`` exists\n\n        For more information, see https://redis.io/commands/lpushx\n        \"\"\"\n        return self.execute_command(\"LPUSHX\", name, *values)\n\n    @overload\n    def lrange(\n        self: SyncClientProtocol, name: KeyT, start: int, end: int\n    ) -> list[bytes | str]: ...\n\n    @overload\n    def lrange(\n        self: AsyncClientProtocol, name: KeyT, start: int, end: int\n    ) -> Awaitable[list[bytes | str]]: ...\n\n    def lrange(\n        self, name: KeyT, start: int, end: int\n    ) -> list[bytes | str] | Awaitable[list[bytes | str]]:\n        \"\"\"\n        Return a slice of the list ``name`` between\n        position ``start`` and ``end``\n\n        ``start`` and ``end`` can be negative numbers just like\n        Python slicing notation\n\n        For more information, see https://redis.io/commands/lrange\n        \"\"\"\n        return self.execute_command(\"LRANGE\", name, start, end, keys=[name])\n\n    @overload\n    def lrem(self: SyncClientProtocol, name: KeyT, count: int, value: str) -> int: ...\n\n    @overload\n    def lrem(\n        self: AsyncClientProtocol, name: KeyT, count: int, value: str\n    ) -> Awaitable[int]: ...\n\n    def lrem(self, name: KeyT, count: int, value: str) -> int | Awaitable[int]:\n        \"\"\"\n        Remove the first ``count`` occurrences of elements equal to ``value``\n        from the list stored at ``name``.\n\n        The count argument influences the operation in the following ways:\n            count > 0: Remove elements equal to value moving from head to tail.\n            count < 0: Remove elements equal to value moving from tail to head.\n            count = 0: Remove all elements equal to value.\n\n            For more information, see https://redis.io/commands/lrem\n        \"\"\"\n        return self.execute_command(\"LREM\", name, count, value)\n\n    @overload\n    def lset(self: SyncClientProtocol, name: KeyT, index: int, value: str) -> bool: ...\n\n    @overload\n    def lset(\n        self: AsyncClientProtocol, name: KeyT, index: int, value: str\n    ) -> Awaitable[bool]: ...\n\n    def lset(self, name: KeyT, index: int, value: str) -> bool | Awaitable[bool]:\n        \"\"\"\n        Set element at ``index`` of list ``name`` to ``value``\n\n        For more information, see https://redis.io/commands/lset\n        \"\"\"\n        return self.execute_command(\"LSET\", name, index, value)\n\n    @overload\n    def ltrim(self: SyncClientProtocol, name: KeyT, start: int, end: int) -> bool: ...\n\n    @overload\n    def ltrim(\n        self: AsyncClientProtocol, name: KeyT, start: int, end: int\n    ) -> Awaitable[bool]: ...\n\n    def ltrim(self, name: KeyT, start: int, end: int) -> bool | Awaitable[bool]:\n        \"\"\"\n        Trim the list ``name``, removing all values not within the slice\n        between ``start`` and ``end``\n\n        ``start`` and ``end`` can be negative numbers just like\n        Python slicing notation\n\n        For more information, see https://redis.io/commands/ltrim\n        \"\"\"\n        return self.execute_command(\"LTRIM\", name, start, end)\n\n    @overload\n    def rpop(\n        self: SyncClientProtocol,\n        name: KeyT,\n        count: int | None = None,\n    ) -> bytes | str | list[bytes | str] | None: ...\n\n    @overload\n    def rpop(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        count: int | None = None,\n    ) -> Awaitable[bytes | str | list[bytes | str] | None]: ...\n\n    def rpop(\n        self,\n        name: KeyT,\n        count: int | None = None,\n    ) -> (bytes | str | list[bytes | str] | None) | Awaitable[\n        bytes | str | list[bytes | str] | None\n    ]:\n        \"\"\"\n        Removes and returns the last elements of the list ``name``.\n\n        By default, the command pops a single element from the end of the list.\n        When provided with the optional ``count`` argument, the reply will\n        consist of up to count elements, depending on the list's length.\n\n        For more information, see https://redis.io/commands/rpop\n        \"\"\"\n        if count is not None:\n            return self.execute_command(\"RPOP\", name, count)\n        else:\n            return self.execute_command(\"RPOP\", name)\n\n    @overload\n    def rpoplpush(\n        self: SyncClientProtocol, src: KeyT, dst: KeyT\n    ) -> bytes | str | None: ...\n\n    @overload\n    def rpoplpush(\n        self: AsyncClientProtocol, src: KeyT, dst: KeyT\n    ) -> Awaitable[bytes | str | None]: ...\n\n    def rpoplpush(self, src: KeyT, dst: KeyT) -> (bytes | str | None) | Awaitable[\n        bytes | str | None\n    ]:\n        \"\"\"\n        RPOP a value off of the ``src`` list and atomically LPUSH it\n        on to the ``dst`` list.  Returns the value.\n\n        For more information, see https://redis.io/commands/rpoplpush\n        \"\"\"\n        return self.execute_command(\"RPOPLPUSH\", src, dst)\n\n    @overload\n    def rpush(self: SyncClientProtocol, name: KeyT, *values: FieldT) -> int: ...\n\n    @overload\n    def rpush(\n        self: AsyncClientProtocol, name: KeyT, *values: FieldT\n    ) -> Awaitable[int]: ...\n\n    def rpush(self, name: KeyT, *values: FieldT) -> int | Awaitable[int]:\n        \"\"\"\n        Push ``values`` onto the tail of the list ``name``\n\n        For more information, see https://redis.io/commands/rpush\n        \"\"\"\n        return self.execute_command(\"RPUSH\", name, *values)\n\n    @overload\n    def rpushx(self: SyncClientProtocol, name: KeyT, *values: str) -> int: ...\n\n    @overload\n    def rpushx(\n        self: AsyncClientProtocol, name: KeyT, *values: str\n    ) -> Awaitable[int]: ...\n\n    def rpushx(self, name: KeyT, *values: str) -> int | Awaitable[int]:\n        \"\"\"\n        Push ``value`` onto the tail of the list ``name`` if ``name`` exists\n\n        For more information, see https://redis.io/commands/rpushx\n        \"\"\"\n        return self.execute_command(\"RPUSHX\", name, *values)\n\n    @overload\n    def lpos(\n        self: SyncClientProtocol,\n        name: KeyT,\n        value: str,\n        rank: int | None = None,\n        count: int | None = None,\n        maxlen: int | None = None,\n    ) -> int | list[int] | None: ...\n\n    @overload\n    def lpos(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        value: str,\n        rank: int | None = None,\n        count: int | None = None,\n        maxlen: int | None = None,\n    ) -> Awaitable[int | list[int] | None]: ...\n\n    def lpos(\n        self,\n        name: KeyT,\n        value: str,\n        rank: int | None = None,\n        count: int | None = None,\n        maxlen: int | None = None,\n    ) -> (int | list[int] | None) | Awaitable[int | list[int] | None]:\n        \"\"\"\n        Get position of ``value`` within the list ``name``\n\n         If specified, ``rank`` indicates the \"rank\" of the first element to\n         return in case there are multiple copies of ``value`` in the list.\n         By default, LPOS returns the position of the first occurrence of\n         ``value`` in the list. When ``rank`` 2, LPOS returns the position of\n         the second ``value`` in the list. If ``rank`` is negative, LPOS\n         searches the list in reverse. For example, -1 would return the\n         position of the last occurrence of ``value`` and -2 would return the\n         position of the next to last occurrence of ``value``.\n\n         If specified, ``count`` indicates that LPOS should return a list of\n         up to ``count`` positions. A ``count`` of 2 would return a list of\n         up to 2 positions. A ``count`` of 0 returns a list of all positions\n         matching ``value``. When ``count`` is specified and but ``value``\n         does not exist in the list, an empty list is returned.\n\n         If specified, ``maxlen`` indicates the maximum number of list\n         elements to scan. A ``maxlen`` of 1000 will only return the\n         position(s) of items within the first 1000 entries in the list.\n         A ``maxlen`` of 0 (the default) will scan the entire list.\n\n         For more information, see https://redis.io/commands/lpos\n        \"\"\"\n        pieces: list[EncodableT] = [name, value]\n        if rank is not None:\n            pieces.extend([\"RANK\", rank])\n\n        if count is not None:\n            pieces.extend([\"COUNT\", count])\n\n        if maxlen is not None:\n            pieces.extend([\"MAXLEN\", maxlen])\n\n        return self.execute_command(\"LPOS\", *pieces, keys=[name])\n\n    @overload\n    def sort(\n        self: SyncClientProtocol,\n        name: KeyT,\n        start: int | None = None,\n        num: int | None = None,\n        by: str | None = None,\n        get: list[str] | None = None,\n        desc: bool = False,\n        alpha: bool = False,\n        store: str | None = None,\n        groups: bool | None = False,\n    ) -> SortResponse: ...\n\n    @overload\n    def sort(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        start: int | None = None,\n        num: int | None = None,\n        by: str | None = None,\n        get: list[str] | None = None,\n        desc: bool = False,\n        alpha: bool = False,\n        store: str | None = None,\n        groups: bool | None = False,\n    ) -> Awaitable[SortResponse]: ...\n\n    def sort(\n        self,\n        name: KeyT,\n        start: int | None = None,\n        num: int | None = None,\n        by: str | None = None,\n        get: list[str] | None = None,\n        desc: bool = False,\n        alpha: bool = False,\n        store: str | None = None,\n        groups: bool | None = False,\n    ) -> SortResponse | Awaitable[SortResponse]:\n        \"\"\"\n        Sort and return the list, set or sorted set at ``name``.\n\n        ``start`` and ``num`` allow for paging through the sorted data\n\n        ``by`` allows using an external key to weight and sort the items.\n            Use an \"*\" to indicate where in the key the item value is located\n\n        ``get`` allows for returning items from external keys rather than the\n            sorted data itself.  Use an \"*\" to indicate where in the key\n            the item value is located\n\n        ``desc`` allows for reversing the sort\n\n        ``alpha`` allows for sorting lexicographically rather than numerically\n\n        ``store`` allows for storing the result of the sort into\n            the key ``store``\n\n        ``groups`` if set to True and if ``get`` contains at least two\n            elements, sort will return a list of tuples, each containing the\n            values fetched from the arguments to ``get``.\n\n        For more information, see https://redis.io/commands/sort\n        \"\"\"\n        if (start is not None and num is None) or (num is not None and start is None):\n            raise DataError(\"``start`` and ``num`` must both be specified\")\n\n        pieces: list[EncodableT] = [name]\n        if by is not None:\n            pieces.extend([b\"BY\", by])\n        if start is not None and num is not None:\n            pieces.extend([b\"LIMIT\", start, num])\n        if get is not None:\n            # If get is a string assume we want to get a single value.\n            # Otherwise assume it's an interable and we want to get multiple\n            # values. We can't just iterate blindly because strings are\n            # iterable.\n            if isinstance(get, (bytes, str)):\n                pieces.extend([b\"GET\", get])\n            else:\n                for g in get:\n                    pieces.extend([b\"GET\", g])\n        if desc:\n            pieces.append(b\"DESC\")\n        if alpha:\n            pieces.append(b\"ALPHA\")\n        if store is not None:\n            pieces.extend([b\"STORE\", store])\n        if groups:\n            if not get or isinstance(get, (bytes, str)) or len(get) < 2:\n                raise DataError(\n                    'when using \"groups\" the \"get\" argument '\n                    \"must be specified and contain at least \"\n                    \"two keys\"\n                )\n\n        options = {\"groups\": len(get) if groups else None}\n        options[\"keys\"] = [name]\n        return self.execute_command(\"SORT\", *pieces, **options)\n\n    @overload\n    def sort_ro(\n        self: SyncClientProtocol,\n        key: str,\n        start: int | None = None,\n        num: int | None = None,\n        by: str | None = None,\n        get: list[str] | None = None,\n        desc: bool = False,\n        alpha: bool = False,\n    ) -> list[bytes | str]: ...\n\n    @overload\n    def sort_ro(\n        self: AsyncClientProtocol,\n        key: str,\n        start: int | None = None,\n        num: int | None = None,\n        by: str | None = None,\n        get: list[str] | None = None,\n        desc: bool = False,\n        alpha: bool = False,\n    ) -> Awaitable[list[bytes | str]]: ...\n\n    def sort_ro(\n        self,\n        key: str,\n        start: int | None = None,\n        num: int | None = None,\n        by: str | None = None,\n        get: list[str] | None = None,\n        desc: bool = False,\n        alpha: bool = False,\n    ) -> list[bytes | str] | Awaitable[list[bytes | str]]:\n        \"\"\"\n        Returns the elements contained in the list, set or sorted set at key.\n        (read-only variant of the SORT command)\n\n        ``start`` and ``num`` allow for paging through the sorted data\n\n        ``by`` allows using an external key to weight and sort the items.\n            Use an \"*\" to indicate where in the key the item value is located\n\n        ``get`` allows for returning items from external keys rather than the\n            sorted data itself.  Use an \"*\" to indicate where in the key\n            the item value is located\n\n        ``desc`` allows for reversing the sort\n\n        ``alpha`` allows for sorting lexicographically rather than numerically\n\n        For more information, see https://redis.io/commands/sort_ro\n        \"\"\"\n        return self.sort(\n            key, start=start, num=num, by=by, get=get, desc=desc, alpha=alpha\n        )\n\n\nAsyncListCommands = ListCommands\n\n\nclass ScanCommands(CommandsProtocol):\n    \"\"\"\n    Redis SCAN commands.\n    see: https://redis.io/commands/scan\n    \"\"\"\n\n    @overload\n    def scan(\n        self: SyncClientProtocol,\n        cursor: int = 0,\n        match: PatternT | None = None,\n        count: int | None = None,\n        _type: str | None = None,\n        **kwargs,\n    ) -> ScanResponse: ...\n\n    @overload\n    def scan(\n        self: AsyncClientProtocol,\n        cursor: int = 0,\n        match: PatternT | None = None,\n        count: int | None = None,\n        _type: str | None = None,\n        **kwargs,\n    ) -> Awaitable[ScanResponse]: ...\n\n    def scan(\n        self,\n        cursor: int = 0,\n        match: PatternT | None = None,\n        count: int | None = None,\n        _type: str | None = None,\n        **kwargs,\n    ) -> ScanResponse | Awaitable[ScanResponse]:\n        \"\"\"\n        Incrementally return lists of key names. Also return a cursor\n        indicating the scan position.\n\n        ``match`` allows for filtering the keys by pattern\n\n        ``count`` provides a hint to Redis about the number of keys to\n            return per batch.\n\n        ``_type`` filters the returned values by a particular Redis type.\n            Stock Redis instances allow for the following types:\n            HASH, LIST, SET, STREAM, STRING, ZSET\n            Additionally, Redis modules can expose other types as well.\n\n        For more information, see https://redis.io/commands/scan\n        \"\"\"\n        pieces: list[EncodableT] = [cursor]\n        if match is not None:\n            pieces.extend([b\"MATCH\", match])\n        if count is not None:\n            pieces.extend([b\"COUNT\", count])\n        if _type is not None:\n            pieces.extend([b\"TYPE\", _type])\n        return self.execute_command(\"SCAN\", *pieces, **kwargs)\n\n    def scan_iter(\n        self,\n        match: Union[PatternT, None] = None,\n        count: Optional[int] = None,\n        _type: Optional[str] = None,\n        **kwargs,\n    ) -> Iterator:\n        \"\"\"\n        Make an iterator using the SCAN command so that the client doesn't\n        need to remember the cursor position.\n\n        ``match`` allows for filtering the keys by pattern\n\n        ``count`` provides a hint to Redis about the number of keys to\n            return per batch.\n\n        ``_type`` filters the returned values by a particular Redis type.\n            Stock Redis instances allow for the following types:\n            HASH, LIST, SET, STREAM, STRING, ZSET\n            Additionally, Redis modules can expose other types as well.\n        \"\"\"\n        cursor = \"0\"\n        while cursor != 0:\n            cursor, data = self.scan(\n                cursor=cursor, match=match, count=count, _type=_type, **kwargs\n            )\n            yield from data\n\n    @overload\n    def sscan(\n        self: SyncClientProtocol,\n        name: KeyT,\n        cursor: int = 0,\n        match: PatternT | None = None,\n        count: int | None = None,\n    ) -> ScanResponse: ...\n\n    @overload\n    def sscan(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        cursor: int = 0,\n        match: PatternT | None = None,\n        count: int | None = None,\n    ) -> Awaitable[ScanResponse]: ...\n\n    def sscan(\n        self,\n        name: KeyT,\n        cursor: int = 0,\n        match: PatternT | None = None,\n        count: int | None = None,\n    ) -> ScanResponse | Awaitable[ScanResponse]:\n        \"\"\"\n        Incrementally return lists of elements in a set. Also return a cursor\n        indicating the scan position.\n\n        ``match`` allows for filtering the keys by pattern\n\n        ``count`` allows for hint the minimum number of returns\n\n        For more information, see https://redis.io/commands/sscan\n        \"\"\"\n        pieces: list[EncodableT] = [name, cursor]\n        if match is not None:\n            pieces.extend([b\"MATCH\", match])\n        if count is not None:\n            pieces.extend([b\"COUNT\", count])\n        return self.execute_command(\"SSCAN\", *pieces)\n\n    def sscan_iter(\n        self,\n        name: KeyT,\n        match: Union[PatternT, None] = None,\n        count: Optional[int] = None,\n    ) -> Iterator:\n        \"\"\"\n        Make an iterator using the SSCAN command so that the client doesn't\n        need to remember the cursor position.\n\n        ``match`` allows for filtering the keys by pattern\n\n        ``count`` allows for hint the minimum number of returns\n        \"\"\"\n        cursor = \"0\"\n        while cursor != 0:\n            cursor, data = self.sscan(name, cursor=cursor, match=match, count=count)\n            yield from data\n\n    @overload\n    def hscan(\n        self: SyncClientProtocol,\n        name: KeyT,\n        cursor: int = 0,\n        match: PatternT | None = None,\n        count: int | None = None,\n        no_values: bool | None = None,\n    ) -> HScanResponse: ...\n\n    @overload\n    def hscan(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        cursor: int = 0,\n        match: PatternT | None = None,\n        count: int | None = None,\n        no_values: bool | None = None,\n    ) -> Awaitable[HScanResponse]: ...\n\n    def hscan(\n        self,\n        name: KeyT,\n        cursor: int = 0,\n        match: PatternT | None = None,\n        count: int | None = None,\n        no_values: bool | None = None,\n    ) -> HScanResponse | Awaitable[HScanResponse]:\n        \"\"\"\n        Incrementally return key/value slices in a hash. Also return a cursor\n        indicating the scan position.\n\n        ``match`` allows for filtering the keys by pattern\n\n        ``count`` allows for hint the minimum number of returns\n\n        ``no_values`` indicates to return only the keys, without values.\n\n        For more information, see https://redis.io/commands/hscan\n        \"\"\"\n        pieces: list[EncodableT] = [name, cursor]\n        if match is not None:\n            pieces.extend([b\"MATCH\", match])\n        if count is not None:\n            pieces.extend([b\"COUNT\", count])\n        if no_values is not None:\n            pieces.extend([b\"NOVALUES\"])\n        return self.execute_command(\"HSCAN\", *pieces, no_values=no_values)\n\n    def hscan_iter(\n        self,\n        name: str,\n        match: Union[PatternT, None] = None,\n        count: Optional[int] = None,\n        no_values: Union[bool, None] = None,\n    ) -> Iterator:\n        \"\"\"\n        Make an iterator using the HSCAN command so that the client doesn't\n        need to remember the cursor position.\n\n        ``match`` allows for filtering the keys by pattern\n\n        ``count`` allows for hint the minimum number of returns\n\n        ``no_values`` indicates to return only the keys, without values\n        \"\"\"\n        cursor = \"0\"\n        while cursor != 0:\n            cursor, data = self.hscan(\n                name, cursor=cursor, match=match, count=count, no_values=no_values\n            )\n            if no_values:\n                yield from data\n            else:\n                yield from data.items()\n\n    @overload\n    def zscan(\n        self: SyncClientProtocol,\n        name: KeyT,\n        cursor: int = 0,\n        match: PatternT | None = None,\n        count: int | None = None,\n        score_cast_func: type | Callable = float,\n    ) -> ZScanResponse: ...\n\n    @overload\n    def zscan(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        cursor: int = 0,\n        match: PatternT | None = None,\n        count: int | None = None,\n        score_cast_func: type | Callable = float,\n    ) -> Awaitable[ZScanResponse]: ...\n\n    def zscan(\n        self,\n        name: KeyT,\n        cursor: int = 0,\n        match: PatternT | None = None,\n        count: int | None = None,\n        score_cast_func: type | Callable = float,\n    ) -> ZScanResponse | Awaitable[ZScanResponse]:\n        \"\"\"\n        Incrementally return lists of elements in a sorted set. Also return a\n        cursor indicating the scan position.\n\n        ``match`` allows for filtering the keys by pattern\n\n        ``count`` allows for hint the minimum number of returns\n\n        ``score_cast_func`` a callable used to cast the score return value\n\n        For more information, see https://redis.io/commands/zscan\n        \"\"\"\n        pieces = [name, cursor]\n        if match is not None:\n            pieces.extend([b\"MATCH\", match])\n        if count is not None:\n            pieces.extend([b\"COUNT\", count])\n        options = {\"score_cast_func\": score_cast_func}\n        return self.execute_command(\"ZSCAN\", *pieces, **options)\n\n    def zscan_iter(\n        self,\n        name: KeyT,\n        match: Union[PatternT, None] = None,\n        count: Optional[int] = None,\n        score_cast_func: Union[type, Callable] = float,\n    ) -> Iterator:\n        \"\"\"\n        Make an iterator using the ZSCAN command so that the client doesn't\n        need to remember the cursor position.\n\n        ``match`` allows for filtering the keys by pattern\n\n        ``count`` allows for hint the minimum number of returns\n\n        ``score_cast_func`` a callable used to cast the score return value\n        \"\"\"\n        cursor = \"0\"\n        while cursor != 0:\n            cursor, data = self.zscan(\n                name,\n                cursor=cursor,\n                match=match,\n                count=count,\n                score_cast_func=score_cast_func,\n            )\n            yield from data\n\n\nclass AsyncScanCommands(ScanCommands):\n    async def scan_iter(\n        self,\n        match: Union[PatternT, None] = None,\n        count: Optional[int] = None,\n        _type: Optional[str] = None,\n        **kwargs,\n    ) -> AsyncIterator:\n        \"\"\"\n        Make an iterator using the SCAN command so that the client doesn't\n        need to remember the cursor position.\n\n        ``match`` allows for filtering the keys by pattern\n\n        ``count`` provides a hint to Redis about the number of keys to\n            return per batch.\n\n        ``_type`` filters the returned values by a particular Redis type.\n            Stock Redis instances allow for the following types:\n            HASH, LIST, SET, STREAM, STRING, ZSET\n            Additionally, Redis modules can expose other types as well.\n        \"\"\"\n        cursor = \"0\"\n        while cursor != 0:\n            cursor, data = await self.scan(\n                cursor=cursor, match=match, count=count, _type=_type, **kwargs\n            )\n            for d in data:\n                yield d\n\n    async def sscan_iter(\n        self,\n        name: KeyT,\n        match: Union[PatternT, None] = None,\n        count: Optional[int] = None,\n    ) -> AsyncIterator:\n        \"\"\"\n        Make an iterator using the SSCAN command so that the client doesn't\n        need to remember the cursor position.\n\n        ``match`` allows for filtering the keys by pattern\n\n        ``count`` allows for hint the minimum number of returns\n        \"\"\"\n        cursor = \"0\"\n        while cursor != 0:\n            cursor, data = await self.sscan(\n                name, cursor=cursor, match=match, count=count\n            )\n            for d in data:\n                yield d\n\n    async def hscan_iter(\n        self,\n        name: str,\n        match: Union[PatternT, None] = None,\n        count: Optional[int] = None,\n        no_values: Union[bool, None] = None,\n    ) -> AsyncIterator:\n        \"\"\"\n        Make an iterator using the HSCAN command so that the client doesn't\n        need to remember the cursor position.\n\n        ``match`` allows for filtering the keys by pattern\n\n        ``count`` allows for hint the minimum number of returns\n\n        ``no_values`` indicates to return only the keys, without values\n        \"\"\"\n        cursor = \"0\"\n        while cursor != 0:\n            cursor, data = await self.hscan(\n                name, cursor=cursor, match=match, count=count, no_values=no_values\n            )\n            if no_values:\n                for it in data:\n                    yield it\n            else:\n                for it in data.items():\n                    yield it\n\n    async def zscan_iter(\n        self,\n        name: KeyT,\n        match: Union[PatternT, None] = None,\n        count: Optional[int] = None,\n        score_cast_func: Union[type, Callable] = float,\n    ) -> AsyncIterator:\n        \"\"\"\n        Make an iterator using the ZSCAN command so that the client doesn't\n        need to remember the cursor position.\n\n        ``match`` allows for filtering the keys by pattern\n\n        ``count`` allows for hint the minimum number of returns\n\n        ``score_cast_func`` a callable used to cast the score return value\n        \"\"\"\n        cursor = \"0\"\n        while cursor != 0:\n            cursor, data = await self.zscan(\n                name,\n                cursor=cursor,\n                match=match,\n                count=count,\n                score_cast_func=score_cast_func,\n            )\n            for d in data:\n                yield d\n\n\nclass SetCommands(CommandsProtocol):\n    \"\"\"\n    Redis commands for Set data type.\n    see: https://redis.io/topics/data-types#sets\n    \"\"\"\n\n    @overload\n    def sadd(self: SyncClientProtocol, name: KeyT, *values: FieldT) -> int: ...\n\n    @overload\n    def sadd(\n        self: AsyncClientProtocol, name: KeyT, *values: FieldT\n    ) -> Awaitable[int]: ...\n\n    def sadd(self, name: KeyT, *values: FieldT) -> int | Awaitable[int]:\n        \"\"\"\n        Add ``value(s)`` to set ``name``\n\n        For more information, see https://redis.io/commands/sadd\n        \"\"\"\n        return self.execute_command(\"SADD\", name, *values)\n\n    @overload\n    def scard(self: SyncClientProtocol, name: KeyT) -> int: ...\n\n    @overload\n    def scard(self: AsyncClientProtocol, name: KeyT) -> Awaitable[int]: ...\n\n    def scard(self, name: KeyT) -> int | Awaitable[int]:\n        \"\"\"\n        Return the number of elements in set ``name``\n\n        For more information, see https://redis.io/commands/scard\n        \"\"\"\n        return self.execute_command(\"SCARD\", name, keys=[name])\n\n    @overload\n    def sdiff(\n        self: SyncClientProtocol, keys: List, *args: List\n    ) -> set[bytes | str]: ...\n\n    @overload\n    def sdiff(\n        self: AsyncClientProtocol, keys: List, *args: List\n    ) -> Awaitable[set[bytes | str]]: ...\n\n    def sdiff(\n        self, keys: List, *args: List\n    ) -> set[bytes | str] | Awaitable[set[bytes | str]]:\n        \"\"\"\n        Return the difference of sets specified by ``keys``\n\n        For more information, see https://redis.io/commands/sdiff\n        \"\"\"\n        args = list_or_args(keys, args)\n        return self.execute_command(\"SDIFF\", *args, keys=args)\n\n    @overload\n    def sdiffstore(\n        self: SyncClientProtocol, dest: str, keys: List, *args: List\n    ) -> int: ...\n\n    @overload\n    def sdiffstore(\n        self: AsyncClientProtocol, dest: str, keys: List, *args: List\n    ) -> Awaitable[int]: ...\n\n    def sdiffstore(self, dest: str, keys: List, *args: List) -> int | Awaitable[int]:\n        \"\"\"\n        Store the difference of sets specified by ``keys`` into a new\n        set named ``dest``.  Returns the number of keys in the new set.\n\n        For more information, see https://redis.io/commands/sdiffstore\n        \"\"\"\n        args = list_or_args(keys, args)\n        return self.execute_command(\"SDIFFSTORE\", dest, *args)\n\n    @overload\n    def sinter(\n        self: SyncClientProtocol, keys: List, *args: List\n    ) -> set[bytes | str]: ...\n\n    @overload\n    def sinter(\n        self: AsyncClientProtocol, keys: List, *args: List\n    ) -> Awaitable[set[bytes | str]]: ...\n\n    def sinter(\n        self, keys: List, *args: List\n    ) -> set[bytes | str] | Awaitable[set[bytes | str]]:\n        \"\"\"\n        Return the intersection of sets specified by ``keys``\n\n        For more information, see https://redis.io/commands/sinter\n        \"\"\"\n        args = list_or_args(keys, args)\n        return self.execute_command(\"SINTER\", *args, keys=args)\n\n    @overload\n    def sintercard(\n        self: SyncClientProtocol, numkeys: int, keys: List[KeyT], limit: int = 0\n    ) -> int: ...\n\n    @overload\n    def sintercard(\n        self: AsyncClientProtocol, numkeys: int, keys: List[KeyT], limit: int = 0\n    ) -> Awaitable[int]: ...\n\n    def sintercard(\n        self, numkeys: int, keys: List[KeyT], limit: int = 0\n    ) -> int | Awaitable[int]:\n        \"\"\"\n        Return the cardinality of the intersect of multiple sets specified by ``keys``.\n\n        When LIMIT provided (defaults to 0 and means unlimited), if the intersection\n        cardinality reaches limit partway through the computation, the algorithm will\n        exit and yield limit as the cardinality\n\n        For more information, see https://redis.io/commands/sintercard\n        \"\"\"\n        args = [numkeys, *keys, \"LIMIT\", limit]\n        return self.execute_command(\"SINTERCARD\", *args, keys=keys)\n\n    @overload\n    def sinterstore(\n        self: SyncClientProtocol, dest: KeyT, keys: List, *args: List\n    ) -> int: ...\n\n    @overload\n    def sinterstore(\n        self: AsyncClientProtocol, dest: KeyT, keys: List, *args: List\n    ) -> Awaitable[int]: ...\n\n    def sinterstore(self, dest: KeyT, keys: List, *args: List) -> int | Awaitable[int]:\n        \"\"\"\n        Store the intersection of sets specified by ``keys`` into a new\n        set named ``dest``.  Returns the number of keys in the new set.\n\n        For more information, see https://redis.io/commands/sinterstore\n        \"\"\"\n        args = list_or_args(keys, args)\n        return self.execute_command(\"SINTERSTORE\", dest, *args)\n\n    @overload\n    def sismember(\n        self: SyncClientProtocol, name: KeyT, value: str\n    ) -> Literal[0] | Literal[1]: ...\n\n    @overload\n    def sismember(\n        self: AsyncClientProtocol, name: KeyT, value: str\n    ) -> Awaitable[Literal[0] | Literal[1]]: ...\n\n    def sismember(self, name: KeyT, value: str) -> (\n        Literal[0] | Literal[1]\n    ) | Awaitable[Literal[0] | Literal[1]]:\n        \"\"\"\n        Return whether ``value`` is a member of set ``name``:\n        - 1 if the value is a member of the set.\n        - 0 if the value is not a member of the set or if key does not exist.\n\n        For more information, see https://redis.io/commands/sismember\n        \"\"\"\n        return self.execute_command(\"SISMEMBER\", name, value, keys=[name])\n\n    @overload\n    def smembers(self: SyncClientProtocol, name: KeyT) -> set[bytes | str]: ...\n\n    @overload\n    def smembers(\n        self: AsyncClientProtocol, name: KeyT\n    ) -> Awaitable[set[bytes | str]]: ...\n\n    def smembers(self, name: KeyT) -> set[bytes | str] | Awaitable[set[bytes | str]]:\n        \"\"\"\n        Return all members of the set ``name``\n\n        For more information, see https://redis.io/commands/smembers\n        \"\"\"\n        return self.execute_command(\"SMEMBERS\", name, keys=[name])\n\n    @overload\n    def smismember(\n        self: SyncClientProtocol, name: KeyT, values: List, *args: List\n    ) -> list[Literal[0] | Literal[1]]: ...\n\n    @overload\n    def smismember(\n        self: AsyncClientProtocol, name: KeyT, values: List, *args: List\n    ) -> Awaitable[list[Literal[0] | Literal[1]]]: ...\n\n    def smismember(\n        self, name: KeyT, values: List, *args: List\n    ) -> list[Literal[0] | Literal[1]] | Awaitable[list[Literal[0] | Literal[1]]]:\n        \"\"\"\n        Return whether each value in ``values`` is a member of the set ``name``\n        as a list of ``int`` in the order of ``values``:\n        - 1 if the value is a member of the set.\n        - 0 if the value is not a member of the set or if key does not exist.\n\n        For more information, see https://redis.io/commands/smismember\n        \"\"\"\n        args = list_or_args(values, args)\n        return self.execute_command(\"SMISMEMBER\", name, *args, keys=[name])\n\n    @overload\n    def smove(self: SyncClientProtocol, src: KeyT, dst: KeyT, value: str) -> bool: ...\n\n    @overload\n    def smove(\n        self: AsyncClientProtocol, src: KeyT, dst: KeyT, value: str\n    ) -> Awaitable[bool]: ...\n\n    def smove(self, src: KeyT, dst: KeyT, value: str) -> bool | Awaitable[bool]:\n        \"\"\"\n        Move ``value`` from set ``src`` to set ``dst`` atomically\n\n        For more information, see https://redis.io/commands/smove\n        \"\"\"\n        return self.execute_command(\"SMOVE\", src, dst, value)\n\n    @overload\n    def spop(\n        self: SyncClientProtocol, name: KeyT, count: int | None = None\n    ) -> bytes | str | set[bytes | str] | None: ...\n\n    @overload\n    def spop(\n        self: AsyncClientProtocol, name: KeyT, count: int | None = None\n    ) -> Awaitable[bytes | str | set[bytes | str] | None]: ...\n\n    def spop(self, name: KeyT, count: int | None = None) -> (\n        bytes | str | set[bytes | str] | None\n    ) | Awaitable[bytes | str | set[bytes | str] | None]:\n        \"\"\"\n        Remove and return a random member of set ``name``\n\n        For more information, see https://redis.io/commands/spop\n        \"\"\"\n        args = (count is not None) and [count] or []\n        return self.execute_command(\"SPOP\", name, *args)\n\n    @overload\n    def srandmember(\n        self: SyncClientProtocol, name: KeyT, number: int | None = None\n    ) -> bytes | str | list[bytes | str] | None: ...\n\n    @overload\n    def srandmember(\n        self: AsyncClientProtocol, name: KeyT, number: int | None = None\n    ) -> Awaitable[bytes | str | list[bytes | str] | None]: ...\n\n    def srandmember(self, name: KeyT, number: int | None = None) -> (\n        bytes | str | list[bytes | str] | None\n    ) | Awaitable[bytes | str | list[bytes | str] | None]:\n        \"\"\"\n        If ``number`` is None, returns a random member of set ``name``.\n\n        If ``number`` is supplied, returns a list of ``number`` random\n        members of set ``name``. Note this is only available when running\n        Redis 2.6+.\n\n        For more information, see https://redis.io/commands/srandmember\n        \"\"\"\n        args = (number is not None) and [number] or []\n        return self.execute_command(\"SRANDMEMBER\", name, *args)\n\n    @overload\n    def srem(self: SyncClientProtocol, name: KeyT, *values: FieldT) -> int: ...\n\n    @overload\n    def srem(\n        self: AsyncClientProtocol, name: KeyT, *values: FieldT\n    ) -> Awaitable[int]: ...\n\n    def srem(self, name: KeyT, *values: FieldT) -> int | Awaitable[int]:\n        \"\"\"\n        Remove ``values`` from set ``name``\n\n        For more information, see https://redis.io/commands/srem\n        \"\"\"\n        return self.execute_command(\"SREM\", name, *values)\n\n    @overload\n    def sunion(\n        self: SyncClientProtocol, keys: List, *args: List\n    ) -> set[bytes | str]: ...\n\n    @overload\n    def sunion(\n        self: AsyncClientProtocol, keys: List, *args: List\n    ) -> Awaitable[set[bytes | str]]: ...\n\n    def sunion(\n        self, keys: List, *args: List\n    ) -> set[bytes | str] | Awaitable[set[bytes | str]]:\n        \"\"\"\n        Return the union of sets specified by ``keys``\n\n        For more information, see https://redis.io/commands/sunion\n        \"\"\"\n        args = list_or_args(keys, args)\n        return self.execute_command(\"SUNION\", *args, keys=args)\n\n    @overload\n    def sunionstore(\n        self: SyncClientProtocol, dest: KeyT, keys: List, *args: List\n    ) -> int: ...\n\n    @overload\n    def sunionstore(\n        self: AsyncClientProtocol, dest: KeyT, keys: List, *args: List\n    ) -> Awaitable[int]: ...\n\n    def sunionstore(self, dest: KeyT, keys: List, *args: List) -> int | Awaitable[int]:\n        \"\"\"\n        Store the union of sets specified by ``keys`` into a new\n        set named ``dest``.  Returns the number of keys in the new set.\n\n        For more information, see https://redis.io/commands/sunionstore\n        \"\"\"\n        args = list_or_args(keys, args)\n        return self.execute_command(\"SUNIONSTORE\", dest, *args)\n\n\nAsyncSetCommands = SetCommands\n\n\nclass StreamCommands(CommandsProtocol):\n    \"\"\"\n    Redis commands for Stream data type.\n    see: https://redis.io/topics/streams-intro\n    \"\"\"\n\n    @overload\n    def xack(\n        self: SyncClientProtocol, name: KeyT, groupname: GroupT, *ids: StreamIdT\n    ) -> int: ...\n\n    @overload\n    def xack(\n        self: AsyncClientProtocol, name: KeyT, groupname: GroupT, *ids: StreamIdT\n    ) -> Awaitable[int]: ...\n\n    def xack(\n        self, name: KeyT, groupname: GroupT, *ids: StreamIdT\n    ) -> int | Awaitable[int]:\n        \"\"\"\n        Acknowledges the successful processing of one or more messages.\n\n        Args:\n            name: name of the stream.\n            groupname: name of the consumer group.\n            *ids: message ids to acknowledge.\n\n        For more information, see https://redis.io/commands/xack\n        \"\"\"\n        return self.execute_command(\"XACK\", name, groupname, *ids)\n\n    @overload\n    def xackdel(\n        self: SyncClientProtocol,\n        name: KeyT,\n        groupname: GroupT,\n        *ids: StreamIdT,\n        ref_policy: Literal[\"KEEPREF\", \"DELREF\", \"ACKED\"] = \"KEEPREF\",\n    ) -> int: ...\n\n    @overload\n    def xackdel(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        groupname: GroupT,\n        *ids: StreamIdT,\n        ref_policy: Literal[\"KEEPREF\", \"DELREF\", \"ACKED\"] = \"KEEPREF\",\n    ) -> Awaitable[int]: ...\n\n    def xackdel(\n        self,\n        name: KeyT,\n        groupname: GroupT,\n        *ids: StreamIdT,\n        ref_policy: Literal[\"KEEPREF\", \"DELREF\", \"ACKED\"] = \"KEEPREF\",\n    ) -> int | Awaitable[int]:\n        \"\"\"\n        Combines the functionality of XACK and XDEL. Acknowledges the specified\n        message IDs in the given consumer group and simultaneously attempts to\n        delete the corresponding entries from the stream.\n        \"\"\"\n        if not ids:\n            raise DataError(\"XACKDEL requires at least one message ID\")\n\n        if ref_policy not in {\"KEEPREF\", \"DELREF\", \"ACKED\"}:\n            raise DataError(\"XACKDEL ref_policy must be one of: KEEPREF, DELREF, ACKED\")\n\n        pieces = [name, groupname, ref_policy, \"IDS\", len(ids)]\n        pieces.extend(ids)\n        return self.execute_command(\"XACKDEL\", *pieces)\n\n    @overload\n    def xadd(\n        self: SyncClientProtocol,\n        name: KeyT,\n        fields: Dict[FieldT, EncodableT],\n        id: StreamIdT = \"*\",\n        maxlen: int | None = None,\n        approximate: bool = True,\n        nomkstream: bool = False,\n        minid: StreamIdT | None = None,\n        limit: int | None = None,\n        ref_policy: Literal[\"KEEPREF\", \"DELREF\", \"ACKED\"] | None = None,\n        idmpauto: str | None = None,\n        idmp: tuple[str, bytes] | None = None,\n    ) -> bytes | str: ...\n\n    @overload\n    def xadd(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        fields: Dict[FieldT, EncodableT],\n        id: StreamIdT = \"*\",\n        maxlen: int | None = None,\n        approximate: bool = True,\n        nomkstream: bool = False,\n        minid: StreamIdT | None = None,\n        limit: int | None = None,\n        ref_policy: Literal[\"KEEPREF\", \"DELREF\", \"ACKED\"] | None = None,\n        idmpauto: str | None = None,\n        idmp: tuple[str, bytes] | None = None,\n    ) -> Awaitable[bytes | str]: ...\n\n    def xadd(\n        self,\n        name: KeyT,\n        fields: Dict[FieldT, EncodableT],\n        id: StreamIdT = \"*\",\n        maxlen: Optional[int] = None,\n        approximate: bool = True,\n        nomkstream: bool = False,\n        minid: Union[StreamIdT, None] = None,\n        limit: int | None = None,\n        ref_policy: Literal[\"KEEPREF\", \"DELREF\", \"ACKED\"] | None = None,\n        idmpauto: str | None = None,\n        idmp: tuple[str, bytes] | None = None,\n    ) -> (bytes | str) | Awaitable[bytes | str]:\n        \"\"\"\n        Add to a stream.\n        name: name of the stream\n        fields: dict of field/value pairs to insert into the stream\n        id: Location to insert this record. By default it is appended.\n        maxlen: truncate old stream members beyond this size.\n        Can't be specified with minid.\n        approximate: actual stream length may be slightly more than maxlen\n        nomkstream: When set to true, do not make a stream\n        minid: the minimum id in the stream to query.\n        Can't be specified with maxlen.\n        limit: specifies the maximum number of entries to retrieve\n        ref_policy: optional reference policy for consumer groups when trimming:\n            - KEEPREF (default): When trimming, preserves references in consumer groups' PEL\n            - DELREF: When trimming, removes all references from consumer groups' PEL\n            - ACKED: When trimming, only removes entries acknowledged by all consumer groups\n        idmpauto: Producer ID for automatic idempotent ID calculation.\n            Automatically calculates an idempotent ID based on entry content to prevent\n            duplicate entries. Can only be used with id='*'. Creates an IDMP map if it\n            doesn't exist yet. The producer ID must be unique per producer and consistent\n            across restarts.\n        idmp: Tuple of (producer_id, idempotent_id) for explicit idempotent ID.\n            Uses a specific idempotent ID to prevent duplicate entries. Can only be used\n            with id='*'. The producer ID must be unique per producer and consistent across\n            restarts. The idempotent ID must be unique per message and per producer.\n            Shorter idempotent IDs require less memory and allow faster processing.\n            Creates an IDMP map if it doesn't exist yet.\n\n        For more information, see https://redis.io/commands/xadd\n        \"\"\"\n        pieces: list[EncodableT] = []\n        if maxlen is not None and minid is not None:\n            raise DataError(\"Only one of ```maxlen``` or ```minid``` may be specified\")\n\n        if idmpauto is not None and idmp is not None:\n            raise DataError(\"Only one of ```idmpauto``` or ```idmp``` may be specified\")\n\n        if (idmpauto is not None or idmp is not None) and id != \"*\":\n            raise DataError(\"IDMPAUTO and IDMP can only be used with id='*'\")\n\n        if ref_policy is not None and ref_policy not in {\"KEEPREF\", \"DELREF\", \"ACKED\"}:\n            raise DataError(\"XADD ref_policy must be one of: KEEPREF, DELREF, ACKED\")\n\n        if nomkstream:\n            pieces.append(b\"NOMKSTREAM\")\n        if ref_policy is not None:\n            pieces.append(ref_policy)\n        if idmpauto is not None:\n            pieces.extend([b\"IDMPAUTO\", idmpauto])\n        if idmp is not None:\n            if not isinstance(idmp, tuple) or len(idmp) != 2:\n                raise DataError(\n                    \"XADD idmp must be a tuple of (producer_id, idempotent_id)\"\n                )\n            pieces.extend([b\"IDMP\", idmp[0], idmp[1]])\n        if maxlen is not None:\n            if not isinstance(maxlen, int) or maxlen < 0:\n                raise DataError(\"XADD maxlen must be non-negative integer\")\n            pieces.append(b\"MAXLEN\")\n            if approximate:\n                pieces.append(b\"~\")\n            pieces.append(str(maxlen))\n        if minid is not None:\n            pieces.append(b\"MINID\")\n            if approximate:\n                pieces.append(b\"~\")\n            pieces.append(minid)\n        if limit is not None:\n            pieces.extend([b\"LIMIT\", limit])\n        pieces.append(id)\n        if not isinstance(fields, dict) or len(fields) == 0:\n            raise DataError(\"XADD fields must be a non-empty dict\")\n        for pair in fields.items():\n            pieces.extend(pair)\n        return self.execute_command(\"XADD\", name, *pieces)\n\n    @overload\n    def xcfgset(\n        self: SyncClientProtocol,\n        name: KeyT,\n        idmp_duration: int | None = None,\n        idmp_maxsize: int | None = None,\n    ) -> bytes | str: ...\n\n    @overload\n    def xcfgset(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        idmp_duration: int | None = None,\n        idmp_maxsize: int | None = None,\n    ) -> Awaitable[bytes | str]: ...\n\n    def xcfgset(\n        self,\n        name: KeyT,\n        idmp_duration: int | None = None,\n        idmp_maxsize: int | None = None,\n    ) -> (bytes | str) | Awaitable[bytes | str]:\n        \"\"\"\n        Configure the idempotency parameters for a stream's IDMP map.\n\n        Sets how long Redis remembers each idempotent ID (iid) and the maximum\n        number of iids to track. This command clears the existing IDMP map\n        (Redis forgets all previously stored iids), but only if the configuration\n        value actually changes.\n\n        Args:\n            name: The name of the stream.\n            idmp_duration: How long Redis remembers each iid in seconds.\n                Default: 100 seconds (or value set by stream-idmp-duration config).\n                Minimum: 1 second, Maximum: 300 seconds.\n                Redis won't forget an iid for this duration (unless maxsize is reached).\n                Should accommodate application crash recovery time.\n            idmp_maxsize: Maximum number of iids Redis remembers per producer ID (pid).\n                Default: 100 iids (or value set by stream-idmp-maxsize config).\n                Minimum: 1 iid, Maximum: 1,000,000 (1M) iids.\n                Should be set to: mark-delay [in msec] × (messages/msec) + margin.\n                Example: 10K msgs/sec (10 msgs/msec), 80 msec mark-delay\n                → maxsize = 10 × 80 + margin = 1000 iids.\n\n        Returns:\n            OK on success.\n\n        For more information, see https://redis.io/commands/xcfgset\n        \"\"\"\n        if idmp_duration is None and idmp_maxsize is None:\n            raise DataError(\n                \"XCFGSET requires at least one of idmp_duration or idmp_maxsize\"\n            )\n\n        pieces: list[EncodableT] = []\n\n        if idmp_duration is not None:\n            if (\n                not isinstance(idmp_duration, int)\n                or idmp_duration < 1\n                or idmp_duration > 300\n            ):\n                raise DataError(\n                    \"XCFGSET idmp_duration must be an integer between 1 and 300\"\n                )\n            pieces.extend([b\"IDMP-DURATION\", idmp_duration])\n\n        if idmp_maxsize is not None:\n            if (\n                not isinstance(idmp_maxsize, int)\n                or idmp_maxsize < 1\n                or idmp_maxsize > 1000000\n            ):\n                raise DataError(\n                    \"XCFGSET idmp_maxsize must be an integer between 1 and 1,000,000\"\n                )\n            pieces.extend([b\"IDMP-MAXSIZE\", idmp_maxsize])\n\n        return self.execute_command(\"XCFGSET\", name, *pieces)\n\n    @overload\n    def xautoclaim(\n        self: SyncClientProtocol,\n        name: KeyT,\n        groupname: GroupT,\n        consumername: ConsumerT,\n        min_idle_time: int,\n        start_id: StreamIdT = \"0-0\",\n        count: int | None = None,\n        justid: bool = False,\n    ) -> list[Any]: ...\n\n    @overload\n    def xautoclaim(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        groupname: GroupT,\n        consumername: ConsumerT,\n        min_idle_time: int,\n        start_id: StreamIdT = \"0-0\",\n        count: int | None = None,\n        justid: bool = False,\n    ) -> Awaitable[list[Any]]: ...\n\n    def xautoclaim(\n        self,\n        name: KeyT,\n        groupname: GroupT,\n        consumername: ConsumerT,\n        min_idle_time: int,\n        start_id: StreamIdT = \"0-0\",\n        count: int | None = None,\n        justid: bool = False,\n    ) -> list[Any] | Awaitable[list[Any]]:\n        \"\"\"\n        Transfers ownership of pending stream entries that match the specified\n        criteria. Conceptually, equivalent to calling XPENDING and then XCLAIM,\n        but provides a more straightforward way to deal with message delivery\n        failures via SCAN-like semantics.\n        name: name of the stream.\n        groupname: name of the consumer group.\n        consumername: name of a consumer that claims the message.\n        min_idle_time: filter messages that were idle less than this amount of\n        milliseconds.\n        start_id: filter messages with equal or greater ID.\n        count: optional integer, upper limit of the number of entries that the\n        command attempts to claim. Set to 100 by default.\n        justid: optional boolean, false by default. Return just an array of IDs\n        of messages successfully claimed, without returning the actual message\n\n        For more information, see https://redis.io/commands/xautoclaim\n        \"\"\"\n        try:\n            if int(min_idle_time) < 0:\n                raise DataError(\n                    \"XAUTOCLAIM min_idle_time must be a nonnegative integer\"\n                )\n        except TypeError:\n            pass\n\n        kwargs = {}\n        pieces = [name, groupname, consumername, min_idle_time, start_id]\n\n        try:\n            if int(count) < 0:\n                raise DataError(\"XPENDING count must be a integer >= 0\")\n            pieces.extend([b\"COUNT\", count])\n        except TypeError:\n            pass\n        if justid:\n            pieces.append(b\"JUSTID\")\n            kwargs[\"parse_justid\"] = True\n\n        return self.execute_command(\"XAUTOCLAIM\", *pieces, **kwargs)\n\n    @overload\n    def xclaim(\n        self: SyncClientProtocol,\n        name: KeyT,\n        groupname: GroupT,\n        consumername: ConsumerT,\n        min_idle_time: int,\n        message_ids: Union[List[StreamIdT], Tuple[StreamIdT]],\n        idle: int | None = None,\n        time: int | None = None,\n        retrycount: int | None = None,\n        force: bool = False,\n        justid: bool = False,\n    ) -> XClaimResponse: ...\n\n    @overload\n    def xclaim(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        groupname: GroupT,\n        consumername: ConsumerT,\n        min_idle_time: int,\n        message_ids: Union[List[StreamIdT], Tuple[StreamIdT]],\n        idle: int | None = None,\n        time: int | None = None,\n        retrycount: int | None = None,\n        force: bool = False,\n        justid: bool = False,\n    ) -> Awaitable[XClaimResponse]: ...\n\n    def xclaim(\n        self,\n        name: KeyT,\n        groupname: GroupT,\n        consumername: ConsumerT,\n        min_idle_time: int,\n        message_ids: Union[List[StreamIdT], Tuple[StreamIdT]],\n        idle: int | None = None,\n        time: int | None = None,\n        retrycount: int | None = None,\n        force: bool = False,\n        justid: bool = False,\n    ) -> XClaimResponse | Awaitable[XClaimResponse]:\n        \"\"\"\n        Changes the ownership of a pending message.\n\n        name: name of the stream.\n\n        groupname: name of the consumer group.\n\n        consumername: name of a consumer that claims the message.\n\n        min_idle_time: filter messages that were idle less than this amount of\n        milliseconds\n\n        message_ids: non-empty list or tuple of message IDs to claim\n\n        idle: optional. Set the idle time (last time it was delivered) of the\n        message in ms\n\n        time: optional integer. This is the same as idle but instead of a\n        relative amount of milliseconds, it sets the idle time to a specific\n        Unix time (in milliseconds).\n\n        retrycount: optional integer. set the retry counter to the specified\n        value. This counter is incremented every time a message is delivered\n        again.\n\n        force: optional boolean, false by default. Creates the pending message\n        entry in the PEL even if certain specified IDs are not already in the\n        PEL assigned to a different client.\n\n        justid: optional boolean, false by default. Return just an array of IDs\n        of messages successfully claimed, without returning the actual message\n\n        For more information, see https://redis.io/commands/xclaim\n        \"\"\"\n        if not isinstance(min_idle_time, int) or min_idle_time < 0:\n            raise DataError(\"XCLAIM min_idle_time must be a non negative integer\")\n        if not isinstance(message_ids, (list, tuple)) or not message_ids:\n            raise DataError(\n                \"XCLAIM message_ids must be a non empty list or \"\n                \"tuple of message IDs to claim\"\n            )\n\n        kwargs = {}\n        pieces: list[EncodableT] = [name, groupname, consumername, str(min_idle_time)]\n        pieces.extend(list(message_ids))\n\n        if idle is not None:\n            if not isinstance(idle, int):\n                raise DataError(\"XCLAIM idle must be an integer\")\n            pieces.extend((b\"IDLE\", str(idle)))\n        if time is not None:\n            if not isinstance(time, int):\n                raise DataError(\"XCLAIM time must be an integer\")\n            pieces.extend((b\"TIME\", str(time)))\n        if retrycount is not None:\n            if not isinstance(retrycount, int):\n                raise DataError(\"XCLAIM retrycount must be an integer\")\n            pieces.extend((b\"RETRYCOUNT\", str(retrycount)))\n\n        if force:\n            if not isinstance(force, bool):\n                raise DataError(\"XCLAIM force must be a boolean\")\n            pieces.append(b\"FORCE\")\n        if justid:\n            if not isinstance(justid, bool):\n                raise DataError(\"XCLAIM justid must be a boolean\")\n            pieces.append(b\"JUSTID\")\n            kwargs[\"parse_justid\"] = True\n        return self.execute_command(\"XCLAIM\", *pieces, **kwargs)\n\n    @overload\n    def xdel(self: SyncClientProtocol, name: KeyT, *ids: StreamIdT) -> int: ...\n\n    @overload\n    def xdel(\n        self: AsyncClientProtocol, name: KeyT, *ids: StreamIdT\n    ) -> Awaitable[int]: ...\n\n    def xdel(self, name: KeyT, *ids: StreamIdT) -> int | Awaitable[int]:\n        \"\"\"\n        Deletes one or more messages from a stream.\n\n        Args:\n            name: name of the stream.\n            *ids: message ids to delete.\n\n        For more information, see https://redis.io/commands/xdel\n        \"\"\"\n        return self.execute_command(\"XDEL\", name, *ids)\n\n    @overload\n    def xdelex(\n        self: SyncClientProtocol,\n        name: KeyT,\n        *ids: StreamIdT,\n        ref_policy: Literal[\"KEEPREF\", \"DELREF\", \"ACKED\"] = \"KEEPREF\",\n    ) -> int: ...\n\n    @overload\n    def xdelex(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        *ids: StreamIdT,\n        ref_policy: Literal[\"KEEPREF\", \"DELREF\", \"ACKED\"] = \"KEEPREF\",\n    ) -> Awaitable[int]: ...\n\n    def xdelex(\n        self,\n        name: KeyT,\n        *ids: StreamIdT,\n        ref_policy: Literal[\"KEEPREF\", \"DELREF\", \"ACKED\"] = \"KEEPREF\",\n    ) -> int | Awaitable[int]:\n        \"\"\"\n        Extended version of XDEL that provides more control over how message entries\n        are deleted concerning consumer groups.\n        \"\"\"\n        if not ids:\n            raise DataError(\"XDELEX requires at least one message ID\")\n\n        if ref_policy not in {\"KEEPREF\", \"DELREF\", \"ACKED\"}:\n            raise DataError(\"XDELEX ref_policy must be one of: KEEPREF, DELREF, ACKED\")\n\n        pieces = [name, ref_policy, \"IDS\", len(ids)]\n        pieces.extend(ids)\n        return self.execute_command(\"XDELEX\", *pieces)\n\n    @overload\n    def xgroup_create(\n        self: SyncClientProtocol,\n        name: KeyT,\n        groupname: GroupT,\n        id: StreamIdT = \"$\",\n        mkstream: bool = False,\n        entries_read: int | None = None,\n    ) -> bool: ...\n\n    @overload\n    def xgroup_create(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        groupname: GroupT,\n        id: StreamIdT = \"$\",\n        mkstream: bool = False,\n        entries_read: int | None = None,\n    ) -> Awaitable[bool]: ...\n\n    def xgroup_create(\n        self,\n        name: KeyT,\n        groupname: GroupT,\n        id: StreamIdT = \"$\",\n        mkstream: bool = False,\n        entries_read: int | None = None,\n    ) -> bool | Awaitable[bool]:\n        \"\"\"\n        Create a new consumer group associated with a stream.\n        name: name of the stream.\n        groupname: name of the consumer group.\n        id: ID of the last item in the stream to consider already delivered.\n\n        For more information, see https://redis.io/commands/xgroup-create\n        \"\"\"\n        pieces: list[EncodableT] = [\"XGROUP CREATE\", name, groupname, id]\n        if mkstream:\n            pieces.append(b\"MKSTREAM\")\n        if entries_read is not None:\n            pieces.extend([\"ENTRIESREAD\", entries_read])\n\n        return self.execute_command(*pieces)\n\n    @overload\n    def xgroup_delconsumer(\n        self: SyncClientProtocol, name: KeyT, groupname: GroupT, consumername: ConsumerT\n    ) -> int: ...\n\n    @overload\n    def xgroup_delconsumer(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        groupname: GroupT,\n        consumername: ConsumerT,\n    ) -> Awaitable[int]: ...\n\n    def xgroup_delconsumer(\n        self, name: KeyT, groupname: GroupT, consumername: ConsumerT\n    ) -> int | Awaitable[int]:\n        \"\"\"\n        Remove a specific consumer from a consumer group.\n        Returns the number of pending messages that the consumer had before it\n        was deleted.\n        name: name of the stream.\n        groupname: name of the consumer group.\n        consumername: name of consumer to delete\n\n        For more information, see https://redis.io/commands/xgroup-delconsumer\n        \"\"\"\n        return self.execute_command(\"XGROUP DELCONSUMER\", name, groupname, consumername)\n\n    @overload\n    def xgroup_destroy(\n        self: SyncClientProtocol, name: KeyT, groupname: GroupT\n    ) -> bool: ...\n\n    @overload\n    def xgroup_destroy(\n        self: AsyncClientProtocol, name: KeyT, groupname: GroupT\n    ) -> Awaitable[bool]: ...\n\n    def xgroup_destroy(self, name: KeyT, groupname: GroupT) -> bool | Awaitable[bool]:\n        \"\"\"\n        Destroy a consumer group.\n        name: name of the stream.\n        groupname: name of the consumer group.\n\n        For more information, see https://redis.io/commands/xgroup-destroy\n        \"\"\"\n        return self.execute_command(\"XGROUP DESTROY\", name, groupname)\n\n    @overload\n    def xgroup_createconsumer(\n        self: SyncClientProtocol, name: KeyT, groupname: GroupT, consumername: ConsumerT\n    ) -> int: ...\n\n    @overload\n    def xgroup_createconsumer(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        groupname: GroupT,\n        consumername: ConsumerT,\n    ) -> Awaitable[int]: ...\n\n    def xgroup_createconsumer(\n        self, name: KeyT, groupname: GroupT, consumername: ConsumerT\n    ) -> int | Awaitable[int]:\n        \"\"\"\n        Consumers in a consumer group are auto-created every time a new\n        consumer name is mentioned by some command.\n        They can be explicitly created by using this command.\n        name: name of the stream.\n        groupname: name of the consumer group.\n        consumername: name of consumer to create.\n\n        See: https://redis.io/commands/xgroup-createconsumer\n        \"\"\"\n        return self.execute_command(\n            \"XGROUP CREATECONSUMER\", name, groupname, consumername\n        )\n\n    @overload\n    def xgroup_setid(\n        self: SyncClientProtocol,\n        name: KeyT,\n        groupname: GroupT,\n        id: StreamIdT,\n        entries_read: int | None = None,\n    ) -> bool: ...\n\n    @overload\n    def xgroup_setid(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        groupname: GroupT,\n        id: StreamIdT,\n        entries_read: int | None = None,\n    ) -> Awaitable[bool]: ...\n\n    def xgroup_setid(\n        self,\n        name: KeyT,\n        groupname: GroupT,\n        id: StreamIdT,\n        entries_read: int | None = None,\n    ) -> bool | Awaitable[bool]:\n        \"\"\"\n        Set the consumer group last delivered ID to something else.\n        name: name of the stream.\n        groupname: name of the consumer group.\n        id: ID of the last item in the stream to consider already delivered.\n\n        For more information, see https://redis.io/commands/xgroup-setid\n        \"\"\"\n        pieces = [name, groupname, id]\n        if entries_read is not None:\n            pieces.extend([\"ENTRIESREAD\", entries_read])\n        return self.execute_command(\"XGROUP SETID\", *pieces)\n\n    @overload\n    def xinfo_consumers(\n        self: SyncClientProtocol, name: KeyT, groupname: GroupT\n    ) -> list[dict[str, Any]]: ...\n\n    @overload\n    def xinfo_consumers(\n        self: AsyncClientProtocol, name: KeyT, groupname: GroupT\n    ) -> Awaitable[list[dict[str, Any]]]: ...\n\n    def xinfo_consumers(\n        self, name: KeyT, groupname: GroupT\n    ) -> list[dict[str, Any]] | Awaitable[list[dict[str, Any]]]:\n        \"\"\"\n        Returns general information about the consumers in the group.\n        name: name of the stream.\n        groupname: name of the consumer group.\n\n        For more information, see https://redis.io/commands/xinfo-consumers\n        \"\"\"\n        return self.execute_command(\"XINFO CONSUMERS\", name, groupname)\n\n    @overload\n    def xinfo_groups(self: SyncClientProtocol, name: KeyT) -> list[dict[str, Any]]: ...\n\n    @overload\n    def xinfo_groups(\n        self: AsyncClientProtocol, name: KeyT\n    ) -> Awaitable[list[dict[str, Any]]]: ...\n\n    def xinfo_groups(\n        self, name: KeyT\n    ) -> list[dict[str, Any]] | Awaitable[list[dict[str, Any]]]:\n        \"\"\"\n        Returns general information about the consumer groups of the stream.\n        name: name of the stream.\n\n        For more information, see https://redis.io/commands/xinfo-groups\n        \"\"\"\n        return self.execute_command(\"XINFO GROUPS\", name)\n\n    @overload\n    def xinfo_stream(\n        self: SyncClientProtocol, name: KeyT, full: bool = False\n    ) -> dict[str, Any]: ...\n\n    @overload\n    def xinfo_stream(\n        self: AsyncClientProtocol, name: KeyT, full: bool = False\n    ) -> Awaitable[dict[str, Any]]: ...\n\n    def xinfo_stream(\n        self, name: KeyT, full: bool = False\n    ) -> dict[str, Any] | Awaitable[dict[str, Any]]:\n        \"\"\"\n        Returns general information about the stream.\n        name: name of the stream.\n        full: optional boolean, false by default. Return full summary\n\n        For more information, see https://redis.io/commands/xinfo-stream\n        \"\"\"\n        pieces = [name]\n        options = {}\n        if full:\n            pieces.append(b\"FULL\")\n            options = {\"full\": full}\n        return self.execute_command(\"XINFO STREAM\", *pieces, **options)\n\n    @overload\n    def xlen(self: SyncClientProtocol, name: KeyT) -> int: ...\n\n    @overload\n    def xlen(self: AsyncClientProtocol, name: KeyT) -> Awaitable[int]: ...\n\n    def xlen(self, name: KeyT) -> int | Awaitable[int]:\n        \"\"\"\n        Returns the number of elements in a given stream.\n\n        For more information, see https://redis.io/commands/xlen\n        \"\"\"\n        return self.execute_command(\"XLEN\", name, keys=[name])\n\n    @overload\n    def xpending(\n        self: SyncClientProtocol, name: KeyT, groupname: GroupT\n    ) -> dict[str, Any]: ...\n\n    @overload\n    def xpending(\n        self: AsyncClientProtocol, name: KeyT, groupname: GroupT\n    ) -> Awaitable[dict[str, Any]]: ...\n\n    def xpending(\n        self, name: KeyT, groupname: GroupT\n    ) -> dict[str, Any] | Awaitable[dict[str, Any]]:\n        \"\"\"\n        Returns information about pending messages of a group.\n        name: name of the stream.\n        groupname: name of the consumer group.\n\n        For more information, see https://redis.io/commands/xpending\n        \"\"\"\n        return self.execute_command(\"XPENDING\", name, groupname, keys=[name])\n\n    @overload\n    def xpending_range(\n        self: SyncClientProtocol,\n        name: KeyT,\n        groupname: GroupT,\n        min: StreamIdT,\n        max: StreamIdT,\n        count: int,\n        consumername: ConsumerT | None = None,\n        idle: int | None = None,\n    ) -> XPendingRangeResponse: ...\n\n    @overload\n    def xpending_range(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        groupname: GroupT,\n        min: StreamIdT,\n        max: StreamIdT,\n        count: int,\n        consumername: ConsumerT | None = None,\n        idle: int | None = None,\n    ) -> Awaitable[XPendingRangeResponse]: ...\n\n    def xpending_range(\n        self,\n        name: KeyT,\n        groupname: GroupT,\n        min: StreamIdT,\n        max: StreamIdT,\n        count: int,\n        consumername: ConsumerT | None = None,\n        idle: int | None = None,\n    ) -> XPendingRangeResponse | Awaitable[XPendingRangeResponse]:\n        \"\"\"\n        Returns information about pending messages, in a range.\n\n        name: name of the stream.\n        groupname: name of the consumer group.\n        idle: available from  version 6.2. filter entries by their\n        idle-time, given in milliseconds (optional).\n        min: minimum stream ID.\n        max: maximum stream ID.\n        count: number of messages to return\n        consumername: name of a consumer to filter by (optional).\n        \"\"\"\n        if {min, max, count} == {None}:\n            if idle is not None or consumername is not None:\n                raise DataError(\n                    \"if XPENDING is provided with idle time\"\n                    \" or consumername, it must be provided\"\n                    \" with min, max and count parameters\"\n                )\n            return self.xpending(name, groupname)\n\n        pieces = [name, groupname]\n        if min is None or max is None or count is None:\n            raise DataError(\n                \"XPENDING must be provided with min, max \"\n                \"and count parameters, or none of them.\"\n            )\n        # idle\n        try:\n            if int(idle) < 0:\n                raise DataError(\"XPENDING idle must be a integer >= 0\")\n            pieces.extend([\"IDLE\", idle])\n        except TypeError:\n            pass\n        # count\n        try:\n            if int(count) < 0:\n                raise DataError(\"XPENDING count must be a integer >= 0\")\n            pieces.extend([min, max, count])\n        except TypeError:\n            pass\n        # consumername\n        if consumername:\n            pieces.append(consumername)\n\n        return self.execute_command(\"XPENDING\", *pieces, parse_detail=True)\n\n    @overload\n    def xrange(\n        self: SyncClientProtocol,\n        name: KeyT,\n        min: StreamIdT = \"-\",\n        max: StreamIdT = \"+\",\n        count: int | None = None,\n    ) -> StreamRangeResponse | None: ...\n\n    @overload\n    def xrange(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        min: StreamIdT = \"-\",\n        max: StreamIdT = \"+\",\n        count: int | None = None,\n    ) -> Awaitable[StreamRangeResponse | None]: ...\n\n    def xrange(\n        self,\n        name: KeyT,\n        min: StreamIdT = \"-\",\n        max: StreamIdT = \"+\",\n        count: int | None = None,\n    ) -> (StreamRangeResponse | None) | Awaitable[StreamRangeResponse | None]:\n        \"\"\"\n        Read stream values within an interval.\n\n        name: name of the stream.\n\n        start: first stream ID. defaults to '-',\n               meaning the earliest available.\n\n        finish: last stream ID. defaults to '+',\n                meaning the latest available.\n\n        count: if set, only return this many items, beginning with the\n               earliest available.\n\n        For more information, see https://redis.io/commands/xrange\n        \"\"\"\n        pieces = [min, max]\n        if count is not None:\n            if not isinstance(count, int) or count < 1:\n                raise DataError(\"XRANGE count must be a positive integer\")\n            pieces.append(b\"COUNT\")\n            pieces.append(str(count))\n\n        return self.execute_command(\"XRANGE\", name, *pieces, keys=[name])\n\n    @overload\n    def xread(\n        self: SyncClientProtocol,\n        streams: Dict[KeyT, StreamIdT],\n        count: int | None = None,\n        block: int | None = None,\n    ) -> XReadResponse: ...\n\n    @overload\n    def xread(\n        self: AsyncClientProtocol,\n        streams: Dict[KeyT, StreamIdT],\n        count: int | None = None,\n        block: int | None = None,\n    ) -> Awaitable[XReadResponse]: ...\n\n    def xread(\n        self,\n        streams: Dict[KeyT, StreamIdT],\n        count: int | None = None,\n        block: int | None = None,\n    ) -> XReadResponse | Awaitable[XReadResponse]:\n        \"\"\"\n        Block and monitor multiple streams for new data.\n\n        streams: a dict of stream names to stream IDs, where\n                   IDs indicate the last ID already seen.\n\n        count: if set, only return this many items, beginning with the\n               earliest available.\n\n        block: number of milliseconds to wait, if nothing already present.\n\n        For more information, see https://redis.io/commands/xread\n        \"\"\"\n        pieces = []\n        if block is not None:\n            if not isinstance(block, int) or block < 0:\n                raise DataError(\"XREAD block must be a non-negative integer\")\n            pieces.append(b\"BLOCK\")\n            pieces.append(str(block))\n        if count is not None:\n            if not isinstance(count, int) or count < 1:\n                raise DataError(\"XREAD count must be a positive integer\")\n            pieces.append(b\"COUNT\")\n            pieces.append(str(count))\n        if not isinstance(streams, dict) or len(streams) == 0:\n            raise DataError(\"XREAD streams must be a non empty dict\")\n        pieces.append(b\"STREAMS\")\n        keys, values = zip(*streams.items())\n        pieces.extend(keys)\n        pieces.extend(values)\n        response = self.execute_command(\"XREAD\", *pieces, keys=keys)\n\n        if inspect.iscoroutine(response):\n            # Async client - wrap in coroutine that awaits and records\n            async def _record_and_return():\n                actual_response = await response\n\n                await async_record_streaming_lag(response=actual_response)\n                return actual_response\n\n            return _record_and_return()\n        else:\n            # Sync client\n            record_streaming_lag_from_response(response=response)\n            return response\n\n    @overload\n    def xreadgroup(\n        self: SyncClientProtocol,\n        groupname: str,\n        consumername: str,\n        streams: Dict[KeyT, StreamIdT],\n        count: int | None = None,\n        block: int | None = None,\n        noack: bool = False,\n        claim_min_idle_time: int | None = None,\n    ) -> XReadResponse: ...\n\n    @overload\n    def xreadgroup(\n        self: AsyncClientProtocol,\n        groupname: str,\n        consumername: str,\n        streams: Dict[KeyT, StreamIdT],\n        count: int | None = None,\n        block: int | None = None,\n        noack: bool = False,\n        claim_min_idle_time: int | None = None,\n    ) -> Awaitable[XReadResponse]: ...\n\n    def xreadgroup(\n        self,\n        groupname: str,\n        consumername: str,\n        streams: Dict[KeyT, StreamIdT],\n        count: int | None = None,\n        block: int | None = None,\n        noack: bool = False,\n        claim_min_idle_time: int | None = None,\n    ) -> XReadResponse | Awaitable[XReadResponse]:\n        \"\"\"\n        Read from a stream via a consumer group.\n\n        groupname: name of the consumer group.\n\n        consumername: name of the requesting consumer.\n\n        streams: a dict of stream names to stream IDs, where\n               IDs indicate the last ID already seen.\n\n        count: if set, only return this many items, beginning with the\n               earliest available.\n\n        block: number of milliseconds to wait, if nothing already present.\n        noack: do not add messages to the PEL\n\n        claim_min_idle_time: accepts an integer type and represents a\n                             time interval in milliseconds\n\n        For more information, see https://redis.io/commands/xreadgroup\n        \"\"\"\n        options = {}\n        pieces: list[EncodableT] = [b\"GROUP\", groupname, consumername]\n        if count is not None:\n            if not isinstance(count, int) or count < 1:\n                raise DataError(\"XREADGROUP count must be a positive integer\")\n            pieces.append(b\"COUNT\")\n            pieces.append(str(count))\n        if block is not None:\n            if not isinstance(block, int) or block < 0:\n                raise DataError(\"XREADGROUP block must be a non-negative integer\")\n            pieces.append(b\"BLOCK\")\n            pieces.append(str(block))\n        if noack:\n            pieces.append(b\"NOACK\")\n        if claim_min_idle_time is not None:\n            if not isinstance(claim_min_idle_time, int) or claim_min_idle_time < 0:\n                raise DataError(\n                    \"XREADGROUP claim_min_idle_time must be a non-negative integer\"\n                )\n            pieces.append(b\"CLAIM\")\n            pieces.append(claim_min_idle_time)\n            options[\"claim_min_idle_time\"] = claim_min_idle_time\n        if not isinstance(streams, dict) or len(streams) == 0:\n            raise DataError(\"XREADGROUP streams must be a non empty dict\")\n        pieces.append(b\"STREAMS\")\n        pieces.extend(streams.keys())\n        pieces.extend(streams.values())\n        response = self.execute_command(\"XREADGROUP\", *pieces, **options)\n\n        if inspect.iscoroutine(response):\n            # Async client - wrap in coroutine that awaits and records\n            async def _record_and_return():\n                actual_response = await response\n\n                await async_record_streaming_lag(\n                    response=actual_response,\n                    consumer_group=groupname,\n                )\n                return actual_response\n\n            return _record_and_return()\n        else:\n            # Sync client\n            record_streaming_lag_from_response(\n                response=response,\n                consumer_group=groupname,\n            )\n            return response\n\n    @overload\n    def xrevrange(\n        self: SyncClientProtocol,\n        name: KeyT,\n        max: StreamIdT = \"+\",\n        min: StreamIdT = \"-\",\n        count: int | None = None,\n    ) -> StreamRangeResponse | None: ...\n\n    @overload\n    def xrevrange(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        max: StreamIdT = \"+\",\n        min: StreamIdT = \"-\",\n        count: int | None = None,\n    ) -> Awaitable[StreamRangeResponse | None]: ...\n\n    def xrevrange(\n        self,\n        name: KeyT,\n        max: StreamIdT = \"+\",\n        min: StreamIdT = \"-\",\n        count: int | None = None,\n    ) -> (StreamRangeResponse | None) | Awaitable[StreamRangeResponse | None]:\n        \"\"\"\n        Read stream values within an interval, in reverse order.\n\n        name: name of the stream\n\n        start: first stream ID. defaults to '+',\n               meaning the latest available.\n\n        finish: last stream ID. defaults to '-',\n                meaning the earliest available.\n\n        count: if set, only return this many items, beginning with the\n               latest available.\n\n        For more information, see https://redis.io/commands/xrevrange\n        \"\"\"\n        pieces: list[EncodableT] = [max, min]\n        if count is not None:\n            if not isinstance(count, int) or count < 1:\n                raise DataError(\"XREVRANGE count must be a positive integer\")\n            pieces.append(b\"COUNT\")\n            pieces.append(str(count))\n\n        return self.execute_command(\"XREVRANGE\", name, *pieces, keys=[name])\n\n    @overload\n    def xtrim(\n        self: SyncClientProtocol,\n        name: KeyT,\n        maxlen: int | None = None,\n        approximate: bool = True,\n        minid: StreamIdT | None = None,\n        limit: int | None = None,\n        ref_policy: Literal[\"KEEPREF\", \"DELREF\", \"ACKED\"] | None = None,\n    ) -> int: ...\n\n    @overload\n    def xtrim(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        maxlen: int | None = None,\n        approximate: bool = True,\n        minid: StreamIdT | None = None,\n        limit: int | None = None,\n        ref_policy: Literal[\"KEEPREF\", \"DELREF\", \"ACKED\"] | None = None,\n    ) -> Awaitable[int]: ...\n\n    def xtrim(\n        self,\n        name: KeyT,\n        maxlen: int | None = None,\n        approximate: bool = True,\n        minid: StreamIdT | None = None,\n        limit: int | None = None,\n        ref_policy: Literal[\"KEEPREF\", \"DELREF\", \"ACKED\"] | None = None,\n    ) -> int | Awaitable[int]:\n        \"\"\"\n        Trims old messages from a stream.\n        name: name of the stream.\n        maxlen: truncate old stream messages beyond this size\n        Can't be specified with minid.\n        approximate: actual stream length may be slightly more than maxlen\n        minid: the minimum id in the stream to query\n        Can't be specified with maxlen.\n        limit: specifies the maximum number of entries to retrieve\n        ref_policy: optional reference policy for consumer groups:\n            - KEEPREF (default): Trims entries but preserves references in consumer groups' PEL\n            - DELREF: Trims entries and removes all references from consumer groups' PEL\n            - ACKED: Only trims entries that were read and acknowledged by all consumer groups\n\n        For more information, see https://redis.io/commands/xtrim\n        \"\"\"\n        pieces: list[EncodableT] = []\n        if maxlen is not None and minid is not None:\n            raise DataError(\"Only one of ``maxlen`` or ``minid`` may be specified\")\n\n        if maxlen is None and minid is None:\n            raise DataError(\"One of ``maxlen`` or ``minid`` must be specified\")\n\n        if ref_policy is not None and ref_policy not in {\"KEEPREF\", \"DELREF\", \"ACKED\"}:\n            raise DataError(\"XTRIM ref_policy must be one of: KEEPREF, DELREF, ACKED\")\n\n        if maxlen is not None:\n            pieces.append(b\"MAXLEN\")\n        if minid is not None:\n            pieces.append(b\"MINID\")\n        if approximate:\n            pieces.append(b\"~\")\n        if maxlen is not None:\n            pieces.append(maxlen)\n        if minid is not None:\n            pieces.append(minid)\n        if limit is not None:\n            pieces.append(b\"LIMIT\")\n            pieces.append(limit)\n        if ref_policy is not None:\n            pieces.append(ref_policy)\n\n        return self.execute_command(\"XTRIM\", name, *pieces)\n\n\nAsyncStreamCommands = StreamCommands\n\n\nclass SortedSetCommands(CommandsProtocol):\n    \"\"\"\n    Redis commands for Sorted Sets data type.\n    see: https://redis.io/topics/data-types-intro#redis-sorted-sets\n    \"\"\"\n\n    def zadd(\n        self,\n        name: KeyT,\n        mapping: Mapping[AnyKeyT, EncodableT],\n        nx: bool = False,\n        xx: bool = False,\n        ch: bool = False,\n        incr: bool = False,\n        gt: bool = False,\n        lt: bool = False,\n    ) -> ResponseT:\n        \"\"\"\n        Set any number of element-name, score pairs to the key ``name``. Pairs\n        are specified as a dict of element-names keys to score values.\n\n        ``nx`` forces ZADD to only create new elements and not to update\n        scores for elements that already exist.\n\n        ``xx`` forces ZADD to only update scores of elements that already\n        exist. New elements will not be added.\n\n        ``ch`` modifies the return value to be the numbers of elements changed.\n        Changed elements include new elements that were added and elements\n        whose scores changed.\n\n        ``incr`` modifies ZADD to behave like ZINCRBY. In this mode only a\n        single element/score pair can be specified and the score is the amount\n        the existing score will be incremented by. When using this mode the\n        return value of ZADD will be the new score of the element.\n\n        ``lt`` only updates existing elements if the new score is less than\n        the current score. This flag doesn't prevent adding new elements.\n\n        ``gt`` only updates existing elements if the new score is greater than\n        the current score. This flag doesn't prevent adding new elements.\n\n        The return value of ZADD varies based on the mode specified. With no\n        options, ZADD returns the number of new elements added to the sorted\n        set.\n\n        ``nx``, ``lt``, and ``gt`` are mutually exclusive options.\n\n        See: https://redis.io/commands/ZADD\n        \"\"\"\n        if not mapping:\n            raise DataError(\"ZADD requires at least one element/score pair\")\n        if nx and xx:\n            raise DataError(\"ZADD allows either 'nx' or 'xx', not both\")\n        if gt and lt:\n            raise DataError(\"ZADD allows either 'gt' or 'lt', not both\")\n        if incr and len(mapping) != 1:\n            raise DataError(\n                \"ZADD option 'incr' only works when passing a single element/score pair\"\n            )\n        if nx and (gt or lt):\n            raise DataError(\"Only one of 'nx', 'lt', or 'gr' may be defined.\")\n\n        pieces: list[EncodableT] = []\n        options = {}\n        if nx:\n            pieces.append(b\"NX\")\n        if xx:\n            pieces.append(b\"XX\")\n        if ch:\n            pieces.append(b\"CH\")\n        if incr:\n            pieces.append(b\"INCR\")\n            options[\"as_score\"] = True\n        if gt:\n            pieces.append(b\"GT\")\n        if lt:\n            pieces.append(b\"LT\")\n        for pair in mapping.items():\n            pieces.append(pair[1])\n            pieces.append(pair[0])\n        return self.execute_command(\"ZADD\", name, *pieces, **options)\n\n    def zcard(self, name: KeyT) -> ResponseT:\n        \"\"\"\n        Return the number of elements in the sorted set ``name``\n\n        For more information, see https://redis.io/commands/zcard\n        \"\"\"\n        return self.execute_command(\"ZCARD\", name, keys=[name])\n\n    @overload\n    def zcount(\n        self: SyncClientProtocol, name: KeyT, min: ZScoreBoundT, max: ZScoreBoundT\n    ) -> int: ...\n\n    @overload\n    def zcount(\n        self: AsyncClientProtocol, name: KeyT, min: ZScoreBoundT, max: ZScoreBoundT\n    ) -> Awaitable[int]: ...\n\n    def zcount(\n        self, name: KeyT, min: ZScoreBoundT, max: ZScoreBoundT\n    ) -> int | Awaitable[int]:\n        \"\"\"\n        Returns the number of elements in the sorted set at key ``name`` with\n        a score between ``min`` and ``max``.\n\n        For more information, see https://redis.io/commands/zcount\n        \"\"\"\n        return self.execute_command(\"ZCOUNT\", name, min, max, keys=[name])\n\n    @overload\n    def zdiff(\n        self: SyncClientProtocol, keys: KeysT, withscores: bool = False\n    ) -> ZSetRangeResponse: ...\n\n    @overload\n    def zdiff(\n        self: AsyncClientProtocol, keys: KeysT, withscores: bool = False\n    ) -> Awaitable[ZSetRangeResponse]: ...\n\n    def zdiff(\n        self, keys: KeysT, withscores: bool = False\n    ) -> ZSetRangeResponse | Awaitable[ZSetRangeResponse]:\n        \"\"\"\n        Returns the difference between the first and all successive input\n        sorted sets provided in ``keys``.\n\n        For more information, see https://redis.io/commands/zdiff\n        \"\"\"\n        pieces = [len(keys), *keys]\n        if withscores:\n            pieces.append(\"WITHSCORES\")\n        return self.execute_command(\"ZDIFF\", *pieces, keys=keys)\n\n    @overload\n    def zdiffstore(self: SyncClientProtocol, dest: KeyT, keys: KeysT) -> int: ...\n\n    @overload\n    def zdiffstore(\n        self: AsyncClientProtocol, dest: KeyT, keys: KeysT\n    ) -> Awaitable[int]: ...\n\n    def zdiffstore(self, dest: KeyT, keys: KeysT) -> int | Awaitable[int]:\n        \"\"\"\n        Computes the difference between the first and all successive input\n        sorted sets provided in ``keys`` and stores the result in ``dest``.\n\n        For more information, see https://redis.io/commands/zdiffstore\n        \"\"\"\n        pieces = [len(keys), *keys]\n        return self.execute_command(\"ZDIFFSTORE\", dest, *pieces)\n\n    @overload\n    def zincrby(\n        self: SyncClientProtocol, name: KeyT, amount: float, value: EncodableT\n    ) -> float | None: ...\n\n    @overload\n    def zincrby(\n        self: AsyncClientProtocol, name: KeyT, amount: float, value: EncodableT\n    ) -> Awaitable[float | None]: ...\n\n    def zincrby(self, name: KeyT, amount: float, value: EncodableT) -> (\n        float | None\n    ) | Awaitable[float | None]:\n        \"\"\"\n        Increment the score of ``value`` in sorted set ``name`` by ``amount``\n\n        For more information, see https://redis.io/commands/zincrby\n        \"\"\"\n        return self.execute_command(\"ZINCRBY\", name, amount, value)\n\n    @overload\n    def zinter(\n        self: SyncClientProtocol,\n        keys: KeysT,\n        aggregate: str | None = None,\n        withscores: bool = False,\n    ) -> ZSetRangeResponse: ...\n\n    @overload\n    def zinter(\n        self: AsyncClientProtocol,\n        keys: KeysT,\n        aggregate: str | None = None,\n        withscores: bool = False,\n    ) -> Awaitable[ZSetRangeResponse]: ...\n\n    def zinter(\n        self, keys: KeysT, aggregate: str | None = None, withscores: bool = False\n    ) -> ZSetRangeResponse | Awaitable[ZSetRangeResponse]:\n        \"\"\"\n        Return the intersect of multiple sorted sets specified by ``keys``.\n        With the ``aggregate`` option, it is possible to specify how the\n        results of the union are aggregated. This option defaults to SUM,\n        where the score of an element is summed across the inputs where it\n        exists. When this option is set to either MIN or MAX, the resulting\n        set will contain the minimum or maximum score of an element across\n        the inputs where it exists.\n\n        For more information, see https://redis.io/commands/zinter\n        \"\"\"\n        return self._zaggregate(\"ZINTER\", None, keys, aggregate, withscores=withscores)\n\n    @overload\n    def zinterstore(\n        self,\n        dest: KeyT,\n        keys: Sequence[KeyT] | Mapping[AnyKeyT, float],\n        aggregate: str | None = None,\n    ) -> int: ...\n\n    @overload\n    def zinterstore(\n        self: AsyncClientProtocol,\n        dest: KeyT,\n        keys: Sequence[KeyT] | Mapping[AnyKeyT, float],\n        aggregate: str | None = None,\n    ) -> Awaitable[int]: ...\n\n    def zinterstore(\n        self,\n        dest: KeyT,\n        keys: Sequence[KeyT] | Mapping[AnyKeyT, float],\n        aggregate: str | None = None,\n    ) -> int | Awaitable[int]:\n        \"\"\"\n        Intersect multiple sorted sets specified by ``keys`` into a new\n        sorted set, ``dest``. Scores in the destination will be aggregated\n        based on the ``aggregate``. This option defaults to SUM, where the\n        score of an element is summed across the inputs where it exists.\n        When this option is set to either MIN or MAX, the resulting set will\n        contain the minimum or maximum score of an element across the inputs\n        where it exists.\n\n        For more information, see https://redis.io/commands/zinterstore\n        \"\"\"\n        return self._zaggregate(\"ZINTERSTORE\", dest, keys, aggregate)\n\n    @overload\n    def zintercard(\n        self: SyncClientProtocol, numkeys: int, keys: List[str], limit: int = 0\n    ) -> int: ...\n\n    @overload\n    def zintercard(\n        self: AsyncClientProtocol, numkeys: int, keys: List[str], limit: int = 0\n    ) -> Awaitable[int]: ...\n\n    def zintercard(\n        self, numkeys: int, keys: List[str], limit: int = 0\n    ) -> int | Awaitable[int]:\n        \"\"\"\n        Return the cardinality of the intersect of multiple sorted sets\n        specified by ``keys``.\n        When LIMIT provided (defaults to 0 and means unlimited), if the intersection\n        cardinality reaches limit partway through the computation, the algorithm will\n        exit and yield limit as the cardinality\n\n        For more information, see https://redis.io/commands/zintercard\n        \"\"\"\n        args = [numkeys, *keys, \"LIMIT\", limit]\n        return self.execute_command(\"ZINTERCARD\", *args, keys=keys)\n\n    @overload\n    def zlexcount(\n        self: SyncClientProtocol, name: KeyT, min: EncodableT, max: EncodableT\n    ) -> int: ...\n\n    @overload\n    def zlexcount(\n        self: AsyncClientProtocol, name: KeyT, min: EncodableT, max: EncodableT\n    ) -> Awaitable[int]: ...\n\n    def zlexcount(\n        self, name: KeyT, min: EncodableT, max: EncodableT\n    ) -> int | Awaitable[int]:\n        \"\"\"\n        Return the number of items in the sorted set ``name`` between the\n        lexicographical range ``min`` and ``max``.\n\n        For more information, see https://redis.io/commands/zlexcount\n        \"\"\"\n        return self.execute_command(\"ZLEXCOUNT\", name, min, max, keys=[name])\n\n    @overload\n    def zpopmax(\n        self: SyncClientProtocol, name: KeyT, count: int | None = None\n    ) -> ZSetRangeResponse: ...\n\n    @overload\n    def zpopmax(\n        self: AsyncClientProtocol, name: KeyT, count: int | None = None\n    ) -> Awaitable[ZSetRangeResponse]: ...\n\n    def zpopmax(\n        self, name: KeyT, count: int | None = None\n    ) -> ZSetRangeResponse | Awaitable[ZSetRangeResponse]:\n        \"\"\"\n        Remove and return up to ``count`` members with the highest scores\n        from the sorted set ``name``.\n\n        For more information, see https://redis.io/commands/zpopmax\n        \"\"\"\n        args = (count is not None) and [count] or []\n        options = {\"withscores\": True}\n        return self.execute_command(\"ZPOPMAX\", name, *args, **options)\n\n    @overload\n    def zpopmin(\n        self: SyncClientProtocol, name: KeyT, count: int | None = None\n    ) -> ZSetRangeResponse: ...\n\n    @overload\n    def zpopmin(\n        self: AsyncClientProtocol, name: KeyT, count: int | None = None\n    ) -> Awaitable[ZSetRangeResponse]: ...\n\n    def zpopmin(\n        self, name: KeyT, count: int | None = None\n    ) -> ZSetRangeResponse | Awaitable[ZSetRangeResponse]:\n        \"\"\"\n        Remove and return up to ``count`` members with the lowest scores\n        from the sorted set ``name``.\n\n        For more information, see https://redis.io/commands/zpopmin\n        \"\"\"\n        args = (count is not None) and [count] or []\n        options = {\"withscores\": True}\n        return self.execute_command(\"ZPOPMIN\", name, *args, **options)\n\n    @overload\n    def zrandmember(\n        self: SyncClientProtocol,\n        key: KeyT,\n        count: int | None = None,\n        withscores: bool = False,\n    ) -> ZRandMemberResponse: ...\n\n    @overload\n    def zrandmember(\n        self: AsyncClientProtocol,\n        key: KeyT,\n        count: int | None = None,\n        withscores: bool = False,\n    ) -> Awaitable[ZRandMemberResponse]: ...\n\n    def zrandmember(\n        self, key: KeyT, count: int | None = None, withscores: bool = False\n    ) -> ZRandMemberResponse | Awaitable[ZRandMemberResponse]:\n        \"\"\"\n        Return a random element from the sorted set value stored at key.\n\n        ``count`` if the argument is positive, return an array of distinct\n        fields. If called with a negative count, the behavior changes and\n        the command is allowed to return the same field multiple times.\n        In this case, the number of returned fields is the absolute value\n        of the specified count.\n\n        ``withscores`` The optional WITHSCORES modifier changes the reply so it\n        includes the respective scores of the randomly selected elements from\n        the sorted set.\n\n        For more information, see https://redis.io/commands/zrandmember\n        \"\"\"\n        params = []\n        if count is not None:\n            params.append(count)\n        if withscores:\n            params.append(\"WITHSCORES\")\n\n        return self.execute_command(\"ZRANDMEMBER\", key, *params)\n\n    @overload\n    def bzpopmax(\n        self: SyncClientProtocol, keys: KeysT, timeout: TimeoutSecT = 0\n    ) -> BlockingZSetPopResponse: ...\n\n    @overload\n    def bzpopmax(\n        self: AsyncClientProtocol, keys: KeysT, timeout: TimeoutSecT = 0\n    ) -> Awaitable[BlockingZSetPopResponse]: ...\n\n    def bzpopmax(\n        self, keys: KeysT, timeout: TimeoutSecT = 0\n    ) -> BlockingZSetPopResponse | Awaitable[BlockingZSetPopResponse]:\n        \"\"\"\n        ZPOPMAX a value off of the first non-empty sorted set\n        named in the ``keys`` list.\n\n        If none of the sorted sets in ``keys`` has a value to ZPOPMAX,\n        then block for ``timeout`` seconds, or until a member gets added\n        to one of the sorted sets.\n\n        If timeout is 0, then block indefinitely.\n\n        For more information, see https://redis.io/commands/bzpopmax\n        \"\"\"\n        if timeout is None:\n            timeout = 0\n        keys = list_or_args(keys, None)\n        keys.append(timeout)\n        return self.execute_command(\"BZPOPMAX\", *keys)\n\n    @overload\n    def bzpopmin(\n        self: SyncClientProtocol, keys: KeysT, timeout: TimeoutSecT = 0\n    ) -> BlockingZSetPopResponse: ...\n\n    @overload\n    def bzpopmin(\n        self: AsyncClientProtocol, keys: KeysT, timeout: TimeoutSecT = 0\n    ) -> Awaitable[BlockingZSetPopResponse]: ...\n\n    def bzpopmin(\n        self, keys: KeysT, timeout: TimeoutSecT = 0\n    ) -> BlockingZSetPopResponse | Awaitable[BlockingZSetPopResponse]:\n        \"\"\"\n        ZPOPMIN a value off of the first non-empty sorted set\n        named in the ``keys`` list.\n\n        If none of the sorted sets in ``keys`` has a value to ZPOPMIN,\n        then block for ``timeout`` seconds, or until a member gets added\n        to one of the sorted sets.\n\n        If timeout is 0, then block indefinitely.\n\n        For more information, see https://redis.io/commands/bzpopmin\n        \"\"\"\n        if timeout is None:\n            timeout = 0\n        keys: list[EncodableT] = list_or_args(keys, None)\n        keys.append(timeout)\n        return self.execute_command(\"BZPOPMIN\", *keys)\n\n    @overload\n    def zmpop(\n        self: SyncClientProtocol,\n        num_keys: int,\n        keys: List[str],\n        min: bool | None = False,\n        max: bool | None = False,\n        count: int | None = 1,\n    ) -> ZMPopResponse: ...\n\n    @overload\n    def zmpop(\n        self: AsyncClientProtocol,\n        num_keys: int,\n        keys: List[str],\n        min: bool | None = False,\n        max: bool | None = False,\n        count: int | None = 1,\n    ) -> Awaitable[ZMPopResponse]: ...\n\n    def zmpop(\n        self,\n        num_keys: int,\n        keys: List[str],\n        min: bool | None = False,\n        max: bool | None = False,\n        count: int | None = 1,\n    ) -> ZMPopResponse | Awaitable[ZMPopResponse]:\n        \"\"\"\n        Pop ``count`` values (default 1) off of the first non-empty sorted set\n        named in the ``keys`` list.\n        For more information, see https://redis.io/commands/zmpop\n        \"\"\"\n        args = [num_keys] + keys\n        if (min and max) or (not min and not max):\n            raise DataError\n        elif min:\n            args.append(\"MIN\")\n        else:\n            args.append(\"MAX\")\n        if count != 1:\n            args.extend([\"COUNT\", count])\n\n        return self.execute_command(\"ZMPOP\", *args)\n\n    @overload\n    def bzmpop(\n        self: SyncClientProtocol,\n        timeout: float,\n        numkeys: int,\n        keys: List[str],\n        min: bool | None = False,\n        max: bool | None = False,\n        count: int | None = 1,\n    ) -> ZMPopResponse: ...\n\n    @overload\n    def bzmpop(\n        self: AsyncClientProtocol,\n        timeout: float,\n        numkeys: int,\n        keys: List[str],\n        min: bool | None = False,\n        max: bool | None = False,\n        count: int | None = 1,\n    ) -> Awaitable[ZMPopResponse]: ...\n\n    def bzmpop(\n        self,\n        timeout: float,\n        numkeys: int,\n        keys: List[str],\n        min: bool | None = False,\n        max: bool | None = False,\n        count: int | None = 1,\n    ) -> ZMPopResponse | Awaitable[ZMPopResponse]:\n        \"\"\"\n        Pop ``count`` values (default 1) off of the first non-empty sorted set\n        named in the ``keys`` list.\n\n        If none of the sorted sets in ``keys`` has a value to pop,\n        then block for ``timeout`` seconds, or until a member gets added\n        to one of the sorted sets.\n\n        If timeout is 0, then block indefinitely.\n\n        For more information, see https://redis.io/commands/bzmpop\n        \"\"\"\n        args = [timeout, numkeys, *keys]\n        if (min and max) or (not min and not max):\n            raise DataError(\"Either min or max, but not both must be set\")\n        elif min:\n            args.append(\"MIN\")\n        else:\n            args.append(\"MAX\")\n        args.extend([\"COUNT\", count])\n\n        return self.execute_command(\"BZMPOP\", *args)\n\n    def _zrange(\n        self,\n        command,\n        dest: Union[KeyT, None],\n        name: KeyT,\n        start: EncodableT,\n        end: EncodableT,\n        desc: bool = False,\n        byscore: bool = False,\n        bylex: bool = False,\n        withscores: bool = False,\n        score_cast_func: Union[type, Callable, None] = float,\n        offset: Optional[int] = None,\n        num: Optional[int] = None,\n    ) -> ResponseT:\n        if byscore and bylex:\n            raise DataError(\"``byscore`` and ``bylex`` can not be specified together.\")\n        if (offset is not None and num is None) or (num is not None and offset is None):\n            raise DataError(\"``offset`` and ``num`` must both be specified.\")\n        if bylex and withscores:\n            raise DataError(\n                \"``withscores`` not supported in combination with ``bylex``.\"\n            )\n        pieces = [command]\n        if dest:\n            pieces.append(dest)\n        pieces.extend([name, start, end])\n        if byscore:\n            pieces.append(\"BYSCORE\")\n        if bylex:\n            pieces.append(\"BYLEX\")\n        if desc:\n            pieces.append(\"REV\")\n        if offset is not None and num is not None:\n            pieces.extend([\"LIMIT\", offset, num])\n        if withscores:\n            pieces.append(\"WITHSCORES\")\n        options = {\"withscores\": withscores, \"score_cast_func\": score_cast_func}\n        options[\"keys\"] = [name]\n        return self.execute_command(*pieces, **options)\n\n    @overload\n    def zrange(\n        self: SyncClientProtocol,\n        name: KeyT,\n        start: EncodableT,\n        end: EncodableT,\n        desc: bool = False,\n        withscores: bool = False,\n        score_cast_func: type | Callable = float,\n        byscore: bool = False,\n        bylex: bool = False,\n        offset: int | None = None,\n        num: int | None = None,\n    ) -> ZSetRangeResponse: ...\n\n    @overload\n    def zrange(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        start: EncodableT,\n        end: EncodableT,\n        desc: bool = False,\n        withscores: bool = False,\n        score_cast_func: type | Callable = float,\n        byscore: bool = False,\n        bylex: bool = False,\n        offset: int | None = None,\n        num: int | None = None,\n    ) -> Awaitable[ZSetRangeResponse]: ...\n\n    def zrange(\n        self,\n        name: KeyT,\n        start: EncodableT,\n        end: EncodableT,\n        desc: bool = False,\n        withscores: bool = False,\n        score_cast_func: type | Callable = float,\n        byscore: bool = False,\n        bylex: bool = False,\n        offset: int | None = None,\n        num: int | None = None,\n    ) -> ZSetRangeResponse | Awaitable[ZSetRangeResponse]:\n        \"\"\"\n        Return a range of values from sorted set ``name`` between\n        ``start`` and ``end`` sorted in ascending order.\n\n        ``start`` and ``end`` can be negative, indicating the end of the range.\n\n        ``desc`` a boolean indicating whether to sort the results in reversed\n        order.\n\n        ``withscores`` indicates to return the scores along with the values.\n        The return type is a list of (value, score) pairs.\n\n        ``score_cast_func`` a callable used to cast the score return value.\n\n        ``byscore`` when set to True, returns the range of elements from the\n        sorted set having scores equal or between ``start`` and ``end``.\n\n        ``bylex`` when set to True, returns the range of elements from the\n        sorted set between the ``start`` and ``end`` lexicographical closed\n        range intervals.\n        Valid ``start`` and ``end`` must start with ( or [, in order to specify\n        whether the range interval is exclusive or inclusive, respectively.\n\n        ``offset`` and ``num`` are specified, then return a slice of the range.\n        Can't be provided when using ``bylex``.\n\n        For more information, see https://redis.io/commands/zrange\n        \"\"\"\n        # Need to support ``desc`` also when using old redis version\n        # because it was supported in 3.5.3 (of redis-py)\n        if not byscore and not bylex and (offset is None and num is None) and desc:\n            return self.zrevrange(name, start, end, withscores, score_cast_func)\n\n        return self._zrange(\n            \"ZRANGE\",\n            None,\n            name,\n            start,\n            end,\n            desc,\n            byscore,\n            bylex,\n            withscores,\n            score_cast_func,\n            offset,\n            num,\n        )\n\n    @overload\n    def zrevrange(\n        self: SyncClientProtocol,\n        name: KeyT,\n        start: int,\n        end: int,\n        withscores: bool = False,\n        score_cast_func: type | Callable = float,\n    ) -> ZSetRangeResponse: ...\n\n    @overload\n    def zrevrange(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        start: int,\n        end: int,\n        withscores: bool = False,\n        score_cast_func: type | Callable = float,\n    ) -> Awaitable[ZSetRangeResponse]: ...\n\n    def zrevrange(\n        self,\n        name: KeyT,\n        start: int,\n        end: int,\n        withscores: bool = False,\n        score_cast_func: type | Callable = float,\n    ) -> ZSetRangeResponse | Awaitable[ZSetRangeResponse]:\n        \"\"\"\n        Return a range of values from sorted set ``name`` between\n        ``start`` and ``end`` sorted in descending order.\n\n        ``start`` and ``end`` can be negative, indicating the end of the range.\n\n        ``withscores`` indicates to return the scores along with the values\n        The return type is a list of (value, score) pairs\n\n        ``score_cast_func`` a callable used to cast the score return value\n\n        For more information, see https://redis.io/commands/zrevrange\n        \"\"\"\n        pieces = [\"ZREVRANGE\", name, start, end]\n        if withscores:\n            pieces.append(b\"WITHSCORES\")\n        options = {\"withscores\": withscores, \"score_cast_func\": score_cast_func}\n        options[\"keys\"] = name\n        return self.execute_command(*pieces, **options)\n\n    @overload\n    def zrangestore(\n        self: SyncClientProtocol,\n        dest: KeyT,\n        name: KeyT,\n        start: EncodableT,\n        end: EncodableT,\n        byscore: bool = False,\n        bylex: bool = False,\n        desc: bool = False,\n        offset: int | None = None,\n        num: int | None = None,\n    ) -> int: ...\n\n    @overload\n    def zrangestore(\n        self: AsyncClientProtocol,\n        dest: KeyT,\n        name: KeyT,\n        start: EncodableT,\n        end: EncodableT,\n        byscore: bool = False,\n        bylex: bool = False,\n        desc: bool = False,\n        offset: int | None = None,\n        num: int | None = None,\n    ) -> Awaitable[int]: ...\n\n    def zrangestore(\n        self,\n        dest: KeyT,\n        name: KeyT,\n        start: EncodableT,\n        end: EncodableT,\n        byscore: bool = False,\n        bylex: bool = False,\n        desc: bool = False,\n        offset: int | None = None,\n        num: int | None = None,\n    ) -> int | Awaitable[int]:\n        \"\"\"\n        Stores in ``dest`` the result of a range of values from sorted set\n        ``name`` between ``start`` and ``end`` sorted in ascending order.\n\n        ``start`` and ``end`` can be negative, indicating the end of the range.\n\n        ``byscore`` when set to True, returns the range of elements from the\n        sorted set having scores equal or between ``start`` and ``end``.\n\n        ``bylex`` when set to True, returns the range of elements from the\n        sorted set between the ``start`` and ``end`` lexicographical closed\n        range intervals.\n        Valid ``start`` and ``end`` must start with ( or [, in order to specify\n        whether the range interval is exclusive or inclusive, respectively.\n\n        ``desc`` a boolean indicating whether to sort the results in reversed\n        order.\n\n        ``offset`` and ``num`` are specified, then return a slice of the range.\n        Can't be provided when using ``bylex``.\n\n        For more information, see https://redis.io/commands/zrangestore\n        \"\"\"\n        return self._zrange(\n            \"ZRANGESTORE\",\n            dest,\n            name,\n            start,\n            end,\n            desc,\n            byscore,\n            bylex,\n            False,\n            None,\n            offset,\n            num,\n        )\n\n    @overload\n    def zrangebylex(\n        self: SyncClientProtocol,\n        name: KeyT,\n        min: EncodableT,\n        max: EncodableT,\n        start: int | None = None,\n        num: int | None = None,\n    ) -> list[bytes | str]: ...\n\n    @overload\n    def zrangebylex(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        min: EncodableT,\n        max: EncodableT,\n        start: int | None = None,\n        num: int | None = None,\n    ) -> Awaitable[list[bytes | str]]: ...\n\n    def zrangebylex(\n        self,\n        name: KeyT,\n        min: EncodableT,\n        max: EncodableT,\n        start: int | None = None,\n        num: int | None = None,\n    ) -> list[bytes | str] | Awaitable[list[bytes | str]]:\n        \"\"\"\n        Return the lexicographical range of values from sorted set ``name``\n        between ``min`` and ``max``.\n\n        If ``start`` and ``num`` are specified, then return a slice of the\n        range.\n\n        For more information, see https://redis.io/commands/zrangebylex\n        \"\"\"\n        if (start is not None and num is None) or (num is not None and start is None):\n            raise DataError(\"``start`` and ``num`` must both be specified\")\n        pieces = [\"ZRANGEBYLEX\", name, min, max]\n        if start is not None and num is not None:\n            pieces.extend([b\"LIMIT\", start, num])\n        return self.execute_command(*pieces, keys=[name])\n\n    @overload\n    def zrevrangebylex(\n        self: SyncClientProtocol,\n        name: KeyT,\n        max: EncodableT,\n        min: EncodableT,\n        start: int | None = None,\n        num: int | None = None,\n    ) -> list[bytes | str]: ...\n\n    @overload\n    def zrevrangebylex(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        max: EncodableT,\n        min: EncodableT,\n        start: int | None = None,\n        num: int | None = None,\n    ) -> Awaitable[list[bytes | str]]: ...\n\n    def zrevrangebylex(\n        self,\n        name: KeyT,\n        max: EncodableT,\n        min: EncodableT,\n        start: int | None = None,\n        num: int | None = None,\n    ) -> list[bytes | str] | Awaitable[list[bytes | str]]:\n        \"\"\"\n        Return the reversed lexicographical range of values from sorted set\n        ``name`` between ``max`` and ``min``.\n\n        If ``start`` and ``num`` are specified, then return a slice of the\n        range.\n\n        For more information, see https://redis.io/commands/zrevrangebylex\n        \"\"\"\n        if (start is not None and num is None) or (num is not None and start is None):\n            raise DataError(\"``start`` and ``num`` must both be specified\")\n        pieces = [\"ZREVRANGEBYLEX\", name, max, min]\n        if start is not None and num is not None:\n            pieces.extend([\"LIMIT\", start, num])\n        return self.execute_command(*pieces, keys=[name])\n\n    @overload\n    def zrangebyscore(\n        self: SyncClientProtocol,\n        name: KeyT,\n        min: ZScoreBoundT,\n        max: ZScoreBoundT,\n        start: int | None = None,\n        num: int | None = None,\n        withscores: bool = False,\n        score_cast_func: type | Callable = float,\n    ) -> ZSetRangeResponse: ...\n\n    @overload\n    def zrangebyscore(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        min: ZScoreBoundT,\n        max: ZScoreBoundT,\n        start: int | None = None,\n        num: int | None = None,\n        withscores: bool = False,\n        score_cast_func: type | Callable = float,\n    ) -> Awaitable[ZSetRangeResponse]: ...\n\n    def zrangebyscore(\n        self,\n        name: KeyT,\n        min: ZScoreBoundT,\n        max: ZScoreBoundT,\n        start: int | None = None,\n        num: int | None = None,\n        withscores: bool = False,\n        score_cast_func: type | Callable = float,\n    ) -> ZSetRangeResponse | Awaitable[ZSetRangeResponse]:\n        \"\"\"\n        Return a range of values from the sorted set ``name`` with scores\n        between ``min`` and ``max``.\n\n        If ``start`` and ``num`` are specified, then return a slice\n        of the range.\n\n        ``withscores`` indicates to return the scores along with the values.\n        The return type is a list of (value, score) pairs\n\n        `score_cast_func`` a callable used to cast the score return value\n\n        For more information, see https://redis.io/commands/zrangebyscore\n        \"\"\"\n        if (start is not None and num is None) or (num is not None and start is None):\n            raise DataError(\"``start`` and ``num`` must both be specified\")\n        pieces = [\"ZRANGEBYSCORE\", name, min, max]\n        if start is not None and num is not None:\n            pieces.extend([\"LIMIT\", start, num])\n        if withscores:\n            pieces.append(\"WITHSCORES\")\n        options = {\"withscores\": withscores, \"score_cast_func\": score_cast_func}\n        options[\"keys\"] = [name]\n        return self.execute_command(*pieces, **options)\n\n    @overload\n    def zrevrangebyscore(\n        self: SyncClientProtocol,\n        name: KeyT,\n        max: ZScoreBoundT,\n        min: ZScoreBoundT,\n        start: int | None = None,\n        num: int | None = None,\n        withscores: bool = False,\n        score_cast_func: type | Callable = float,\n    ) -> ZSetRangeResponse: ...\n\n    @overload\n    def zrevrangebyscore(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        max: ZScoreBoundT,\n        min: ZScoreBoundT,\n        start: int | None = None,\n        num: int | None = None,\n        withscores: bool = False,\n        score_cast_func: type | Callable = float,\n    ) -> Awaitable[ZSetRangeResponse]: ...\n\n    def zrevrangebyscore(\n        self,\n        name: KeyT,\n        max: ZScoreBoundT,\n        min: ZScoreBoundT,\n        start: int | None = None,\n        num: int | None = None,\n        withscores: bool = False,\n        score_cast_func: type | Callable = float,\n    ) -> ZSetRangeResponse | Awaitable[ZSetRangeResponse]:\n        \"\"\"\n        Return a range of values from the sorted set ``name`` with scores\n        between ``min`` and ``max`` in descending order.\n\n        If ``start`` and ``num`` are specified, then return a slice\n        of the range.\n\n        ``withscores`` indicates to return the scores along with the values.\n        The return type is a list of (value, score) pairs\n\n        ``score_cast_func`` a callable used to cast the score return value\n\n        For more information, see https://redis.io/commands/zrevrangebyscore\n        \"\"\"\n        if (start is not None and num is None) or (num is not None and start is None):\n            raise DataError(\"``start`` and ``num`` must both be specified\")\n        pieces = [\"ZREVRANGEBYSCORE\", name, max, min]\n        if start is not None and num is not None:\n            pieces.extend([\"LIMIT\", start, num])\n        if withscores:\n            pieces.append(\"WITHSCORES\")\n        options = {\"withscores\": withscores, \"score_cast_func\": score_cast_func}\n        options[\"keys\"] = [name]\n        return self.execute_command(*pieces, **options)\n\n    @overload\n    def zrank(\n        self: SyncClientProtocol,\n        name: KeyT,\n        value: EncodableT,\n        withscore: bool = False,\n        score_cast_func: type | Callable = float,\n    ) -> int | list[Any] | None: ...\n\n    @overload\n    def zrank(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        value: EncodableT,\n        withscore: bool = False,\n        score_cast_func: type | Callable = float,\n    ) -> Awaitable[int | list[Any] | None]: ...\n\n    def zrank(\n        self,\n        name: KeyT,\n        value: EncodableT,\n        withscore: bool = False,\n        score_cast_func: type | Callable = float,\n    ) -> (int | list[Any] | None) | Awaitable[int | list[Any] | None]:\n        \"\"\"\n        Returns a 0-based value indicating the rank of ``value`` in sorted set\n        ``name``.\n        The optional WITHSCORE argument supplements the command's\n        reply with the score of the element returned.\n\n        ``score_cast_func`` a callable used to cast the score return value\n\n        For more information, see https://redis.io/commands/zrank\n        \"\"\"\n        pieces = [\"ZRANK\", name, value]\n        if withscore:\n            pieces.append(\"WITHSCORE\")\n\n        options = {\"withscore\": withscore, \"score_cast_func\": score_cast_func}\n\n        return self.execute_command(*pieces, **options)\n\n    @overload\n    def zrem(self: SyncClientProtocol, name: KeyT, *values: FieldT) -> int: ...\n\n    @overload\n    def zrem(\n        self: AsyncClientProtocol, name: KeyT, *values: FieldT\n    ) -> Awaitable[int]: ...\n\n    def zrem(self, name: KeyT, *values: FieldT) -> int | Awaitable[int]:\n        \"\"\"\n        Remove member ``values`` from sorted set ``name``\n\n        For more information, see https://redis.io/commands/zrem\n        \"\"\"\n        return self.execute_command(\"ZREM\", name, *values)\n\n    @overload\n    def zremrangebylex(\n        self: SyncClientProtocol, name: KeyT, min: EncodableT, max: EncodableT\n    ) -> int: ...\n\n    @overload\n    def zremrangebylex(\n        self: AsyncClientProtocol, name: KeyT, min: EncodableT, max: EncodableT\n    ) -> Awaitable[int]: ...\n\n    def zremrangebylex(\n        self, name: KeyT, min: EncodableT, max: EncodableT\n    ) -> int | Awaitable[int]:\n        \"\"\"\n        Remove all elements in the sorted set ``name`` between the\n        lexicographical range specified by ``min`` and ``max``.\n\n        Returns the number of elements removed.\n\n        For more information, see https://redis.io/commands/zremrangebylex\n        \"\"\"\n        return self.execute_command(\"ZREMRANGEBYLEX\", name, min, max)\n\n    @overload\n    def zremrangebyrank(\n        self: SyncClientProtocol, name: KeyT, min: int, max: int\n    ) -> int: ...\n\n    @overload\n    def zremrangebyrank(\n        self: AsyncClientProtocol, name: KeyT, min: int, max: int\n    ) -> Awaitable[int]: ...\n\n    def zremrangebyrank(self, name: KeyT, min: int, max: int) -> int | Awaitable[int]:\n        \"\"\"\n        Remove all elements in the sorted set ``name`` with ranks between\n        ``min`` and ``max``. Values are 0-based, ordered from smallest score\n        to largest. Values can be negative indicating the highest scores.\n        Returns the number of elements removed\n\n        For more information, see https://redis.io/commands/zremrangebyrank\n        \"\"\"\n        return self.execute_command(\"ZREMRANGEBYRANK\", name, min, max)\n\n    @overload\n    def zremrangebyscore(\n        self: SyncClientProtocol, name: KeyT, min: ZScoreBoundT, max: ZScoreBoundT\n    ) -> int: ...\n\n    @overload\n    def zremrangebyscore(\n        self: AsyncClientProtocol, name: KeyT, min: ZScoreBoundT, max: ZScoreBoundT\n    ) -> Awaitable[int]: ...\n\n    def zremrangebyscore(\n        self, name: KeyT, min: ZScoreBoundT, max: ZScoreBoundT\n    ) -> int | Awaitable[int]:\n        \"\"\"\n        Remove all elements in the sorted set ``name`` with scores\n        between ``min`` and ``max``. Returns the number of elements removed.\n\n        For more information, see https://redis.io/commands/zremrangebyscore\n        \"\"\"\n        return self.execute_command(\"ZREMRANGEBYSCORE\", name, min, max)\n\n    @overload\n    def zrevrank(\n        self: SyncClientProtocol,\n        name: KeyT,\n        value: EncodableT,\n        withscore: bool = False,\n        score_cast_func: type | Callable = float,\n    ) -> int | list[Any] | None: ...\n\n    @overload\n    def zrevrank(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        value: EncodableT,\n        withscore: bool = False,\n        score_cast_func: type | Callable = float,\n    ) -> Awaitable[int | list[Any] | None]: ...\n\n    def zrevrank(\n        self,\n        name: KeyT,\n        value: EncodableT,\n        withscore: bool = False,\n        score_cast_func: type | Callable = float,\n    ) -> (int | list[Any] | None) | Awaitable[int | list[Any] | None]:\n        \"\"\"\n        Returns a 0-based value indicating the descending rank of\n        ``value`` in sorted set ``name``.\n        The optional ``withscore`` argument supplements the command's\n        reply with the score of the element returned.\n\n        ``score_cast_func`` a callable used to cast the score return value\n\n        For more information, see https://redis.io/commands/zrevrank\n        \"\"\"\n        pieces = [\"ZREVRANK\", name, value]\n        if withscore:\n            pieces.append(\"WITHSCORE\")\n\n        options = {\"withscore\": withscore, \"score_cast_func\": score_cast_func}\n\n        return self.execute_command(*pieces, **options)\n\n    @overload\n    def zscore(\n        self: SyncClientProtocol, name: KeyT, value: EncodableT\n    ) -> float | None: ...\n\n    @overload\n    def zscore(\n        self: AsyncClientProtocol, name: KeyT, value: EncodableT\n    ) -> Awaitable[float | None]: ...\n\n    def zscore(self, name: KeyT, value: EncodableT) -> (float | None) | Awaitable[\n        float | None\n    ]:\n        \"\"\"\n        Return the score of element ``value`` in sorted set ``name``\n\n        For more information, see https://redis.io/commands/zscore\n        \"\"\"\n        return self.execute_command(\"ZSCORE\", name, value, keys=[name])\n\n    @overload\n    def zunion(\n        self: SyncClientProtocol,\n        keys: Sequence[KeyT] | Mapping[AnyKeyT, float],\n        aggregate: str | None = None,\n        withscores: bool = False,\n        score_cast_func: type | Callable = float,\n    ) -> ZSetRangeResponse: ...\n\n    @overload\n    def zunion(\n        self: AsyncClientProtocol,\n        keys: Sequence[KeyT] | Mapping[AnyKeyT, float],\n        aggregate: str | None = None,\n        withscores: bool = False,\n        score_cast_func: type | Callable = float,\n    ) -> Awaitable[ZSetRangeResponse]: ...\n\n    def zunion(\n        self,\n        keys: Sequence[KeyT] | Mapping[AnyKeyT, float],\n        aggregate: str | None = None,\n        withscores: bool = False,\n        score_cast_func: type | Callable = float,\n    ) -> ZSetRangeResponse | Awaitable[ZSetRangeResponse]:\n        \"\"\"\n        Return the union of multiple sorted sets specified by ``keys``.\n        ``keys`` can be provided as dictionary of keys and their weights.\n        Scores will be aggregated based on the ``aggregate``, or SUM if\n        none is provided.\n\n        ``score_cast_func`` a callable used to cast the score return value\n\n        For more information, see https://redis.io/commands/zunion\n        \"\"\"\n        return self._zaggregate(\n            \"ZUNION\",\n            None,\n            keys,\n            aggregate,\n            withscores=withscores,\n            score_cast_func=score_cast_func,\n        )\n\n    @overload\n    def zunionstore(\n        self: SyncClientProtocol,\n        dest: KeyT,\n        keys: Sequence[KeyT] | Mapping[AnyKeyT, float],\n        aggregate: str | None = None,\n    ) -> int: ...\n\n    @overload\n    def zunionstore(\n        self: AsyncClientProtocol,\n        dest: KeyT,\n        keys: Sequence[KeyT] | Mapping[AnyKeyT, float],\n        aggregate: str | None = None,\n    ) -> Awaitable[int]: ...\n\n    def zunionstore(\n        self,\n        dest: KeyT,\n        keys: Sequence[KeyT] | Mapping[AnyKeyT, float],\n        aggregate: str | None = None,\n    ) -> int | Awaitable[int]:\n        \"\"\"\n        Union multiple sorted sets specified by ``keys`` into\n        a new sorted set, ``dest``. Scores in the destination will be\n        aggregated based on the ``aggregate``, or SUM if none is provided.\n\n        For more information, see https://redis.io/commands/zunionstore\n        \"\"\"\n        return self._zaggregate(\"ZUNIONSTORE\", dest, keys, aggregate)\n\n    @overload\n    def zmscore(\n        self: SyncClientProtocol, key: KeyT, members: List[str]\n    ) -> list[float | None]: ...\n\n    @overload\n    def zmscore(\n        self: AsyncClientProtocol, key: KeyT, members: List[str]\n    ) -> Awaitable[list[float | None]]: ...\n\n    def zmscore(\n        self, key: KeyT, members: List[str]\n    ) -> list[float | None] | Awaitable[list[float | None]]:\n        \"\"\"\n        Returns the scores associated with the specified members\n        in the sorted set stored at key.\n        ``members`` should be a list of the member name.\n        Return type is a list of score.\n        If the member does not exist, a None will be returned\n        in corresponding position.\n\n        For more information, see https://redis.io/commands/zmscore\n        \"\"\"\n        if not members:\n            raise DataError(\"ZMSCORE members must be a non-empty list\")\n        pieces = [key] + members\n        return self.execute_command(\"ZMSCORE\", *pieces, keys=[key])\n\n    def _zaggregate(\n        self,\n        command: str,\n        dest: Union[KeyT, None],\n        keys: Union[Sequence[KeyT], Mapping[AnyKeyT, float]],\n        aggregate: Optional[str] = None,\n        **options,\n    ) -> ResponseT:\n        pieces: list[EncodableT] = [command]\n        if dest is not None:\n            pieces.append(dest)\n        pieces.append(len(keys))\n        if isinstance(keys, dict):\n            keys, weights = keys.keys(), keys.values()\n        else:\n            weights = None\n        pieces.extend(keys)\n        if weights:\n            pieces.append(b\"WEIGHTS\")\n            pieces.extend(weights)\n        if aggregate:\n            if aggregate.upper() in [\"SUM\", \"MIN\", \"MAX\"]:\n                pieces.append(b\"AGGREGATE\")\n                pieces.append(aggregate)\n            else:\n                raise DataError(\"aggregate can be sum, min or max.\")\n        if options.get(\"withscores\", False):\n            pieces.append(b\"WITHSCORES\")\n        options[\"keys\"] = keys\n        return self.execute_command(*pieces, **options)\n\n\nAsyncSortedSetCommands = SortedSetCommands\n\n\nclass HyperlogCommands(CommandsProtocol):\n    \"\"\"\n    Redis commands of HyperLogLogs data type.\n    see: https://redis.io/topics/data-types-intro#hyperloglogs\n    \"\"\"\n\n    @overload\n    def pfadd(self: SyncClientProtocol, name: KeyT, *values: FieldT) -> int: ...\n\n    @overload\n    def pfadd(\n        self: AsyncClientProtocol, name: KeyT, *values: FieldT\n    ) -> Awaitable[int]: ...\n\n    def pfadd(self, name: KeyT, *values: FieldT) -> int | Awaitable[int]:\n        \"\"\"\n        Adds the specified elements to the specified HyperLogLog.\n\n        For more information, see https://redis.io/commands/pfadd\n        \"\"\"\n        return self.execute_command(\"PFADD\", name, *values)\n\n    @overload\n    def pfcount(self: SyncClientProtocol, *sources: KeyT) -> int: ...\n\n    @overload\n    def pfcount(self: AsyncClientProtocol, *sources: KeyT) -> Awaitable[int]: ...\n\n    def pfcount(self, *sources: KeyT) -> int | Awaitable[int]:\n        \"\"\"\n        Return the approximated cardinality of\n        the set observed by the HyperLogLog at key(s).\n\n        For more information, see https://redis.io/commands/pfcount\n        \"\"\"\n        return self.execute_command(\"PFCOUNT\", *sources)\n\n    @overload\n    def pfmerge(self: SyncClientProtocol, dest: KeyT, *sources: KeyT) -> bool: ...\n\n    @overload\n    def pfmerge(\n        self: AsyncClientProtocol, dest: KeyT, *sources: KeyT\n    ) -> Awaitable[bool]: ...\n\n    def pfmerge(self, dest: KeyT, *sources: KeyT) -> bool | Awaitable[bool]:\n        \"\"\"\n        Merge N different HyperLogLogs into a single one.\n\n        For more information, see https://redis.io/commands/pfmerge\n        \"\"\"\n        return self.execute_command(\"PFMERGE\", dest, *sources)\n\n\nAsyncHyperlogCommands = HyperlogCommands\n\n\nclass HashDataPersistOptions(Enum):\n    # set the value for each provided key to each\n    # provided value only if all do not already exist.\n    FNX = \"FNX\"\n\n    # set the value for each provided key to each\n    # provided value only if all already exist.\n    FXX = \"FXX\"\n\n\nclass HashCommands(CommandsProtocol):\n    \"\"\"\n    Redis commands for Hash data type.\n    see: https://redis.io/topics/data-types-intro#redis-hashes\n    \"\"\"\n\n    @overload\n    def hdel(self: SyncClientProtocol, name: str, *keys: str) -> int: ...\n\n    @overload\n    def hdel(self: AsyncClientProtocol, name: str, *keys: str) -> Awaitable[int]: ...\n\n    def hdel(self, name: str, *keys: str) -> int | Awaitable[int]:\n        \"\"\"\n        Delete ``keys`` from hash ``name``\n\n        For more information, see https://redis.io/commands/hdel\n        \"\"\"\n        return self.execute_command(\"HDEL\", name, *keys)\n\n    @overload\n    def hexists(self: SyncClientProtocol, name: str, key: str) -> bool: ...\n\n    @overload\n    def hexists(self: AsyncClientProtocol, name: str, key: str) -> Awaitable[bool]: ...\n\n    def hexists(self, name: str, key: str) -> bool | Awaitable[bool]:\n        \"\"\"\n        Returns a boolean indicating if ``key`` exists within hash ``name``\n\n        For more information, see https://redis.io/commands/hexists\n        \"\"\"\n        return self.execute_command(\"HEXISTS\", name, key, keys=[name])\n\n    @overload\n    def hget(self: SyncClientProtocol, name: str, key: str) -> bytes | str | None: ...\n\n    @overload\n    def hget(\n        self: AsyncClientProtocol, name: str, key: str\n    ) -> Awaitable[bytes | str | None]: ...\n\n    def hget(self, name: str, key: str) -> (bytes | str | None) | Awaitable[\n        bytes | str | None\n    ]:\n        \"\"\"\n        Return the value of ``key`` within the hash ``name``\n\n        For more information, see https://redis.io/commands/hget\n        \"\"\"\n        return self.execute_command(\"HGET\", name, key, keys=[name])\n\n    @overload\n    def hgetall(\n        self: SyncClientProtocol, name: str\n    ) -> dict[bytes | str, bytes | str]: ...\n\n    @overload\n    def hgetall(\n        self: AsyncClientProtocol, name: str\n    ) -> Awaitable[dict[bytes | str, bytes | str]]: ...\n\n    def hgetall(\n        self, name: str\n    ) -> dict[bytes | str, bytes | str] | Awaitable[dict[bytes | str, bytes | str]]:\n        \"\"\"\n        Return a Python dict of the hash's name/value pairs\n\n        For more information, see https://redis.io/commands/hgetall\n        \"\"\"\n        return self.execute_command(\"HGETALL\", name, keys=[name])\n\n    @overload\n    def hgetdel(\n        self: SyncClientProtocol, name: str, *keys: str\n    ) -> list[bytes | str | None]: ...\n\n    @overload\n    def hgetdel(\n        self: AsyncClientProtocol, name: str, *keys: str\n    ) -> Awaitable[list[bytes | str | None]]: ...\n\n    def hgetdel(\n        self, name: str, *keys: str\n    ) -> list[bytes | str | None] | Awaitable[list[bytes | str | None]]:\n        \"\"\"\n        Return the value of ``key`` within the hash ``name`` and\n        delete the field in the hash.\n        This command is similar to HGET, except for the fact that it also deletes\n        the key on success from the hash with the provided ```name```.\n\n        Available since Redis 8.0\n        For more information, see https://redis.io/commands/hgetdel\n        \"\"\"\n        if len(keys) == 0:\n            raise DataError(\"'hgetdel' should have at least one key provided\")\n\n        return self.execute_command(\"HGETDEL\", name, \"FIELDS\", len(keys), *keys)\n\n    @overload\n    def hgetex(\n        self: SyncClientProtocol,\n        name: KeyT,\n        *keys: str,\n        ex: ExpiryT | None = None,\n        px: ExpiryT | None = None,\n        exat: AbsExpiryT | None = None,\n        pxat: AbsExpiryT | None = None,\n        persist: bool = False,\n    ) -> list[bytes | str | None]: ...\n\n    @overload\n    def hgetex(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        *keys: str,\n        ex: ExpiryT | None = None,\n        px: ExpiryT | None = None,\n        exat: AbsExpiryT | None = None,\n        pxat: AbsExpiryT | None = None,\n        persist: bool = False,\n    ) -> Awaitable[list[bytes | str | None]]: ...\n\n    def hgetex(\n        self,\n        name: KeyT,\n        *keys: str,\n        ex: ExpiryT | None = None,\n        px: ExpiryT | None = None,\n        exat: AbsExpiryT | None = None,\n        pxat: AbsExpiryT | None = None,\n        persist: bool = False,\n    ) -> list[bytes | str | None] | Awaitable[list[bytes | str | None]]:\n        \"\"\"\n        Return the values of ``key`` and ``keys`` within the hash ``name``\n        and optionally set their expiration.\n\n        ``ex`` sets an expire flag on ``kyes`` for ``ex`` seconds.\n\n        ``px`` sets an expire flag on ``keys`` for ``px`` milliseconds.\n\n        ``exat`` sets an expire flag on ``keys`` for ``ex`` seconds,\n        specified in unix time.\n\n        ``pxat`` sets an expire flag on ``keys`` for ``ex`` milliseconds,\n        specified in unix time.\n\n        ``persist`` remove the time to live associated with the ``keys``.\n\n        Available since Redis 8.0\n        For more information, see https://redis.io/commands/hgetex\n        \"\"\"\n        if not keys:\n            raise DataError(\"'hgetex' should have at least one key provided\")\n\n        if not at_most_one_value_set((ex, px, exat, pxat, persist)):\n            raise DataError(\n                \"``ex``, ``px``, ``exat``, ``pxat``, \"\n                \"and ``persist`` are mutually exclusive.\"\n            )\n\n        exp_options: list[EncodableT] = extract_expire_flags(ex, px, exat, pxat)\n\n        if persist:\n            exp_options.append(\"PERSIST\")\n\n        return self.execute_command(\n            \"HGETEX\",\n            name,\n            *exp_options,\n            \"FIELDS\",\n            len(keys),\n            *keys,\n        )\n\n    @overload\n    def hincrby(\n        self: SyncClientProtocol, name: str, key: str, amount: int = 1\n    ) -> int: ...\n\n    @overload\n    def hincrby(\n        self: AsyncClientProtocol, name: str, key: str, amount: int = 1\n    ) -> Awaitable[int]: ...\n\n    def hincrby(self, name: str, key: str, amount: int = 1) -> int | Awaitable[int]:\n        \"\"\"\n        Increment the value of ``key`` in hash ``name`` by ``amount``\n\n        For more information, see https://redis.io/commands/hincrby\n        \"\"\"\n        return self.execute_command(\"HINCRBY\", name, key, amount)\n\n    @overload\n    def hincrbyfloat(\n        self: SyncClientProtocol, name: str, key: str, amount: float = 1.0\n    ) -> float: ...\n\n    @overload\n    def hincrbyfloat(\n        self: AsyncClientProtocol, name: str, key: str, amount: float = 1.0\n    ) -> Awaitable[float]: ...\n\n    def hincrbyfloat(\n        self, name: str, key: str, amount: float = 1.0\n    ) -> float | Awaitable[float]:\n        \"\"\"\n        Increment the value of ``key`` in hash ``name`` by floating ``amount``\n\n        For more information, see https://redis.io/commands/hincrbyfloat\n        \"\"\"\n        return self.execute_command(\"HINCRBYFLOAT\", name, key, amount)\n\n    @overload\n    def hkeys(self: SyncClientProtocol, name: str) -> list[bytes | str]: ...\n\n    @overload\n    def hkeys(self: AsyncClientProtocol, name: str) -> Awaitable[list[bytes | str]]: ...\n\n    def hkeys(self, name: str) -> list[bytes | str] | Awaitable[list[bytes | str]]:\n        \"\"\"\n        Return the list of keys within hash ``name``\n\n        For more information, see https://redis.io/commands/hkeys\n        \"\"\"\n        return self.execute_command(\"HKEYS\", name, keys=[name])\n\n    @overload\n    def hlen(self: SyncClientProtocol, name: str) -> int: ...\n\n    @overload\n    def hlen(self: AsyncClientProtocol, name: str) -> Awaitable[int]: ...\n\n    def hlen(self, name: str) -> int | Awaitable[int]:\n        \"\"\"\n        Return the number of elements in hash ``name``\n\n        For more information, see https://redis.io/commands/hlen\n        \"\"\"\n        return self.execute_command(\"HLEN\", name, keys=[name])\n\n    @overload\n    def hset(\n        self: SyncClientProtocol,\n        name: str,\n        key: str | None = None,\n        value: str | None = None,\n        mapping: dict | None = None,\n        items: list | None = None,\n    ) -> int: ...\n\n    @overload\n    def hset(\n        self: AsyncClientProtocol,\n        name: str,\n        key: str | None = None,\n        value: str | None = None,\n        mapping: dict | None = None,\n        items: list | None = None,\n    ) -> Awaitable[int]: ...\n\n    def hset(\n        self,\n        name: str,\n        key: str | None = None,\n        value: str | None = None,\n        mapping: dict | None = None,\n        items: list | None = None,\n    ) -> int | Awaitable[int]:\n        \"\"\"\n        Set ``key`` to ``value`` within hash ``name``,\n        ``mapping`` accepts a dict of key/value pairs that will be\n        added to hash ``name``.\n        ``items`` accepts a list of key/value pairs that will be\n        added to hash ``name``.\n        Returns the number of fields that were added.\n\n        For more information, see https://redis.io/commands/hset\n        \"\"\"\n\n        if key is None and not mapping and not items:\n            raise DataError(\"'hset' with no key value pairs\")\n\n        pieces = []\n        if items:\n            pieces.extend(items)\n        if key is not None:\n            pieces.extend((key, value))\n        if mapping:\n            for pair in mapping.items():\n                pieces.extend(pair)\n\n        return self.execute_command(\"HSET\", name, *pieces)\n\n    @overload\n    def hsetex(\n        self: SyncClientProtocol,\n        name: str,\n        key: str | None = None,\n        value: str | None = None,\n        mapping: dict | None = None,\n        items: list | None = None,\n        ex: ExpiryT | None = None,\n        px: ExpiryT | None = None,\n        exat: AbsExpiryT | None = None,\n        pxat: AbsExpiryT | None = None,\n        data_persist_option: HashDataPersistOptions | None = None,\n        keepttl: bool = False,\n    ) -> int: ...\n\n    @overload\n    def hsetex(\n        self: AsyncClientProtocol,\n        name: str,\n        key: str | None = None,\n        value: str | None = None,\n        mapping: dict | None = None,\n        items: list | None = None,\n        ex: ExpiryT | None = None,\n        px: ExpiryT | None = None,\n        exat: AbsExpiryT | None = None,\n        pxat: AbsExpiryT | None = None,\n        data_persist_option: HashDataPersistOptions | None = None,\n        keepttl: bool = False,\n    ) -> Awaitable[int]: ...\n\n    def hsetex(\n        self,\n        name: str,\n        key: str | None = None,\n        value: str | None = None,\n        mapping: dict | None = None,\n        items: list | None = None,\n        ex: ExpiryT | None = None,\n        px: ExpiryT | None = None,\n        exat: AbsExpiryT | None = None,\n        pxat: AbsExpiryT | None = None,\n        data_persist_option: HashDataPersistOptions | None = None,\n        keepttl: bool = False,\n    ) -> int | Awaitable[int]:\n        \"\"\"\n        Set ``key`` to ``value`` within hash ``name``\n\n        ``mapping`` accepts a dict of key/value pairs that will be\n        added to hash ``name``.\n\n        ``items`` accepts a list of key/value pairs that will be\n        added to hash ``name``.\n\n        ``ex`` sets an expire flag on ``keys`` for ``ex`` seconds.\n\n        ``px`` sets an expire flag on ``keys`` for ``px`` milliseconds.\n\n        ``exat`` sets an expire flag on ``keys`` for ``ex`` seconds,\n            specified in unix time.\n\n        ``pxat`` sets an expire flag on ``keys`` for ``ex`` milliseconds,\n            specified in unix time.\n\n        ``data_persist_option`` can be set to ``FNX`` or ``FXX`` to control the\n            behavior of the command.\n            ``FNX`` will set the value for each provided key to each\n                provided value only if all do not already exist.\n            ``FXX`` will set the value for each provided key to each\n                provided value only if all already exist.\n\n        ``keepttl`` if True, retain the time to live associated with the keys.\n\n        Returns the number of fields that were added.\n\n        Available since Redis 8.0\n        For more information, see https://redis.io/commands/hsetex\n        \"\"\"\n        if key is None and not mapping and not items:\n            raise DataError(\"'hsetex' with no key value pairs\")\n\n        if items and len(items) % 2 != 0:\n            raise DataError(\n                \"'hsetex' with odd number of items. \"\n                \"'items' must contain a list of key/value pairs.\"\n            )\n\n        if not at_most_one_value_set((ex, px, exat, pxat, keepttl)):\n            raise DataError(\n                \"``ex``, ``px``, ``exat``, ``pxat``, \"\n                \"and ``keepttl`` are mutually exclusive.\"\n            )\n\n        exp_options: list[EncodableT] = extract_expire_flags(ex, px, exat, pxat)\n        if data_persist_option:\n            exp_options.append(data_persist_option.value)\n\n        if keepttl:\n            exp_options.append(\"KEEPTTL\")\n\n        pieces = []\n        if items:\n            pieces.extend(items)\n        if key is not None:\n            pieces.extend((key, value))\n        if mapping:\n            for pair in mapping.items():\n                pieces.extend(pair)\n\n        return self.execute_command(\n            \"HSETEX\", name, *exp_options, \"FIELDS\", int(len(pieces) / 2), *pieces\n        )\n\n    @overload\n    def hsetnx(self: SyncClientProtocol, name: str, key: str, value: str) -> int: ...\n\n    @overload\n    def hsetnx(\n        self: AsyncClientProtocol, name: str, key: str, value: str\n    ) -> Awaitable[int]: ...\n\n    def hsetnx(self, name: str, key: str, value: str) -> int | Awaitable[int]:\n        \"\"\"\n        Set ``key`` to ``value`` within hash ``name`` if ``key`` does not\n        exist.  Returns 1 if HSETNX created a field, otherwise 0.\n\n        For more information, see https://redis.io/commands/hsetnx\n        \"\"\"\n        return self.execute_command(\"HSETNX\", name, key, value)\n\n    @overload\n    def hmset(self: SyncClientProtocol, name: str, mapping: dict) -> bool: ...\n\n    @overload\n    def hmset(\n        self: AsyncClientProtocol, name: str, mapping: dict\n    ) -> Awaitable[bool]: ...\n\n    @deprecated_function(\n        version=\"4.0.0\",\n        reason=\"Use 'hset' instead.\",\n        name=\"hmset\",\n    )\n    def hmset(self, name: str, mapping: dict) -> bool | Awaitable[bool]:\n        \"\"\"\n        Set key to value within hash ``name`` for each corresponding\n        key and value from the ``mapping`` dict.\n\n        For more information, see https://redis.io/commands/hmset\n        \"\"\"\n        if not mapping:\n            raise DataError(\"'hmset' with 'mapping' of length 0\")\n        items = []\n        for pair in mapping.items():\n            items.extend(pair)\n        return self.execute_command(\"HMSET\", name, *items)\n\n    @overload\n    def hmget(\n        self: SyncClientProtocol, name: str, keys: List, *args: List\n    ) -> list[bytes | str | None]: ...\n\n    @overload\n    def hmget(\n        self: AsyncClientProtocol, name: str, keys: List, *args: List\n    ) -> Awaitable[list[bytes | str | None]]: ...\n\n    def hmget(\n        self, name: str, keys: List, *args: List\n    ) -> list[bytes | str | None] | Awaitable[list[bytes | str | None]]:\n        \"\"\"\n        Returns a list of values ordered identically to ``keys``\n\n        For more information, see https://redis.io/commands/hmget\n        \"\"\"\n        args = list_or_args(keys, args)\n        return self.execute_command(\"HMGET\", name, *args, keys=[name])\n\n    @overload\n    def hvals(self: SyncClientProtocol, name: str) -> list[bytes | str]: ...\n\n    @overload\n    def hvals(self: AsyncClientProtocol, name: str) -> Awaitable[list[bytes | str]]: ...\n\n    def hvals(self, name: str) -> list[bytes | str] | Awaitable[list[bytes | str]]:\n        \"\"\"\n        Return the list of values within hash ``name``\n\n        For more information, see https://redis.io/commands/hvals\n        \"\"\"\n        return self.execute_command(\"HVALS\", name, keys=[name])\n\n    @overload\n    def hstrlen(self: SyncClientProtocol, name: str, key: str) -> int: ...\n\n    @overload\n    def hstrlen(self: AsyncClientProtocol, name: str, key: str) -> Awaitable[int]: ...\n\n    def hstrlen(self, name: str, key: str) -> int | Awaitable[int]:\n        \"\"\"\n        Return the number of bytes stored in the value of ``key``\n        within hash ``name``\n\n        For more information, see https://redis.io/commands/hstrlen\n        \"\"\"\n        return self.execute_command(\"HSTRLEN\", name, key, keys=[name])\n\n    @overload\n    def hexpire(\n        self: SyncClientProtocol,\n        name: KeyT,\n        seconds: ExpiryT,\n        *fields: str,\n        nx: bool = False,\n        xx: bool = False,\n        gt: bool = False,\n        lt: bool = False,\n    ) -> list[int]: ...\n\n    @overload\n    def hexpire(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        seconds: ExpiryT,\n        *fields: str,\n        nx: bool = False,\n        xx: bool = False,\n        gt: bool = False,\n        lt: bool = False,\n    ) -> Awaitable[list[int]]: ...\n\n    def hexpire(\n        self,\n        name: KeyT,\n        seconds: ExpiryT,\n        *fields: str,\n        nx: bool = False,\n        xx: bool = False,\n        gt: bool = False,\n        lt: bool = False,\n    ) -> list[int] | Awaitable[list[int]]:\n        \"\"\"\n        Sets or updates the expiration time for fields within a hash key, using relative\n        time in seconds.\n\n        If a field already has an expiration time, the behavior of the update can be\n        controlled using the `nx`, `xx`, `gt`, and `lt` parameters.\n\n        The return value provides detailed information about the outcome for each field.\n\n        For more information, see https://redis.io/commands/hexpire\n\n        Args:\n            name: The name of the hash key.\n            seconds: Expiration time in seconds, relative. Can be an integer, or a\n                     Python `timedelta` object.\n            fields: List of fields within the hash to apply the expiration time to.\n            nx: Set expiry only when the field has no expiry.\n            xx: Set expiry only when the field has an existing expiry.\n            gt: Set expiry only when the new expiry is greater than the current one.\n            lt: Set expiry only when the new expiry is less than the current one.\n\n        Returns:\n            Returns a list which contains for each field in the request:\n                - `-2` if the field does not exist, or if the key does not exist.\n                - `0` if the specified NX | XX | GT | LT condition was not met.\n                - `1` if the expiration time was set or updated.\n                - `2` if the field was deleted because the specified expiration time is\n                  in the past.\n        \"\"\"\n        conditions = [nx, xx, gt, lt]\n        if sum(conditions) > 1:\n            raise ValueError(\"Only one of 'nx', 'xx', 'gt', 'lt' can be specified.\")\n\n        if isinstance(seconds, datetime.timedelta):\n            seconds = int(seconds.total_seconds())\n\n        options = []\n        if nx:\n            options.append(\"NX\")\n        if xx:\n            options.append(\"XX\")\n        if gt:\n            options.append(\"GT\")\n        if lt:\n            options.append(\"LT\")\n\n        return self.execute_command(\n            \"HEXPIRE\", name, seconds, *options, \"FIELDS\", len(fields), *fields\n        )\n\n    @overload\n    def hpexpire(\n        self: SyncClientProtocol,\n        name: KeyT,\n        milliseconds: ExpiryT,\n        *fields: str,\n        nx: bool = False,\n        xx: bool = False,\n        gt: bool = False,\n        lt: bool = False,\n    ) -> list[int]: ...\n\n    @overload\n    def hpexpire(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        milliseconds: ExpiryT,\n        *fields: str,\n        nx: bool = False,\n        xx: bool = False,\n        gt: bool = False,\n        lt: bool = False,\n    ) -> Awaitable[list[int]]: ...\n\n    def hpexpire(\n        self,\n        name: KeyT,\n        milliseconds: ExpiryT,\n        *fields: str,\n        nx: bool = False,\n        xx: bool = False,\n        gt: bool = False,\n        lt: bool = False,\n    ) -> list[int] | Awaitable[list[int]]:\n        \"\"\"\n        Sets or updates the expiration time for fields within a hash key, using relative\n        time in milliseconds.\n\n        If a field already has an expiration time, the behavior of the update can be\n        controlled using the `nx`, `xx`, `gt`, and `lt` parameters.\n\n        The return value provides detailed information about the outcome for each field.\n\n        For more information, see https://redis.io/commands/hpexpire\n\n        Args:\n            name: The name of the hash key.\n            milliseconds: Expiration time in milliseconds, relative. Can be an integer,\n                          or a Python `timedelta` object.\n            fields: List of fields within the hash to apply the expiration time to.\n            nx: Set expiry only when the field has no expiry.\n            xx: Set expiry only when the field has an existing expiry.\n            gt: Set expiry only when the new expiry is greater than the current one.\n            lt: Set expiry only when the new expiry is less than the current one.\n\n        Returns:\n            Returns a list which contains for each field in the request:\n                - `-2` if the field does not exist, or if the key does not exist.\n                - `0` if the specified NX | XX | GT | LT condition was not met.\n                - `1` if the expiration time was set or updated.\n                - `2` if the field was deleted because the specified expiration time is\n                  in the past.\n        \"\"\"\n        conditions = [nx, xx, gt, lt]\n        if sum(conditions) > 1:\n            raise ValueError(\"Only one of 'nx', 'xx', 'gt', 'lt' can be specified.\")\n\n        if isinstance(milliseconds, datetime.timedelta):\n            milliseconds = int(milliseconds.total_seconds() * 1000)\n\n        options = []\n        if nx:\n            options.append(\"NX\")\n        if xx:\n            options.append(\"XX\")\n        if gt:\n            options.append(\"GT\")\n        if lt:\n            options.append(\"LT\")\n\n        return self.execute_command(\n            \"HPEXPIRE\", name, milliseconds, *options, \"FIELDS\", len(fields), *fields\n        )\n\n    @overload\n    def hexpireat(\n        self: SyncClientProtocol,\n        name: KeyT,\n        unix_time_seconds: AbsExpiryT,\n        *fields: str,\n        nx: bool = False,\n        xx: bool = False,\n        gt: bool = False,\n        lt: bool = False,\n    ) -> list[int]: ...\n\n    @overload\n    def hexpireat(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        unix_time_seconds: AbsExpiryT,\n        *fields: str,\n        nx: bool = False,\n        xx: bool = False,\n        gt: bool = False,\n        lt: bool = False,\n    ) -> Awaitable[list[int]]: ...\n\n    def hexpireat(\n        self,\n        name: KeyT,\n        unix_time_seconds: AbsExpiryT,\n        *fields: str,\n        nx: bool = False,\n        xx: bool = False,\n        gt: bool = False,\n        lt: bool = False,\n    ) -> list[int] | Awaitable[list[int]]:\n        \"\"\"\n        Sets or updates the expiration time for fields within a hash key, using an\n        absolute Unix timestamp in seconds.\n\n        If a field already has an expiration time, the behavior of the update can be\n        controlled using the `nx`, `xx`, `gt`, and `lt` parameters.\n\n        The return value provides detailed information about the outcome for each field.\n\n        For more information, see https://redis.io/commands/hexpireat\n\n        Args:\n            name: The name of the hash key.\n            unix_time_seconds: Expiration time as Unix timestamp in seconds. Can be an\n                               integer or a Python `datetime` object.\n            fields: List of fields within the hash to apply the expiration time to.\n            nx: Set expiry only when the field has no expiry.\n            xx: Set expiry only when the field has an existing expiration time.\n            gt: Set expiry only when the new expiry is greater than the current one.\n            lt: Set expiry only when the new expiry is less than the current one.\n\n        Returns:\n            Returns a list which contains for each field in the request:\n                - `-2` if the field does not exist, or if the key does not exist.\n                - `0` if the specified NX | XX | GT | LT condition was not met.\n                - `1` if the expiration time was set or updated.\n                - `2` if the field was deleted because the specified expiration time is\n                  in the past.\n        \"\"\"\n        conditions = [nx, xx, gt, lt]\n        if sum(conditions) > 1:\n            raise ValueError(\"Only one of 'nx', 'xx', 'gt', 'lt' can be specified.\")\n\n        if isinstance(unix_time_seconds, datetime.datetime):\n            unix_time_seconds = int(unix_time_seconds.timestamp())\n\n        options = []\n        if nx:\n            options.append(\"NX\")\n        if xx:\n            options.append(\"XX\")\n        if gt:\n            options.append(\"GT\")\n        if lt:\n            options.append(\"LT\")\n\n        return self.execute_command(\n            \"HEXPIREAT\",\n            name,\n            unix_time_seconds,\n            *options,\n            \"FIELDS\",\n            len(fields),\n            *fields,\n        )\n\n    @overload\n    def hpexpireat(\n        self: SyncClientProtocol,\n        name: KeyT,\n        unix_time_milliseconds: AbsExpiryT,\n        *fields: str,\n        nx: bool = False,\n        xx: bool = False,\n        gt: bool = False,\n        lt: bool = False,\n    ) -> list[int]: ...\n\n    @overload\n    def hpexpireat(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        unix_time_milliseconds: AbsExpiryT,\n        *fields: str,\n        nx: bool = False,\n        xx: bool = False,\n        gt: bool = False,\n        lt: bool = False,\n    ) -> Awaitable[list[int]]: ...\n\n    def hpexpireat(\n        self,\n        name: KeyT,\n        unix_time_milliseconds: AbsExpiryT,\n        *fields: str,\n        nx: bool = False,\n        xx: bool = False,\n        gt: bool = False,\n        lt: bool = False,\n    ) -> list[int] | Awaitable[list[int]]:\n        \"\"\"\n        Sets or updates the expiration time for fields within a hash key, using an\n        absolute Unix timestamp in milliseconds.\n\n        If a field already has an expiration time, the behavior of the update can be\n        controlled using the `nx`, `xx`, `gt`, and `lt` parameters.\n\n        The return value provides detailed information about the outcome for each field.\n\n        For more information, see https://redis.io/commands/hpexpireat\n\n        Args:\n            name: The name of the hash key.\n            unix_time_milliseconds: Expiration time as Unix timestamp in milliseconds.\n                                    Can be an integer or a Python `datetime` object.\n            fields: List of fields within the hash to apply the expiry.\n            nx: Set expiry only when the field has no expiry.\n            xx: Set expiry only when the field has an existing expiry.\n            gt: Set expiry only when the new expiry is greater than the current one.\n            lt: Set expiry only when the new expiry is less than the current one.\n\n        Returns:\n            Returns a list which contains for each field in the request:\n                - `-2` if the field does not exist, or if the key does not exist.\n                - `0` if the specified NX | XX | GT | LT condition was not met.\n                - `1` if the expiration time was set or updated.\n                - `2` if the field was deleted because the specified expiration time is\n                  in the past.\n        \"\"\"\n        conditions = [nx, xx, gt, lt]\n        if sum(conditions) > 1:\n            raise ValueError(\"Only one of 'nx', 'xx', 'gt', 'lt' can be specified.\")\n\n        if isinstance(unix_time_milliseconds, datetime.datetime):\n            unix_time_milliseconds = int(unix_time_milliseconds.timestamp() * 1000)\n\n        options = []\n        if nx:\n            options.append(\"NX\")\n        if xx:\n            options.append(\"XX\")\n        if gt:\n            options.append(\"GT\")\n        if lt:\n            options.append(\"LT\")\n\n        return self.execute_command(\n            \"HPEXPIREAT\",\n            name,\n            unix_time_milliseconds,\n            *options,\n            \"FIELDS\",\n            len(fields),\n            *fields,\n        )\n\n    @overload\n    def hpersist(self: SyncClientProtocol, name: KeyT, *fields: str) -> list[int]: ...\n\n    @overload\n    def hpersist(\n        self: AsyncClientProtocol, name: KeyT, *fields: str\n    ) -> Awaitable[list[int]]: ...\n\n    def hpersist(self, name: KeyT, *fields: str) -> list[int] | Awaitable[list[int]]:\n        \"\"\"\n        Removes the expiration time for each specified field in a hash.\n\n        For more information, see https://redis.io/commands/hpersist\n\n        Args:\n            name: The name of the hash key.\n            fields: A list of fields within the hash from which to remove the\n                    expiration time.\n\n        Returns:\n            Returns a list which contains for each field in the request:\n                - `-2` if the field does not exist, or if the key does not exist.\n                - `-1` if the field exists but has no associated expiration time.\n                - `1` if the expiration time was successfully removed from the field.\n        \"\"\"\n        return self.execute_command(\"HPERSIST\", name, \"FIELDS\", len(fields), *fields)\n\n    @overload\n    def hexpiretime(self: SyncClientProtocol, key: KeyT, *fields: str) -> list[int]: ...\n\n    @overload\n    def hexpiretime(\n        self: AsyncClientProtocol, key: KeyT, *fields: str\n    ) -> Awaitable[list[int]]: ...\n\n    def hexpiretime(self, key: KeyT, *fields: str) -> list[int] | Awaitable[list[int]]:\n        \"\"\"\n        Returns the expiration times of hash fields as Unix timestamps in seconds.\n\n        For more information, see https://redis.io/commands/hexpiretime\n\n        Args:\n            key: The hash key.\n            fields: A list of fields within the hash for which to get the expiration\n                    time.\n\n        Returns:\n            Returns a list which contains for each field in the request:\n                - `-2` if the field does not exist, or if the key does not exist.\n                - `-1` if the field exists but has no associated expire time.\n                - A positive integer representing the expiration Unix timestamp in\n                  seconds, if the field has an associated expiration time.\n        \"\"\"\n        return self.execute_command(\n            \"HEXPIRETIME\", key, \"FIELDS\", len(fields), *fields, keys=[key]\n        )\n\n    @overload\n    def hpexpiretime(\n        self: SyncClientProtocol, key: KeyT, *fields: str\n    ) -> list[int]: ...\n\n    @overload\n    def hpexpiretime(\n        self: AsyncClientProtocol, key: KeyT, *fields: str\n    ) -> Awaitable[list[int]]: ...\n\n    def hpexpiretime(self, key: KeyT, *fields: str) -> list[int] | Awaitable[list[int]]:\n        \"\"\"\n        Returns the expiration times of hash fields as Unix timestamps in milliseconds.\n\n        For more information, see https://redis.io/commands/hpexpiretime\n\n        Args:\n            key: The hash key.\n            fields: A list of fields within the hash for which to get the expiration\n                    time.\n\n        Returns:\n            Returns a list which contains for each field in the request:\n                - `-2` if the field does not exist, or if the key does not exist.\n                - `-1` if the field exists but has no associated expire time.\n                - A positive integer representing the expiration Unix timestamp in\n                  milliseconds, if the field has an associated expiration time.\n        \"\"\"\n        return self.execute_command(\n            \"HPEXPIRETIME\", key, \"FIELDS\", len(fields), *fields, keys=[key]\n        )\n\n    @overload\n    def httl(self: SyncClientProtocol, key: KeyT, *fields: str) -> list[int]: ...\n\n    @overload\n    def httl(\n        self: AsyncClientProtocol, key: KeyT, *fields: str\n    ) -> Awaitable[list[int]]: ...\n\n    def httl(self, key: KeyT, *fields: str) -> list[int] | Awaitable[list[int]]:\n        \"\"\"\n        Returns the TTL (Time To Live) in seconds for each specified field within a hash\n        key.\n\n        For more information, see https://redis.io/commands/httl\n\n        Args:\n            key: The hash key.\n            fields: A list of fields within the hash for which to get the TTL.\n\n        Returns:\n            Returns a list which contains for each field in the request:\n                - `-2` if the field does not exist, or if the key does not exist.\n                - `-1` if the field exists but has no associated expire time.\n                - A positive integer representing the TTL in seconds if the field has\n                  an associated expiration time.\n        \"\"\"\n        return self.execute_command(\n            \"HTTL\", key, \"FIELDS\", len(fields), *fields, keys=[key]\n        )\n\n    @overload\n    def hpttl(self: SyncClientProtocol, key: KeyT, *fields: str) -> list[int]: ...\n\n    @overload\n    def hpttl(\n        self: AsyncClientProtocol, key: KeyT, *fields: str\n    ) -> Awaitable[list[int]]: ...\n\n    def hpttl(self, key: KeyT, *fields: str) -> list[int] | Awaitable[list[int]]:\n        \"\"\"\n        Returns the TTL (Time To Live) in milliseconds for each specified field within a\n        hash key.\n\n        For more information, see https://redis.io/commands/hpttl\n\n        Args:\n            key: The hash key.\n            fields: A list of fields within the hash for which to get the TTL.\n\n        Returns:\n            Returns a list which contains for each field in the request:\n                - `-2` if the field does not exist, or if the key does not exist.\n                - `-1` if the field exists but has no associated expire time.\n                - A positive integer representing the TTL in milliseconds if the field\n                  has an associated expiration time.\n        \"\"\"\n        return self.execute_command(\n            \"HPTTL\", key, \"FIELDS\", len(fields), *fields, keys=[key]\n        )\n\n\nAsyncHashCommands = HashCommands\n\n\nclass Script:\n    \"\"\"\n    An executable Lua script object returned by ``register_script``\n    \"\"\"\n\n    def __init__(self, registered_client: \"redis.client.Redis\", script: ScriptTextT):\n        self.registered_client = registered_client\n        self.script = script\n        # Precalculate and store the SHA1 hex digest of the script.\n\n        if isinstance(script, str):\n            # We need the encoding from the client in order to generate an\n            # accurate byte representation of the script\n            encoder = self.get_encoder()\n            script = encoder.encode(script)\n        self.sha = hashlib.sha1(script).hexdigest()\n\n    def __call__(\n        self,\n        keys: Union[Sequence[KeyT], None] = None,\n        args: Union[Iterable[EncodableT], None] = None,\n        client: Union[\"redis.client.Redis\", None] = None,\n    ):\n        \"\"\"Execute the script, passing any required ``args``\"\"\"\n        keys = keys or []\n        args = args or []\n        if client is None:\n            client = self.registered_client\n        args = tuple(keys) + tuple(args)\n        # make sure the Redis server knows about the script\n        from redis.client import Pipeline\n\n        if isinstance(client, Pipeline):\n            # Make sure the pipeline can register the script before executing.\n            client.scripts.add(self)\n        try:\n            return client.evalsha(self.sha, len(keys), *args)\n        except NoScriptError:\n            # Maybe the client is pointed to a different server than the client\n            # that created this instance?\n            # Overwrite the sha just in case there was a discrepancy.\n            self.sha = client.script_load(self.script)\n            return client.evalsha(self.sha, len(keys), *args)\n\n    def get_encoder(self):\n        \"\"\"Get the encoder to encode string scripts into bytes.\"\"\"\n        try:\n            return self.registered_client.get_encoder()\n        except AttributeError:\n            # DEPRECATED\n            # In version <=4.1.2, this was the code we used to get the encoder.\n            # However, after 4.1.2 we added support for scripting in clustered\n            # redis. ClusteredRedis doesn't have a `.connection_pool` attribute\n            # so we changed the Script class to use\n            # `self.registered_client.get_encoder` (see above).\n            # However, that is technically a breaking change, as consumers who\n            # use Scripts directly might inject a `registered_client` that\n            # doesn't have a `.get_encoder` field. This try/except prevents us\n            # from breaking backward-compatibility. Ideally, it would be\n            # removed in the next major release.\n            return self.registered_client.connection_pool.get_encoder()\n\n\nclass AsyncScript:\n    \"\"\"\n    An executable Lua script object returned by ``register_script``\n    \"\"\"\n\n    def __init__(\n        self,\n        registered_client: \"redis.asyncio.client.Redis\",\n        script: ScriptTextT,\n    ):\n        self.registered_client = registered_client\n        self.script = script\n        # Precalculate and store the SHA1 hex digest of the script.\n\n        if isinstance(script, str):\n            # We need the encoding from the client in order to generate an\n            # accurate byte representation of the script\n            try:\n                encoder = registered_client.connection_pool.get_encoder()\n            except AttributeError:\n                # Cluster\n                encoder = registered_client.get_encoder()\n            script = encoder.encode(script)\n        self.sha = hashlib.sha1(script).hexdigest()\n\n    async def __call__(\n        self,\n        keys: Union[Sequence[KeyT], None] = None,\n        args: Union[Iterable[EncodableT], None] = None,\n        client: Union[\"redis.asyncio.client.Redis\", None] = None,\n    ):\n        \"\"\"Execute the script, passing any required ``args``\"\"\"\n        keys = keys or []\n        args = args or []\n        if client is None:\n            client = self.registered_client\n        args = tuple(keys) + tuple(args)\n        # make sure the Redis server knows about the script\n        from redis.asyncio.client import Pipeline\n\n        if isinstance(client, Pipeline):\n            # Make sure the pipeline can register the script before executing.\n            client.scripts.add(self)\n        try:\n            return await client.evalsha(self.sha, len(keys), *args)\n        except NoScriptError:\n            # Maybe the client is pointed to a different server than the client\n            # that created this instance?\n            # Overwrite the sha just in case there was a discrepancy.\n            self.sha = await client.script_load(self.script)\n            return await client.evalsha(self.sha, len(keys), *args)\n\n\nclass PubSubCommands(CommandsProtocol):\n    \"\"\"\n    Redis PubSub commands.\n    see https://redis.io/topics/pubsub\n    \"\"\"\n\n    @overload\n    def publish(\n        self: SyncClientProtocol, channel: ChannelT, message: EncodableT, **kwargs\n    ) -> int: ...\n\n    @overload\n    def publish(\n        self: AsyncClientProtocol, channel: ChannelT, message: EncodableT, **kwargs\n    ) -> Awaitable[int]: ...\n\n    def publish(\n        self, channel: ChannelT, message: EncodableT, **kwargs\n    ) -> int | Awaitable[int]:\n        \"\"\"\n        Publish ``message`` on ``channel``.\n        Returns the number of subscribers the message was delivered to.\n\n        For more information, see https://redis.io/commands/publish\n        \"\"\"\n        response = self.execute_command(\"PUBLISH\", channel, message, **kwargs)\n        record_pubsub_message(\n            direction=PubSubDirection.PUBLISH,\n            channel=str_if_bytes(channel),\n        )\n        return response\n\n    @overload\n    def spublish(\n        self: SyncClientProtocol, shard_channel: ChannelT, message: EncodableT\n    ) -> int: ...\n\n    @overload\n    def spublish(\n        self: AsyncClientProtocol, shard_channel: ChannelT, message: EncodableT\n    ) -> Awaitable[int]: ...\n\n    def spublish(\n        self, shard_channel: ChannelT, message: EncodableT\n    ) -> int | Awaitable[int]:\n        \"\"\"\n        Posts a message to the given shard channel.\n        Returns the number of clients that received the message\n\n        For more information, see https://redis.io/commands/spublish\n        \"\"\"\n        response = self.execute_command(\"SPUBLISH\", shard_channel, message)\n        record_pubsub_message(\n            direction=PubSubDirection.PUBLISH,\n            channel=str_if_bytes(shard_channel),\n            sharded=True,\n        )\n        return response\n\n    @overload\n    def pubsub_channels(\n        self: SyncClientProtocol, pattern: PatternT = \"*\", **kwargs\n    ) -> list[bytes | str]: ...\n\n    @overload\n    def pubsub_channels(\n        self: AsyncClientProtocol, pattern: PatternT = \"*\", **kwargs\n    ) -> Awaitable[list[bytes | str]]: ...\n\n    def pubsub_channels(\n        self, pattern: PatternT = \"*\", **kwargs\n    ) -> list[bytes | str] | Awaitable[list[bytes | str]]:\n        \"\"\"\n        Return a list of channels that have at least one subscriber\n\n        For more information, see https://redis.io/commands/pubsub-channels\n        \"\"\"\n        return self.execute_command(\"PUBSUB CHANNELS\", pattern, **kwargs)\n\n    @overload\n    def pubsub_shardchannels(\n        self: SyncClientProtocol, pattern: PatternT = \"*\", **kwargs\n    ) -> list[bytes | str]: ...\n\n    @overload\n    def pubsub_shardchannels(\n        self: AsyncClientProtocol, pattern: PatternT = \"*\", **kwargs\n    ) -> Awaitable[list[bytes | str]]: ...\n\n    def pubsub_shardchannels(\n        self, pattern: PatternT = \"*\", **kwargs\n    ) -> list[bytes | str] | Awaitable[list[bytes | str]]:\n        \"\"\"\n        Return a list of shard_channels that have at least one subscriber\n\n        For more information, see https://redis.io/commands/pubsub-shardchannels\n        \"\"\"\n        return self.execute_command(\"PUBSUB SHARDCHANNELS\", pattern, **kwargs)\n\n    @overload\n    def pubsub_numpat(self: SyncClientProtocol, **kwargs) -> int: ...\n\n    @overload\n    def pubsub_numpat(self: AsyncClientProtocol, **kwargs) -> Awaitable[int]: ...\n\n    def pubsub_numpat(self, **kwargs) -> int | Awaitable[int]:\n        \"\"\"\n        Returns the number of subscriptions to patterns\n\n        For more information, see https://redis.io/commands/pubsub-numpat\n        \"\"\"\n        return self.execute_command(\"PUBSUB NUMPAT\", **kwargs)\n\n    @overload\n    def pubsub_numsub(\n        self: SyncClientProtocol, *args: ChannelT, **kwargs\n    ) -> list[tuple[bytes | str, int]]: ...\n\n    @overload\n    def pubsub_numsub(\n        self: AsyncClientProtocol, *args: ChannelT, **kwargs\n    ) -> Awaitable[list[tuple[bytes | str, int]]]: ...\n\n    def pubsub_numsub(\n        self, *args: ChannelT, **kwargs\n    ) -> list[tuple[bytes | str, int]] | Awaitable[list[tuple[bytes | str, int]]]:\n        \"\"\"\n        Return a list of (channel, number of subscribers) tuples\n        for each channel given in ``*args``\n\n        For more information, see https://redis.io/commands/pubsub-numsub\n        \"\"\"\n        return self.execute_command(\"PUBSUB NUMSUB\", *args, **kwargs)\n\n    @overload\n    def pubsub_shardnumsub(\n        self: SyncClientProtocol, *args: ChannelT, **kwargs\n    ) -> list[tuple[bytes | str, int]]: ...\n\n    @overload\n    def pubsub_shardnumsub(\n        self: AsyncClientProtocol, *args: ChannelT, **kwargs\n    ) -> Awaitable[list[tuple[bytes | str, int]]]: ...\n\n    def pubsub_shardnumsub(\n        self, *args: ChannelT, **kwargs\n    ) -> list[tuple[bytes | str, int]] | Awaitable[list[tuple[bytes | str, int]]]:\n        \"\"\"\n        Return a list of (shard_channel, number of subscribers) tuples\n        for each channel given in ``*args``\n\n        For more information, see https://redis.io/commands/pubsub-shardnumsub\n        \"\"\"\n        return self.execute_command(\"PUBSUB SHARDNUMSUB\", *args, **kwargs)\n\n\nAsyncPubSubCommands = PubSubCommands\n\n\nclass ScriptCommands(CommandsProtocol):\n    \"\"\"\n    Redis Lua script commands. see:\n    https://redis.io/ebook/part-3-next-steps/chapter-11-scripting-redis-with-lua/\n    \"\"\"\n\n    def _eval(\n        self,\n        command: str,\n        script: str,\n        numkeys: int,\n        *keys_and_args: Union[KeyT, EncodableT],\n    ) -> Any:\n        return self.execute_command(command, script, numkeys, *keys_and_args)\n\n    @overload\n    def eval(\n        self: SyncClientProtocol,\n        script: str,\n        numkeys: int,\n        *keys_and_args: KeyT | EncodableT,\n    ) -> Any: ...\n\n    @overload\n    def eval(\n        self: AsyncClientProtocol,\n        script: str,\n        numkeys: int,\n        *keys_and_args: KeyT | EncodableT,\n    ) -> Awaitable[Any]: ...\n\n    def eval(\n        self, script: str, numkeys: int, *keys_and_args: KeyT | EncodableT\n    ) -> Any | Awaitable[Any]:\n        \"\"\"\n        Execute the Lua ``script``, specifying the ``numkeys`` the script\n        will touch and the key names and argument values in ``keys_and_args``.\n        Returns the result of the script.\n\n        In practice, use the object returned by ``register_script``. This\n        function exists purely for Redis API completion.\n\n        For more information, see  https://redis.io/commands/eval\n        \"\"\"\n        return self._eval(\"EVAL\", script, numkeys, *keys_and_args)\n\n    @overload\n    def eval_ro(\n        self: SyncClientProtocol,\n        script: str,\n        numkeys: int,\n        *keys_and_args: KeyT | EncodableT,\n    ) -> Any: ...\n\n    @overload\n    def eval_ro(\n        self: AsyncClientProtocol,\n        script: str,\n        numkeys: int,\n        *keys_and_args: KeyT | EncodableT,\n    ) -> Awaitable[Any]: ...\n\n    def eval_ro(\n        self, script: str, numkeys: int, *keys_and_args: KeyT | EncodableT\n    ) -> Any | Awaitable[Any]:\n        \"\"\"\n        The read-only variant of the EVAL command\n\n        Execute the read-only Lua ``script`` specifying the ``numkeys`` the script\n        will touch and the key names and argument values in ``keys_and_args``.\n        Returns the result of the script.\n\n        For more information, see  https://redis.io/commands/eval_ro\n        \"\"\"\n        return self._eval(\"EVAL_RO\", script, numkeys, *keys_and_args)\n\n    def _evalsha(\n        self,\n        command: str,\n        sha: str,\n        numkeys: int,\n        *keys_and_args: Union[KeyT, EncodableT],\n    ) -> Any:\n        return self.execute_command(command, sha, numkeys, *keys_and_args)\n\n    @overload\n    def evalsha(\n        self: SyncClientProtocol,\n        sha: str,\n        numkeys: int,\n        *keys_and_args: KeyT | EncodableT,\n    ) -> Any: ...\n\n    @overload\n    def evalsha(\n        self: AsyncClientProtocol,\n        sha: str,\n        numkeys: int,\n        *keys_and_args: KeyT | EncodableT,\n    ) -> Awaitable[Any]: ...\n\n    def evalsha(\n        self, sha: str, numkeys: int, *keys_and_args: KeyT | EncodableT\n    ) -> Any | Awaitable[Any]:\n        \"\"\"\n        Use the ``sha`` to execute a Lua script already registered via EVAL\n        or SCRIPT LOAD. Specify the ``numkeys`` the script will touch and the\n        key names and argument values in ``keys_and_args``. Returns the result\n        of the script.\n\n        In practice, use the object returned by ``register_script``. This\n        function exists purely for Redis API completion.\n\n        For more information, see  https://redis.io/commands/evalsha\n        \"\"\"\n        return self._evalsha(\"EVALSHA\", sha, numkeys, *keys_and_args)\n\n    @overload\n    def evalsha_ro(\n        self: SyncClientProtocol,\n        sha: str,\n        numkeys: int,\n        *keys_and_args: KeyT | EncodableT,\n    ) -> Any: ...\n\n    @overload\n    def evalsha_ro(\n        self: AsyncClientProtocol,\n        sha: str,\n        numkeys: int,\n        *keys_and_args: KeyT | EncodableT,\n    ) -> Awaitable[Any]: ...\n\n    def evalsha_ro(\n        self, sha: str, numkeys: int, *keys_and_args: KeyT | EncodableT\n    ) -> Any | Awaitable[Any]:\n        \"\"\"\n        The read-only variant of the EVALSHA command\n\n        Use the ``sha`` to execute a read-only Lua script already registered via EVAL\n        or SCRIPT LOAD. Specify the ``numkeys`` the script will touch and the\n        key names and argument values in ``keys_and_args``. Returns the result\n        of the script.\n\n        For more information, see  https://redis.io/commands/evalsha_ro\n        \"\"\"\n        return self._evalsha(\"EVALSHA_RO\", sha, numkeys, *keys_and_args)\n\n    @overload\n    def script_exists(self: SyncClientProtocol, *args: str) -> list[bool]: ...\n\n    @overload\n    def script_exists(\n        self: AsyncClientProtocol, *args: str\n    ) -> Awaitable[list[bool]]: ...\n\n    def script_exists(self, *args: str) -> list[bool] | Awaitable[list[bool]]:\n        \"\"\"\n        Check if a script exists in the script cache by specifying the SHAs of\n        each script as ``args``. Returns a list of boolean values indicating if\n        if each already script exists in the cache_data.\n\n        For more information, see  https://redis.io/commands/script-exists\n        \"\"\"\n        return self.execute_command(\"SCRIPT EXISTS\", *args)\n\n    def script_debug(self, *args) -> None:\n        raise NotImplementedError(\n            \"SCRIPT DEBUG is intentionally not implemented in the client.\"\n        )\n\n    @overload\n    def script_flush(\n        self: SyncClientProtocol,\n        sync_type: Literal[\"SYNC\"] | Literal[\"ASYNC\"] | None = None,\n    ) -> bool: ...\n\n    @overload\n    def script_flush(\n        self: AsyncClientProtocol,\n        sync_type: Literal[\"SYNC\"] | Literal[\"ASYNC\"] | None = None,\n    ) -> Awaitable[bool]: ...\n\n    def script_flush(\n        self, sync_type: Literal[\"SYNC\"] | Literal[\"ASYNC\"] | None = None\n    ) -> bool | Awaitable[bool]:\n        \"\"\"Flush all scripts from the script cache_data.\n\n        ``sync_type`` is by default SYNC (synchronous) but it can also be\n                      ASYNC.\n\n        For more information, see  https://redis.io/commands/script-flush\n        \"\"\"\n\n        # Redis pre 6 had no sync_type.\n        if sync_type not in [\"SYNC\", \"ASYNC\", None]:\n            raise DataError(\n                \"SCRIPT FLUSH defaults to SYNC in redis > 6.2, or \"\n                \"accepts SYNC/ASYNC. For older versions, \"\n                \"of redis leave as None.\"\n            )\n        if sync_type is None:\n            pieces = []\n        else:\n            pieces = [sync_type]\n        return self.execute_command(\"SCRIPT FLUSH\", *pieces)\n\n    @overload\n    def script_kill(self: SyncClientProtocol) -> bool: ...\n\n    @overload\n    def script_kill(self: AsyncClientProtocol) -> Awaitable[bool]: ...\n\n    def script_kill(self) -> bool | Awaitable[bool]:\n        \"\"\"\n        Kill the currently executing Lua script\n\n        For more information, see https://redis.io/commands/script-kill\n        \"\"\"\n        return self.execute_command(\"SCRIPT KILL\")\n\n    @overload\n    def script_load(self: SyncClientProtocol, script: ScriptTextT) -> str: ...\n\n    @overload\n    def script_load(\n        self: AsyncClientProtocol, script: ScriptTextT\n    ) -> Awaitable[str]: ...\n\n    def script_load(self, script: ScriptTextT) -> str | Awaitable[str]:\n        \"\"\"\n        Load a Lua ``script`` into the script cache_data. Returns the SHA.\n\n        For more information, see https://redis.io/commands/script-load\n        \"\"\"\n        return self.execute_command(\"SCRIPT LOAD\", script)\n\n    def register_script(self: \"redis.client.Redis\", script: ScriptTextT) -> Script:\n        \"\"\"\n        Register a Lua ``script`` specifying the ``keys`` it will touch.\n        Returns a Script object that is callable and hides the complexity of\n        deal with scripts, keys, and shas. This is the preferred way to work\n        with Lua scripts.\n        \"\"\"\n        return Script(self, script)\n\n\nclass AsyncScriptCommands(ScriptCommands):\n    async def script_debug(self, *args) -> None:\n        return super().script_debug()\n\n    def register_script(\n        self: \"redis.asyncio.client.Redis\",\n        script: ScriptTextT,\n    ) -> AsyncScript:\n        \"\"\"\n        Register a Lua ``script`` specifying the ``keys`` it will touch.\n        Returns a Script object that is callable and hides the complexity of\n        deal with scripts, keys, and shas. This is the preferred way to work\n        with Lua scripts.\n        \"\"\"\n        return AsyncScript(self, script)\n\n\nclass GeoCommands(CommandsProtocol):\n    \"\"\"\n    Redis Geospatial commands.\n    see: https://redis.com/redis-best-practices/indexing-patterns/geospatial/\n    \"\"\"\n\n    @overload\n    def geoadd(\n        self: SyncClientProtocol,\n        name: KeyT,\n        values: Sequence[EncodableT],\n        nx: bool = False,\n        xx: bool = False,\n        ch: bool = False,\n    ) -> int: ...\n\n    @overload\n    def geoadd(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        values: Sequence[EncodableT],\n        nx: bool = False,\n        xx: bool = False,\n        ch: bool = False,\n    ) -> Awaitable[int]: ...\n\n    def geoadd(\n        self,\n        name: KeyT,\n        values: Sequence[EncodableT],\n        nx: bool = False,\n        xx: bool = False,\n        ch: bool = False,\n    ) -> int | Awaitable[int]:\n        \"\"\"\n        Add the specified geospatial items to the specified key identified\n        by the ``name`` argument. The Geospatial items are given as ordered\n        members of the ``values`` argument, each item or place is formed by\n        the triad longitude, latitude and name.\n\n        Note: You can use ZREM to remove elements.\n\n        ``nx`` forces ZADD to only create new elements and not to update\n        scores for elements that already exist.\n\n        ``xx`` forces ZADD to only update scores of elements that already\n        exist. New elements will not be added.\n\n        ``ch`` modifies the return value to be the numbers of elements changed.\n        Changed elements include new elements that were added and elements\n        whose scores changed.\n\n        For more information, see https://redis.io/commands/geoadd\n        \"\"\"\n        if nx and xx:\n            raise DataError(\"GEOADD allows either 'nx' or 'xx', not both\")\n        if len(values) % 3 != 0:\n            raise DataError(\"GEOADD requires places with lon, lat and name values\")\n        pieces = [name]\n        if nx:\n            pieces.append(\"NX\")\n        if xx:\n            pieces.append(\"XX\")\n        if ch:\n            pieces.append(\"CH\")\n        pieces.extend(values)\n        return self.execute_command(\"GEOADD\", *pieces)\n\n    @overload\n    def geodist(\n        self: SyncClientProtocol,\n        name: KeyT,\n        place1: FieldT,\n        place2: FieldT,\n        unit: str | None = None,\n    ) -> float | None: ...\n\n    @overload\n    def geodist(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        place1: FieldT,\n        place2: FieldT,\n        unit: str | None = None,\n    ) -> Awaitable[float | None]: ...\n\n    def geodist(\n        self, name: KeyT, place1: FieldT, place2: FieldT, unit: str | None = None\n    ) -> (float | None) | Awaitable[float | None]:\n        \"\"\"\n        Return the distance between ``place1`` and ``place2`` members of the\n        ``name`` key.\n        The units must be one of the following : m, km mi, ft. By default\n        meters are used.\n\n        For more information, see https://redis.io/commands/geodist\n        \"\"\"\n        pieces: list[EncodableT] = [name, place1, place2]\n        if unit and unit not in (\"m\", \"km\", \"mi\", \"ft\"):\n            raise DataError(\"GEODIST invalid unit\")\n        elif unit:\n            pieces.append(unit)\n        return self.execute_command(\"GEODIST\", *pieces, keys=[name])\n\n    @overload\n    def geohash(\n        self: SyncClientProtocol, name: KeyT, *values: FieldT\n    ) -> list[bytes | str | None]: ...\n\n    @overload\n    def geohash(\n        self: AsyncClientProtocol, name: KeyT, *values: FieldT\n    ) -> Awaitable[list[bytes | str | None]]: ...\n\n    def geohash(\n        self, name: KeyT, *values: FieldT\n    ) -> list[bytes | str | None] | Awaitable[list[bytes | str | None]]:\n        \"\"\"\n        Return the geo hash string for each item of ``values`` members of\n        the specified key identified by the ``name`` argument.\n\n        For more information, see https://redis.io/commands/geohash\n        \"\"\"\n        return self.execute_command(\"GEOHASH\", name, *values, keys=[name])\n\n    @overload\n    def geopos(\n        self: SyncClientProtocol, name: KeyT, *values: FieldT\n    ) -> list[tuple[float, float] | None]: ...\n\n    @overload\n    def geopos(\n        self: AsyncClientProtocol, name: KeyT, *values: FieldT\n    ) -> Awaitable[list[tuple[float, float] | None]]: ...\n\n    def geopos(\n        self, name: KeyT, *values: FieldT\n    ) -> list[tuple[float, float] | None] | Awaitable[list[tuple[float, float] | None]]:\n        \"\"\"\n        Return the positions of each item of ``values`` as members of\n        the specified key identified by the ``name`` argument. Each position\n        is represented by the pairs lon and lat.\n\n        For more information, see https://redis.io/commands/geopos\n        \"\"\"\n        return self.execute_command(\"GEOPOS\", name, *values, keys=[name])\n\n    @overload\n    def georadius(\n        self: SyncClientProtocol,\n        name: KeyT,\n        longitude: float,\n        latitude: float,\n        radius: float,\n        unit: str | None = None,\n        withdist: bool = False,\n        withcoord: bool = False,\n        withhash: bool = False,\n        count: int | None = None,\n        sort: str | None = None,\n        store: KeyT | None = None,\n        store_dist: KeyT | None = None,\n        any: bool = False,\n    ) -> list[Any] | int: ...\n\n    @overload\n    def georadius(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        longitude: float,\n        latitude: float,\n        radius: float,\n        unit: str | None = None,\n        withdist: bool = False,\n        withcoord: bool = False,\n        withhash: bool = False,\n        count: int | None = None,\n        sort: str | None = None,\n        store: KeyT | None = None,\n        store_dist: KeyT | None = None,\n        any: bool = False,\n    ) -> Awaitable[list[Any] | int]: ...\n\n    def georadius(\n        self,\n        name: KeyT,\n        longitude: float,\n        latitude: float,\n        radius: float,\n        unit: str | None = None,\n        withdist: bool = False,\n        withcoord: bool = False,\n        withhash: bool = False,\n        count: int | None = None,\n        sort: str | None = None,\n        store: KeyT | None = None,\n        store_dist: KeyT | None = None,\n        any: bool = False,\n    ) -> (list[Any] | int) | Awaitable[list[Any] | int]:\n        \"\"\"\n        Return the members of the specified key identified by the\n        ``name`` argument which are within the borders of the area specified\n        with the ``latitude`` and ``longitude`` location and the maximum\n        distance from the center specified by the ``radius`` value.\n\n        The units must be one of the following : m, km mi, ft. By default\n\n        ``withdist`` indicates to return the distances of each place.\n\n        ``withcoord`` indicates to return the latitude and longitude of\n        each place.\n\n        ``withhash`` indicates to return the geohash string of each place.\n\n        ``count`` indicates to return the number of elements up to N.\n\n        ``sort`` indicates to return the places in a sorted way, ASC for\n        nearest to fairest and DESC for fairest to nearest.\n\n        ``store`` indicates to save the places names in a sorted set named\n        with a specific key, each element of the destination sorted set is\n        populated with the score got from the original geo sorted set.\n\n        ``store_dist`` indicates to save the places names in a sorted set\n        named with a specific key, instead of ``store`` the sorted set\n        destination score is set with the distance.\n\n        For more information, see https://redis.io/commands/georadius\n        \"\"\"\n        return self._georadiusgeneric(\n            \"GEORADIUS\",\n            name,\n            longitude,\n            latitude,\n            radius,\n            unit=unit,\n            withdist=withdist,\n            withcoord=withcoord,\n            withhash=withhash,\n            count=count,\n            sort=sort,\n            store=store,\n            store_dist=store_dist,\n            any=any,\n        )\n\n    @overload\n    def georadiusbymember(\n        self: SyncClientProtocol,\n        name: KeyT,\n        member: FieldT,\n        radius: float,\n        unit: str | None = None,\n        withdist: bool = False,\n        withcoord: bool = False,\n        withhash: bool = False,\n        count: int | None = None,\n        sort: str | None = None,\n        store: KeyT | None = None,\n        store_dist: KeyT | None = None,\n        any: bool = False,\n    ) -> list[Any] | int: ...\n\n    @overload\n    def georadiusbymember(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        member: FieldT,\n        radius: float,\n        unit: str | None = None,\n        withdist: bool = False,\n        withcoord: bool = False,\n        withhash: bool = False,\n        count: int | None = None,\n        sort: str | None = None,\n        store: KeyT | None = None,\n        store_dist: KeyT | None = None,\n        any: bool = False,\n    ) -> Awaitable[list[Any] | int]: ...\n\n    def georadiusbymember(\n        self,\n        name: KeyT,\n        member: FieldT,\n        radius: float,\n        unit: str | None = None,\n        withdist: bool = False,\n        withcoord: bool = False,\n        withhash: bool = False,\n        count: int | None = None,\n        sort: str | None = None,\n        store: Union[KeyT, None] = None,\n        store_dist: Union[KeyT, None] = None,\n        any: bool = False,\n    ) -> (list[Any] | int) | Awaitable[list[Any] | int]:\n        \"\"\"\n        This command is exactly like ``georadius`` with the sole difference\n        that instead of taking, as the center of the area to query, a longitude\n        and latitude value, it takes the name of a member already existing\n        inside the geospatial index represented by the sorted set.\n\n        For more information, see https://redis.io/commands/georadiusbymember\n        \"\"\"\n        return self._georadiusgeneric(\n            \"GEORADIUSBYMEMBER\",\n            name,\n            member,\n            radius,\n            unit=unit,\n            withdist=withdist,\n            withcoord=withcoord,\n            withhash=withhash,\n            count=count,\n            sort=sort,\n            store=store,\n            store_dist=store_dist,\n            any=any,\n        )\n\n    def _georadiusgeneric(\n        self, command: str, *args: EncodableT, **kwargs: Union[EncodableT, None]\n    ) -> ResponseT:\n        pieces = list(args)\n        if kwargs[\"unit\"] and kwargs[\"unit\"] not in (\"m\", \"km\", \"mi\", \"ft\"):\n            raise DataError(\"GEORADIUS invalid unit\")\n        elif kwargs[\"unit\"]:\n            pieces.append(kwargs[\"unit\"])\n        else:\n            pieces.append(\"m\")\n\n        if kwargs[\"any\"] and kwargs[\"count\"] is None:\n            raise DataError(\"``any`` can't be provided without ``count``\")\n\n        for arg_name, byte_repr in (\n            (\"withdist\", \"WITHDIST\"),\n            (\"withcoord\", \"WITHCOORD\"),\n            (\"withhash\", \"WITHHASH\"),\n        ):\n            if kwargs[arg_name]:\n                pieces.append(byte_repr)\n\n        if kwargs[\"count\"] is not None:\n            pieces.extend([\"COUNT\", kwargs[\"count\"]])\n            if kwargs[\"any\"]:\n                pieces.append(\"ANY\")\n\n        if kwargs[\"sort\"]:\n            if kwargs[\"sort\"] == \"ASC\":\n                pieces.append(\"ASC\")\n            elif kwargs[\"sort\"] == \"DESC\":\n                pieces.append(\"DESC\")\n            else:\n                raise DataError(\"GEORADIUS invalid sort\")\n\n        if kwargs[\"store\"] and kwargs[\"store_dist\"]:\n            raise DataError(\"GEORADIUS store and store_dist cant be set together\")\n\n        if kwargs[\"store\"]:\n            pieces.extend([b\"STORE\", kwargs[\"store\"]])\n\n        if kwargs[\"store_dist\"]:\n            pieces.extend([b\"STOREDIST\", kwargs[\"store_dist\"]])\n\n        return self.execute_command(command, *pieces, **kwargs)\n\n    @overload\n    def geosearch(\n        self: SyncClientProtocol,\n        name: KeyT,\n        member: FieldT | None = None,\n        longitude: float | None = None,\n        latitude: float | None = None,\n        unit: str = \"m\",\n        radius: float | None = None,\n        width: float | None = None,\n        height: float | None = None,\n        sort: str | None = None,\n        count: int | None = None,\n        any: bool = False,\n        withcoord: bool = False,\n        withdist: bool = False,\n        withhash: bool = False,\n    ) -> list[Any]: ...\n\n    @overload\n    def geosearch(\n        self: AsyncClientProtocol,\n        name: KeyT,\n        member: FieldT | None = None,\n        longitude: float | None = None,\n        latitude: float | None = None,\n        unit: str = \"m\",\n        radius: float | None = None,\n        width: float | None = None,\n        height: float | None = None,\n        sort: str | None = None,\n        count: int | None = None,\n        any: bool = False,\n        withcoord: bool = False,\n        withdist: bool = False,\n        withhash: bool = False,\n    ) -> Awaitable[list[Any]]: ...\n\n    def geosearch(\n        self,\n        name: KeyT,\n        member: FieldT | None = None,\n        longitude: float | None = None,\n        latitude: float | None = None,\n        unit: str = \"m\",\n        radius: float | None = None,\n        width: float | None = None,\n        height: float | None = None,\n        sort: str | None = None,\n        count: int | None = None,\n        any: bool = False,\n        withcoord: bool = False,\n        withdist: bool = False,\n        withhash: bool = False,\n    ) -> list[Any] | Awaitable[list[Any]]:\n        \"\"\"\n        Return the members of specified key identified by the\n        ``name`` argument, which are within the borders of the\n        area specified by a given shape. This command extends the\n        GEORADIUS command, so in addition to searching within circular\n        areas, it supports searching within rectangular areas.\n\n        This command should be used in place of the deprecated\n        GEORADIUS and GEORADIUSBYMEMBER commands.\n\n        ``member`` Use the position of the given existing\n         member in the sorted set. Can't be given with ``longitude``\n         and ``latitude``.\n\n        ``longitude`` and ``latitude`` Use the position given by\n        this coordinates. Can't be given with ``member``\n        ``radius`` Similar to GEORADIUS, search inside circular\n        area according the given radius. Can't be given with\n        ``height`` and ``width``.\n        ``height`` and ``width`` Search inside an axis-aligned\n        rectangle, determined by the given height and width.\n        Can't be given with ``radius``\n\n        ``unit`` must be one of the following : m, km, mi, ft.\n        `m` for meters (the default value), `km` for kilometers,\n        `mi` for miles and `ft` for feet.\n\n        ``sort`` indicates to return the places in a sorted way,\n        ASC for nearest to furthest and DESC for furthest to nearest.\n\n        ``count`` limit the results to the first count matching items.\n\n        ``any`` is set to True, the command will return as soon as\n        enough matches are found. Can't be provided without ``count``\n\n        ``withdist`` indicates to return the distances of each place.\n        ``withcoord`` indicates to return the latitude and longitude of\n        each place.\n\n        ``withhash`` indicates to return the geohash string of each place.\n\n        For more information, see https://redis.io/commands/geosearch\n        \"\"\"\n\n        return self._geosearchgeneric(\n            \"GEOSEARCH\",\n            name,\n            member=member,\n            longitude=longitude,\n            latitude=latitude,\n            unit=unit,\n            radius=radius,\n            width=width,\n            height=height,\n            sort=sort,\n            count=count,\n            any=any,\n            withcoord=withcoord,\n            withdist=withdist,\n            withhash=withhash,\n            store=None,\n            store_dist=None,\n        )\n\n    @overload\n    def geosearchstore(\n        self: SyncClientProtocol,\n        dest: KeyT,\n        name: KeyT,\n        member: FieldT | None = None,\n        longitude: float | None = None,\n        latitude: float | None = None,\n        unit: str = \"m\",\n        radius: float | None = None,\n        width: float | None = None,\n        height: float | None = None,\n        sort: str | None = None,\n        count: int | None = None,\n        any: bool = False,\n        storedist: bool = False,\n    ) -> int: ...\n\n    @overload\n    def geosearchstore(\n        self: AsyncClientProtocol,\n        dest: KeyT,\n        name: KeyT,\n        member: FieldT | None = None,\n        longitude: float | None = None,\n        latitude: float | None = None,\n        unit: str = \"m\",\n        radius: float | None = None,\n        width: float | None = None,\n        height: float | None = None,\n        sort: str | None = None,\n        count: int | None = None,\n        any: bool = False,\n        storedist: bool = False,\n    ) -> Awaitable[int]: ...\n\n    def geosearchstore(\n        self,\n        dest: KeyT,\n        name: KeyT,\n        member: FieldT | None = None,\n        longitude: float | None = None,\n        latitude: float | None = None,\n        unit: str = \"m\",\n        radius: float | None = None,\n        width: float | None = None,\n        height: float | None = None,\n        sort: str | None = None,\n        count: int | None = None,\n        any: bool = False,\n        storedist: bool = False,\n    ) -> int | Awaitable[int]:\n        \"\"\"\n        This command is like GEOSEARCH, but stores the result in\n        ``dest``. By default, it stores the results in the destination\n        sorted set with their geospatial information.\n        if ``store_dist`` set to True, the command will stores the\n        items in a sorted set populated with their distance from the\n        center of the circle or box, as a floating-point number.\n\n        For more information, see https://redis.io/commands/geosearchstore\n        \"\"\"\n        return self._geosearchgeneric(\n            \"GEOSEARCHSTORE\",\n            dest,\n            name,\n            member=member,\n            longitude=longitude,\n            latitude=latitude,\n            unit=unit,\n            radius=radius,\n            width=width,\n            height=height,\n            sort=sort,\n            count=count,\n            any=any,\n            withcoord=None,\n            withdist=None,\n            withhash=None,\n            store=None,\n            store_dist=storedist,\n        )\n\n    def _geosearchgeneric(\n        self, command: str, *args: EncodableT, **kwargs: Union[EncodableT, None]\n    ) -> ResponseT:\n        pieces = list(args)\n\n        # FROMMEMBER or FROMLONLAT\n        if kwargs[\"member\"] is None:\n            if kwargs[\"longitude\"] is None or kwargs[\"latitude\"] is None:\n                raise DataError(\"GEOSEARCH must have member or longitude and latitude\")\n        if kwargs[\"member\"]:\n            if kwargs[\"longitude\"] or kwargs[\"latitude\"]:\n                raise DataError(\n                    \"GEOSEARCH member and longitude or latitude cant be set together\"\n                )\n            pieces.extend([b\"FROMMEMBER\", kwargs[\"member\"]])\n        if kwargs[\"longitude\"] is not None and kwargs[\"latitude\"] is not None:\n            pieces.extend([b\"FROMLONLAT\", kwargs[\"longitude\"], kwargs[\"latitude\"]])\n\n        # BYRADIUS or BYBOX\n        if kwargs[\"radius\"] is None:\n            if kwargs[\"width\"] is None or kwargs[\"height\"] is None:\n                raise DataError(\"GEOSEARCH must have radius or width and height\")\n        if kwargs[\"unit\"] is None:\n            raise DataError(\"GEOSEARCH must have unit\")\n        if kwargs[\"unit\"].lower() not in (\"m\", \"km\", \"mi\", \"ft\"):\n            raise DataError(\"GEOSEARCH invalid unit\")\n        if kwargs[\"radius\"]:\n            if kwargs[\"width\"] or kwargs[\"height\"]:\n                raise DataError(\n                    \"GEOSEARCH radius and width or height cant be set together\"\n                )\n            pieces.extend([b\"BYRADIUS\", kwargs[\"radius\"], kwargs[\"unit\"]])\n        if kwargs[\"width\"] and kwargs[\"height\"]:\n            pieces.extend([b\"BYBOX\", kwargs[\"width\"], kwargs[\"height\"], kwargs[\"unit\"]])\n\n        # sort\n        if kwargs[\"sort\"]:\n            if kwargs[\"sort\"].upper() == \"ASC\":\n                pieces.append(b\"ASC\")\n            elif kwargs[\"sort\"].upper() == \"DESC\":\n                pieces.append(b\"DESC\")\n            else:\n                raise DataError(\"GEOSEARCH invalid sort\")\n\n        # count any\n        if kwargs[\"count\"]:\n            pieces.extend([b\"COUNT\", kwargs[\"count\"]])\n            if kwargs[\"any\"]:\n                pieces.append(b\"ANY\")\n        elif kwargs[\"any\"]:\n            raise DataError(\"GEOSEARCH ``any`` can't be provided without count\")\n\n        # other properties\n        for arg_name, byte_repr in (\n            (\"withdist\", b\"WITHDIST\"),\n            (\"withcoord\", b\"WITHCOORD\"),\n            (\"withhash\", b\"WITHHASH\"),\n            (\"store_dist\", b\"STOREDIST\"),\n        ):\n            if kwargs[arg_name]:\n                pieces.append(byte_repr)\n\n        kwargs[\"keys\"] = [args[0] if command == \"GEOSEARCH\" else args[1]]\n\n        return self.execute_command(command, *pieces, **kwargs)\n\n\nAsyncGeoCommands = GeoCommands\n\n\nclass ModuleCommands(CommandsProtocol):\n    \"\"\"\n    Redis Module commands.\n    see: https://redis.io/topics/modules-intro\n    \"\"\"\n\n    @overload\n    def module_load(self: SyncClientProtocol, path, *args) -> bool: ...\n\n    @overload\n    def module_load(self: AsyncClientProtocol, path, *args) -> Awaitable[bool]: ...\n\n    def module_load(self, path, *args) -> bool | Awaitable[bool]:\n        \"\"\"\n        Loads the module from ``path``.\n        Passes all ``*args`` to the module, during loading.\n        Raises ``ModuleError`` if a module is not found at ``path``.\n\n        For more information, see https://redis.io/commands/module-load\n        \"\"\"\n        return self.execute_command(\"MODULE LOAD\", path, *args)\n\n    @overload\n    def module_loadex(\n        self: SyncClientProtocol,\n        path: str,\n        options: List[str] | None = None,\n        args: List[str] | None = None,\n    ) -> bytes | str: ...\n\n    @overload\n    def module_loadex(\n        self: AsyncClientProtocol,\n        path: str,\n        options: List[str] | None = None,\n        args: List[str] | None = None,\n    ) -> Awaitable[bytes | str]: ...\n\n    def module_loadex(\n        self,\n        path: str,\n        options: List[str] | None = None,\n        args: List[str] | None = None,\n    ) -> (bytes | str) | Awaitable[bytes | str]:\n        \"\"\"\n        Loads a module from a dynamic library at runtime with configuration directives.\n\n        For more information, see https://redis.io/commands/module-loadex\n        \"\"\"\n        pieces = []\n        if options is not None:\n            pieces.append(\"CONFIG\")\n            pieces.extend(options)\n        if args is not None:\n            pieces.append(\"ARGS\")\n            pieces.extend(args)\n\n        return self.execute_command(\"MODULE LOADEX\", path, *pieces)\n\n    @overload\n    def module_unload(self: SyncClientProtocol, name) -> bool: ...\n\n    @overload\n    def module_unload(self: AsyncClientProtocol, name) -> Awaitable[bool]: ...\n\n    def module_unload(self, name) -> bool | Awaitable[bool]:\n        \"\"\"\n        Unloads the module ``name``.\n        Raises ``ModuleError`` if ``name`` is not in loaded modules.\n\n        For more information, see https://redis.io/commands/module-unload\n        \"\"\"\n        return self.execute_command(\"MODULE UNLOAD\", name)\n\n    @overload\n    def module_list(self: SyncClientProtocol) -> list[dict[Any, Any]]: ...\n\n    @overload\n    def module_list(self: AsyncClientProtocol) -> Awaitable[list[dict[Any, Any]]]: ...\n\n    def module_list(self) -> list[dict[Any, Any]] | Awaitable[list[dict[Any, Any]]]:\n        \"\"\"\n        Returns a list of dictionaries containing the name and version of\n        all loaded modules.\n\n        For more information, see https://redis.io/commands/module-list\n        \"\"\"\n        return self.execute_command(\"MODULE LIST\")\n\n    def command_info(self) -> None:\n        raise NotImplementedError(\n            \"COMMAND INFO is intentionally not implemented in the client.\"\n        )\n\n    @overload\n    def command_count(self: SyncClientProtocol) -> int: ...\n\n    @overload\n    def command_count(self: AsyncClientProtocol) -> Awaitable[int]: ...\n\n    def command_count(self) -> int | Awaitable[int]:\n        return self.execute_command(\"COMMAND COUNT\")\n\n    @overload\n    def command_getkeys(self: SyncClientProtocol, *args) -> list[bytes | str]: ...\n\n    @overload\n    def command_getkeys(\n        self: AsyncClientProtocol, *args\n    ) -> Awaitable[list[bytes | str]]: ...\n\n    def command_getkeys(\n        self, *args\n    ) -> list[bytes | str] | Awaitable[list[bytes | str]]:\n        return self.execute_command(\"COMMAND GETKEYS\", *args)\n\n    @overload\n    def command(self: SyncClientProtocol) -> dict[str, dict[str, Any]]: ...\n\n    @overload\n    def command(\n        self: AsyncClientProtocol,\n    ) -> Awaitable[dict[str, dict[str, Any]]]: ...\n\n    def command(\n        self,\n    ) -> dict[str, dict[str, Any]] | Awaitable[dict[str, dict[str, Any]]]:\n        return self.execute_command(\"COMMAND\")\n\n\nclass AsyncModuleCommands(ModuleCommands):\n    async def command_info(self) -> None:\n        return super().command_info()\n\n\nclass ClusterCommands(CommandsProtocol):\n    \"\"\"\n    Class for Redis Cluster commands\n    \"\"\"\n\n    @overload\n    def cluster(self: SyncClientProtocol, cluster_arg, *args, **kwargs) -> Any: ...\n\n    @overload\n    def cluster(\n        self: AsyncClientProtocol, cluster_arg, *args, **kwargs\n    ) -> Awaitable[Any]: ...\n\n    def cluster(self, cluster_arg, *args, **kwargs) -> Any | Awaitable[Any]:\n        return self.execute_command(f\"CLUSTER {cluster_arg.upper()}\", *args, **kwargs)\n\n    @overload\n    def readwrite(self: SyncClientProtocol, **kwargs) -> bool: ...\n\n    @overload\n    def readwrite(self: AsyncClientProtocol, **kwargs) -> Awaitable[bool]: ...\n\n    def readwrite(self, **kwargs) -> bool | Awaitable[bool]:\n        \"\"\"\n        Disables read queries for a connection to a Redis Cluster slave node.\n\n        For more information, see https://redis.io/commands/readwrite\n        \"\"\"\n        return self.execute_command(\"READWRITE\", **kwargs)\n\n    @overload\n    def readonly(self: SyncClientProtocol, **kwargs) -> bool: ...\n\n    @overload\n    def readonly(self: AsyncClientProtocol, **kwargs) -> Awaitable[bool]: ...\n\n    def readonly(self, **kwargs) -> bool | Awaitable[bool]:\n        \"\"\"\n        Enables read queries for a connection to a Redis Cluster replica node.\n\n        For more information, see https://redis.io/commands/readonly\n        \"\"\"\n        return self.execute_command(\"READONLY\", **kwargs)\n\n\nAsyncClusterCommands = ClusterCommands\n\n\nclass FunctionCommands:\n    \"\"\"\n    Redis Function commands\n    \"\"\"\n\n    @overload\n    def function_load(\n        self: SyncClientProtocol, code: str, replace: bool | None = False\n    ) -> bytes | str: ...\n\n    @overload\n    def function_load(\n        self: AsyncClientProtocol, code: str, replace: bool | None = False\n    ) -> Awaitable[bytes | str]: ...\n\n    def function_load(self, code: str, replace: bool | None = False) -> (\n        bytes | str\n    ) | Awaitable[bytes | str]:\n        \"\"\"\n        Load a library to Redis.\n        :param code: the source code (must start with\n        Shebang statement that provides a metadata about the library)\n        :param replace: changes the behavior to overwrite the existing library\n        with the new contents.\n        Return the library name that was loaded.\n\n        For more information, see https://redis.io/commands/function-load\n        \"\"\"\n        pieces = [\"REPLACE\"] if replace else []\n        pieces.append(code)\n        return self.execute_command(\"FUNCTION LOAD\", *pieces)\n\n    @overload\n    def function_delete(self: SyncClientProtocol, library: str) -> bool: ...\n\n    @overload\n    def function_delete(self: AsyncClientProtocol, library: str) -> Awaitable[bool]: ...\n\n    def function_delete(self, library: str) -> bool | Awaitable[bool]:\n        \"\"\"\n        Delete the library called ``library`` and all its functions.\n\n        For more information, see https://redis.io/commands/function-delete\n        \"\"\"\n        return self.execute_command(\"FUNCTION DELETE\", library)\n\n    @overload\n    def function_flush(self: SyncClientProtocol, mode: str = \"SYNC\") -> bool: ...\n\n    @overload\n    def function_flush(\n        self: AsyncClientProtocol, mode: str = \"SYNC\"\n    ) -> Awaitable[bool]: ...\n\n    def function_flush(self, mode: str = \"SYNC\") -> bool | Awaitable[bool]:\n        \"\"\"\n        Deletes all the libraries.\n\n        For more information, see https://redis.io/commands/function-flush\n        \"\"\"\n        return self.execute_command(\"FUNCTION FLUSH\", mode)\n\n    @overload\n    def function_list(\n        self: SyncClientProtocol,\n        library: str | None = \"*\",\n        withcode: bool | None = False,\n    ) -> list[Any]: ...\n\n    @overload\n    def function_list(\n        self: AsyncClientProtocol,\n        library: str | None = \"*\",\n        withcode: bool | None = False,\n    ) -> Awaitable[list[Any]]: ...\n\n    def function_list(\n        self, library: str | None = \"*\", withcode: bool | None = False\n    ) -> list[Any] | Awaitable[list[Any]]:\n        \"\"\"\n        Return information about the functions and libraries.\n\n        Args:\n\n            library: specify a pattern for matching library names\n            withcode: cause the server to include the libraries source implementation\n                in the reply\n        \"\"\"\n        args = [\"LIBRARYNAME\", library]\n        if withcode:\n            args.append(\"WITHCODE\")\n        return self.execute_command(\"FUNCTION LIST\", *args)\n\n    def _fcall(self, command: str, function, numkeys: int, *keys_and_args: Any) -> Any:\n        return self.execute_command(command, function, numkeys, *keys_and_args)\n\n    @overload\n    def fcall(\n        self: SyncClientProtocol, function, numkeys: int, *keys_and_args: Any\n    ) -> Any: ...\n\n    @overload\n    def fcall(\n        self: AsyncClientProtocol, function, numkeys: int, *keys_and_args: Any\n    ) -> Awaitable[Any]: ...\n\n    def fcall(\n        self, function, numkeys: int, *keys_and_args: Any\n    ) -> Any | Awaitable[Any]:\n        \"\"\"\n        Invoke a function.\n\n        For more information, see https://redis.io/commands/fcall\n        \"\"\"\n        return self._fcall(\"FCALL\", function, numkeys, *keys_and_args)\n\n    @overload\n    def fcall_ro(\n        self: SyncClientProtocol, function, numkeys: int, *keys_and_args: Any\n    ) -> Any: ...\n\n    @overload\n    def fcall_ro(\n        self: AsyncClientProtocol, function, numkeys: int, *keys_and_args: Any\n    ) -> Awaitable[Any]: ...\n\n    def fcall_ro(\n        self, function, numkeys: int, *keys_and_args: Any\n    ) -> Any | Awaitable[Any]:\n        \"\"\"\n        This is a read-only variant of the FCALL command that cannot\n        execute commands that modify data.\n\n        For more information, see https://redis.io/commands/fcall_ro\n        \"\"\"\n        return self._fcall(\"FCALL_RO\", function, numkeys, *keys_and_args)\n\n    @overload\n    def function_dump(self: SyncClientProtocol) -> bytes: ...\n\n    @overload\n    def function_dump(self: AsyncClientProtocol) -> Awaitable[bytes]: ...\n\n    def function_dump(self) -> bytes | Awaitable[bytes]:\n        \"\"\"\n        Return the serialized payload of loaded libraries.\n\n        For more information, see https://redis.io/commands/function-dump\n        \"\"\"\n        from redis.client import NEVER_DECODE\n\n        options = {}\n        options[NEVER_DECODE] = []\n\n        return self.execute_command(\"FUNCTION DUMP\", **options)\n\n    @overload\n    def function_restore(\n        self: SyncClientProtocol, payload: str, policy: str | None = \"APPEND\"\n    ) -> bool: ...\n\n    @overload\n    def function_restore(\n        self: AsyncClientProtocol, payload: str, policy: str | None = \"APPEND\"\n    ) -> Awaitable[bool]: ...\n\n    def function_restore(\n        self, payload: str, policy: str | None = \"APPEND\"\n    ) -> bool | Awaitable[bool]:\n        \"\"\"\n        Restore libraries from the serialized ``payload``.\n        You can use the optional policy argument to provide a policy\n        for handling existing libraries.\n\n        For more information, see https://redis.io/commands/function-restore\n        \"\"\"\n        return self.execute_command(\"FUNCTION RESTORE\", payload, policy)\n\n    @overload\n    def function_kill(self: SyncClientProtocol) -> bytes | str: ...\n\n    @overload\n    def function_kill(self: AsyncClientProtocol) -> Awaitable[bytes | str]: ...\n\n    def function_kill(self) -> (bytes | str) | Awaitable[bytes | str]:\n        \"\"\"\n        Kill a function that is currently executing.\n\n        For more information, see https://redis.io/commands/function-kill\n        \"\"\"\n        return self.execute_command(\"FUNCTION KILL\")\n\n    @overload\n    def function_stats(self: SyncClientProtocol) -> Any: ...\n\n    @overload\n    def function_stats(self: AsyncClientProtocol) -> Awaitable[Any]: ...\n\n    def function_stats(self) -> Any | Awaitable[Any]:\n        \"\"\"\n        Return information about the function that's currently running\n        and information about the available execution engines.\n\n        For more information, see https://redis.io/commands/function-stats\n        \"\"\"\n        return self.execute_command(\"FUNCTION STATS\")\n\n\nAsyncFunctionCommands = FunctionCommands\n\n\nclass DataAccessCommands(\n    BasicKeyCommands,\n    HyperlogCommands,\n    HashCommands,\n    GeoCommands,\n    ListCommands,\n    ScanCommands,\n    SetCommands,\n    StreamCommands,\n    SortedSetCommands,\n):\n    \"\"\"\n    A class containing all of the implemented data access redis commands.\n    This class is to be used as a mixin for synchronous Redis clients.\n    \"\"\"\n\n\nclass AsyncDataAccessCommands(\n    AsyncBasicKeyCommands,\n    AsyncHyperlogCommands,\n    AsyncHashCommands,\n    AsyncGeoCommands,\n    AsyncListCommands,\n    AsyncScanCommands,\n    AsyncSetCommands,\n    AsyncStreamCommands,\n    AsyncSortedSetCommands,\n):\n    \"\"\"\n    A class containing all of the implemented data access redis commands.\n    This class is to be used as a mixin for asynchronous Redis clients.\n    \"\"\"\n\n\nclass CoreCommands(\n    ACLCommands,\n    ClusterCommands,\n    DataAccessCommands,\n    ManagementCommands,\n    ModuleCommands,\n    PubSubCommands,\n    ScriptCommands,\n    FunctionCommands,\n):\n    \"\"\"\n    A class containing all of the implemented redis commands. This class is\n    to be used as a mixin for synchronous Redis clients.\n    \"\"\"\n\n\nclass AsyncCoreCommands(\n    AsyncACLCommands,\n    AsyncClusterCommands,\n    AsyncDataAccessCommands,\n    AsyncManagementCommands,\n    AsyncModuleCommands,\n    AsyncPubSubCommands,\n    AsyncScriptCommands,\n    AsyncFunctionCommands,\n):\n    \"\"\"\n    A class containing all of the implemented redis commands. This class is\n    to be used as a mixin for asynchronous Redis clients.\n    \"\"\"\n"
  },
  {
    "path": "redis/commands/helpers.py",
    "content": "import copy\nimport random\nimport string\nfrom typing import Any, Iterable, List, Tuple\n\nimport redis\nfrom redis.typing import KeysT, KeyT\n\n\ndef list_or_args(keys: KeysT, args: Tuple[KeyT, ...]) -> List[KeyT]:\n    # returns a single new list combining keys and args\n    try:\n        iter(keys)\n        # a string or bytes instance can be iterated, but indicates\n        # keys wasn't passed as a list\n        if isinstance(keys, (bytes, str)):\n            keys = [keys]\n        else:\n            keys = list(keys)\n    except TypeError:\n        keys = [keys]\n    if args:\n        keys.extend(args)\n    return keys\n\n\ndef nativestr(x):\n    \"\"\"Return the decoded binary string, or a string, depending on type.\"\"\"\n    r = x.decode(\"utf-8\", \"replace\") if isinstance(x, bytes) else x\n    if r == \"null\":\n        return\n    return r\n\n\ndef delist(x):\n    \"\"\"Given a list of binaries, return the stringified version.\"\"\"\n    if x is None:\n        return x\n    return [nativestr(obj) for obj in x]\n\n\ndef parse_to_list(response):\n    \"\"\"Optimistically parse the response to a list.\"\"\"\n    res = []\n\n    special_values = {\"infinity\", \"nan\", \"-infinity\"}\n\n    if response is None:\n        return res\n\n    for item in response:\n        if item is None:\n            res.append(None)\n            continue\n        try:\n            item_str = nativestr(item)\n        except TypeError:\n            res.append(None)\n            continue\n\n        if isinstance(item_str, str) and item_str.lower() in special_values:\n            res.append(item_str)  # Keep as string\n        else:\n            try:\n                res.append(int(item))\n            except ValueError:\n                try:\n                    res.append(float(item))\n                except ValueError:\n                    res.append(item_str)\n\n    return res\n\n\ndef random_string(length=10):\n    \"\"\"\n    Returns a random N character long string.\n    \"\"\"\n    return \"\".join(  # nosec\n        random.choice(string.ascii_lowercase) for x in range(length)\n    )\n\n\ndef decode_dict_keys(obj):\n    \"\"\"Decode the keys of the given dictionary with utf-8.\"\"\"\n    newobj = copy.copy(obj)\n    for k in obj.keys():\n        if isinstance(k, bytes):\n            newobj[k.decode(\"utf-8\")] = newobj[k]\n            newobj.pop(k)\n    return newobj\n\n\ndef get_protocol_version(client):\n    if isinstance(client, redis.Redis) or isinstance(client, redis.asyncio.Redis):\n        return client.connection_pool.connection_kwargs.get(\"protocol\")\n    elif isinstance(client, redis.cluster.AbstractRedisCluster):\n        return client.nodes_manager.connection_kwargs.get(\"protocol\")\n\n\ndef at_most_one_value_set(iterable: Iterable[Any]):\n    \"\"\"\n    Checks that at most one of the values in the iterable is truthy.\n\n    Args:\n        iterable: An iterable of values to check.\n\n    Returns:\n        True if at most one value is truthy, False otherwise.\n\n    Raises:\n        Might raise an error if the values in iterable are not boolean-compatible.\n        For example if the type of the values implement\n        __len__ or __bool__ methods and they raise an error.\n    \"\"\"\n    values = (bool(x) for x in iterable)\n    return sum(values) <= 1\n"
  },
  {
    "path": "redis/commands/json/__init__.py",
    "content": "from json import JSONDecodeError, JSONDecoder, JSONEncoder\n\nimport redis\n\nfrom ..helpers import get_protocol_version, nativestr\nfrom .commands import JSONCommands\nfrom .decoders import bulk_of_jsons, decode_list\n\n\nclass JSON(JSONCommands):\n    \"\"\"\n    Create a client for talking to json.\n\n    :param decoder:\n    :type json.JSONDecoder: An instance of json.JSONDecoder\n\n    :param encoder:\n    :type json.JSONEncoder: An instance of json.JSONEncoder\n    \"\"\"\n\n    def __init__(\n        self, client, version=None, decoder=JSONDecoder(), encoder=JSONEncoder()\n    ):\n        \"\"\"\n        Create a client for talking to json.\n\n        :param decoder:\n        :type json.JSONDecoder: An instance of json.JSONDecoder\n\n        :param encoder:\n        :type json.JSONEncoder: An instance of json.JSONEncoder\n        \"\"\"\n        # Set the module commands' callbacks\n        self._MODULE_CALLBACKS = {\n            \"JSON.ARRPOP\": self._decode,\n            \"JSON.DEBUG\": self._decode,\n            \"JSON.GET\": self._decode,\n            \"JSON.MERGE\": lambda r: r and nativestr(r) == \"OK\",\n            \"JSON.MGET\": bulk_of_jsons(self._decode),\n            \"JSON.MSET\": lambda r: r and nativestr(r) == \"OK\",\n            \"JSON.RESP\": self._decode,\n            \"JSON.SET\": lambda r: r and nativestr(r) == \"OK\",\n            \"JSON.TOGGLE\": self._decode,\n        }\n\n        _RESP2_MODULE_CALLBACKS = {\n            \"JSON.ARRAPPEND\": self._decode,\n            \"JSON.ARRINDEX\": self._decode,\n            \"JSON.ARRINSERT\": self._decode,\n            \"JSON.ARRLEN\": self._decode,\n            \"JSON.ARRTRIM\": self._decode,\n            \"JSON.CLEAR\": int,\n            \"JSON.DEL\": int,\n            \"JSON.FORGET\": int,\n            \"JSON.GET\": self._decode,\n            \"JSON.NUMINCRBY\": self._decode,\n            \"JSON.NUMMULTBY\": self._decode,\n            \"JSON.OBJKEYS\": self._decode,\n            \"JSON.STRAPPEND\": self._decode,\n            \"JSON.OBJLEN\": self._decode,\n            \"JSON.STRLEN\": self._decode,\n            \"JSON.TOGGLE\": self._decode,\n        }\n\n        _RESP3_MODULE_CALLBACKS = {}\n\n        self.client = client\n        self.execute_command = client.execute_command\n        self.MODULE_VERSION = version\n\n        if get_protocol_version(self.client) in [\"3\", 3]:\n            self._MODULE_CALLBACKS.update(_RESP3_MODULE_CALLBACKS)\n        else:\n            self._MODULE_CALLBACKS.update(_RESP2_MODULE_CALLBACKS)\n\n        for key, value in self._MODULE_CALLBACKS.items():\n            self.client.set_response_callback(key, value)\n\n        self.__encoder__ = encoder\n        self.__decoder__ = decoder\n\n    def _decode(self, obj):\n        \"\"\"Get the decoder.\"\"\"\n        if obj is None:\n            return obj\n\n        try:\n            x = self.__decoder__.decode(obj)\n            if x is None:\n                raise TypeError\n            return x\n        except TypeError:\n            try:\n                return self.__decoder__.decode(obj.decode())\n            except AttributeError:\n                return decode_list(obj)\n        except (AttributeError, JSONDecodeError):\n            return decode_list(obj)\n\n    def _encode(self, obj):\n        \"\"\"Get the encoder.\"\"\"\n        return self.__encoder__.encode(obj)\n\n    def pipeline(self, transaction=True, shard_hint=None):\n        \"\"\"Creates a pipeline for the JSON module, that can be used for executing\n        JSON commands, as well as classic core commands.\n\n        Usage example:\n\n        r = redis.Redis()\n        pipe = r.json().pipeline()\n        pipe.jsonset('foo', '.', {'hello!': 'world'})\n        pipe.jsonget('foo')\n        pipe.jsonget('notakey')\n        \"\"\"\n        if isinstance(self.client, redis.RedisCluster):\n            p = ClusterPipeline(\n                nodes_manager=self.client.nodes_manager,\n                commands_parser=self.client.commands_parser,\n                startup_nodes=self.client.nodes_manager.startup_nodes,\n                result_callbacks=self.client.result_callbacks,\n                cluster_response_callbacks=self.client.cluster_response_callbacks,\n                cluster_error_retry_attempts=self.client.retry.get_retries(),\n                read_from_replicas=self.client.read_from_replicas,\n                reinitialize_steps=self.client.reinitialize_steps,\n                lock=self.client._lock,\n            )\n\n        else:\n            p = Pipeline(\n                connection_pool=self.client.connection_pool,\n                response_callbacks=self._MODULE_CALLBACKS,\n                transaction=transaction,\n                shard_hint=shard_hint,\n            )\n\n        p._encode = self._encode\n        p._decode = self._decode\n        return p\n\n\nclass ClusterPipeline(JSONCommands, redis.cluster.ClusterPipeline):\n    \"\"\"Cluster pipeline for the module.\"\"\"\n\n\nclass Pipeline(JSONCommands, redis.client.Pipeline):\n    \"\"\"Pipeline for the module.\"\"\"\n"
  },
  {
    "path": "redis/commands/json/_util.py",
    "content": "from typing import List, Mapping, Union\n\nJsonType = Union[\n    str, int, float, bool, None, Mapping[str, \"JsonType\"], List[\"JsonType\"]\n]\n"
  },
  {
    "path": "redis/commands/json/commands.py",
    "content": "import os\nfrom json import JSONDecodeError, loads\nfrom typing import Dict, List, Optional, Tuple, Union\n\nfrom redis.exceptions import DataError\nfrom redis.utils import deprecated_function\n\nfrom ._util import JsonType\nfrom .decoders import decode_dict_keys\nfrom .path import Path\n\n\nclass JSONCommands:\n    \"\"\"json commands.\"\"\"\n\n    def arrappend(\n        self, name: str, path: Optional[str] = Path.root_path(), *args: JsonType\n    ) -> List[Optional[int]]:\n        \"\"\"Append the objects ``args`` to the array under the\n        ``path` in key ``name``.\n\n        For more information see `JSON.ARRAPPEND <https://redis.io/commands/json.arrappend>`_..\n        \"\"\"  # noqa\n        pieces = [name, str(path)]\n        for o in args:\n            pieces.append(self._encode(o))\n        return self.execute_command(\"JSON.ARRAPPEND\", *pieces)\n\n    def arrindex(\n        self,\n        name: str,\n        path: str,\n        scalar: int,\n        start: Optional[int] = None,\n        stop: Optional[int] = None,\n    ) -> List[Optional[int]]:\n        \"\"\"\n        Return the index of ``scalar`` in the JSON array under ``path`` at key\n        ``name``.\n\n        The search can be limited using the optional inclusive ``start``\n        and exclusive ``stop`` indices.\n\n        For more information see `JSON.ARRINDEX <https://redis.io/commands/json.arrindex>`_.\n        \"\"\"  # noqa\n        pieces = [name, str(path), self._encode(scalar)]\n        if start is not None:\n            pieces.append(start)\n            if stop is not None:\n                pieces.append(stop)\n\n        return self.execute_command(\"JSON.ARRINDEX\", *pieces, keys=[name])\n\n    def arrinsert(\n        self, name: str, path: str, index: int, *args: JsonType\n    ) -> List[Optional[int]]:\n        \"\"\"Insert the objects ``args`` to the array at index ``index``\n        under the ``path` in key ``name``.\n\n        For more information see `JSON.ARRINSERT <https://redis.io/commands/json.arrinsert>`_.\n        \"\"\"  # noqa\n        pieces = [name, str(path), index]\n        for o in args:\n            pieces.append(self._encode(o))\n        return self.execute_command(\"JSON.ARRINSERT\", *pieces)\n\n    def arrlen(\n        self, name: str, path: Optional[str] = Path.root_path()\n    ) -> List[Optional[int]]:\n        \"\"\"Return the length of the array JSON value under ``path``\n        at key``name``.\n\n        For more information see `JSON.ARRLEN <https://redis.io/commands/json.arrlen>`_.\n        \"\"\"  # noqa\n        return self.execute_command(\"JSON.ARRLEN\", name, str(path), keys=[name])\n\n    def arrpop(\n        self,\n        name: str,\n        path: Optional[str] = Path.root_path(),\n        index: Optional[int] = -1,\n    ) -> List[Optional[str]]:\n        \"\"\"Pop the element at ``index`` in the array JSON value under\n        ``path`` at key ``name``.\n\n        For more information see `JSON.ARRPOP <https://redis.io/commands/json.arrpop>`_.\n        \"\"\"  # noqa\n        return self.execute_command(\"JSON.ARRPOP\", name, str(path), index)\n\n    def arrtrim(\n        self, name: str, path: str, start: int, stop: int\n    ) -> List[Optional[int]]:\n        \"\"\"Trim the array JSON value under ``path`` at key ``name`` to the\n        inclusive range given by ``start`` and ``stop``.\n\n        For more information see `JSON.ARRTRIM <https://redis.io/commands/json.arrtrim>`_.\n        \"\"\"  # noqa\n        return self.execute_command(\"JSON.ARRTRIM\", name, str(path), start, stop)\n\n    def type(self, name: str, path: Optional[str] = Path.root_path()) -> List[str]:\n        \"\"\"Get the type of the JSON value under ``path`` from key ``name``.\n\n        For more information see `JSON.TYPE <https://redis.io/commands/json.type>`_.\n        \"\"\"  # noqa\n        return self.execute_command(\"JSON.TYPE\", name, str(path), keys=[name])\n\n    def resp(self, name: str, path: Optional[str] = Path.root_path()) -> List:\n        \"\"\"Return the JSON value under ``path`` at key ``name``.\n\n        For more information see `JSON.RESP <https://redis.io/commands/json.resp>`_.\n        \"\"\"  # noqa\n        return self.execute_command(\"JSON.RESP\", name, str(path), keys=[name])\n\n    def objkeys(\n        self, name: str, path: Optional[str] = Path.root_path()\n    ) -> List[Optional[List[str]]]:\n        \"\"\"Return the key names in the dictionary JSON value under ``path`` at\n        key ``name``.\n\n        For more information see `JSON.OBJKEYS <https://redis.io/commands/json.objkeys>`_.\n        \"\"\"  # noqa\n        return self.execute_command(\"JSON.OBJKEYS\", name, str(path), keys=[name])\n\n    def objlen(\n        self, name: str, path: Optional[str] = Path.root_path()\n    ) -> List[Optional[int]]:\n        \"\"\"Return the length of the dictionary JSON value under ``path`` at key\n        ``name``.\n\n        For more information see `JSON.OBJLEN <https://redis.io/commands/json.objlen>`_.\n        \"\"\"  # noqa\n        return self.execute_command(\"JSON.OBJLEN\", name, str(path), keys=[name])\n\n    def numincrby(self, name: str, path: str, number: int) -> str:\n        \"\"\"Increment the numeric (integer or floating point) JSON value under\n        ``path`` at key ``name`` by the provided ``number``.\n\n        For more information see `JSON.NUMINCRBY <https://redis.io/commands/json.numincrby>`_.\n        \"\"\"  # noqa\n        return self.execute_command(\n            \"JSON.NUMINCRBY\", name, str(path), self._encode(number)\n        )\n\n    @deprecated_function(version=\"4.0.0\", reason=\"deprecated since redisjson 1.0.0\")\n    def nummultby(self, name: str, path: str, number: int) -> str:\n        \"\"\"Multiply the numeric (integer or floating point) JSON value under\n        ``path`` at key ``name`` with the provided ``number``.\n\n        For more information see `JSON.NUMMULTBY <https://redis.io/commands/json.nummultby>`_.\n        \"\"\"  # noqa\n        return self.execute_command(\n            \"JSON.NUMMULTBY\", name, str(path), self._encode(number)\n        )\n\n    def clear(self, name: str, path: Optional[str] = Path.root_path()) -> int:\n        \"\"\"Empty arrays and objects (to have zero slots/keys without deleting the\n        array/object).\n\n        Return the count of cleared paths (ignoring non-array and non-objects\n        paths).\n\n        For more information see `JSON.CLEAR <https://redis.io/commands/json.clear>`_.\n        \"\"\"  # noqa\n        return self.execute_command(\"JSON.CLEAR\", name, str(path))\n\n    def delete(self, key: str, path: Optional[str] = Path.root_path()) -> int:\n        \"\"\"Delete the JSON value stored at key ``key`` under ``path``.\n\n        For more information see `JSON.DEL <https://redis.io/commands/json.del>`_.\n        \"\"\"\n        return self.execute_command(\"JSON.DEL\", key, str(path))\n\n    # forget is an alias for delete\n    forget = delete\n\n    def get(\n        self, name: str, *args, no_escape: Optional[bool] = False\n    ) -> Optional[List[JsonType]]:\n        \"\"\"\n        Get the object stored as a JSON value at key ``name``.\n\n        ``args`` is zero or more paths, and defaults to root path\n        ```no_escape`` is a boolean flag to add no_escape option to get\n        non-ascii characters\n\n        For more information see `JSON.GET <https://redis.io/commands/json.get>`_.\n        \"\"\"  # noqa\n        pieces = [name]\n        if no_escape:\n            pieces.append(\"noescape\")\n\n        if len(args) == 0:\n            pieces.append(Path.root_path())\n\n        else:\n            for p in args:\n                pieces.append(str(p))\n\n        # Handle case where key doesn't exist. The JSONDecoder would raise a\n        # TypeError exception since it can't decode None\n        try:\n            return self.execute_command(\"JSON.GET\", *pieces, keys=[name])\n        except TypeError:\n            return None\n\n    def mget(self, keys: List[str], path: str) -> List[JsonType]:\n        \"\"\"\n        Get the objects stored as a JSON values under ``path``. ``keys``\n        is a list of one or more keys.\n\n        For more information see `JSON.MGET <https://redis.io/commands/json.mget>`_.\n        \"\"\"  # noqa\n        pieces = []\n        pieces += keys\n        pieces.append(str(path))\n        return self.execute_command(\"JSON.MGET\", *pieces, keys=keys)\n\n    def set(\n        self,\n        name: str,\n        path: str,\n        obj: JsonType,\n        nx: Optional[bool] = False,\n        xx: Optional[bool] = False,\n        decode_keys: Optional[bool] = False,\n    ) -> Optional[str]:\n        \"\"\"\n        Set the JSON value at key ``name`` under the ``path`` to ``obj``.\n\n        ``nx`` if set to True, set ``value`` only if it does not exist.\n        ``xx`` if set to True, set ``value`` only if it exists.\n        ``decode_keys`` If set to True, the keys of ``obj`` will be decoded\n        with utf-8.\n\n        For the purpose of using this within a pipeline, this command is also\n        aliased to JSON.SET.\n\n        For more information see `JSON.SET <https://redis.io/commands/json.set>`_.\n        \"\"\"\n        if decode_keys:\n            obj = decode_dict_keys(obj)\n\n        pieces = [name, str(path), self._encode(obj)]\n\n        # Handle existential modifiers\n        if nx and xx:\n            raise Exception(\n                \"nx and xx are mutually exclusive: use one, the \"\n                \"other or neither - but not both\"\n            )\n        elif nx:\n            pieces.append(\"NX\")\n        elif xx:\n            pieces.append(\"XX\")\n        return self.execute_command(\"JSON.SET\", *pieces)\n\n    def mset(self, triplets: List[Tuple[str, str, JsonType]]) -> Optional[str]:\n        \"\"\"\n        Set the JSON value at key ``name`` under the ``path`` to ``obj``\n        for one or more keys.\n\n        ``triplets`` is a list of one or more triplets of key, path, value.\n\n        For the purpose of using this within a pipeline, this command is also\n        aliased to JSON.MSET.\n\n        For more information see `JSON.MSET <https://redis.io/commands/json.mset>`_.\n        \"\"\"\n        pieces = []\n        for triplet in triplets:\n            pieces.extend([triplet[0], str(triplet[1]), self._encode(triplet[2])])\n        return self.execute_command(\"JSON.MSET\", *pieces)\n\n    def merge(\n        self,\n        name: str,\n        path: str,\n        obj: JsonType,\n        decode_keys: Optional[bool] = False,\n    ) -> Optional[str]:\n        \"\"\"\n        Merges a given JSON value into matching paths. Consequently, JSON values\n        at matching paths are updated, deleted, or expanded with new children\n\n        ``decode_keys`` If set to True, the keys of ``obj`` will be decoded\n        with utf-8.\n\n        For more information see `JSON.MERGE <https://redis.io/commands/json.merge>`_.\n        \"\"\"\n        if decode_keys:\n            obj = decode_dict_keys(obj)\n\n        pieces = [name, str(path), self._encode(obj)]\n\n        return self.execute_command(\"JSON.MERGE\", *pieces)\n\n    def set_file(\n        self,\n        name: str,\n        path: str,\n        file_name: str,\n        nx: Optional[bool] = False,\n        xx: Optional[bool] = False,\n        decode_keys: Optional[bool] = False,\n    ) -> Optional[str]:\n        \"\"\"\n        Set the JSON value at key ``name`` under the ``path`` to the content\n        of the json file ``file_name``.\n\n        ``nx`` if set to True, set ``value`` only if it does not exist.\n        ``xx`` if set to True, set ``value`` only if it exists.\n        ``decode_keys`` If set to True, the keys of ``obj`` will be decoded\n        with utf-8.\n\n        \"\"\"\n\n        with open(file_name) as fp:\n            file_content = loads(fp.read())\n\n        return self.set(name, path, file_content, nx=nx, xx=xx, decode_keys=decode_keys)\n\n    def set_path(\n        self,\n        json_path: str,\n        root_folder: str,\n        nx: Optional[bool] = False,\n        xx: Optional[bool] = False,\n        decode_keys: Optional[bool] = False,\n    ) -> Dict[str, bool]:\n        \"\"\"\n        Iterate over ``root_folder`` and set each JSON file to a value\n        under ``json_path`` with the file name as the key.\n\n        ``nx`` if set to True, set ``value`` only if it does not exist.\n        ``xx`` if set to True, set ``value`` only if it exists.\n        ``decode_keys`` If set to True, the keys of ``obj`` will be decoded\n        with utf-8.\n\n        \"\"\"\n        set_files_result = {}\n        for root, dirs, files in os.walk(root_folder):\n            for file in files:\n                file_path = os.path.join(root, file)\n                try:\n                    file_name = file_path.rsplit(\".\")[0]\n                    self.set_file(\n                        file_name,\n                        json_path,\n                        file_path,\n                        nx=nx,\n                        xx=xx,\n                        decode_keys=decode_keys,\n                    )\n                    set_files_result[file_path] = True\n                except JSONDecodeError:\n                    set_files_result[file_path] = False\n\n        return set_files_result\n\n    def strlen(self, name: str, path: Optional[str] = None) -> List[Optional[int]]:\n        \"\"\"Return the length of the string JSON value under ``path`` at key\n        ``name``.\n\n        For more information see `JSON.STRLEN <https://redis.io/commands/json.strlen>`_.\n        \"\"\"  # noqa\n        pieces = [name]\n        if path is not None:\n            pieces.append(str(path))\n        return self.execute_command(\"JSON.STRLEN\", *pieces, keys=[name])\n\n    def toggle(\n        self, name: str, path: Optional[str] = Path.root_path()\n    ) -> Union[bool, List[Optional[int]]]:\n        \"\"\"Toggle boolean value under ``path`` at key ``name``.\n        returning the new value.\n\n        For more information see `JSON.TOGGLE <https://redis.io/commands/json.toggle>`_.\n        \"\"\"  # noqa\n        return self.execute_command(\"JSON.TOGGLE\", name, str(path))\n\n    def strappend(\n        self, name: str, value: str, path: Optional[str] = Path.root_path()\n    ) -> Union[int, List[Optional[int]]]:\n        \"\"\"Append to the string JSON value. If two options are specified after\n        the key name, the path is determined to be the first. If a single\n        option is passed, then the root_path (i.e Path.root_path()) is used.\n\n        For more information see `JSON.STRAPPEND <https://redis.io/commands/json.strappend>`_.\n        \"\"\"  # noqa\n        pieces = [name, str(path), self._encode(value)]\n        return self.execute_command(\"JSON.STRAPPEND\", *pieces)\n\n    def debug(\n        self,\n        subcommand: str,\n        key: Optional[str] = None,\n        path: Optional[str] = Path.root_path(),\n    ) -> Union[int, List[str]]:\n        \"\"\"Return the memory usage in bytes of a value under ``path`` from\n        key ``name``.\n\n        For more information see `JSON.DEBUG <https://redis.io/commands/json.debug>`_.\n        \"\"\"  # noqa\n        valid_subcommands = [\"MEMORY\", \"HELP\"]\n        if subcommand not in valid_subcommands:\n            raise DataError(\"The only valid subcommands are \", str(valid_subcommands))\n        pieces = [subcommand]\n        if subcommand == \"MEMORY\":\n            if key is None:\n                raise DataError(\"No key specified\")\n            pieces.append(key)\n            pieces.append(str(path))\n        return self.execute_command(\"JSON.DEBUG\", *pieces)\n\n    @deprecated_function(\n        version=\"4.0.0\", reason=\"redisjson-py supported this, call get directly.\"\n    )\n    def jsonget(self, *args, **kwargs):\n        return self.get(*args, **kwargs)\n\n    @deprecated_function(\n        version=\"4.0.0\", reason=\"redisjson-py supported this, call get directly.\"\n    )\n    def jsonmget(self, *args, **kwargs):\n        return self.mget(*args, **kwargs)\n\n    @deprecated_function(\n        version=\"4.0.0\", reason=\"redisjson-py supported this, call get directly.\"\n    )\n    def jsonset(self, *args, **kwargs):\n        return self.set(*args, **kwargs)\n"
  },
  {
    "path": "redis/commands/json/decoders.py",
    "content": "import copy\nimport re\n\nfrom ..helpers import nativestr\n\n\ndef bulk_of_jsons(d):\n    \"\"\"Replace serialized JSON values with objects in a\n    bulk array response (list).\n    \"\"\"\n\n    def _f(b):\n        for index, item in enumerate(b):\n            if item is not None:\n                b[index] = d(item)\n        return b\n\n    return _f\n\n\ndef decode_dict_keys(obj):\n    \"\"\"Decode the keys of the given dictionary with utf-8.\"\"\"\n    newobj = copy.copy(obj)\n    for k in obj.keys():\n        if isinstance(k, bytes):\n            newobj[k.decode(\"utf-8\")] = newobj[k]\n            newobj.pop(k)\n    return newobj\n\n\ndef unstring(obj):\n    \"\"\"\n    Attempt to parse string to native integer formats.\n    One can't simply call int/float in a try/catch because there is a\n    semantic difference between (for example) 15.0 and 15.\n    \"\"\"\n    floatreg = \"^\\\\d+.\\\\d+$\"\n    match = re.findall(floatreg, obj)\n    if match != []:\n        return float(match[0])\n\n    intreg = \"^\\\\d+$\"\n    match = re.findall(intreg, obj)\n    if match != []:\n        return int(match[0])\n    return obj\n\n\ndef decode_list(b):\n    \"\"\"\n    Given a non-deserializable object, make a best effort to\n    return a useful set of results.\n    \"\"\"\n    if isinstance(b, list):\n        return [nativestr(obj) for obj in b]\n    elif isinstance(b, bytes):\n        return unstring(nativestr(b))\n    elif isinstance(b, str):\n        return unstring(b)\n    return b\n"
  },
  {
    "path": "redis/commands/json/path.py",
    "content": "class Path:\n    \"\"\"This class represents a path in a JSON value.\"\"\"\n\n    strPath = \"\"\n\n    @staticmethod\n    def root_path():\n        \"\"\"Return the root path's string representation.\"\"\"\n        return \".\"\n\n    def __init__(self, path):\n        \"\"\"Make a new path based on the string representation in `path`.\"\"\"\n        self.strPath = path\n\n    def __repr__(self):\n        return self.strPath\n"
  },
  {
    "path": "redis/commands/policies.py",
    "content": "from abc import ABC, abstractmethod\nfrom typing import Optional\n\nfrom redis._parsers.commands import (\n    CommandPolicies,\n    CommandsParser,\n    PolicyRecords,\n    RequestPolicy,\n    ResponsePolicy,\n)\n\nSTATIC_POLICIES: PolicyRecords = {\n    \"ft\": {\n        \"explaincli\": CommandPolicies(\n            request_policy=RequestPolicy.DEFAULT_KEYLESS,\n            response_policy=ResponsePolicy.DEFAULT_KEYLESS,\n        ),\n        \"suglen\": CommandPolicies(\n            request_policy=RequestPolicy.DEFAULT_KEYED,\n            response_policy=ResponsePolicy.DEFAULT_KEYED,\n        ),\n        \"profile\": CommandPolicies(\n            request_policy=RequestPolicy.DEFAULT_KEYLESS,\n            response_policy=ResponsePolicy.DEFAULT_KEYLESS,\n        ),\n        \"dropindex\": CommandPolicies(\n            request_policy=RequestPolicy.DEFAULT_KEYLESS,\n            response_policy=ResponsePolicy.DEFAULT_KEYLESS,\n        ),\n        \"aliasupdate\": CommandPolicies(\n            request_policy=RequestPolicy.DEFAULT_KEYLESS,\n            response_policy=ResponsePolicy.DEFAULT_KEYLESS,\n        ),\n        \"alter\": CommandPolicies(\n            request_policy=RequestPolicy.DEFAULT_KEYLESS,\n            response_policy=ResponsePolicy.DEFAULT_KEYLESS,\n        ),\n        \"aggregate\": CommandPolicies(\n            request_policy=RequestPolicy.DEFAULT_KEYLESS,\n            response_policy=ResponsePolicy.DEFAULT_KEYLESS,\n        ),\n        \"syndump\": CommandPolicies(\n            request_policy=RequestPolicy.DEFAULT_KEYLESS,\n            response_policy=ResponsePolicy.DEFAULT_KEYLESS,\n        ),\n        \"create\": CommandPolicies(\n            request_policy=RequestPolicy.DEFAULT_KEYLESS,\n            response_policy=ResponsePolicy.DEFAULT_KEYLESS,\n        ),\n        \"explain\": CommandPolicies(\n            request_policy=RequestPolicy.DEFAULT_KEYLESS,\n            response_policy=ResponsePolicy.DEFAULT_KEYLESS,\n        ),\n        \"sugget\": CommandPolicies(\n            request_policy=RequestPolicy.DEFAULT_KEYED,\n            response_policy=ResponsePolicy.DEFAULT_KEYED,\n        ),\n        \"dictdel\": CommandPolicies(\n            request_policy=RequestPolicy.DEFAULT_KEYLESS,\n            response_policy=ResponsePolicy.DEFAULT_KEYLESS,\n        ),\n        \"aliasadd\": CommandPolicies(\n            request_policy=RequestPolicy.DEFAULT_KEYLESS,\n            response_policy=ResponsePolicy.DEFAULT_KEYLESS,\n        ),\n        \"dictadd\": CommandPolicies(\n            request_policy=RequestPolicy.DEFAULT_KEYLESS,\n            response_policy=ResponsePolicy.DEFAULT_KEYLESS,\n        ),\n        \"synupdate\": CommandPolicies(\n            request_policy=RequestPolicy.DEFAULT_KEYLESS,\n            response_policy=ResponsePolicy.DEFAULT_KEYLESS,\n        ),\n        \"drop\": CommandPolicies(\n            request_policy=RequestPolicy.DEFAULT_KEYLESS,\n            response_policy=ResponsePolicy.DEFAULT_KEYLESS,\n        ),\n        \"info\": CommandPolicies(\n            request_policy=RequestPolicy.DEFAULT_KEYLESS,\n            response_policy=ResponsePolicy.DEFAULT_KEYLESS,\n        ),\n        \"sugadd\": CommandPolicies(\n            request_policy=RequestPolicy.DEFAULT_KEYED,\n            response_policy=ResponsePolicy.DEFAULT_KEYED,\n        ),\n        \"dictdump\": CommandPolicies(\n            request_policy=RequestPolicy.DEFAULT_KEYLESS,\n            response_policy=ResponsePolicy.DEFAULT_KEYLESS,\n        ),\n        \"cursor\": CommandPolicies(\n            request_policy=RequestPolicy.SPECIAL,\n            response_policy=ResponsePolicy.DEFAULT_KEYLESS,\n        ),\n        \"search\": CommandPolicies(\n            request_policy=RequestPolicy.DEFAULT_KEYLESS,\n            response_policy=ResponsePolicy.DEFAULT_KEYLESS,\n        ),\n        \"tagvals\": CommandPolicies(\n            request_policy=RequestPolicy.DEFAULT_KEYLESS,\n            response_policy=ResponsePolicy.DEFAULT_KEYLESS,\n        ),\n        \"aliasdel\": CommandPolicies(\n            request_policy=RequestPolicy.DEFAULT_KEYLESS,\n            response_policy=ResponsePolicy.DEFAULT_KEYLESS,\n        ),\n        \"sugdel\": CommandPolicies(\n            request_policy=RequestPolicy.DEFAULT_KEYED,\n            response_policy=ResponsePolicy.DEFAULT_KEYED,\n        ),\n        \"spellcheck\": CommandPolicies(\n            request_policy=RequestPolicy.DEFAULT_KEYLESS,\n            response_policy=ResponsePolicy.DEFAULT_KEYLESS,\n        ),\n    },\n    \"core\": {\n        \"command\": CommandPolicies(\n            request_policy=RequestPolicy.DEFAULT_KEYLESS,\n            response_policy=ResponsePolicy.DEFAULT_KEYLESS,\n        ),\n    },\n}\n\n\nclass PolicyResolver(ABC):\n    @abstractmethod\n    def resolve(self, command_name: str) -> Optional[CommandPolicies]:\n        \"\"\"\n        Resolves the command name and determines the associated command policies.\n\n        Args:\n            command_name: The name of the command to resolve.\n\n        Returns:\n            CommandPolicies: The policies associated with the specified command.\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def with_fallback(self, fallback: \"PolicyResolver\") -> \"PolicyResolver\":\n        \"\"\"\n        Factory method to instantiate a policy resolver with a fallback resolver.\n\n        Args:\n            fallback: Fallback resolver\n\n        Returns:\n            PolicyResolver: Returns a new policy resolver with the specified fallback resolver.\n        \"\"\"\n        pass\n\n\nclass AsyncPolicyResolver(ABC):\n    @abstractmethod\n    async def resolve(self, command_name: str) -> Optional[CommandPolicies]:\n        \"\"\"\n        Resolves the command name and determines the associated command policies.\n\n        Args:\n            command_name: The name of the command to resolve.\n\n        Returns:\n            CommandPolicies: The policies associated with the specified command.\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def with_fallback(self, fallback: \"AsyncPolicyResolver\") -> \"AsyncPolicyResolver\":\n        \"\"\"\n        Factory method to instantiate an async policy resolver with a fallback resolver.\n\n        Args:\n            fallback: Fallback resolver\n\n        Returns:\n            AsyncPolicyResolver: Returns a new policy resolver with the specified fallback resolver.\n        \"\"\"\n        pass\n\n\nclass BasePolicyResolver(PolicyResolver):\n    \"\"\"\n    Base class for policy resolvers.\n    \"\"\"\n\n    def __init__(\n        self, policies: PolicyRecords, fallback: Optional[PolicyResolver] = None\n    ) -> None:\n        self._policies = policies\n        self._fallback = fallback\n\n    def resolve(self, command_name: str) -> Optional[CommandPolicies]:\n        parts = command_name.split(\".\")\n\n        if len(parts) > 2:\n            raise ValueError(f\"Wrong command or module name: {command_name}\")\n\n        module, command = parts if len(parts) == 2 else (\"core\", parts[0])\n\n        if self._policies.get(module, None) is None:\n            if self._fallback is not None:\n                return self._fallback.resolve(command_name)\n            else:\n                return None\n\n        if self._policies.get(module).get(command, None) is None:\n            if self._fallback is not None:\n                return self._fallback.resolve(command_name)\n            else:\n                return None\n\n        return self._policies.get(module).get(command)\n\n    @abstractmethod\n    def with_fallback(self, fallback: \"PolicyResolver\") -> \"PolicyResolver\":\n        pass\n\n\nclass AsyncBasePolicyResolver(AsyncPolicyResolver):\n    \"\"\"\n    Async base class for policy resolvers.\n    \"\"\"\n\n    def __init__(\n        self, policies: PolicyRecords, fallback: Optional[AsyncPolicyResolver] = None\n    ) -> None:\n        self._policies = policies\n        self._fallback = fallback\n\n    async def resolve(self, command_name: str) -> Optional[CommandPolicies]:\n        parts = command_name.split(\".\")\n\n        if len(parts) > 2:\n            raise ValueError(f\"Wrong command or module name: {command_name}\")\n\n        module, command = parts if len(parts) == 2 else (\"core\", parts[0])\n\n        if self._policies.get(module, None) is None:\n            if self._fallback is not None:\n                return await self._fallback.resolve(command_name)\n            else:\n                return None\n\n        if self._policies.get(module).get(command, None) is None:\n            if self._fallback is not None:\n                return await self._fallback.resolve(command_name)\n            else:\n                return None\n\n        return self._policies.get(module).get(command)\n\n    @abstractmethod\n    def with_fallback(self, fallback: \"AsyncPolicyResolver\") -> \"AsyncPolicyResolver\":\n        pass\n\n\nclass DynamicPolicyResolver(BasePolicyResolver):\n    \"\"\"\n    Resolves policy dynamically based on the COMMAND output.\n    \"\"\"\n\n    def __init__(\n        self, commands_parser: CommandsParser, fallback: Optional[PolicyResolver] = None\n    ) -> None:\n        \"\"\"\n        Parameters:\n            commands_parser (CommandsParser): COMMAND output parser.\n            fallback (Optional[PolicyResolver]): An optional resolver to be used when the\n                primary policies cannot handle a specific request.\n        \"\"\"\n        self._commands_parser = commands_parser\n        super().__init__(commands_parser.get_command_policies(), fallback)\n\n    def with_fallback(self, fallback: \"PolicyResolver\") -> \"PolicyResolver\":\n        return DynamicPolicyResolver(self._commands_parser, fallback)\n\n\nclass StaticPolicyResolver(BasePolicyResolver):\n    \"\"\"\n    Resolves policy from a static list of policy records.\n    \"\"\"\n\n    def __init__(self, fallback: Optional[PolicyResolver] = None) -> None:\n        \"\"\"\n        Parameters:\n            fallback (Optional[PolicyResolver]): An optional fallback policy resolver\n            used for resolving policies if static policies are inadequate.\n        \"\"\"\n        super().__init__(STATIC_POLICIES, fallback)\n\n    def with_fallback(self, fallback: \"PolicyResolver\") -> \"PolicyResolver\":\n        return StaticPolicyResolver(fallback)\n\n\nclass AsyncDynamicPolicyResolver(AsyncBasePolicyResolver):\n    \"\"\"\n    Async version of DynamicPolicyResolver.\n    \"\"\"\n\n    def __init__(\n        self,\n        policy_records: PolicyRecords,\n        fallback: Optional[AsyncPolicyResolver] = None,\n    ) -> None:\n        \"\"\"\n        Parameters:\n            policy_records (PolicyRecords): Policy records.\n            fallback (Optional[AsyncPolicyResolver]): An optional resolver to be used when the\n                primary policies cannot handle a specific request.\n        \"\"\"\n        super().__init__(policy_records, fallback)\n\n    def with_fallback(self, fallback: \"AsyncPolicyResolver\") -> \"AsyncPolicyResolver\":\n        return AsyncDynamicPolicyResolver(self._policies, fallback)\n\n\nclass AsyncStaticPolicyResolver(AsyncBasePolicyResolver):\n    \"\"\"\n    Async version of StaticPolicyResolver.\n    \"\"\"\n\n    def __init__(self, fallback: Optional[AsyncPolicyResolver] = None) -> None:\n        \"\"\"\n        Parameters:\n            fallback (Optional[AsyncPolicyResolver]): An optional fallback policy resolver\n            used for resolving policies if static policies are inadequate.\n        \"\"\"\n        super().__init__(STATIC_POLICIES, fallback)\n\n    def with_fallback(self, fallback: \"AsyncPolicyResolver\") -> \"AsyncPolicyResolver\":\n        return AsyncStaticPolicyResolver(fallback)\n"
  },
  {
    "path": "redis/commands/redismodules.py",
    "content": "from __future__ import annotations\n\nfrom json import JSONDecoder, JSONEncoder\nfrom typing import TYPE_CHECKING\n\nif TYPE_CHECKING:\n    from .bf import BFBloom, CFBloom, CMSBloom, TDigestBloom, TOPKBloom\n    from .json import JSON\n    from .search import AsyncSearch, Search\n    from .timeseries import TimeSeries\n    from .vectorset import AsyncVectorSet, VectorSet\n\n\nclass RedisModuleCommands:\n    \"\"\"This class contains the wrapper functions to bring supported redis\n    modules into the command namespace.\n    \"\"\"\n\n    def json(self, encoder=JSONEncoder(), decoder=JSONDecoder()) -> JSON:\n        \"\"\"Access the json namespace, providing support for redis json.\"\"\"\n\n        from .json import JSON\n\n        jj = JSON(client=self, encoder=encoder, decoder=decoder)\n        return jj\n\n    def ft(self, index_name=\"idx\") -> Search:\n        \"\"\"Access the search namespace, providing support for redis search.\"\"\"\n\n        from .search import Search\n\n        s = Search(client=self, index_name=index_name)\n        return s\n\n    def ts(self) -> TimeSeries:\n        \"\"\"Access the timeseries namespace, providing support for\n        redis timeseries data.\n        \"\"\"\n\n        from .timeseries import TimeSeries\n\n        s = TimeSeries(client=self)\n        return s\n\n    def bf(self) -> BFBloom:\n        \"\"\"Access the bloom namespace.\"\"\"\n\n        from .bf import BFBloom\n\n        bf = BFBloom(client=self)\n        return bf\n\n    def cf(self) -> CFBloom:\n        \"\"\"Access the bloom namespace.\"\"\"\n\n        from .bf import CFBloom\n\n        cf = CFBloom(client=self)\n        return cf\n\n    def cms(self) -> CMSBloom:\n        \"\"\"Access the bloom namespace.\"\"\"\n\n        from .bf import CMSBloom\n\n        cms = CMSBloom(client=self)\n        return cms\n\n    def topk(self) -> TOPKBloom:\n        \"\"\"Access the bloom namespace.\"\"\"\n\n        from .bf import TOPKBloom\n\n        topk = TOPKBloom(client=self)\n        return topk\n\n    def tdigest(self) -> TDigestBloom:\n        \"\"\"Access the bloom namespace.\"\"\"\n\n        from .bf import TDigestBloom\n\n        tdigest = TDigestBloom(client=self)\n        return tdigest\n\n    def vset(self) -> VectorSet:\n        \"\"\"Access the VectorSet commands namespace.\"\"\"\n\n        from .vectorset import VectorSet\n\n        vset = VectorSet(client=self)\n        return vset\n\n\nclass AsyncRedisModuleCommands(RedisModuleCommands):\n    def ft(self, index_name=\"idx\") -> AsyncSearch:\n        \"\"\"Access the search namespace, providing support for redis search.\"\"\"\n\n        from .search import AsyncSearch\n\n        s = AsyncSearch(client=self, index_name=index_name)\n        return s\n\n    def vset(self) -> AsyncVectorSet:\n        \"\"\"Access the VectorSet commands namespace.\"\"\"\n\n        from .vectorset import AsyncVectorSet\n\n        vset = AsyncVectorSet(client=self)\n        return vset\n"
  },
  {
    "path": "redis/commands/search/__init__.py",
    "content": "from typing import Literal\n\nfrom redis.client import Pipeline as RedisPipeline\n\nfrom ...asyncio.client import Pipeline as AsyncioPipeline\nfrom .commands import (\n    AGGREGATE_CMD,\n    CONFIG_CMD,\n    HYBRID_CMD,\n    INFO_CMD,\n    PROFILE_CMD,\n    SEARCH_CMD,\n    SPELLCHECK_CMD,\n    SYNDUMP_CMD,\n    AsyncSearchCommands,\n    SearchCommands,\n)\n\n\nclass Search(SearchCommands):\n    \"\"\"\n    Create a client for talking to search.\n    It abstracts the API of the module and lets you just use the engine.\n    \"\"\"\n\n    class BatchIndexer:\n        \"\"\"\n        A batch indexer allows you to automatically batch\n        document indexing in pipelines, flushing it every N documents.\n        \"\"\"\n\n        def __init__(self, client, chunk_size=1000):\n            self.client = client\n            self.execute_command = client.execute_command\n            self._pipeline = client.pipeline(transaction=False, shard_hint=None)\n            self.total = 0\n            self.chunk_size = chunk_size\n            self.current_chunk = 0\n\n        def __del__(self):\n            if self.current_chunk:\n                self.commit()\n\n        def add_document(\n            self,\n            doc_id,\n            nosave=False,\n            score=1.0,\n            payload=None,\n            replace=False,\n            partial=False,\n            no_create=False,\n            **fields,\n        ):\n            \"\"\"\n            Add a document to the batch query\n            \"\"\"\n            self.client._add_document(\n                doc_id,\n                conn=self._pipeline,\n                nosave=nosave,\n                score=score,\n                payload=payload,\n                replace=replace,\n                partial=partial,\n                no_create=no_create,\n                **fields,\n            )\n            self.current_chunk += 1\n            self.total += 1\n            if self.current_chunk >= self.chunk_size:\n                self.commit()\n\n        def add_document_hash(self, doc_id, score=1.0, replace=False):\n            \"\"\"\n            Add a hash to the batch query\n            \"\"\"\n            self.client._add_document_hash(\n                doc_id, conn=self._pipeline, score=score, replace=replace\n            )\n            self.current_chunk += 1\n            self.total += 1\n            if self.current_chunk >= self.chunk_size:\n                self.commit()\n\n        def commit(self):\n            \"\"\"\n            Manually commit and flush the batch indexing query\n            \"\"\"\n            self._pipeline.execute()\n            self.current_chunk = 0\n\n    def __init__(self, client, index_name=\"idx\"):\n        \"\"\"\n        Create a new Client for the given index_name.\n        The default name is `idx`\n\n        If conn is not None, we employ an already existing redis connection\n        \"\"\"\n        self._MODULE_CALLBACKS = {}\n        self.client = client\n        self.index_name = index_name\n        self.execute_command = client.execute_command\n        self._pipeline = client.pipeline\n        self._RESP2_MODULE_CALLBACKS = {\n            INFO_CMD: self._parse_info,\n            SEARCH_CMD: self._parse_search,\n            HYBRID_CMD: self._parse_hybrid_search,\n            AGGREGATE_CMD: self._parse_aggregate,\n            PROFILE_CMD: self._parse_profile,\n            SPELLCHECK_CMD: self._parse_spellcheck,\n            CONFIG_CMD: self._parse_config_get,\n            SYNDUMP_CMD: self._parse_syndump,\n        }\n\n    def pipeline(self, transaction=True, shard_hint=None):\n        \"\"\"Creates a pipeline for the SEARCH module, that can be used for executing\n        SEARCH commands, as well as classic core commands.\n        \"\"\"\n        p = Pipeline(\n            connection_pool=self.client.connection_pool,\n            response_callbacks=self._MODULE_CALLBACKS,\n            transaction=transaction,\n            shard_hint=shard_hint,\n        )\n        p.index_name = self.index_name\n        return p\n\n\nclass AsyncSearch(Search, AsyncSearchCommands):\n    class BatchIndexer(Search.BatchIndexer):\n        \"\"\"\n        A batch indexer allows you to automatically batch\n        document indexing in pipelines, flushing it every N documents.\n        \"\"\"\n\n        async def add_document(\n            self,\n            doc_id,\n            nosave=False,\n            score=1.0,\n            payload=None,\n            replace=False,\n            partial=False,\n            no_create=False,\n            **fields,\n        ):\n            \"\"\"\n            Add a document to the batch query\n            \"\"\"\n            self.client._add_document(\n                doc_id,\n                conn=self._pipeline,\n                nosave=nosave,\n                score=score,\n                payload=payload,\n                replace=replace,\n                partial=partial,\n                no_create=no_create,\n                **fields,\n            )\n            self.current_chunk += 1\n            self.total += 1\n            if self.current_chunk >= self.chunk_size:\n                await self.commit()\n\n        async def commit(self):\n            \"\"\"\n            Manually commit and flush the batch indexing query\n            \"\"\"\n            await self._pipeline.execute()\n            self.current_chunk = 0\n\n    def pipeline(self, transaction=True, shard_hint=None):\n        \"\"\"Creates a pipeline for the SEARCH module, that can be used for executing\n        SEARCH commands, as well as classic core commands.\n        \"\"\"\n        p = AsyncPipeline(\n            connection_pool=self.client.connection_pool,\n            response_callbacks=self._MODULE_CALLBACKS,\n            transaction=transaction,\n            shard_hint=shard_hint,\n        )\n        p.index_name = self.index_name\n        return p\n\n\nclass Pipeline(SearchCommands, RedisPipeline):\n    \"\"\"Pipeline for the module.\"\"\"\n\n    _is_async_client: Literal[False] = False\n\n\nclass AsyncPipeline(AsyncSearchCommands, AsyncioPipeline, Pipeline):\n    \"\"\"AsyncPipeline for the module.\"\"\"\n\n    _is_async_client: Literal[True] = True\n"
  },
  {
    "path": "redis/commands/search/_util.py",
    "content": "def to_string(s, encoding: str = \"utf-8\"):\n    if isinstance(s, str):\n        return s\n    elif isinstance(s, bytes):\n        return s.decode(encoding, \"ignore\")\n    else:\n        return s  # Not a string we care about\n"
  },
  {
    "path": "redis/commands/search/aggregation.py",
    "content": "from typing import List, Optional, Tuple, Union\n\nfrom redis.commands.search.dialect import DEFAULT_DIALECT\n\nFIELDNAME = object()\n\n\nclass Limit:\n    def __init__(self, offset: int = 0, count: int = 0) -> None:\n        self.offset = offset\n        self.count = count\n\n    def build_args(self):\n        if self.count:\n            return [\"LIMIT\", str(self.offset), str(self.count)]\n        else:\n            return []\n\n\nclass Reducer:\n    \"\"\"\n    Base reducer object for all reducers.\n\n    See the `redisearch.reducers` module for the actual reducers.\n    \"\"\"\n\n    NAME = None\n\n    def __init__(self, *args: str) -> None:\n        self._args: Tuple[str, ...] = args\n        self._field: Optional[str] = None\n        self._alias: Optional[str] = None\n\n    def alias(self, alias: str) -> \"Reducer\":\n        \"\"\"\n        Set the alias for this reducer.\n\n        ### Parameters\n\n        - **alias**: The value of the alias for this reducer. If this is the\n            special value `aggregation.FIELDNAME` then this reducer will be\n            aliased using the same name as the field upon which it operates.\n            Note that using `FIELDNAME` is only possible on reducers which\n            operate on a single field value.\n\n        This method returns the `Reducer` object making it suitable for\n        chaining.\n        \"\"\"\n        if alias is FIELDNAME:\n            if not self._field:\n                raise ValueError(\"Cannot use FIELDNAME alias with no field\")\n            else:\n                # Chop off initial '@'\n                alias = self._field[1:]\n        self._alias = alias\n        return self\n\n    @property\n    def args(self) -> Tuple[str, ...]:\n        return self._args\n\n\nclass SortDirection:\n    \"\"\"\n    This special class is used to indicate sort direction.\n    \"\"\"\n\n    DIRSTRING: Optional[str] = None\n\n    def __init__(self, field: str) -> None:\n        self.field = field\n\n\nclass Asc(SortDirection):\n    \"\"\"\n    Indicate that the given field should be sorted in ascending order\n    \"\"\"\n\n    DIRSTRING = \"ASC\"\n\n\nclass Desc(SortDirection):\n    \"\"\"\n    Indicate that the given field should be sorted in descending order\n    \"\"\"\n\n    DIRSTRING = \"DESC\"\n\n\nclass AggregateRequest:\n    \"\"\"\n    Aggregation request which can be passed to `Client.aggregate`.\n    \"\"\"\n\n    def __init__(self, query: str = \"*\") -> None:\n        \"\"\"\n        Create an aggregation request. This request may then be passed to\n        `client.aggregate()`.\n\n        In order for the request to be usable, it must contain at least one\n        group.\n\n        - **query** Query string for filtering records.\n\n        All member methods (except `build_args()`)\n        return the object itself, making them useful for chaining.\n        \"\"\"\n        self._query: str = query\n        self._aggregateplan: List[str] = []\n        self._loadfields: List[str] = []\n        self._loadall: bool = False\n        self._max: int = 0\n        self._with_schema: bool = False\n        self._verbatim: bool = False\n        self._cursor: List[str] = []\n        self._dialect: int = DEFAULT_DIALECT\n        self._add_scores: bool = False\n        self._scorer: str = \"TFIDF\"\n\n    def load(self, *fields: str) -> \"AggregateRequest\":\n        \"\"\"\n        Indicate the fields to be returned in the response. These fields are\n        returned in addition to any others implicitly specified.\n\n        ### Parameters\n\n        - **fields**: If fields not specified, all the fields will be loaded.\n        Otherwise, fields should be given in the format of `@field`.\n        \"\"\"\n        if fields:\n            self._loadfields.extend(fields)\n        else:\n            self._loadall = True\n        return self\n\n    def group_by(\n        self, fields: Union[str, List[str]], *reducers: Reducer\n    ) -> \"AggregateRequest\":\n        \"\"\"\n        Specify by which fields to group the aggregation.\n\n        ### Parameters\n\n        - **fields**: Fields to group by. This can either be a single string,\n            or a list of strings. both cases, the field should be specified as\n            `@field`.\n        - **reducers**: One or more reducers. Reducers may be found in the\n            `aggregation` module.\n        \"\"\"\n        fields = [fields] if isinstance(fields, str) else fields\n\n        ret = [\"GROUPBY\", str(len(fields)), *fields]\n        for reducer in reducers:\n            ret += [\"REDUCE\", reducer.NAME, str(len(reducer.args))]\n            ret.extend(reducer.args)\n            if reducer._alias is not None:\n                ret += [\"AS\", reducer._alias]\n\n        self._aggregateplan.extend(ret)\n        return self\n\n    def apply(self, **kwexpr) -> \"AggregateRequest\":\n        \"\"\"\n        Specify one or more projection expressions to add to each result\n\n        ### Parameters\n\n        - **kwexpr**: One or more key-value pairs for a projection. The key is\n            the alias for the projection, and the value is the projection\n            expression itself, for example `apply(square_root=\"sqrt(@foo)\")`\n        \"\"\"\n        for alias, expr in kwexpr.items():\n            ret = [\"APPLY\", expr]\n            if alias is not None:\n                ret += [\"AS\", alias]\n            self._aggregateplan.extend(ret)\n\n        return self\n\n    def limit(self, offset: int, num: int) -> \"AggregateRequest\":\n        \"\"\"\n        Sets the limit for the most recent group or query.\n\n        If no group has been defined yet (via `group_by()`) then this sets\n        the limit for the initial pool of results from the query. Otherwise,\n        this limits the number of items operated on from the previous group.\n\n        Setting a limit on the initial search results may be useful when\n        attempting to execute an aggregation on a sample of a large data set.\n\n        ### Parameters\n\n        - **offset**: Result offset from which to begin paging\n        - **num**: Number of results to return\n\n\n        Example of sorting the initial results:\n\n        ```\n        AggregateRequest(\"@sale_amount:[10000, inf]\")\\\n            .limit(0, 10)\\\n            .group_by(\"@state\", r.count())\n        ```\n\n        Will only group by the states found in the first 10 results of the\n        query `@sale_amount:[10000, inf]`. On the other hand,\n\n        ```\n        AggregateRequest(\"@sale_amount:[10000, inf]\")\\\n            .limit(0, 1000)\\\n            .group_by(\"@state\", r.count()\\\n            .limit(0, 10)\n        ```\n\n        Will group all the results matching the query, but only return the\n        first 10 groups.\n\n        If you only wish to return a *top-N* style query, consider using\n        `sort_by()` instead.\n\n        \"\"\"\n        _limit = Limit(offset, num)\n        self._aggregateplan.extend(_limit.build_args())\n        return self\n\n    def sort_by(self, *fields: str, **kwargs) -> \"AggregateRequest\":\n        \"\"\"\n        Indicate how the results should be sorted. This can also be used for\n        *top-N* style queries\n\n        ### Parameters\n\n        - **fields**: The fields by which to sort. This can be either a single\n            field or a list of fields. If you wish to specify order, you can\n            use the `Asc` or `Desc` wrapper classes.\n        - **max**: Maximum number of results to return. This can be\n            used instead of `LIMIT` and is also faster.\n\n\n        Example of sorting by `foo` ascending and `bar` descending:\n\n        ```\n        sort_by(Asc(\"@foo\"), Desc(\"@bar\"))\n        ```\n\n        Return the top 10 customers:\n\n        ```\n        AggregateRequest()\\\n            .group_by(\"@customer\", r.sum(\"@paid\").alias(FIELDNAME))\\\n            .sort_by(Desc(\"@paid\"), max=10)\n        ```\n        \"\"\"\n\n        fields_args = []\n        for f in fields:\n            if isinstance(f, (Asc, Desc)):\n                fields_args += [f.field, f.DIRSTRING]\n            else:\n                fields_args += [f]\n\n        ret = [\"SORTBY\", str(len(fields_args))]\n        ret.extend(fields_args)\n        max = kwargs.get(\"max\", 0)\n        if max > 0:\n            ret += [\"MAX\", str(max)]\n\n        self._aggregateplan.extend(ret)\n        return self\n\n    def filter(self, expressions: Union[str, List[str]]) -> \"AggregateRequest\":\n        \"\"\"\n        Specify filter for post-query results using predicates relating to\n        values in the result set.\n\n        ### Parameters\n\n        - **fields**: Fields to group by. This can either be a single string,\n            or a list of strings.\n        \"\"\"\n        if isinstance(expressions, str):\n            expressions = [expressions]\n\n        for expression in expressions:\n            self._aggregateplan.extend([\"FILTER\", expression])\n\n        return self\n\n    def with_schema(self) -> \"AggregateRequest\":\n        \"\"\"\n        If set, the `schema` property will contain a list of `[field, type]`\n        entries in the result object.\n        \"\"\"\n        self._with_schema = True\n        return self\n\n    def add_scores(self) -> \"AggregateRequest\":\n        \"\"\"\n        If set, includes the score as an ordinary field of the row.\n        \"\"\"\n        self._add_scores = True\n        return self\n\n    def scorer(self, scorer: str) -> \"AggregateRequest\":\n        \"\"\"\n        Use a different scoring function to evaluate document relevance.\n        Default is `TFIDF`.\n\n        :param scorer: The scoring function to use\n                       (e.g. `TFIDF.DOCNORM` or `BM25`)\n        \"\"\"\n        self._scorer = scorer\n        return self\n\n    def verbatim(self) -> \"AggregateRequest\":\n        self._verbatim = True\n        return self\n\n    def cursor(self, count: int = 0, max_idle: float = 0.0) -> \"AggregateRequest\":\n        args = [\"WITHCURSOR\"]\n        if count:\n            args += [\"COUNT\", str(count)]\n        if max_idle:\n            args += [\"MAXIDLE\", str(max_idle * 1000)]\n        self._cursor = args\n        return self\n\n    def build_args(self) -> List[str]:\n        # @foo:bar ...\n        ret = [self._query]\n\n        if self._with_schema:\n            ret.append(\"WITHSCHEMA\")\n\n        if self._verbatim:\n            ret.append(\"VERBATIM\")\n\n        if self._scorer:\n            ret.extend([\"SCORER\", self._scorer])\n\n        if self._add_scores:\n            ret.append(\"ADDSCORES\")\n\n        if self._cursor:\n            ret += self._cursor\n\n        if self._loadall:\n            ret.append(\"LOAD\")\n            ret.append(\"*\")\n\n        elif self._loadfields:\n            ret.append(\"LOAD\")\n            ret.append(str(len(self._loadfields)))\n            ret.extend(self._loadfields)\n\n        if self._dialect:\n            ret.extend([\"DIALECT\", str(self._dialect)])\n\n        ret.extend(self._aggregateplan)\n\n        return ret\n\n    def dialect(self, dialect: int) -> \"AggregateRequest\":\n        \"\"\"\n        Add a dialect field to the aggregate command.\n\n        - **dialect** - dialect version to execute the query under\n        \"\"\"\n        self._dialect = dialect\n        return self\n\n\nclass Cursor:\n    def __init__(self, cid: int) -> None:\n        self.cid = cid\n        self.max_idle = 0\n        self.count = 0\n\n    def build_args(self):\n        args = [str(self.cid)]\n        if self.max_idle:\n            args += [\"MAXIDLE\", str(self.max_idle)]\n        if self.count:\n            args += [\"COUNT\", str(self.count)]\n        return args\n\n\nclass AggregateResult:\n    def __init__(self, rows, cursor: Cursor, schema) -> None:\n        self.rows = rows\n        self.cursor = cursor\n        self.schema = schema\n\n    def __repr__(self) -> str:\n        cid = self.cursor.cid if self.cursor else -1\n        return (\n            f\"<{self.__class__.__name__} at 0x{id(self):x} \"\n            f\"Rows={len(self.rows)}, Cursor={cid}>\"\n        )\n"
  },
  {
    "path": "redis/commands/search/commands.py",
    "content": "import itertools\nimport time\nfrom typing import Any, Dict, List, Optional, Union\n\nfrom redis._parsers.helpers import pairs_to_dict\nfrom redis.client import NEVER_DECODE, Pipeline\nfrom redis.commands.search.hybrid_query import (\n    CombineResultsMethod,\n    HybridCursorQuery,\n    HybridPostProcessingConfig,\n    HybridQuery,\n)\nfrom redis.commands.search.hybrid_result import HybridCursorResult, HybridResult\nfrom redis.utils import deprecated_function, experimental_method\n\nfrom ..helpers import get_protocol_version\nfrom ._util import to_string\nfrom .aggregation import (\n    AggregateRequest,\n    AggregateResult,\n    Cursor,\n)\nfrom .document import Document\nfrom .field import Field\nfrom .index_definition import IndexDefinition\nfrom .profile_information import ProfileInformation\nfrom .query import Query\nfrom .result import Result\nfrom .suggestion import SuggestionParser\n\nNUMERIC = \"NUMERIC\"\n\nCREATE_CMD = \"FT.CREATE\"\nALTER_CMD = \"FT.ALTER\"\nSEARCH_CMD = \"FT.SEARCH\"\nADD_CMD = \"FT.ADD\"\nADDHASH_CMD = \"FT.ADDHASH\"\nDROPINDEX_CMD = \"FT.DROPINDEX\"\nEXPLAIN_CMD = \"FT.EXPLAIN\"\nEXPLAINCLI_CMD = \"FT.EXPLAINCLI\"\nDEL_CMD = \"FT.DEL\"\nAGGREGATE_CMD = \"FT.AGGREGATE\"\nPROFILE_CMD = \"FT.PROFILE\"\nCURSOR_CMD = \"FT.CURSOR\"\nSPELLCHECK_CMD = \"FT.SPELLCHECK\"\nDICT_ADD_CMD = \"FT.DICTADD\"\nDICT_DEL_CMD = \"FT.DICTDEL\"\nDICT_DUMP_CMD = \"FT.DICTDUMP\"\nMGET_CMD = \"FT.MGET\"\nCONFIG_CMD = \"FT.CONFIG\"\nTAGVALS_CMD = \"FT.TAGVALS\"\nALIAS_ADD_CMD = \"FT.ALIASADD\"\nALIAS_UPDATE_CMD = \"FT.ALIASUPDATE\"\nALIAS_DEL_CMD = \"FT.ALIASDEL\"\nINFO_CMD = \"FT.INFO\"\nSUGADD_COMMAND = \"FT.SUGADD\"\nSUGDEL_COMMAND = \"FT.SUGDEL\"\nSUGLEN_COMMAND = \"FT.SUGLEN\"\nSUGGET_COMMAND = \"FT.SUGGET\"\nSYNUPDATE_CMD = \"FT.SYNUPDATE\"\nSYNDUMP_CMD = \"FT.SYNDUMP\"\nHYBRID_CMD = \"FT.HYBRID\"\n\nNOOFFSETS = \"NOOFFSETS\"\nNOFIELDS = \"NOFIELDS\"\nNOHL = \"NOHL\"\nNOFREQS = \"NOFREQS\"\nMAXTEXTFIELDS = \"MAXTEXTFIELDS\"\nTEMPORARY = \"TEMPORARY\"\nSTOPWORDS = \"STOPWORDS\"\nSKIPINITIALSCAN = \"SKIPINITIALSCAN\"\nWITHSCORES = \"WITHSCORES\"\nFUZZY = \"FUZZY\"\nWITHPAYLOADS = \"WITHPAYLOADS\"\n\n\nclass SearchCommands:\n    \"\"\"Search commands.\"\"\"\n\n    def _parse_results(self, cmd, res, **kwargs):\n        if get_protocol_version(self.client) in [\"3\", 3]:\n            return ProfileInformation(res) if cmd == \"FT.PROFILE\" else res\n        else:\n            return self._RESP2_MODULE_CALLBACKS[cmd](res, **kwargs)\n\n    def _parse_info(self, res, **kwargs):\n        it = map(to_string, res)\n        return dict(zip(it, it))\n\n    def _parse_search(self, res, **kwargs):\n        return Result(\n            res,\n            not kwargs[\"query\"]._no_content,\n            duration=kwargs[\"duration\"],\n            has_payload=kwargs[\"query\"]._with_payloads,\n            with_scores=kwargs[\"query\"]._with_scores,\n            field_encodings=kwargs[\"query\"]._return_fields_decode_as,\n        )\n\n    def _parse_hybrid_search(self, res, **kwargs):\n        res_dict = pairs_to_dict(res, decode_keys=True)\n        if \"cursor\" in kwargs:\n            return HybridCursorResult(\n                search_cursor_id=int(res_dict[\"SEARCH\"]),\n                vsim_cursor_id=int(res_dict[\"VSIM\"]),\n            )\n\n        results: List[Dict[str, Any]] = []\n        # the original results are a list of lists\n        # we convert them to a list of dicts\n        for res_item in res_dict[\"results\"]:\n            item_dict = pairs_to_dict(res_item, decode_keys=True)\n            results.append(item_dict)\n\n        return HybridResult(\n            total_results=int(res_dict[\"total_results\"]),\n            results=results,\n            warnings=res_dict[\"warnings\"],\n            execution_time=float(res_dict[\"execution_time\"]),\n        )\n\n    def _parse_aggregate(self, res, **kwargs):\n        return self._get_aggregate_result(res, kwargs[\"query\"], kwargs[\"has_cursor\"])\n\n    def _parse_profile(self, res, **kwargs):\n        query = kwargs[\"query\"]\n        if isinstance(query, AggregateRequest):\n            result = self._get_aggregate_result(res[0], query, query._cursor)\n        else:\n            result = Result(\n                res[0],\n                not query._no_content,\n                duration=kwargs[\"duration\"],\n                has_payload=query._with_payloads,\n                with_scores=query._with_scores,\n            )\n\n        return result, ProfileInformation(res[1])\n\n    def _parse_spellcheck(self, res, **kwargs):\n        corrections = {}\n        if res == 0:\n            return corrections\n\n        for _correction in res:\n            if isinstance(_correction, int) and _correction == 0:\n                continue\n\n            if len(_correction) != 3:\n                continue\n            if not _correction[2]:\n                continue\n            if not _correction[2][0]:\n                continue\n\n            # For spellcheck output\n            # 1)  1) \"TERM\"\n            #     2) \"{term1}\"\n            #     3)  1)  1)  \"{score1}\"\n            #             2)  \"{suggestion1}\"\n            #         2)  1)  \"{score2}\"\n            #             2)  \"{suggestion2}\"\n            #\n            # Following dictionary will be made\n            # corrections = {\n            #     '{term1}': [\n            #         {'score': '{score1}', 'suggestion': '{suggestion1}'},\n            #         {'score': '{score2}', 'suggestion': '{suggestion2}'}\n            #     ]\n            # }\n            corrections[_correction[1]] = [\n                {\"score\": _item[0], \"suggestion\": _item[1]} for _item in _correction[2]\n            ]\n\n        return corrections\n\n    def _parse_config_get(self, res, **kwargs):\n        return {kvs[0]: kvs[1] for kvs in res} if res else {}\n\n    def _parse_syndump(self, res, **kwargs):\n        return {res[i]: res[i + 1] for i in range(0, len(res), 2)}\n\n    def batch_indexer(self, chunk_size=100):\n        \"\"\"\n        Create a new batch indexer from the client with a given chunk size\n        \"\"\"\n        return self.BatchIndexer(self, chunk_size=chunk_size)\n\n    def create_index(\n        self,\n        fields: List[Field],\n        no_term_offsets: bool = False,\n        no_field_flags: bool = False,\n        stopwords: Optional[List[str]] = None,\n        definition: Optional[IndexDefinition] = None,\n        max_text_fields=False,\n        temporary=None,\n        no_highlight: bool = False,\n        no_term_frequencies: bool = False,\n        skip_initial_scan: bool = False,\n    ):\n        \"\"\"\n        Creates the search index. The index must not already exist.\n\n        For more information, see https://redis.io/commands/ft.create/\n\n        Args:\n            fields: A list of Field objects.\n            no_term_offsets: If `true`, term offsets will not be saved in the index.\n            no_field_flags: If true, field flags that allow searching in specific fields\n                            will not be saved.\n            stopwords: If provided, the index will be created with this custom stopword\n                       list. The list can be empty.\n            definition: If provided, the index will be created with this custom index\n                        definition.\n            max_text_fields: If true, indexes will be encoded as if there were more than\n                             32 text fields, allowing for additional fields beyond 32.\n            temporary: Creates a lightweight temporary index which will expire after the\n                       specified period of inactivity. The internal idle timer is reset\n                       whenever the index is searched or added to.\n            no_highlight: If true, disables highlighting support. Also implied by\n                          `no_term_offsets`.\n            no_term_frequencies: If true, term frequencies will not be saved in the\n                                 index.\n            skip_initial_scan: If true, the initial scan and indexing will be skipped.\n\n        \"\"\"\n        args = [CREATE_CMD, self.index_name]\n        if definition is not None:\n            args += definition.args\n        if max_text_fields:\n            args.append(MAXTEXTFIELDS)\n        if temporary is not None and isinstance(temporary, int):\n            args.append(TEMPORARY)\n            args.append(temporary)\n        if no_term_offsets:\n            args.append(NOOFFSETS)\n        if no_highlight:\n            args.append(NOHL)\n        if no_field_flags:\n            args.append(NOFIELDS)\n        if no_term_frequencies:\n            args.append(NOFREQS)\n        if skip_initial_scan:\n            args.append(SKIPINITIALSCAN)\n        if stopwords is not None and isinstance(stopwords, (list, tuple, set)):\n            args += [STOPWORDS, len(stopwords)]\n            if len(stopwords) > 0:\n                args += list(stopwords)\n\n        args.append(\"SCHEMA\")\n        try:\n            args += list(itertools.chain(*(f.redis_args() for f in fields)))\n        except TypeError:\n            args += fields.redis_args()\n\n        return self.execute_command(*args)\n\n    def alter_schema_add(self, fields: Union[Field, List[Field]]):\n        \"\"\"\n        Alter the existing search index by adding new fields. The index\n        must already exist.\n\n        ### Parameters:\n\n        - **fields**: a list of Field objects to add for the index\n\n        For more information see `FT.ALTER <https://redis.io/commands/ft.alter>`_.\n        \"\"\"  # noqa\n\n        args = [ALTER_CMD, self.index_name, \"SCHEMA\", \"ADD\"]\n        try:\n            args += list(itertools.chain(*(f.redis_args() for f in fields)))\n        except TypeError:\n            args += fields.redis_args()\n\n        return self.execute_command(*args)\n\n    def dropindex(self, delete_documents: bool = False):\n        \"\"\"\n        Drop the index if it exists.\n        Replaced `drop_index` in RediSearch 2.0.\n        Default behavior was changed to not delete the indexed documents.\n\n        ### Parameters:\n\n        - **delete_documents**: If `True`, all documents will be deleted.\n\n        For more information see `FT.DROPINDEX <https://redis.io/commands/ft.dropindex>`_.\n        \"\"\"  # noqa\n        args = [DROPINDEX_CMD, self.index_name]\n\n        delete_str = (\n            \"DD\"\n            if isinstance(delete_documents, bool) and delete_documents is True\n            else \"\"\n        )\n\n        if delete_str:\n            args.append(delete_str)\n\n        return self.execute_command(*args)\n\n    def _add_document(\n        self,\n        doc_id,\n        conn=None,\n        nosave=False,\n        score=1.0,\n        payload=None,\n        replace=False,\n        partial=False,\n        language=None,\n        no_create=False,\n        **fields,\n    ):\n        \"\"\"\n        Internal add_document used for both batch and single doc indexing\n        \"\"\"\n\n        if partial or no_create:\n            replace = True\n\n        args = [ADD_CMD, self.index_name, doc_id, score]\n        if nosave:\n            args.append(\"NOSAVE\")\n        if payload is not None:\n            args.append(\"PAYLOAD\")\n            args.append(payload)\n        if replace:\n            args.append(\"REPLACE\")\n            if partial:\n                args.append(\"PARTIAL\")\n            if no_create:\n                args.append(\"NOCREATE\")\n        if language:\n            args += [\"LANGUAGE\", language]\n        args.append(\"FIELDS\")\n        args += list(itertools.chain(*fields.items()))\n\n        if conn is not None:\n            return conn.execute_command(*args)\n\n        return self.execute_command(*args)\n\n    def _add_document_hash(\n        self, doc_id, conn=None, score=1.0, language=None, replace=False\n    ):\n        \"\"\"\n        Internal add_document_hash used for both batch and single doc indexing\n        \"\"\"\n\n        args = [ADDHASH_CMD, self.index_name, doc_id, score]\n\n        if replace:\n            args.append(\"REPLACE\")\n\n        if language:\n            args += [\"LANGUAGE\", language]\n\n        if conn is not None:\n            return conn.execute_command(*args)\n\n        return self.execute_command(*args)\n\n    @deprecated_function(\n        version=\"2.0.0\", reason=\"deprecated since redisearch 2.0, call hset instead\"\n    )\n    def add_document(\n        self,\n        doc_id: str,\n        nosave: bool = False,\n        score: float = 1.0,\n        payload: Optional[bool] = None,\n        replace: bool = False,\n        partial: bool = False,\n        language: Optional[str] = None,\n        no_create: bool = False,\n        **fields: List[str],\n    ):\n        \"\"\"\n        Add a single document to the index.\n\n        Args:\n\n            doc_id: the id of the saved document.\n            nosave: if set to true, we just index the document, and don't\n                      save a copy of it. This means that searches will just\n                      return ids.\n            score: the document ranking, between 0.0 and 1.0\n            payload: optional inner-index payload we can save for fast\n                     access in scoring functions\n            replace: if True, and the document already is in the index,\n                     we perform an update and reindex the document\n            partial: if True, the fields specified will be added to the\n                       existing document.\n                       This has the added benefit that any fields specified\n                       with `no_index`\n                       will not be reindexed again. Implies `replace`\n            language: Specify the language used for document tokenization.\n            no_create: if True, the document is only updated and reindexed\n                         if it already exists.\n                         If the document does not exist, an error will be\n                         returned. Implies `replace`\n            fields: kwargs dictionary of the document fields to be saved\n                    and/or indexed.\n                    NOTE: Geo points shoule be encoded as strings of \"lon,lat\"\n        \"\"\"  # noqa\n        return self._add_document(\n            doc_id,\n            conn=None,\n            nosave=nosave,\n            score=score,\n            payload=payload,\n            replace=replace,\n            partial=partial,\n            language=language,\n            no_create=no_create,\n            **fields,\n        )\n\n    @deprecated_function(\n        version=\"2.0.0\", reason=\"deprecated since redisearch 2.0, call hset instead\"\n    )\n    def add_document_hash(self, doc_id, score=1.0, language=None, replace=False):\n        \"\"\"\n        Add a hash document to the index.\n\n        ### Parameters\n\n        - **doc_id**: the document's id. This has to be an existing HASH key\n                      in Redis that will hold the fields the index needs.\n        - **score**:  the document ranking, between 0.0 and 1.0\n        - **replace**: if True, and the document already is in the index, we\n                      perform an update and reindex the document\n        - **language**: Specify the language used for document tokenization.\n        \"\"\"  # noqa\n        return self._add_document_hash(\n            doc_id, conn=None, score=score, language=language, replace=replace\n        )\n\n    @deprecated_function(version=\"2.0.0\", reason=\"deprecated since redisearch 2.0\")\n    def delete_document(self, doc_id, conn=None, delete_actual_document=False):\n        \"\"\"\n        Delete a document from index\n        Returns 1 if the document was deleted, 0 if not\n\n        ### Parameters\n\n        - **delete_actual_document**: if set to True, RediSearch also delete\n                                      the actual document if it is in the index\n        \"\"\"  # noqa\n        args = [DEL_CMD, self.index_name, doc_id]\n        if delete_actual_document:\n            args.append(\"DD\")\n\n        if conn is not None:\n            return conn.execute_command(*args)\n\n        return self.execute_command(*args)\n\n    def load_document(self, id):\n        \"\"\"\n        Load a single document by id\n        \"\"\"\n        fields = self.client.hgetall(id)\n        f2 = {to_string(k): to_string(v) for k, v in fields.items()}\n        fields = f2\n\n        try:\n            del fields[\"id\"]\n        except KeyError:\n            pass\n\n        return Document(id=id, **fields)\n\n    @deprecated_function(version=\"2.0.0\", reason=\"deprecated since redisearch 2.0\")\n    def get(self, *ids):\n        \"\"\"\n        Returns the full contents of multiple documents.\n\n        ### Parameters\n\n        - **ids**: the ids of the saved documents.\n\n        \"\"\"\n\n        return self.execute_command(MGET_CMD, self.index_name, *ids)\n\n    def info(self):\n        \"\"\"\n        Get info an stats about the the current index, including the number of\n        documents, memory consumption, etc\n\n        For more information see `FT.INFO <https://redis.io/commands/ft.info>`_.\n        \"\"\"\n\n        res = self.execute_command(INFO_CMD, self.index_name)\n        return self._parse_results(INFO_CMD, res)\n\n    def get_params_args(\n        self, query_params: Optional[Dict[str, Union[str, int, float, bytes]]]\n    ):\n        if query_params is None:\n            return []\n        args = []\n        if len(query_params) > 0:\n            args.append(\"PARAMS\")\n            args.append(len(query_params) * 2)\n            for key, value in query_params.items():\n                args.append(key)\n                args.append(value)\n        return args\n\n    def _mk_query_args(\n        self, query, query_params: Optional[Dict[str, Union[str, int, float, bytes]]]\n    ):\n        args = [self.index_name]\n\n        if isinstance(query, str):\n            # convert the query from a text to a query object\n            query = Query(query)\n        if not isinstance(query, Query):\n            raise ValueError(f\"Bad query type {type(query)}\")\n\n        args += query.get_args()\n        args += self.get_params_args(query_params)\n\n        return args, query\n\n    def search(\n        self,\n        query: Union[str, Query],\n        query_params: Union[Dict[str, Union[str, int, float, bytes]], None] = None,\n    ):\n        \"\"\"\n        Search the index for a given query, and return a result of documents\n\n        ### Parameters\n\n        - **query**: the search query. Either a text for simple queries with\n                     default parameters, or a Query object for complex queries.\n                     See RediSearch's documentation on query format\n\n        For more information see `FT.SEARCH <https://redis.io/commands/ft.search>`_.\n        \"\"\"  # noqa\n        args, query = self._mk_query_args(query, query_params=query_params)\n        st = time.monotonic()\n\n        options = {}\n        if get_protocol_version(self.client) not in [\"3\", 3]:\n            options[NEVER_DECODE] = True\n\n        res = self.execute_command(SEARCH_CMD, *args, **options)\n\n        if isinstance(res, Pipeline):\n            return res\n\n        return self._parse_results(\n            SEARCH_CMD, res, query=query, duration=(time.monotonic() - st) * 1000.0\n        )\n\n    @experimental_method()\n    def hybrid_search(\n        self,\n        query: HybridQuery,\n        combine_method: Optional[CombineResultsMethod] = None,\n        post_processing: Optional[HybridPostProcessingConfig] = None,\n        params_substitution: Optional[Dict[str, Union[str, int, float, bytes]]] = None,\n        timeout: Optional[int] = None,\n        cursor: Optional[HybridCursorQuery] = None,\n    ) -> Union[HybridResult, HybridCursorResult, Pipeline]:\n        \"\"\"\n        Execute a hybrid search using both text and vector queries\n\n        Args:\n            - **query**: HybridQuery object\n                        Contains the text and vector queries\n            - **combine_method**: CombineResultsMethod object\n                        Contains the combine method and parameters\n            - **post_processing**: HybridPostProcessingConfig object\n                        Contains the post processing configuration\n            - **params_substitution**: Dict[str, Union[str, int, float, bytes]]\n                        Contains the parameters substitution\n            - **timeout**: int - contains the timeout in milliseconds\n            - **cursor**: HybridCursorQuery object - contains the cursor configuration\n\n\n        For more information see `FT.SEARCH <https://redis.io/commands/ft.hybrid>`.\n        \"\"\"\n        index = self.index_name\n        options = {}\n        pieces = [HYBRID_CMD, index]\n        pieces.extend(query.get_args())\n        if combine_method:\n            pieces.extend(combine_method.get_args())\n        if post_processing:\n            pieces.extend(post_processing.build_args())\n        if params_substitution:\n            pieces.extend(self.get_params_args(params_substitution))\n        if timeout:\n            pieces.extend((\"TIMEOUT\", timeout))\n        if cursor:\n            options[\"cursor\"] = True\n            pieces.extend(cursor.build_args())\n\n        if get_protocol_version(self.client) not in [\"3\", 3]:\n            options[NEVER_DECODE] = True\n\n        res = self.execute_command(*pieces, **options)\n\n        if isinstance(res, Pipeline):\n            return res\n\n        return self._parse_results(HYBRID_CMD, res, **options)\n\n    def explain(\n        self,\n        query: Union[str, Query],\n        query_params: Optional[Dict[str, Union[str, int, float, bytes]]] = None,\n    ):\n        \"\"\"Returns the execution plan for a complex query.\n\n        For more information see `FT.EXPLAIN <https://redis.io/commands/ft.explain>`_.\n        \"\"\"  # noqa\n        args, query_text = self._mk_query_args(query, query_params=query_params)\n        return self.execute_command(EXPLAIN_CMD, *args)\n\n    def explain_cli(self, query: Union[str, Query]):  # noqa\n        raise NotImplementedError(\"EXPLAINCLI will not be implemented.\")\n\n    def aggregate(\n        self,\n        query: Union[AggregateRequest, Cursor],\n        query_params: Optional[Dict[str, Union[str, int, float, bytes]]] = None,\n    ):\n        \"\"\"\n        Issue an aggregation query.\n\n        ### Parameters\n\n        **query**: This can be either an `AggregateRequest`, or a `Cursor`\n\n        An `AggregateResult` object is returned. You can access the rows from\n        its `rows` property, which will always yield the rows of the result.\n\n        For more information see `FT.AGGREGATE <https://redis.io/commands/ft.aggregate>`_.\n        \"\"\"  # noqa\n        if isinstance(query, AggregateRequest):\n            has_cursor = bool(query._cursor)\n            cmd = [AGGREGATE_CMD, self.index_name] + query.build_args()\n        elif isinstance(query, Cursor):\n            has_cursor = True\n            cmd = [CURSOR_CMD, \"READ\", self.index_name] + query.build_args()\n        else:\n            raise ValueError(\"Bad query\", query)\n        cmd += self.get_params_args(query_params)\n\n        raw = self.execute_command(*cmd)\n        return self._parse_results(\n            AGGREGATE_CMD, raw, query=query, has_cursor=has_cursor\n        )\n\n    def _get_aggregate_result(\n        self, raw: List, query: Union[AggregateRequest, Cursor], has_cursor: bool\n    ):\n        if has_cursor:\n            if isinstance(query, Cursor):\n                query.cid = raw[1]\n                cursor = query\n            else:\n                cursor = Cursor(raw[1])\n            raw = raw[0]\n        else:\n            cursor = None\n\n        if isinstance(query, AggregateRequest) and query._with_schema:\n            schema = raw[0]\n            rows = raw[2:]\n        else:\n            schema = None\n            rows = raw[1:]\n\n        return AggregateResult(rows, cursor, schema)\n\n    def profile(\n        self,\n        query: Union[Query, AggregateRequest],\n        limited: bool = False,\n        query_params: Optional[Dict[str, Union[str, int, float, bytes]]] = None,\n    ):\n        \"\"\"\n        Performs a search or aggregate command and collects performance\n        information.\n\n        ### Parameters\n\n        **query**: This can be either an `AggregateRequest` or `Query`.\n        **limited**: If set to True, removes details of reader iterator.\n        **query_params**: Define one or more value parameters.\n        Each parameter has a name and a value.\n\n        \"\"\"\n        st = time.monotonic()\n        cmd = [PROFILE_CMD, self.index_name, \"\"]\n        if limited:\n            cmd.append(\"LIMITED\")\n        cmd.append(\"QUERY\")\n\n        if isinstance(query, AggregateRequest):\n            cmd[2] = \"AGGREGATE\"\n            cmd += query.build_args()\n        elif isinstance(query, Query):\n            cmd[2] = \"SEARCH\"\n            cmd += query.get_args()\n            cmd += self.get_params_args(query_params)\n        else:\n            raise ValueError(\"Must provide AggregateRequest object or Query object.\")\n\n        res = self.execute_command(*cmd)\n\n        return self._parse_results(\n            PROFILE_CMD, res, query=query, duration=(time.monotonic() - st) * 1000.0\n        )\n\n    def spellcheck(self, query, distance=None, include=None, exclude=None):\n        \"\"\"\n        Issue a spellcheck query\n\n        Args:\n\n            query: search query.\n            distance: the maximal Levenshtein distance for spelling\n                       suggestions (default: 1, max: 4).\n            include: specifies an inclusion custom dictionary.\n            exclude: specifies an exclusion custom dictionary.\n\n        For more information see `FT.SPELLCHECK <https://redis.io/commands/ft.spellcheck>`_.\n        \"\"\"  # noqa\n        cmd = [SPELLCHECK_CMD, self.index_name, query]\n        if distance:\n            cmd.extend([\"DISTANCE\", distance])\n\n        if include:\n            cmd.extend([\"TERMS\", \"INCLUDE\", include])\n\n        if exclude:\n            cmd.extend([\"TERMS\", \"EXCLUDE\", exclude])\n\n        res = self.execute_command(*cmd)\n\n        return self._parse_results(SPELLCHECK_CMD, res)\n\n    def dict_add(self, name: str, *terms: List[str]):\n        \"\"\"Adds terms to a dictionary.\n\n        ### Parameters\n\n        - **name**: Dictionary name.\n        - **terms**: List of items for adding to the dictionary.\n\n        For more information see `FT.DICTADD <https://redis.io/commands/ft.dictadd>`_.\n        \"\"\"  # noqa\n        cmd = [DICT_ADD_CMD, name]\n        cmd.extend(terms)\n        return self.execute_command(*cmd)\n\n    def dict_del(self, name: str, *terms: List[str]):\n        \"\"\"Deletes terms from a dictionary.\n\n        ### Parameters\n\n        - **name**: Dictionary name.\n        - **terms**: List of items for removing from the dictionary.\n\n        For more information see `FT.DICTDEL <https://redis.io/commands/ft.dictdel>`_.\n        \"\"\"  # noqa\n        cmd = [DICT_DEL_CMD, name]\n        cmd.extend(terms)\n        return self.execute_command(*cmd)\n\n    def dict_dump(self, name: str):\n        \"\"\"Dumps all terms in the given dictionary.\n\n        ### Parameters\n\n        - **name**: Dictionary name.\n\n        For more information see `FT.DICTDUMP <https://redis.io/commands/ft.dictdump>`_.\n        \"\"\"  # noqa\n        cmd = [DICT_DUMP_CMD, name]\n        return self.execute_command(*cmd)\n\n    @deprecated_function(\n        version=\"8.0.0\",\n        reason=\"deprecated since Redis 8.0, call config_set from core module instead\",\n    )\n    def config_set(self, option: str, value: str) -> bool:\n        \"\"\"Set runtime configuration option.\n\n        ### Parameters\n\n        - **option**: the name of the configuration option.\n        - **value**: a value for the configuration option.\n\n        For more information see `FT.CONFIG SET <https://redis.io/commands/ft.config-set>`_.\n        \"\"\"  # noqa\n        cmd = [CONFIG_CMD, \"SET\", option, value]\n        raw = self.execute_command(*cmd)\n        return raw == \"OK\"\n\n    @deprecated_function(\n        version=\"8.0.0\",\n        reason=\"deprecated since Redis 8.0, call config_get from core module instead\",\n    )\n    def config_get(self, option: str) -> str:\n        \"\"\"Get runtime configuration option value.\n\n        ### Parameters\n\n        - **option**: the name of the configuration option.\n\n        For more information see `FT.CONFIG GET <https://redis.io/commands/ft.config-get>`_.\n        \"\"\"  # noqa\n        cmd = [CONFIG_CMD, \"GET\", option]\n        res = self.execute_command(*cmd)\n        return self._parse_results(CONFIG_CMD, res)\n\n    def tagvals(self, tagfield: str):\n        \"\"\"\n        Return a list of all possible tag values\n\n        ### Parameters\n\n        - **tagfield**: Tag field name\n\n        For more information see `FT.TAGVALS <https://redis.io/commands/ft.tagvals>`_.\n        \"\"\"  # noqa\n\n        return self.execute_command(TAGVALS_CMD, self.index_name, tagfield)\n\n    def aliasadd(self, alias: str):\n        \"\"\"\n        Alias a search index - will fail if alias already exists\n\n        ### Parameters\n\n        - **alias**: Name of the alias to create\n\n        For more information see `FT.ALIASADD <https://redis.io/commands/ft.aliasadd>`_.\n        \"\"\"  # noqa\n\n        return self.execute_command(ALIAS_ADD_CMD, alias, self.index_name)\n\n    def aliasupdate(self, alias: str):\n        \"\"\"\n        Updates an alias - will fail if alias does not already exist\n\n        ### Parameters\n\n        - **alias**: Name of the alias to create\n\n        For more information see `FT.ALIASUPDATE <https://redis.io/commands/ft.aliasupdate>`_.\n        \"\"\"  # noqa\n\n        return self.execute_command(ALIAS_UPDATE_CMD, alias, self.index_name)\n\n    def aliasdel(self, alias: str):\n        \"\"\"\n        Removes an alias to a search index\n\n        ### Parameters\n\n        - **alias**: Name of the alias to delete\n\n        For more information see `FT.ALIASDEL <https://redis.io/commands/ft.aliasdel>`_.\n        \"\"\"  # noqa\n        return self.execute_command(ALIAS_DEL_CMD, alias)\n\n    def sugadd(self, key, *suggestions, **kwargs):\n        \"\"\"\n        Add suggestion terms to the AutoCompleter engine. Each suggestion has\n        a score and string.\n        If kwargs[\"increment\"] is true and the terms are already in the\n        server's dictionary, we increment their scores.\n\n        For more information see `FT.SUGADD <https://redis.io/commands/ft.sugadd/>`_.\n        \"\"\"  # noqa\n        # If Transaction is not False it will MULTI/EXEC which will error\n        pipe = self.pipeline(transaction=False)\n        for sug in suggestions:\n            args = [SUGADD_COMMAND, key, sug.string, sug.score]\n            if kwargs.get(\"increment\"):\n                args.append(\"INCR\")\n            if sug.payload:\n                args.append(\"PAYLOAD\")\n                args.append(sug.payload)\n\n            pipe.execute_command(*args)\n\n        return pipe.execute()[-1]\n\n    def suglen(self, key: str) -> int:\n        \"\"\"\n        Return the number of entries in the AutoCompleter index.\n\n        For more information see `FT.SUGLEN <https://redis.io/commands/ft.suglen>`_.\n        \"\"\"  # noqa\n        return self.execute_command(SUGLEN_COMMAND, key)\n\n    def sugdel(self, key: str, string: str) -> int:\n        \"\"\"\n        Delete a string from the AutoCompleter index.\n        Returns 1 if the string was found and deleted, 0 otherwise.\n\n        For more information see `FT.SUGDEL <https://redis.io/commands/ft.sugdel>`_.\n        \"\"\"  # noqa\n        return self.execute_command(SUGDEL_COMMAND, key, string)\n\n    def sugget(\n        self,\n        key: str,\n        prefix: str,\n        fuzzy: bool = False,\n        num: int = 10,\n        with_scores: bool = False,\n        with_payloads: bool = False,\n    ) -> List[SuggestionParser]:\n        \"\"\"\n        Get a list of suggestions from the AutoCompleter, for a given prefix.\n\n        Parameters:\n\n        prefix : str\n            The prefix we are searching. **Must be valid ascii or utf-8**\n        fuzzy : bool\n            If set to true, the prefix search is done in fuzzy mode.\n            **NOTE**: Running fuzzy searches on short (<3 letters) prefixes\n            can be very\n            slow, and even scan the entire index.\n        with_scores : bool\n            If set to true, we also return the (refactored) score of\n            each suggestion.\n            This is normally not needed, and is NOT the original score\n            inserted into the index.\n        with_payloads : bool\n            Return suggestion payloads\n        num : int\n            The maximum number of results we return. Note that we might\n            return less. The algorithm trims irrelevant suggestions.\n\n        Returns:\n\n        list:\n             A list of Suggestion objects. If with_scores was False, the\n             score of all suggestions is 1.\n\n        For more information see `FT.SUGGET <https://redis.io/commands/ft.sugget>`_.\n        \"\"\"  # noqa\n        args = [SUGGET_COMMAND, key, prefix, \"MAX\", num]\n        if fuzzy:\n            args.append(FUZZY)\n        if with_scores:\n            args.append(WITHSCORES)\n        if with_payloads:\n            args.append(WITHPAYLOADS)\n\n        res = self.execute_command(*args)\n        results = []\n        if not res:\n            return results\n\n        parser = SuggestionParser(with_scores, with_payloads, res)\n        return [s for s in parser]\n\n    def synupdate(self, groupid: str, skipinitial: bool = False, *terms: List[str]):\n        \"\"\"\n        Updates a synonym group.\n        The command is used to create or update a synonym group with\n        additional terms.\n        Only documents which were indexed after the update will be affected.\n\n        Parameters:\n\n        groupid :\n            Synonym group id.\n        skipinitial : bool\n            If set to true, we do not scan and index.\n        terms :\n            The terms.\n\n        For more information see `FT.SYNUPDATE <https://redis.io/commands/ft.synupdate>`_.\n        \"\"\"  # noqa\n        cmd = [SYNUPDATE_CMD, self.index_name, groupid]\n        if skipinitial:\n            cmd.extend([\"SKIPINITIALSCAN\"])\n        cmd.extend(terms)\n        return self.execute_command(*cmd)\n\n    def syndump(self):\n        \"\"\"\n        Dumps the contents of a synonym group.\n\n        The command is used to dump the synonyms data structure.\n        Returns a list of synonym terms and their synonym group ids.\n\n        For more information see `FT.SYNDUMP <https://redis.io/commands/ft.syndump>`_.\n        \"\"\"  # noqa\n        res = self.execute_command(SYNDUMP_CMD, self.index_name)\n        return self._parse_results(SYNDUMP_CMD, res)\n\n\nclass AsyncSearchCommands(SearchCommands):\n    async def info(self):\n        \"\"\"\n        Get info an stats about the the current index, including the number of\n        documents, memory consumption, etc\n\n        For more information see `FT.INFO <https://redis.io/commands/ft.info>`_.\n        \"\"\"\n\n        res = await self.execute_command(INFO_CMD, self.index_name)\n        return self._parse_results(INFO_CMD, res)\n\n    async def search(\n        self,\n        query: Union[str, Query],\n        query_params: Optional[Dict[str, Union[str, int, float, bytes]]] = None,\n    ):\n        \"\"\"\n        Search the index for a given query, and return a result of documents\n\n        ### Parameters\n\n        - **query**: the search query. Either a text for simple queries with\n                     default parameters, or a Query object for complex queries.\n                     See RediSearch's documentation on query format\n\n        For more information see `FT.SEARCH <https://redis.io/commands/ft.search>`_.\n        \"\"\"  # noqa\n        args, query = self._mk_query_args(query, query_params=query_params)\n        st = time.monotonic()\n\n        options = {}\n        if get_protocol_version(self.client) not in [\"3\", 3]:\n            options[NEVER_DECODE] = True\n\n        res = await self.execute_command(SEARCH_CMD, *args, **options)\n\n        if isinstance(res, Pipeline):\n            return res\n\n        return self._parse_results(\n            SEARCH_CMD, res, query=query, duration=(time.monotonic() - st) * 1000.0\n        )\n\n    @experimental_method()\n    async def hybrid_search(\n        self,\n        query: HybridQuery,\n        combine_method: Optional[CombineResultsMethod] = None,\n        post_processing: Optional[HybridPostProcessingConfig] = None,\n        params_substitution: Optional[Dict[str, Union[str, int, float, bytes]]] = None,\n        timeout: Optional[int] = None,\n        cursor: Optional[HybridCursorQuery] = None,\n    ) -> Union[HybridResult, HybridCursorResult, Pipeline]:\n        \"\"\"\n        Execute a hybrid search using both text and vector queries\n\n        Args:\n            - **query**: HybridQuery object\n                        Contains the text and vector queries\n            - **combine_method**: CombineResultsMethod object\n                        Contains the combine method and parameters\n            - **post_processing**: HybridPostProcessingConfig object\n                        Contains the post processing configuration\n            - **params_substitution**: Dict[str, Union[str, int, float, bytes]]\n                        Contains the parameters substitution\n            - **timeout**: int - contains the timeout in milliseconds\n            - **cursor**: HybridCursorQuery object - contains the cursor configuration\n\n\n        For more information see `FT.SEARCH <https://redis.io/commands/ft.hybrid>`.\n        \"\"\"\n        index = self.index_name\n        options = {}\n        pieces = [HYBRID_CMD, index]\n        pieces.extend(query.get_args())\n        if combine_method:\n            pieces.extend(combine_method.get_args())\n        if post_processing:\n            pieces.extend(post_processing.build_args())\n        if params_substitution:\n            pieces.extend(self.get_params_args(params_substitution))\n        if timeout:\n            pieces.extend((\"TIMEOUT\", timeout))\n        if cursor:\n            options[\"cursor\"] = True\n            pieces.extend(cursor.build_args())\n\n        if get_protocol_version(self.client) not in [\"3\", 3]:\n            options[NEVER_DECODE] = True\n\n        res = await self.execute_command(*pieces, **options)\n\n        if isinstance(res, Pipeline):\n            return res\n\n        return self._parse_results(HYBRID_CMD, res, **options)\n\n    async def aggregate(\n        self,\n        query: Union[AggregateResult, Cursor],\n        query_params: Optional[Dict[str, Union[str, int, float, bytes]]] = None,\n    ):\n        \"\"\"\n        Issue an aggregation query.\n\n        ### Parameters\n\n        **query**: This can be either an `AggregateRequest`, or a `Cursor`\n\n        An `AggregateResult` object is returned. You can access the rows from\n        its `rows` property, which will always yield the rows of the result.\n\n        For more information see `FT.AGGREGATE <https://redis.io/commands/ft.aggregate>`_.\n        \"\"\"  # noqa\n        if isinstance(query, AggregateRequest):\n            has_cursor = bool(query._cursor)\n            cmd = [AGGREGATE_CMD, self.index_name] + query.build_args()\n        elif isinstance(query, Cursor):\n            has_cursor = True\n            cmd = [CURSOR_CMD, \"READ\", self.index_name] + query.build_args()\n        else:\n            raise ValueError(\"Bad query\", query)\n        cmd += self.get_params_args(query_params)\n\n        raw = await self.execute_command(*cmd)\n        return self._parse_results(\n            AGGREGATE_CMD, raw, query=query, has_cursor=has_cursor\n        )\n\n    async def spellcheck(self, query, distance=None, include=None, exclude=None):\n        \"\"\"\n        Issue a spellcheck query\n\n        ### Parameters\n\n        **query**: search query.\n        **distance***: the maximal Levenshtein distance for spelling\n                       suggestions (default: 1, max: 4).\n        **include**: specifies an inclusion custom dictionary.\n        **exclude**: specifies an exclusion custom dictionary.\n\n        For more information see `FT.SPELLCHECK <https://redis.io/commands/ft.spellcheck>`_.\n        \"\"\"  # noqa\n        cmd = [SPELLCHECK_CMD, self.index_name, query]\n        if distance:\n            cmd.extend([\"DISTANCE\", distance])\n\n        if include:\n            cmd.extend([\"TERMS\", \"INCLUDE\", include])\n\n        if exclude:\n            cmd.extend([\"TERMS\", \"EXCLUDE\", exclude])\n\n        res = await self.execute_command(*cmd)\n\n        return self._parse_results(SPELLCHECK_CMD, res)\n\n    @deprecated_function(\n        version=\"8.0.0\",\n        reason=\"deprecated since Redis 8.0, call config_set from core module instead\",\n    )\n    async def config_set(self, option: str, value: str) -> bool:\n        \"\"\"Set runtime configuration option.\n\n        ### Parameters\n\n        - **option**: the name of the configuration option.\n        - **value**: a value for the configuration option.\n\n        For more information see `FT.CONFIG SET <https://redis.io/commands/ft.config-set>`_.\n        \"\"\"  # noqa\n        cmd = [CONFIG_CMD, \"SET\", option, value]\n        raw = await self.execute_command(*cmd)\n        return raw == \"OK\"\n\n    @deprecated_function(\n        version=\"8.0.0\",\n        reason=\"deprecated since Redis 8.0, call config_get from core module instead\",\n    )\n    async def config_get(self, option: str) -> str:\n        \"\"\"Get runtime configuration option value.\n\n        ### Parameters\n\n        - **option**: the name of the configuration option.\n\n        For more information see `FT.CONFIG GET <https://redis.io/commands/ft.config-get>`_.\n        \"\"\"  # noqa\n        cmd = [CONFIG_CMD, \"GET\", option]\n        res = {}\n        res = await self.execute_command(*cmd)\n        return self._parse_results(CONFIG_CMD, res)\n\n    async def load_document(self, id):\n        \"\"\"\n        Load a single document by id\n        \"\"\"\n        fields = await self.client.hgetall(id)\n        f2 = {to_string(k): to_string(v) for k, v in fields.items()}\n        fields = f2\n\n        try:\n            del fields[\"id\"]\n        except KeyError:\n            pass\n\n        return Document(id=id, **fields)\n\n    async def sugadd(self, key, *suggestions, **kwargs):\n        \"\"\"\n        Add suggestion terms to the AutoCompleter engine. Each suggestion has\n        a score and string.\n        If kwargs[\"increment\"] is true and the terms are already in the\n        server's dictionary, we increment their scores.\n\n        For more information see `FT.SUGADD <https://redis.io/commands/ft.sugadd>`_.\n        \"\"\"  # noqa\n        # If Transaction is not False it will MULTI/EXEC which will error\n        pipe = self.pipeline(transaction=False)\n        for sug in suggestions:\n            args = [SUGADD_COMMAND, key, sug.string, sug.score]\n            if kwargs.get(\"increment\"):\n                args.append(\"INCR\")\n            if sug.payload:\n                args.append(\"PAYLOAD\")\n                args.append(sug.payload)\n\n            pipe.execute_command(*args)\n\n        return (await pipe.execute())[-1]\n\n    async def sugget(\n        self,\n        key: str,\n        prefix: str,\n        fuzzy: bool = False,\n        num: int = 10,\n        with_scores: bool = False,\n        with_payloads: bool = False,\n    ) -> List[SuggestionParser]:\n        \"\"\"\n        Get a list of suggestions from the AutoCompleter, for a given prefix.\n\n        Parameters:\n\n        prefix : str\n            The prefix we are searching. **Must be valid ascii or utf-8**\n        fuzzy : bool\n            If set to true, the prefix search is done in fuzzy mode.\n            **NOTE**: Running fuzzy searches on short (<3 letters) prefixes\n            can be very\n            slow, and even scan the entire index.\n        with_scores : bool\n            If set to true, we also return the (refactored) score of\n            each suggestion.\n            This is normally not needed, and is NOT the original score\n            inserted into the index.\n        with_payloads : bool\n            Return suggestion payloads\n        num : int\n            The maximum number of results we return. Note that we might\n            return less. The algorithm trims irrelevant suggestions.\n\n        Returns:\n\n        list:\n             A list of Suggestion objects. If with_scores was False, the\n             score of all suggestions is 1.\n\n        For more information see `FT.SUGGET <https://redis.io/commands/ft.sugget>`_.\n        \"\"\"  # noqa\n        args = [SUGGET_COMMAND, key, prefix, \"MAX\", num]\n        if fuzzy:\n            args.append(FUZZY)\n        if with_scores:\n            args.append(WITHSCORES)\n        if with_payloads:\n            args.append(WITHPAYLOADS)\n\n        ret = await self.execute_command(*args)\n        results = []\n        if not ret:\n            return results\n\n        parser = SuggestionParser(with_scores, with_payloads, ret)\n        return [s for s in parser]\n"
  },
  {
    "path": "redis/commands/search/dialect.py",
    "content": "# Value for the default dialect to be used as a part of\n# Search or Aggregate query.\nDEFAULT_DIALECT = 2\n"
  },
  {
    "path": "redis/commands/search/document.py",
    "content": "class Document:\n    \"\"\"\n    Represents a single document in a result set\n    \"\"\"\n\n    def __init__(self, id, payload=None, **fields):\n        self.id = id\n        self.payload = payload\n        for k, v in fields.items():\n            setattr(self, k, v)\n\n    def __repr__(self):\n        return f\"Document {self.__dict__}\"\n\n    def __getitem__(self, item):\n        value = getattr(self, item)\n        return value\n"
  },
  {
    "path": "redis/commands/search/field.py",
    "content": "from typing import List\n\nfrom redis import DataError\n\n\nclass Field:\n    \"\"\"\n    A class representing a field in a document.\n    \"\"\"\n\n    NUMERIC = \"NUMERIC\"\n    TEXT = \"TEXT\"\n    WEIGHT = \"WEIGHT\"\n    GEO = \"GEO\"\n    TAG = \"TAG\"\n    VECTOR = \"VECTOR\"\n    SORTABLE = \"SORTABLE\"\n    NOINDEX = \"NOINDEX\"\n    AS = \"AS\"\n    GEOSHAPE = \"GEOSHAPE\"\n    INDEX_MISSING = \"INDEXMISSING\"\n    INDEX_EMPTY = \"INDEXEMPTY\"\n\n    def __init__(\n        self,\n        name: str,\n        args: List[str] = None,\n        sortable: bool = False,\n        no_index: bool = False,\n        index_missing: bool = False,\n        index_empty: bool = False,\n        as_name: str = None,\n    ):\n        \"\"\"\n        Create a new field object.\n\n        Args:\n            name: The name of the field.\n            args:\n            sortable: If `True`, the field will be sortable.\n            no_index: If `True`, the field will not be indexed.\n            index_missing: If `True`, it will be possible to search for documents that\n                           have this field missing.\n            index_empty: If `True`, it will be possible to search for documents that\n                         have this field empty.\n            as_name: If provided, this alias will be used for the field.\n        \"\"\"\n        if args is None:\n            args = []\n        self.name = name\n        self.args = args\n        self.args_suffix = list()\n        self.as_name = as_name\n\n        if no_index:\n            self.args_suffix.append(Field.NOINDEX)\n        if index_missing:\n            self.args_suffix.append(Field.INDEX_MISSING)\n        if index_empty:\n            self.args_suffix.append(Field.INDEX_EMPTY)\n        if sortable:\n            self.args_suffix.append(Field.SORTABLE)\n\n        if no_index and not sortable:\n            raise ValueError(\"Non-Sortable non-Indexable fields are ignored\")\n\n    def append_arg(self, value):\n        self.args.append(value)\n\n    def redis_args(self):\n        args = [self.name]\n        if self.as_name:\n            args += [self.AS, self.as_name]\n        args += self.args\n        args += self.args_suffix\n        return args\n\n\nclass TextField(Field):\n    \"\"\"\n    TextField is used to define a text field in a schema definition\n    \"\"\"\n\n    NOSTEM = \"NOSTEM\"\n    PHONETIC = \"PHONETIC\"\n\n    def __init__(\n        self,\n        name: str,\n        weight: float = 1.0,\n        no_stem: bool = False,\n        phonetic_matcher: str = None,\n        withsuffixtrie: bool = False,\n        **kwargs,\n    ):\n        Field.__init__(self, name, args=[Field.TEXT, Field.WEIGHT, weight], **kwargs)\n\n        if no_stem:\n            Field.append_arg(self, self.NOSTEM)\n        if phonetic_matcher and phonetic_matcher in [\n            \"dm:en\",\n            \"dm:fr\",\n            \"dm:pt\",\n            \"dm:es\",\n        ]:\n            Field.append_arg(self, self.PHONETIC)\n            Field.append_arg(self, phonetic_matcher)\n        if withsuffixtrie:\n            Field.append_arg(self, \"WITHSUFFIXTRIE\")\n\n\nclass NumericField(Field):\n    \"\"\"\n    NumericField is used to define a numeric field in a schema definition\n    \"\"\"\n\n    def __init__(self, name: str, **kwargs):\n        Field.__init__(self, name, args=[Field.NUMERIC], **kwargs)\n\n\nclass GeoShapeField(Field):\n    \"\"\"\n    GeoShapeField is used to enable within/contain indexing/searching\n    \"\"\"\n\n    SPHERICAL = \"SPHERICAL\"\n    FLAT = \"FLAT\"\n\n    def __init__(self, name: str, coord_system=None, **kwargs):\n        args = [Field.GEOSHAPE]\n        if coord_system:\n            args.append(coord_system)\n        Field.__init__(self, name, args=args, **kwargs)\n\n\nclass GeoField(Field):\n    \"\"\"\n    GeoField is used to define a geo-indexing field in a schema definition\n    \"\"\"\n\n    def __init__(self, name: str, **kwargs):\n        Field.__init__(self, name, args=[Field.GEO], **kwargs)\n\n\nclass TagField(Field):\n    \"\"\"\n    TagField is a tag-indexing field with simpler compression and tokenization.\n    See http://redisearch.io/Tags/\n    \"\"\"\n\n    SEPARATOR = \"SEPARATOR\"\n    CASESENSITIVE = \"CASESENSITIVE\"\n\n    def __init__(\n        self,\n        name: str,\n        separator: str = \",\",\n        case_sensitive: bool = False,\n        withsuffixtrie: bool = False,\n        **kwargs,\n    ):\n        args = [Field.TAG, self.SEPARATOR, separator]\n        if case_sensitive:\n            args.append(self.CASESENSITIVE)\n        if withsuffixtrie:\n            args.append(\"WITHSUFFIXTRIE\")\n\n        Field.__init__(self, name, args=args, **kwargs)\n\n\nclass VectorField(Field):\n    \"\"\"\n    Allows vector similarity queries against the value in this attribute.\n    See https://oss.redis.com/redisearch/Vectors/#vector_fields.\n    \"\"\"\n\n    def __init__(self, name: str, algorithm: str, attributes: dict, **kwargs):\n        \"\"\"\n        Create Vector Field. Notice that Vector cannot have sortable or no_index tag,\n        although it's also a Field.\n\n        ``name`` is the name of the field.\n\n        ``algorithm`` can be \"FLAT\", \"HNSW\", or \"SVS-VAMANA\".\n\n        ``attributes`` each algorithm can have specific attributes. Some of them\n        are mandatory and some of them are optional. See\n        https://oss.redis.com/redisearch/master/Vectors/#specific_creation_attributes_per_algorithm\n        for more information.\n        \"\"\"\n        sort = kwargs.get(\"sortable\", False)\n        noindex = kwargs.get(\"no_index\", False)\n\n        if sort or noindex:\n            raise DataError(\"Cannot set 'sortable' or 'no_index' in Vector fields.\")\n\n        if algorithm.upper() not in [\"FLAT\", \"HNSW\", \"SVS-VAMANA\"]:\n            raise DataError(\n                \"Realtime vector indexing supporting 3 Indexing Methods:\"\n                \"'FLAT', 'HNSW', and 'SVS-VAMANA'.\"\n            )\n\n        attr_li = []\n\n        for key, value in attributes.items():\n            attr_li.extend([key, value])\n\n        Field.__init__(\n            self, name, args=[Field.VECTOR, algorithm, len(attr_li), *attr_li], **kwargs\n        )\n"
  },
  {
    "path": "redis/commands/search/hybrid_query.py",
    "content": "from enum import Enum\nfrom typing import Any, Dict, List, Optional, Union\n\nfrom redis.utils import experimental\n\ntry:\n    from typing import Self  # Py 3.11+\nexcept ImportError:\n    from typing_extensions import Self\n\nfrom redis.commands.search.aggregation import Limit, Reducer\nfrom redis.commands.search.query import Filter, SortbyField\n\n\n@experimental\nclass HybridSearchQuery:\n    def __init__(\n        self,\n        query_string: str,\n        scorer: Optional[str] = None,\n        yield_score_as: Optional[str] = None,\n    ) -> None:\n        \"\"\"\n        Create a new hybrid search query object.\n\n        Args:\n            query_string: The query string.\n            scorer: Scoring algorithm for text search query.\n                Allowed values are \"TFIDF\", \"TFIDF.DOCNORM\", \"DISMAX\", \"DOCSCORE\",\n                \"BM25\", \"BM25STD\", \"BM25STD.TANH\", \"HAMMING\", etc.\n                For more information about supported scoring algorithms, see\n                https://redis.io/docs/latest/develop/ai/search-and-query/advanced-concepts/scoring/\n            yield_score_as: The name of the field to yield the score as.\n        \"\"\"\n        self._query_string = query_string\n        self._scorer = scorer\n        self._yield_score_as = yield_score_as\n\n    def query_string(self) -> str:\n        \"\"\"Return the query string of this query object.\"\"\"\n        return self._query_string\n\n    def scorer(self, scorer: str) -> \"HybridSearchQuery\":\n        \"\"\"\n        Scoring algorithm for text search query.\n        Allowed values are \"TFIDF\", \"TFIDF.DOCNORM\", \"DISMAX\", \"DOCSCORE\", \"BM25\",\n        \"BM25STD\", \"BM25STD.TANH\", \"HAMMING\", etc.\n\n        For more information about supported scoring algorithms,\n        see https://redis.io/docs/latest/develop/ai/search-and-query/advanced-concepts/scoring/\n        \"\"\"\n        self._scorer = scorer\n        return self\n\n    def yield_score_as(self, alias: str) -> \"HybridSearchQuery\":\n        \"\"\"\n        Yield the score as a field.\n        \"\"\"\n        self._yield_score_as = alias\n        return self\n\n    def get_args(self) -> List[str]:\n        args = [\"SEARCH\", self._query_string]\n        if self._scorer:\n            args.extend((\"SCORER\", self._scorer))\n        if self._yield_score_as:\n            args.extend((\"YIELD_SCORE_AS\", self._yield_score_as))\n        return args\n\n\nclass VectorSearchMethods(Enum):\n    KNN = \"KNN\"\n    RANGE = \"RANGE\"\n\n\n@experimental\nclass HybridVsimQuery:\n    def __init__(\n        self,\n        vector_field_name: str,\n        vector_data: Union[bytes, str],\n        vsim_search_method: Optional[VectorSearchMethods] = None,\n        vsim_search_method_params: Optional[Dict[str, Any]] = None,\n        filter: Optional[\"Filter\"] = None,\n        yield_score_as: Optional[str] = None,\n    ) -> None:\n        \"\"\"\n        Create a new hybrid vsim query object.\n\n        Args:\n            vector_field_name: Vector field name.\n\n            vector_data: Vector data for the search.\n\n            vsim_search_method: Search method that will be used for the vsim search.\n\n            vsim_search_method_params: Search method parameters. Use the param names\n                for keys and the values for the values.\n                Example for KNN: {\"K\": 10, \"EF_RUNTIME\": 100}\n                                    where K is mandatory and defines the number of results\n                                    and EF_RUNTIME is optional and definesthe exploration factor.\n                Example for RANGE: {\"RADIUS\": 10, \"EPSILON\": 0.1}\n                                    where RADIUS is mandatory and defines the radius of the search\n                                    and EPSILON is optional and defines the accuracy of the search.\n            yield_score_as: The name of the field to yield the score as.\n\n            filter: If defined, a filter will be applied on the vsim query results.\n        \"\"\"\n        self._vector_field = vector_field_name\n        self._vector_data = vector_data\n        if vsim_search_method and vsim_search_method_params:\n            self.vsim_method_params(vsim_search_method, **vsim_search_method_params)\n        else:\n            self._vsim_method_params = None\n        self._filter = filter\n        self._yield_score_as = yield_score_as\n\n    def vector_field(self) -> str:\n        \"\"\"Return the vector field name of this query object.\"\"\"\n        return self._vector_field\n\n    def vector_data(self) -> Union[bytes, str]:\n        \"\"\"Return the vector data of this query object.\"\"\"\n        return self._vector_data\n\n    def vsim_method_params(\n        self,\n        method: VectorSearchMethods,\n        **kwargs,\n    ) -> \"HybridVsimQuery\":\n        \"\"\"\n        Add search method parameters to the query.\n\n        Args:\n            method: Vector search method name. Supported values are \"KNN\" or \"RANGE\".\n            kwargs: Search method parameters. Use the param names for keys and the\n                values for the values. Example: {\"K\": 10, \"EF_RUNTIME\": 100}.\n        \"\"\"\n        vsim_method_params: List[Union[str, int]] = [method.value]\n        if kwargs:\n            vsim_method_params.append(len(kwargs.items()) * 2)\n            for key, value in kwargs.items():\n                vsim_method_params.extend((key, value))\n        self._vsim_method_params = vsim_method_params\n\n        return self\n\n    def filter(self, flt: \"HybridFilter\") -> \"HybridVsimQuery\":\n        \"\"\"\n        Add a filter to the query.\n\n        Args:\n            flt: A HybridFilter object, used on a corresponding field.\n        \"\"\"\n        self._filter = flt\n        return self\n\n    def yield_score_as(self, alias: str) -> \"HybridVsimQuery\":\n        \"\"\"\n        Return the score as a field with name `alias`.\n        \"\"\"\n        self._yield_score_as = alias\n        return self\n\n    def get_args(self) -> List[str]:\n        args = [\"VSIM\", self._vector_field, self._vector_data]\n        if self._vsim_method_params:\n            args.extend(self._vsim_method_params)\n        if self._filter:\n            args.extend(self._filter.args)\n        if self._yield_score_as:\n            args.extend((\"YIELD_SCORE_AS\", self._yield_score_as))\n\n        return args\n\n\nclass HybridQuery:\n    def __init__(\n        self,\n        search_query: HybridSearchQuery,\n        vector_similarity_query: HybridVsimQuery,\n    ) -> None:\n        \"\"\"\n        Create a new hybrid query object.\n\n        Args:\n            search_query: HybridSearchQuery object containing the text query.\n            vector_similarity_query: HybridVsimQuery object containing the vector similarity query.\n        \"\"\"\n        self._search_query = search_query\n        self._vector_similarity_query = vector_similarity_query\n\n    def get_args(self) -> List[str]:\n        args = []\n        args.extend(self._search_query.get_args())\n        args.extend(self._vector_similarity_query.get_args())\n        return args\n\n\nclass CombinationMethods(Enum):\n    RRF = \"RRF\"\n    LINEAR = \"LINEAR\"\n\n\n@experimental\nclass CombineResultsMethod:\n    def __init__(self, method: CombinationMethods, **kwargs) -> None:\n        \"\"\"\n        Create a new combine results method object.\n\n        Args:\n            method: The combine method to use - RRF or LINEAR.\n            kwargs: Additional combine parameters.\n                    For RRF, the following parameters are supported(at least one should be provided):\n                                WINDOW: Limits fusion scopeLimits fusion scope.\n                                CONSTANT: Controls decay of rank influence.\n                                YIELD_SCORE_AS: The name of the field to yield the calculated score as.\n                    For LINEAR, supported parameters (at least one should be provided):\n                                ALPHA: The weight of the first query.\n                                BETA: The weight of the second query.\n                                YIELD_SCORE_AS: The name of the field to yield the calculated score as.\n\n                    The additional parameters are not validated and are passed as is to the server.\n                    The supported format is to provide the parameter names and values like the following:\n                        CombineResultsMethod(CombinationMethods.RRF, WINDOW=3, CONSTANT=0.5)\n                        CombineResultsMethod(CombinationMethods.LINEAR, ALPHA=0.5, BETA=0.5)\n        \"\"\"\n        self._method = method\n        self._kwargs = kwargs\n\n    def get_args(self) -> List[Union[str, int]]:\n        args: List[Union[str, int]] = [\"COMBINE\", self._method.value]\n        if self._kwargs:\n            args.append(len(self._kwargs.items()) * 2)\n            for key, value in self._kwargs.items():\n                args.extend((key, value))\n        return args\n\n\n@experimental\nclass HybridPostProcessingConfig:\n    def __init__(self) -> None:\n        \"\"\"\n        Create a new hybrid post processing configuration object.\n        \"\"\"\n        self._load_statements = []\n        self._apply_statements = []\n        self._groupby_statements = []\n        self._sortby_fields = []\n        self._filter = None\n        self._limit = None\n\n    def load(self, *fields: str) -> Self:\n        \"\"\"\n        Add load statement parameters to the query.\n        \"\"\"\n        if fields:\n            fields_str = \" \".join(fields)\n            fields_list = fields_str.split(\" \")\n            self._load_statements.extend((\"LOAD\", len(fields_list), *fields_list))\n        return self\n\n    def group_by(self, fields: List[str], *reducers: Reducer) -> Self:\n        \"\"\"\n        Specify by which fields to group the aggregation.\n\n        Args:\n            fields: Fields to group by. This can either be a single string or a list\n                of strings. In both cases, the field should be specified as `@field`.\n            reducers: One or more reducers. Reducers may be found in the\n                `aggregation` module.\n        \"\"\"\n\n        fields = [fields] if isinstance(fields, str) else fields\n\n        ret = [\"GROUPBY\", str(len(fields)), *fields]\n        for reducer in reducers:\n            ret.extend((\"REDUCE\", reducer.NAME, str(len(reducer.args))))\n            ret.extend(reducer.args)\n            if reducer._alias is not None:\n                ret.extend((\"AS\", reducer._alias))\n\n        self._groupby_statements.extend(ret)\n        return self\n\n    def apply(self, **kwexpr) -> Self:\n        \"\"\"\n        Specify one or more projection expressions to add to each result.\n\n        Args:\n            kwexpr: One or more key-value pairs for a projection. The key is\n                the alias for the projection, and the value is the projection\n                expression itself, for example `apply(square_root=\"sqrt(@foo)\")`.\n        \"\"\"\n        apply_args = []\n        for alias, expr in kwexpr.items():\n            ret = [\"APPLY\", expr]\n            if alias is not None:\n                ret.extend((\"AS\", alias))\n            apply_args.extend(ret)\n\n        self._apply_statements.extend(apply_args)\n\n        return self\n\n    def sort_by(self, *sortby: \"SortbyField\") -> Self:\n        \"\"\"\n        Add sortby parameters to the query.\n        \"\"\"\n        self._sortby_fields = [*sortby]\n        return self\n\n    def filter(self, filter: \"HybridFilter\") -> Self:\n        \"\"\"\n        Add a numeric or string filter to the query.\n\n        Currently, only one of each filter is supported by the engine.\n\n        Args:\n            filter: A NumericFilter or GeoFilter object, used on a corresponding field.\n        \"\"\"\n        self._filter = filter\n        return self\n\n    def limit(self, offset: int, num: int) -> Self:\n        \"\"\"\n        Add limit parameters to the query.\n        \"\"\"\n        self._limit = Limit(offset, num)\n        return self\n\n    def build_args(self) -> List[str]:\n        args = []\n        if self._load_statements:\n            args.extend(self._load_statements)\n        if self._groupby_statements:\n            args.extend(self._groupby_statements)\n        if self._apply_statements:\n            args.extend(self._apply_statements)\n        if self._sortby_fields:\n            sortby_args = []\n            for f in self._sortby_fields:\n                sortby_args.extend(f.args)\n            args.extend((\"SORTBY\", len(sortby_args), *sortby_args))\n        if self._filter:\n            args.extend(self._filter.args)\n        if self._limit:\n            args.extend(self._limit.build_args())\n\n        return args\n\n\n@experimental\nclass HybridFilter(Filter):\n    def __init__(\n        self,\n        conditions: str,\n    ) -> None:\n        \"\"\"\n        Create a new hybrid filter object.\n\n        Args:\n            conditions: Filter conditions.\n        \"\"\"\n        args = [conditions]\n        Filter.__init__(self, \"FILTER\", *args)\n\n\n@experimental\nclass HybridCursorQuery:\n    def __init__(self, count: int = 0, max_idle: int = 0) -> None:\n        \"\"\"\n        Create a new hybrid cursor query object.\n\n        Args:\n            count: Number of results to return per cursor iteration.\n            max_idle: Maximum idle time for the cursor.\n        \"\"\"\n        self.count = count\n        self.max_idle = max_idle\n\n    def build_args(self):\n        args = [\"WITHCURSOR\"]\n        if self.count:\n            args += [\"COUNT\", str(self.count)]\n        if self.max_idle:\n            args += [\"MAXIDLE\", str(self.max_idle)]\n        return args\n"
  },
  {
    "path": "redis/commands/search/hybrid_result.py",
    "content": "from dataclasses import dataclass\nfrom typing import Any, Dict, List, Union\n\n\n@dataclass\nclass HybridResult:\n    \"\"\"\n    Represents the result of a hybrid search query execution\n    Returned by the `hybrid_search` command, when using RESP version 2.\n    \"\"\"\n\n    total_results: int\n    results: List[Dict[str, Any]]\n    warnings: List[Union[str, bytes]]\n    execution_time: float\n\n\nclass HybridCursorResult:\n    def __init__(self, search_cursor_id: int, vsim_cursor_id: int) -> None:\n        \"\"\"\n        Represents the result of a hybrid search query execution with cursor\n\n        search_cursor_id: int - cursor id for the search query\n        vsim_cursor_id: int - cursor id for the vector similarity query\n        \"\"\"\n        self.search_cursor_id = search_cursor_id\n        self.vsim_cursor_id = vsim_cursor_id\n"
  },
  {
    "path": "redis/commands/search/index_definition.py",
    "content": "from enum import Enum\n\n\nclass IndexType(Enum):\n    \"\"\"Enum of the currently supported index types.\"\"\"\n\n    HASH = 1\n    JSON = 2\n\n\nclass IndexDefinition:\n    \"\"\"IndexDefinition is used to define a index definition for automatic\n    indexing on Hash or Json update.\"\"\"\n\n    def __init__(\n        self,\n        prefix=[],\n        filter=None,\n        language_field=None,\n        language=None,\n        score_field=None,\n        score=1.0,\n        payload_field=None,\n        index_type=None,\n    ):\n        self.args = []\n        self._append_index_type(index_type)\n        self._append_prefix(prefix)\n        self._append_filter(filter)\n        self._append_language(language_field, language)\n        self._append_score(score_field, score)\n        self._append_payload(payload_field)\n\n    def _append_index_type(self, index_type):\n        \"\"\"Append `ON HASH` or `ON JSON` according to the enum.\"\"\"\n        if index_type is IndexType.HASH:\n            self.args.extend([\"ON\", \"HASH\"])\n        elif index_type is IndexType.JSON:\n            self.args.extend([\"ON\", \"JSON\"])\n        elif index_type is not None:\n            raise RuntimeError(f\"index_type must be one of {list(IndexType)}\")\n\n    def _append_prefix(self, prefix):\n        \"\"\"Append PREFIX.\"\"\"\n        if len(prefix) > 0:\n            self.args.append(\"PREFIX\")\n            self.args.append(len(prefix))\n            for p in prefix:\n                self.args.append(p)\n\n    def _append_filter(self, filter):\n        \"\"\"Append FILTER.\"\"\"\n        if filter is not None:\n            self.args.append(\"FILTER\")\n            self.args.append(filter)\n\n    def _append_language(self, language_field, language):\n        \"\"\"Append LANGUAGE_FIELD and LANGUAGE.\"\"\"\n        if language_field is not None:\n            self.args.append(\"LANGUAGE_FIELD\")\n            self.args.append(language_field)\n        if language is not None:\n            self.args.append(\"LANGUAGE\")\n            self.args.append(language)\n\n    def _append_score(self, score_field, score):\n        \"\"\"Append SCORE_FIELD and SCORE.\"\"\"\n        if score_field is not None:\n            self.args.append(\"SCORE_FIELD\")\n            self.args.append(score_field)\n        if score is not None:\n            self.args.append(\"SCORE\")\n            self.args.append(score)\n\n    def _append_payload(self, payload_field):\n        \"\"\"Append PAYLOAD_FIELD.\"\"\"\n        if payload_field is not None:\n            self.args.append(\"PAYLOAD_FIELD\")\n            self.args.append(payload_field)\n"
  },
  {
    "path": "redis/commands/search/profile_information.py",
    "content": "from typing import Any\n\n\nclass ProfileInformation:\n    \"\"\"\n    Wrapper around FT.PROFILE response\n    \"\"\"\n\n    def __init__(self, info: Any) -> None:\n        self._info: Any = info\n\n    @property\n    def info(self) -> Any:\n        return self._info\n"
  },
  {
    "path": "redis/commands/search/query.py",
    "content": "from typing import List, Optional, Tuple, Union\n\nfrom redis.commands.search.dialect import DEFAULT_DIALECT\n\n\nclass Query:\n    \"\"\"\n    Query is used to build complex queries that have more parameters than just\n    the query string. The query string is set in the constructor, and other\n    options have setter functions.\n\n    The setter functions return the query object so they can be chained.\n    i.e. `Query(\"foo\").verbatim().filter(...)` etc.\n    \"\"\"\n\n    def __init__(self, query_string: str) -> None:\n        \"\"\"\n        Create a new query object.\n        The query string is set in the constructor, and other options have\n        setter functions.\n        \"\"\"\n\n        self._query_string: str = query_string\n        self._offset: int = 0\n        self._num: int = 10\n        self._no_content: bool = False\n        self._no_stopwords: bool = False\n        self._fields: Optional[List[str]] = None\n        self._verbatim: bool = False\n        self._with_payloads: bool = False\n        self._with_scores: bool = False\n        self._scorer: Optional[str] = None\n        self._filters: List = list()\n        self._ids: Optional[Tuple[str, ...]] = None\n        self._slop: int = -1\n        self._timeout: Optional[float] = None\n        self._in_order: bool = False\n        self._sortby: Optional[SortbyField] = None\n        self._return_fields: List = []\n        self._return_fields_decode_as: dict = {}\n        self._summarize_fields: List = []\n        self._highlight_fields: List = []\n        self._language: Optional[str] = None\n        self._expander: Optional[str] = None\n        self._dialect: int = DEFAULT_DIALECT\n\n    def query_string(self) -> str:\n        \"\"\"Return the query string of this query only.\"\"\"\n        return self._query_string\n\n    def limit_ids(self, *ids) -> \"Query\":\n        \"\"\"Limit the results to a specific set of pre-known document\n        ids of any length.\"\"\"\n        self._ids = ids\n        return self\n\n    def return_fields(self, *fields) -> \"Query\":\n        \"\"\"Add fields to return fields.\"\"\"\n        for field in fields:\n            self.return_field(field)\n        return self\n\n    def return_field(\n        self,\n        field: str,\n        as_field: Optional[str] = None,\n        decode_field: Optional[bool] = True,\n        encoding: Optional[str] = \"utf8\",\n    ) -> \"Query\":\n        \"\"\"\n        Add a field to the list of fields to return.\n\n        - **field**: The field to include in query results\n        - **as_field**: The alias for the field\n        - **decode_field**: Whether to decode the field from bytes to string\n        - **encoding**: The encoding to use when decoding the field\n        \"\"\"\n        self._return_fields.append(field)\n        self._return_fields_decode_as[field] = encoding if decode_field else None\n        if as_field is not None:\n            self._return_fields += (\"AS\", as_field)\n        return self\n\n    def _mk_field_list(self, fields: Optional[Union[List[str], str]]) -> List:\n        if not fields:\n            return []\n        return [fields] if isinstance(fields, str) else list(fields)\n\n    def summarize(\n        self,\n        fields: Optional[List] = None,\n        context_len: Optional[int] = None,\n        num_frags: Optional[int] = None,\n        sep: Optional[str] = None,\n    ) -> \"Query\":\n        \"\"\"\n        Return an abridged format of the field, containing only the segments of\n        the field that contain the matching term(s).\n\n        If `fields` is specified, then only the mentioned fields are\n        summarized; otherwise, all results are summarized.\n\n        Server-side defaults are used for each option (except `fields`)\n        if not specified\n\n        - **fields** List of fields to summarize. All fields are summarized\n        if not specified\n        - **context_len** Amount of context to include with each fragment\n        - **num_frags** Number of fragments per document\n        - **sep** Separator string to separate fragments\n        \"\"\"\n        args = [\"SUMMARIZE\"]\n        fields = self._mk_field_list(fields)\n        if fields:\n            args += [\"FIELDS\", str(len(fields))] + fields\n\n        if context_len is not None:\n            args += [\"LEN\", str(context_len)]\n        if num_frags is not None:\n            args += [\"FRAGS\", str(num_frags)]\n        if sep is not None:\n            args += [\"SEPARATOR\", sep]\n\n        self._summarize_fields = args\n        return self\n\n    def highlight(\n        self, fields: Optional[List[str]] = None, tags: Optional[List[str]] = None\n    ) -> \"Query\":\n        \"\"\"\n        Apply specified markup to matched term(s) within the returned field(s).\n\n        - **fields** If specified, then only those mentioned fields are\n        highlighted, otherwise all fields are highlighted\n        - **tags** A list of two strings to surround the match.\n        \"\"\"\n        args = [\"HIGHLIGHT\"]\n        fields = self._mk_field_list(fields)\n        if fields:\n            args += [\"FIELDS\", str(len(fields))] + fields\n        if tags:\n            args += [\"TAGS\"] + list(tags)\n\n        self._highlight_fields = args\n        return self\n\n    def language(self, language: str) -> \"Query\":\n        \"\"\"\n        Analyze the query as being in the specified language.\n\n        :param language: The language (e.g. `chinese` or `english`)\n        \"\"\"\n        self._language = language\n        return self\n\n    def slop(self, slop: int) -> \"Query\":\n        \"\"\"Allow a maximum of N intervening non-matched terms between\n        phrase terms (0 means exact phrase).\n        \"\"\"\n        self._slop = slop\n        return self\n\n    def timeout(self, timeout: float) -> \"Query\":\n        \"\"\"overrides the timeout parameter of the module\"\"\"\n        self._timeout = timeout\n        return self\n\n    def in_order(self) -> \"Query\":\n        \"\"\"\n        Match only documents where the query terms appear in\n        the same order in the document.\n        i.e., for the query \"hello world\", we do not match \"world hello\"\n        \"\"\"\n        self._in_order = True\n        return self\n\n    def scorer(self, scorer: str) -> \"Query\":\n        \"\"\"\n        Use a different scoring function to evaluate document relevance.\n        Default is `TFIDF`.\n\n        Since Redis 8.0 default was changed to BM25STD.\n\n        :param scorer: The scoring function to use\n                       (e.g. `TFIDF.DOCNORM` or `BM25`)\n        \"\"\"\n        self._scorer = scorer\n        return self\n\n    def get_args(self) -> List[Union[str, int, float]]:\n        \"\"\"Format the redis arguments for this query and return them.\"\"\"\n        args: List[Union[str, int, float]] = [self._query_string]\n        args += self._get_args_tags()\n        args += self._summarize_fields + self._highlight_fields\n        args += [\"LIMIT\", self._offset, self._num]\n        return args\n\n    def _get_args_tags(self) -> List[Union[str, int, float]]:\n        args: List[Union[str, int, float]] = []\n        if self._no_content:\n            args.append(\"NOCONTENT\")\n        if self._fields:\n            args.append(\"INFIELDS\")\n            args.append(len(self._fields))\n            args += self._fields\n        if self._verbatim:\n            args.append(\"VERBATIM\")\n        if self._no_stopwords:\n            args.append(\"NOSTOPWORDS\")\n        if self._filters:\n            for flt in self._filters:\n                if not isinstance(flt, Filter):\n                    raise AttributeError(\"Did not receive a Filter object.\")\n                args += flt.args\n        if self._with_payloads:\n            args.append(\"WITHPAYLOADS\")\n        if self._scorer:\n            args += [\"SCORER\", self._scorer]\n        if self._with_scores:\n            args.append(\"WITHSCORES\")\n        if self._ids:\n            args.append(\"INKEYS\")\n            args.append(len(self._ids))\n            args += self._ids\n        if self._slop >= 0:\n            args += [\"SLOP\", self._slop]\n        if self._timeout is not None:\n            args += [\"TIMEOUT\", self._timeout]\n        if self._in_order:\n            args.append(\"INORDER\")\n        if self._return_fields:\n            args.append(\"RETURN\")\n            args.append(len(self._return_fields))\n            args += self._return_fields\n        if self._sortby:\n            if not isinstance(self._sortby, SortbyField):\n                raise AttributeError(\"Did not receive a SortByField.\")\n            args.append(\"SORTBY\")\n            args += self._sortby.args\n        if self._language:\n            args += [\"LANGUAGE\", self._language]\n        if self._expander:\n            args += [\"EXPANDER\", self._expander]\n        if self._dialect:\n            args += [\"DIALECT\", self._dialect]\n\n        return args\n\n    def paging(self, offset: int, num: int) -> \"Query\":\n        \"\"\"\n        Set the paging for the query (defaults to 0..10).\n\n        - **offset**: Paging offset for the results. Defaults to 0\n        - **num**: How many results do we want\n        \"\"\"\n        self._offset = offset\n        self._num = num\n        return self\n\n    def verbatim(self) -> \"Query\":\n        \"\"\"Set the query to be verbatim, i.e., use no query expansion\n        or stemming.\n        \"\"\"\n        self._verbatim = True\n        return self\n\n    def no_content(self) -> \"Query\":\n        \"\"\"Set the query to only return ids and not the document content.\"\"\"\n        self._no_content = True\n        return self\n\n    def no_stopwords(self) -> \"Query\":\n        \"\"\"\n        Prevent the query from being filtered for stopwords.\n        Only useful in very big queries that you are certain contain\n        no stopwords.\n        \"\"\"\n        self._no_stopwords = True\n        return self\n\n    def with_payloads(self) -> \"Query\":\n        \"\"\"Ask the engine to return document payloads.\"\"\"\n        self._with_payloads = True\n        return self\n\n    def with_scores(self) -> \"Query\":\n        \"\"\"Ask the engine to return document search scores.\"\"\"\n        self._with_scores = True\n        return self\n\n    def limit_fields(self, *fields: str) -> \"Query\":\n        \"\"\"\n        Limit the search to specific TEXT fields only.\n\n        - **fields**: Each element should be a string, case sensitive field name\n        from the defined schema.\n        \"\"\"\n        self._fields = list(fields)\n        return self\n\n    def add_filter(self, flt: \"Filter\") -> \"Query\":\n        \"\"\"\n        Add a numeric or geo filter to the query.\n        **Currently, only one of each filter is supported by the engine**\n\n        - **flt**: A NumericFilter or GeoFilter object, used on a\n        corresponding field\n        \"\"\"\n\n        self._filters.append(flt)\n        return self\n\n    def sort_by(self, field: str, asc: bool = True) -> \"Query\":\n        \"\"\"\n        Add a sortby field to the query.\n\n        - **field** - the name of the field to sort by\n        - **asc** - when `True`, sorting will be done in ascending order\n        \"\"\"\n        self._sortby = SortbyField(field, asc)\n        return self\n\n    def expander(self, expander: str) -> \"Query\":\n        \"\"\"\n        Add an expander field to the query.\n\n        - **expander** - the name of the expander\n        \"\"\"\n        self._expander = expander\n        return self\n\n    def dialect(self, dialect: int) -> \"Query\":\n        \"\"\"\n        Add a dialect field to the query.\n\n        - **dialect** - dialect version to execute the query under\n        \"\"\"\n        self._dialect = dialect\n        return self\n\n\nclass Filter:\n    def __init__(self, keyword: str, field: str, *args: Union[str, float]) -> None:\n        self.args = [keyword, field] + list(args)\n\n\nclass NumericFilter(Filter):\n    INF = \"+inf\"\n    NEG_INF = \"-inf\"\n\n    def __init__(\n        self,\n        field: str,\n        minval: Union[int, str],\n        maxval: Union[int, str],\n        minExclusive: bool = False,\n        maxExclusive: bool = False,\n    ) -> None:\n        args = [\n            minval if not minExclusive else f\"({minval}\",\n            maxval if not maxExclusive else f\"({maxval}\",\n        ]\n\n        Filter.__init__(self, \"FILTER\", field, *args)\n\n\nclass GeoFilter(Filter):\n    METERS = \"m\"\n    KILOMETERS = \"km\"\n    FEET = \"ft\"\n    MILES = \"mi\"\n\n    def __init__(\n        self, field: str, lon: float, lat: float, radius: float, unit: str = KILOMETERS\n    ) -> None:\n        Filter.__init__(self, \"GEOFILTER\", field, lon, lat, radius, unit)\n\n\nclass SortbyField:\n    def __init__(self, field: str, asc=True) -> None:\n        self.args = [field, \"ASC\" if asc else \"DESC\"]\n"
  },
  {
    "path": "redis/commands/search/querystring.py",
    "content": "def tags(*t):\n    \"\"\"\n    Indicate that the values should be matched to a tag field\n\n    ### Parameters\n\n    - **t**: Tags to search for\n    \"\"\"\n    if not t:\n        raise ValueError(\"At least one tag must be specified\")\n    return TagValue(*t)\n\n\ndef between(a, b, inclusive_min=True, inclusive_max=True):\n    \"\"\"\n    Indicate that value is a numeric range\n    \"\"\"\n    return RangeValue(a, b, inclusive_min=inclusive_min, inclusive_max=inclusive_max)\n\n\ndef equal(n):\n    \"\"\"\n    Match a numeric value\n    \"\"\"\n    return between(n, n)\n\n\ndef lt(n):\n    \"\"\"\n    Match any value less than n\n    \"\"\"\n    return between(None, n, inclusive_max=False)\n\n\ndef le(n):\n    \"\"\"\n    Match any value less or equal to n\n    \"\"\"\n    return between(None, n, inclusive_max=True)\n\n\ndef gt(n):\n    \"\"\"\n    Match any value greater than n\n    \"\"\"\n    return between(n, None, inclusive_min=False)\n\n\ndef ge(n):\n    \"\"\"\n    Match any value greater or equal to n\n    \"\"\"\n    return between(n, None, inclusive_min=True)\n\n\ndef geo(lat, lon, radius, unit=\"km\"):\n    \"\"\"\n    Indicate that value is a geo region\n    \"\"\"\n    return GeoValue(lat, lon, radius, unit)\n\n\nclass Value:\n    @property\n    def combinable(self):\n        \"\"\"\n        Whether this type of value may be combined with other values\n        for the same field. This makes the filter potentially more efficient\n        \"\"\"\n        return False\n\n    @staticmethod\n    def make_value(v):\n        \"\"\"\n        Convert an object to a value, if it is not a value already\n        \"\"\"\n        if isinstance(v, Value):\n            return v\n        return ScalarValue(v)\n\n    def to_string(self):\n        raise NotImplementedError()\n\n    def __str__(self):\n        return self.to_string()\n\n\nclass RangeValue(Value):\n    combinable = False\n\n    def __init__(self, a, b, inclusive_min=False, inclusive_max=False):\n        if a is None:\n            a = \"-inf\"\n        if b is None:\n            b = \"inf\"\n        self.range = [str(a), str(b)]\n        self.inclusive_min = inclusive_min\n        self.inclusive_max = inclusive_max\n\n    def to_string(self):\n        return \"[{1}{0[0]} {2}{0[1]}]\".format(\n            self.range,\n            \"(\" if not self.inclusive_min else \"\",\n            \"(\" if not self.inclusive_max else \"\",\n        )\n\n\nclass ScalarValue(Value):\n    combinable = True\n\n    def __init__(self, v):\n        self.v = str(v)\n\n    def to_string(self):\n        return self.v\n\n\nclass TagValue(Value):\n    combinable = False\n\n    def __init__(self, *tags):\n        self.tags = tags\n\n    def to_string(self):\n        return \"{\" + \" | \".join(str(t) for t in self.tags) + \"}\"\n\n\nclass GeoValue(Value):\n    def __init__(self, lon, lat, radius, unit=\"km\"):\n        self.lon = lon\n        self.lat = lat\n        self.radius = radius\n        self.unit = unit\n\n    def to_string(self):\n        return f\"[{self.lon} {self.lat} {self.radius} {self.unit}]\"\n\n\nclass Node:\n    def __init__(self, *children, **kwparams):\n        \"\"\"\n        Create a node\n\n        ### Parameters\n\n        - **children**: One or more sub-conditions. These can be additional\n            `intersect`, `disjunct`, `union`, `optional`, or any other `Node`\n            type.\n\n            The semantics of multiple conditions are dependent on the type of\n            query. For an `intersection` node, this amounts to a logical AND,\n            for a `union` node, this amounts to a logical `OR`.\n\n        - **kwparams**: key-value parameters. Each key is the name of a field,\n            and the value should be a field value. This can be one of the\n            following:\n\n            - Simple string (for text field matches)\n            - value returned by one of the helper functions\n            - list of either a string or a value\n\n\n        ### Examples\n\n        Field `num` should be between 1 and 10\n        ```\n        intersect(num=between(1, 10)\n        ```\n\n        Name can either be `bob` or `john`\n\n        ```\n        union(name=(\"bob\", \"john\"))\n        ```\n\n        Don't select countries in Israel, Japan, or US\n\n        ```\n        disjunct_union(country=(\"il\", \"jp\", \"us\"))\n        ```\n        \"\"\"\n\n        self.params = []\n\n        kvparams = {}\n        for k, v in kwparams.items():\n            curvals = kvparams.setdefault(k, [])\n            if isinstance(v, (str, int, float)):\n                curvals.append(Value.make_value(v))\n            elif isinstance(v, Value):\n                curvals.append(v)\n            else:\n                curvals.extend(Value.make_value(subv) for subv in v)\n\n        self.params += [Node.to_node(p) for p in children]\n\n        for k, v in kvparams.items():\n            self.params.extend(self.join_fields(k, v))\n\n    def join_fields(self, key, vals):\n        if len(vals) == 1:\n            return [BaseNode(f\"@{key}:{vals[0].to_string()}\")]\n        if not vals[0].combinable:\n            return [BaseNode(f\"@{key}:{v.to_string()}\") for v in vals]\n        s = BaseNode(f\"@{key}:({self.JOINSTR.join(v.to_string() for v in vals)})\")\n        return [s]\n\n    @classmethod\n    def to_node(cls, obj):  # noqa\n        if isinstance(obj, Node):\n            return obj\n        return BaseNode(obj)\n\n    @property\n    def JOINSTR(self):\n        raise NotImplementedError()\n\n    def to_string(self, with_parens=None):\n        with_parens = self._should_use_paren(with_parens)\n        pre, post = (\"(\", \")\") if with_parens else (\"\", \"\")\n        return f\"{pre}{self.JOINSTR.join(n.to_string() for n in self.params)}{post}\"\n\n    def _should_use_paren(self, optval):\n        if optval is not None:\n            return optval\n        return len(self.params) > 1\n\n    def __str__(self):\n        return self.to_string()\n\n\nclass BaseNode(Node):\n    def __init__(self, s):\n        super().__init__()\n        self.s = str(s)\n\n    def to_string(self, with_parens=None):\n        return self.s\n\n\nclass IntersectNode(Node):\n    \"\"\"\n    Create an intersection node. All children need to be satisfied in order for\n    this node to evaluate as true\n    \"\"\"\n\n    JOINSTR = \" \"\n\n\nclass UnionNode(Node):\n    \"\"\"\n    Create a union node. Any of the children need to be satisfied in order for\n    this node to evaluate as true\n    \"\"\"\n\n    JOINSTR = \"|\"\n\n\nclass DisjunctNode(IntersectNode):\n    \"\"\"\n    Create a disjunct node. In order for this node to be true, all of its\n    children must evaluate to false\n    \"\"\"\n\n    def to_string(self, with_parens=None):\n        with_parens = self._should_use_paren(with_parens)\n        ret = super().to_string(with_parens=False)\n        if with_parens:\n            return \"(-\" + ret + \")\"\n        else:\n            return \"-\" + ret\n\n\nclass DistjunctUnion(DisjunctNode):\n    \"\"\"\n    This node is true if *all* of its children are false. This is equivalent to\n    ```\n    disjunct(union(...))\n    ```\n    \"\"\"\n\n    JOINSTR = \"|\"\n\n\nclass OptionalNode(IntersectNode):\n    \"\"\"\n    Create an optional node. If this nodes evaluates to true, then the document\n    will be rated higher in score/rank.\n    \"\"\"\n\n    def to_string(self, with_parens=None):\n        with_parens = self._should_use_paren(with_parens)\n        ret = super().to_string(with_parens=False)\n        if with_parens:\n            return \"(~\" + ret + \")\"\n        else:\n            return \"~\" + ret\n\n\ndef intersect(*args, **kwargs):\n    return IntersectNode(*args, **kwargs)\n\n\ndef union(*args, **kwargs):\n    return UnionNode(*args, **kwargs)\n\n\ndef disjunct(*args, **kwargs):\n    return DisjunctNode(*args, **kwargs)\n\n\ndef disjunct_union(*args, **kwargs):\n    return DistjunctUnion(*args, **kwargs)\n\n\ndef querystring(*args, **kwargs):\n    return intersect(*args, **kwargs).to_string()\n"
  },
  {
    "path": "redis/commands/search/reducers.py",
    "content": "from typing import Union\n\nfrom .aggregation import Asc, Desc, Reducer, SortDirection\n\n\nclass FieldOnlyReducer(Reducer):\n    \"\"\"See https://redis.io/docs/interact/search-and-query/search/aggregations/\"\"\"\n\n    def __init__(self, field: str) -> None:\n        super().__init__(field)\n        self._field = field\n\n\nclass count(Reducer):\n    \"\"\"\n    Counts the number of results in the group\n    \"\"\"\n\n    NAME = \"COUNT\"\n\n    def __init__(self) -> None:\n        super().__init__()\n\n\nclass sum(FieldOnlyReducer):\n    \"\"\"\n    Calculates the sum of all the values in the given fields within the group\n    \"\"\"\n\n    NAME = \"SUM\"\n\n    def __init__(self, field: str) -> None:\n        super().__init__(field)\n\n\nclass min(FieldOnlyReducer):\n    \"\"\"\n    Calculates the smallest value in the given field within the group\n    \"\"\"\n\n    NAME = \"MIN\"\n\n    def __init__(self, field: str) -> None:\n        super().__init__(field)\n\n\nclass max(FieldOnlyReducer):\n    \"\"\"\n    Calculates the largest value in the given field within the group\n    \"\"\"\n\n    NAME = \"MAX\"\n\n    def __init__(self, field: str) -> None:\n        super().__init__(field)\n\n\nclass avg(FieldOnlyReducer):\n    \"\"\"\n    Calculates the mean value in the given field within the group\n    \"\"\"\n\n    NAME = \"AVG\"\n\n    def __init__(self, field: str) -> None:\n        super().__init__(field)\n\n\nclass tolist(FieldOnlyReducer):\n    \"\"\"\n    Returns all the matched properties in a list\n    \"\"\"\n\n    NAME = \"TOLIST\"\n\n    def __init__(self, field: str) -> None:\n        super().__init__(field)\n\n\nclass count_distinct(FieldOnlyReducer):\n    \"\"\"\n    Calculate the number of distinct values contained in all the results in\n    the group for the given field\n    \"\"\"\n\n    NAME = \"COUNT_DISTINCT\"\n\n    def __init__(self, field: str) -> None:\n        super().__init__(field)\n\n\nclass count_distinctish(FieldOnlyReducer):\n    \"\"\"\n    Calculate the number of distinct values contained in all the results in the\n    group for the given field. This uses a faster algorithm than\n    `count_distinct` but is less accurate\n    \"\"\"\n\n    NAME = \"COUNT_DISTINCTISH\"\n\n\nclass quantile(Reducer):\n    \"\"\"\n    Return the value for the nth percentile within the range of values for the\n    field within the group.\n    \"\"\"\n\n    NAME = \"QUANTILE\"\n\n    def __init__(self, field: str, pct: float) -> None:\n        super().__init__(field, str(pct))\n        self._field = field\n\n\nclass stddev(FieldOnlyReducer):\n    \"\"\"\n    Return the standard deviation for the values within the group\n    \"\"\"\n\n    NAME = \"STDDEV\"\n\n    def __init__(self, field: str) -> None:\n        super().__init__(field)\n\n\nclass first_value(Reducer):\n    \"\"\"\n    Selects the first value within the group according to sorting parameters\n    \"\"\"\n\n    NAME = \"FIRST_VALUE\"\n\n    def __init__(self, field: str, *byfields: Union[Asc, Desc]) -> None:\n        \"\"\"\n        Selects the first value of the given field within the group.\n\n        ### Parameter\n\n        - **field**: Source field used for the value\n        - **byfields**: How to sort the results. This can be either the\n            *class* of `aggregation.Asc` or `aggregation.Desc` in which\n            case the field `field` is also used as the sort input.\n\n            `byfields` can also be one or more *instances* of `Asc` or `Desc`\n            indicating the sort order for these fields\n        \"\"\"\n\n        fieldstrs = []\n        if (\n            len(byfields) == 1\n            and isinstance(byfields[0], type)\n            and issubclass(byfields[0], SortDirection)\n        ):\n            byfields = [byfields[0](field)]\n\n        for f in byfields:\n            fieldstrs += [f.field, f.DIRSTRING]\n\n        args = [field]\n        if fieldstrs:\n            args += [\"BY\"] + fieldstrs\n        super().__init__(*args)\n        self._field = field\n\n\nclass random_sample(Reducer):\n    \"\"\"\n    Returns a random sample of items from the dataset, from the given property\n    \"\"\"\n\n    NAME = \"RANDOM_SAMPLE\"\n\n    def __init__(self, field: str, size: int) -> None:\n        \"\"\"\n        ### Parameter\n\n        **field**: Field to sample from\n        **size**: Return this many items (can be less)\n        \"\"\"\n        args = [field, str(size)]\n        super().__init__(*args)\n        self._field = field\n"
  },
  {
    "path": "redis/commands/search/result.py",
    "content": "from typing import Optional\n\nfrom ._util import to_string\nfrom .document import Document\n\n\nclass Result:\n    \"\"\"\n    Represents the result of a search query, and has an array of Document\n    objects\n    \"\"\"\n\n    def __init__(\n        self,\n        res,\n        hascontent,\n        duration=0,\n        has_payload=False,\n        with_scores=False,\n        field_encodings: Optional[dict] = None,\n    ):\n        \"\"\"\n        - duration: the execution time of the query\n        - has_payload: whether the query has payloads\n        - with_scores: whether the query has scores\n        - field_encodings: a dictionary of field encodings if any is provided\n        \"\"\"\n\n        self.total = res[0]\n        self.duration = duration\n        self.docs = []\n\n        step = 1\n        if hascontent:\n            step = step + 1\n        if has_payload:\n            step = step + 1\n        if with_scores:\n            step = step + 1\n\n        offset = 2 if with_scores else 1\n\n        for i in range(1, len(res), step):\n            id = to_string(res[i])\n            payload = to_string(res[i + offset]) if has_payload else None\n            # fields_offset = 2 if has_payload else 1\n            fields_offset = offset + 1 if has_payload else offset\n            score = float(res[i + 1]) if with_scores else None\n\n            fields = {}\n            if hascontent and res[i + fields_offset] is not None:\n                keys = map(to_string, res[i + fields_offset][::2])\n                values = res[i + fields_offset][1::2]\n\n                for key, value in zip(keys, values):\n                    if field_encodings is None or key not in field_encodings:\n                        fields[key] = to_string(value)\n                        continue\n\n                    encoding = field_encodings[key]\n\n                    # If the encoding is None, we don't need to decode the value\n                    if encoding is None:\n                        fields[key] = value\n                    else:\n                        fields[key] = to_string(value, encoding=encoding)\n\n            try:\n                del fields[\"id\"]\n            except KeyError:\n                pass\n\n            try:\n                fields[\"json\"] = fields[\"$\"]\n                del fields[\"$\"]\n            except KeyError:\n                pass\n\n            doc = (\n                Document(id, score=score, payload=payload, **fields)\n                if with_scores\n                else Document(id, payload=payload, **fields)\n            )\n            self.docs.append(doc)\n\n    def __repr__(self) -> str:\n        return f\"Result{{{self.total} total, docs: {self.docs}}}\"\n"
  },
  {
    "path": "redis/commands/search/suggestion.py",
    "content": "from typing import Optional\n\nfrom ._util import to_string\n\n\nclass Suggestion:\n    \"\"\"\n    Represents a single suggestion being sent or returned from the\n    autocomplete server\n    \"\"\"\n\n    def __init__(\n        self, string: str, score: float = 1.0, payload: Optional[str] = None\n    ) -> None:\n        self.string = to_string(string)\n        self.payload = to_string(payload)\n        self.score = score\n\n    def __repr__(self) -> str:\n        return self.string\n\n\nclass SuggestionParser:\n    \"\"\"\n    Internal class used to parse results from the `SUGGET` command.\n    This needs to consume either 1, 2, or 3 values at a time from\n    the return value depending on what objects were requested\n    \"\"\"\n\n    def __init__(self, with_scores: bool, with_payloads: bool, ret) -> None:\n        self.with_scores = with_scores\n        self.with_payloads = with_payloads\n\n        if with_scores and with_payloads:\n            self.sugsize = 3\n            self._scoreidx = 1\n            self._payloadidx = 2\n        elif with_scores:\n            self.sugsize = 2\n            self._scoreidx = 1\n        elif with_payloads:\n            self.sugsize = 2\n            self._payloadidx = 1\n        else:\n            self.sugsize = 1\n            self._scoreidx = -1\n\n        self._sugs = ret\n\n    def __iter__(self):\n        for i in range(0, len(self._sugs), self.sugsize):\n            ss = self._sugs[i]\n            score = float(self._sugs[i + self._scoreidx]) if self.with_scores else 1.0\n            payload = self._sugs[i + self._payloadidx] if self.with_payloads else None\n            yield Suggestion(ss, score, payload)\n"
  },
  {
    "path": "redis/commands/sentinel.py",
    "content": "import warnings\n\n\nclass SentinelCommands:\n    \"\"\"\n    A class containing the commands specific to redis sentinel. This class is\n    to be used as a mixin.\n    \"\"\"\n\n    def sentinel(self, *args):\n        \"\"\"Redis Sentinel's SENTINEL command.\"\"\"\n        warnings.warn(DeprecationWarning(\"Use the individual sentinel_* methods\"))\n\n    def sentinel_get_master_addr_by_name(self, service_name, return_responses=False):\n        \"\"\"\n        Returns a (host, port) pair for the given ``service_name`` when return_responses is True,\n        otherwise returns a boolean value that indicates if the command was successful.\n        \"\"\"\n        return self.execute_command(\n            \"SENTINEL GET-MASTER-ADDR-BY-NAME\",\n            service_name,\n            once=True,\n            return_responses=return_responses,\n        )\n\n    def sentinel_master(self, service_name, return_responses=False):\n        \"\"\"\n        Returns a dictionary containing the specified masters state, when return_responses is True,\n        otherwise returns a boolean value that indicates if the command was successful.\n        \"\"\"\n        return self.execute_command(\n            \"SENTINEL MASTER\", service_name, return_responses=return_responses\n        )\n\n    def sentinel_masters(self):\n        \"\"\"\n        Returns a list of dictionaries containing each master's state.\n\n        Important: This function is called by the Sentinel implementation and is\n        called directly on the Redis standalone client for sentinels,\n        so it doesn't support the \"once\" and \"return_responses\" options.\n        \"\"\"\n        return self.execute_command(\"SENTINEL MASTERS\")\n\n    def sentinel_monitor(self, name, ip, port, quorum):\n        \"\"\"Add a new master to Sentinel to be monitored\"\"\"\n        return self.execute_command(\"SENTINEL MONITOR\", name, ip, port, quorum)\n\n    def sentinel_remove(self, name):\n        \"\"\"Remove a master from Sentinel's monitoring\"\"\"\n        return self.execute_command(\"SENTINEL REMOVE\", name)\n\n    def sentinel_sentinels(self, service_name, return_responses=False):\n        \"\"\"\n        Returns a list of sentinels for ``service_name``, when return_responses is True,\n        otherwise returns a boolean value that indicates if the command was successful.\n        \"\"\"\n        return self.execute_command(\n            \"SENTINEL SENTINELS\", service_name, return_responses=return_responses\n        )\n\n    def sentinel_set(self, name, option, value):\n        \"\"\"Set Sentinel monitoring parameters for a given master\"\"\"\n        return self.execute_command(\"SENTINEL SET\", name, option, value)\n\n    def sentinel_slaves(self, service_name):\n        \"\"\"\n        Returns a list of slaves for ``service_name``\n\n        Important: This function is called by the Sentinel implementation and is\n        called directly on the Redis standalone client for sentinels,\n        so it doesn't support the \"once\" and \"return_responses\" options.\n        \"\"\"\n        return self.execute_command(\"SENTINEL SLAVES\", service_name)\n\n    def sentinel_reset(self, pattern):\n        \"\"\"\n        This command will reset all the masters with matching name.\n        The pattern argument is a glob-style pattern.\n\n        The reset process clears any previous state in a master (including a\n        failover in progress), and removes every slave and sentinel already\n        discovered and associated with the master.\n        \"\"\"\n        return self.execute_command(\"SENTINEL RESET\", pattern, once=True)\n\n    def sentinel_failover(self, new_master_name):\n        \"\"\"\n        Force a failover as if the master was not reachable, and without\n        asking for agreement to other Sentinels (however a new version of the\n        configuration will be published so that the other Sentinels will\n        update their configurations).\n        \"\"\"\n        return self.execute_command(\"SENTINEL FAILOVER\", new_master_name)\n\n    def sentinel_ckquorum(self, new_master_name):\n        \"\"\"\n        Check if the current Sentinel configuration is able to reach the\n        quorum needed to failover a master, and the majority needed to\n        authorize the failover.\n\n        This command should be used in monitoring systems to check if a\n        Sentinel deployment is ok.\n        \"\"\"\n        return self.execute_command(\"SENTINEL CKQUORUM\", new_master_name, once=True)\n\n    def sentinel_flushconfig(self):\n        \"\"\"\n        Force Sentinel to rewrite its configuration on disk, including the\n        current Sentinel state.\n\n        Normally Sentinel rewrites the configuration every time something\n        changes in its state (in the context of the subset of the state which\n        is persisted on disk across restart).\n        However sometimes it is possible that the configuration file is lost\n        because of operation errors, disk failures, package upgrade scripts or\n        configuration managers. In those cases a way to to force Sentinel to\n        rewrite the configuration file is handy.\n\n        This command works even if the previous configuration file is\n        completely missing.\n        \"\"\"\n        return self.execute_command(\"SENTINEL FLUSHCONFIG\")\n\n\nclass AsyncSentinelCommands(SentinelCommands):\n    async def sentinel(self, *args) -> None:\n        \"\"\"Redis Sentinel's SENTINEL command.\"\"\"\n        super().sentinel(*args)\n"
  },
  {
    "path": "redis/commands/timeseries/__init__.py",
    "content": "import redis\nfrom redis._parsers.helpers import bool_ok\n\nfrom ..helpers import get_protocol_version, parse_to_list\nfrom .commands import (\n    ALTER_CMD,\n    CREATE_CMD,\n    CREATERULE_CMD,\n    DEL_CMD,\n    DELETERULE_CMD,\n    GET_CMD,\n    INFO_CMD,\n    MGET_CMD,\n    MRANGE_CMD,\n    MREVRANGE_CMD,\n    QUERYINDEX_CMD,\n    RANGE_CMD,\n    REVRANGE_CMD,\n    TimeSeriesCommands,\n)\nfrom .info import TSInfo\nfrom .utils import parse_get, parse_m_get, parse_m_range, parse_range\n\n\nclass TimeSeries(TimeSeriesCommands):\n    \"\"\"\n    This class subclasses redis-py's `Redis` and implements RedisTimeSeries's\n    commands (prefixed with \"ts\").\n    The client allows to interact with RedisTimeSeries and use all of it's\n    functionality.\n    \"\"\"\n\n    def __init__(self, client=None, **kwargs):\n        \"\"\"Create a new RedisTimeSeries client.\"\"\"\n        # Set the module commands' callbacks\n        self._MODULE_CALLBACKS = {\n            ALTER_CMD: bool_ok,\n            CREATE_CMD: bool_ok,\n            CREATERULE_CMD: bool_ok,\n            DELETERULE_CMD: bool_ok,\n        }\n\n        _RESP2_MODULE_CALLBACKS = {\n            DEL_CMD: int,\n            GET_CMD: parse_get,\n            INFO_CMD: TSInfo,\n            MGET_CMD: parse_m_get,\n            MRANGE_CMD: parse_m_range,\n            MREVRANGE_CMD: parse_m_range,\n            RANGE_CMD: parse_range,\n            REVRANGE_CMD: parse_range,\n            QUERYINDEX_CMD: parse_to_list,\n        }\n        _RESP3_MODULE_CALLBACKS = {}\n\n        self.client = client\n        self.execute_command = client.execute_command\n\n        if get_protocol_version(self.client) in [\"3\", 3]:\n            self._MODULE_CALLBACKS.update(_RESP3_MODULE_CALLBACKS)\n        else:\n            self._MODULE_CALLBACKS.update(_RESP2_MODULE_CALLBACKS)\n\n        for k, v in self._MODULE_CALLBACKS.items():\n            self.client.set_response_callback(k, v)\n\n    def pipeline(self, transaction=True, shard_hint=None):\n        \"\"\"Creates a pipeline for the TimeSeries module, that can be used\n        for executing only TimeSeries commands and core commands.\n\n        Usage example:\n\n        r = redis.Redis()\n        pipe = r.ts().pipeline()\n        for i in range(100):\n            pipeline.add(\"with_pipeline\", i, 1.1 * i)\n        pipeline.execute()\n\n        \"\"\"\n        if isinstance(self.client, redis.RedisCluster):\n            p = ClusterPipeline(\n                nodes_manager=self.client.nodes_manager,\n                commands_parser=self.client.commands_parser,\n                startup_nodes=self.client.nodes_manager.startup_nodes,\n                result_callbacks=self.client.result_callbacks,\n                cluster_response_callbacks=self.client.cluster_response_callbacks,\n                cluster_error_retry_attempts=self.client.retry.get_retries(),\n                read_from_replicas=self.client.read_from_replicas,\n                reinitialize_steps=self.client.reinitialize_steps,\n                lock=self.client._lock,\n            )\n\n        else:\n            p = Pipeline(\n                connection_pool=self.client.connection_pool,\n                response_callbacks=self._MODULE_CALLBACKS,\n                transaction=transaction,\n                shard_hint=shard_hint,\n            )\n        return p\n\n\nclass ClusterPipeline(TimeSeriesCommands, redis.cluster.ClusterPipeline):\n    \"\"\"Cluster pipeline for the module.\"\"\"\n\n\nclass Pipeline(TimeSeriesCommands, redis.client.Pipeline):\n    \"\"\"Pipeline for the module.\"\"\"\n"
  },
  {
    "path": "redis/commands/timeseries/commands.py",
    "content": "from typing import Dict, List, Optional, Tuple, Union\n\nfrom redis.exceptions import DataError\nfrom redis.typing import KeyT, Number\n\nADD_CMD = \"TS.ADD\"\nALTER_CMD = \"TS.ALTER\"\nCREATERULE_CMD = \"TS.CREATERULE\"\nCREATE_CMD = \"TS.CREATE\"\nDECRBY_CMD = \"TS.DECRBY\"\nDELETERULE_CMD = \"TS.DELETERULE\"\nDEL_CMD = \"TS.DEL\"\nGET_CMD = \"TS.GET\"\nINCRBY_CMD = \"TS.INCRBY\"\nINFO_CMD = \"TS.INFO\"\nMADD_CMD = \"TS.MADD\"\nMGET_CMD = \"TS.MGET\"\nMRANGE_CMD = \"TS.MRANGE\"\nMREVRANGE_CMD = \"TS.MREVRANGE\"\nQUERYINDEX_CMD = \"TS.QUERYINDEX\"\nRANGE_CMD = \"TS.RANGE\"\nREVRANGE_CMD = \"TS.REVRANGE\"\n\n\nclass TimeSeriesCommands:\n    \"\"\"RedisTimeSeries Commands.\"\"\"\n\n    def create(\n        self,\n        key: KeyT,\n        retention_msecs: Optional[int] = None,\n        uncompressed: Optional[bool] = False,\n        labels: Optional[Dict[str, str]] = None,\n        chunk_size: Optional[int] = None,\n        duplicate_policy: Optional[str] = None,\n        ignore_max_time_diff: Optional[int] = None,\n        ignore_max_val_diff: Optional[Number] = None,\n    ):\n        \"\"\"\n        Create a new time-series.\n\n        For more information see https://redis.io/commands/ts.create/\n\n        Args:\n            key:\n                The time-series key.\n            retention_msecs:\n                Maximum age for samples, compared to the highest reported timestamp in\n                milliseconds. If `None` or `0` is passed, the series is not trimmed at\n                all.\n            uncompressed:\n                Changes data storage from compressed (default) to uncompressed.\n            labels:\n                A dictionary of label-value pairs that represent metadata labels of the\n                key.\n            chunk_size:\n                Memory size, in bytes, allocated for each data chunk. Must be a multiple\n                of 8 in the range `[48..1048576]`. In earlier versions of the module the\n                minimum value was different.\n            duplicate_policy:\n                Policy for handling multiple samples with identical timestamps. Can be\n                one of:\n\n                - 'block': An error will occur and the new value will be ignored.\n                - 'first': Ignore the new value.\n                - 'last': Override with the latest value.\n                - 'min': Only override if the value is lower than the existing value.\n                - 'max': Only override if the value is higher than the existing value.\n                - 'sum': If a previous sample exists, add the new sample to it so\n                  that the updated value is equal to (previous + new). If no\n                  previous sample exists, set the updated value equal to the new\n                  value.\n\n            ignore_max_time_diff:\n                A non-negative integer value, in milliseconds, that sets an ignore\n                threshold for added timestamps. If the difference between the last\n                timestamp and the new timestamp is lower than this threshold, the new\n                entry is ignored. Only applicable if `duplicate_policy` is set to\n                `last`, and if `ignore_max_val_diff` is also set. Available since\n                RedisTimeSeries version 1.12.0.\n            ignore_max_val_diff:\n                A non-negative floating point value, that sets an ignore threshold for\n                added values. If the difference between the last value and the new value\n                is lower than this threshold, the new entry is ignored. Only applicable\n                if `duplicate_policy` is set to `last`, and if `ignore_max_time_diff` is\n                also set. Available since RedisTimeSeries version 1.12.0.\n        \"\"\"\n        params = [key]\n        self._append_retention(params, retention_msecs)\n        self._append_uncompressed(params, uncompressed)\n        self._append_chunk_size(params, chunk_size)\n        self._append_duplicate_policy(params, duplicate_policy)\n        self._append_labels(params, labels)\n        self._append_insertion_filters(\n            params, ignore_max_time_diff, ignore_max_val_diff\n        )\n\n        return self.execute_command(CREATE_CMD, *params)\n\n    def alter(\n        self,\n        key: KeyT,\n        retention_msecs: Optional[int] = None,\n        labels: Optional[Dict[str, str]] = None,\n        chunk_size: Optional[int] = None,\n        duplicate_policy: Optional[str] = None,\n        ignore_max_time_diff: Optional[int] = None,\n        ignore_max_val_diff: Optional[Number] = None,\n    ):\n        \"\"\"\n        Update an existing time series.\n\n        For more information see https://redis.io/commands/ts.alter/\n\n        Args:\n            key:\n                The time-series key.\n            retention_msecs:\n                Maximum age for samples, compared to the highest reported timestamp in\n                milliseconds. If `None` or `0` is passed, the series is not trimmed at\n                all.\n            labels:\n                A dictionary of label-value pairs that represent metadata labels of the\n                key.\n            chunk_size:\n                Memory size, in bytes, allocated for each data chunk. Must be a multiple\n                of 8 in the range `[48..1048576]`. In earlier versions of the module the\n                minimum value was different. Changing this value does not affect\n                existing chunks.\n            duplicate_policy:\n                Policy for handling multiple samples with identical timestamps. Can be\n                one of:\n\n                - 'block': An error will occur and the new value will be ignored.\n                - 'first': Ignore the new value.\n                - 'last': Override with the latest value.\n                - 'min': Only override if the value is lower than the existing value.\n                - 'max': Only override if the value is higher than the existing value.\n                - 'sum': If a previous sample exists, add the new sample to it so\n                  that the updated value is equal to (previous + new). If no\n                  previous sample exists, set the updated value equal to the new\n                  value.\n\n            ignore_max_time_diff:\n                A non-negative integer value, in milliseconds, that sets an ignore\n                threshold for added timestamps. If the difference between the last\n                timestamp and the new timestamp is lower than this threshold, the new\n                entry is ignored. Only applicable if `duplicate_policy` is set to\n                `last`, and if `ignore_max_val_diff` is also set. Available since\n                RedisTimeSeries version 1.12.0.\n            ignore_max_val_diff:\n                A non-negative floating point value, that sets an ignore threshold for\n                added values. If the difference between the last value and the new value\n                is lower than this threshold, the new entry is ignored. Only applicable\n                if `duplicate_policy` is set to `last`, and if `ignore_max_time_diff` is\n                also set. Available since RedisTimeSeries version 1.12.0.\n        \"\"\"\n        params = [key]\n        self._append_retention(params, retention_msecs)\n        self._append_chunk_size(params, chunk_size)\n        self._append_duplicate_policy(params, duplicate_policy)\n        self._append_labels(params, labels)\n        self._append_insertion_filters(\n            params, ignore_max_time_diff, ignore_max_val_diff\n        )\n\n        return self.execute_command(ALTER_CMD, *params)\n\n    def add(\n        self,\n        key: KeyT,\n        timestamp: Union[int, str],\n        value: Union[Number, str],\n        retention_msecs: Optional[int] = None,\n        uncompressed: Optional[bool] = False,\n        labels: Optional[Dict[str, str]] = None,\n        chunk_size: Optional[int] = None,\n        duplicate_policy: Optional[str] = None,\n        ignore_max_time_diff: Optional[int] = None,\n        ignore_max_val_diff: Optional[Number] = None,\n        on_duplicate: Optional[str] = None,\n    ):\n        \"\"\"\n        Append a sample to a time series. When the specified key does not exist, a new\n        time series is created.\n\n        For more information see https://redis.io/commands/ts.add/\n\n        Args:\n            key:\n                The time-series key.\n            timestamp:\n                Timestamp of the sample. `*` can be used for automatic timestamp (using\n                the system clock).\n            value:\n                Numeric data value of the sample.\n            retention_msecs:\n                Maximum age for samples, compared to the highest reported timestamp in\n                milliseconds. If `None` or `0` is passed, the series is not trimmed at\n                all.\n            uncompressed:\n                Changes data storage from compressed (default) to uncompressed.\n            labels:\n                A dictionary of label-value pairs that represent metadata labels of the\n                key.\n            chunk_size:\n                Memory size, in bytes, allocated for each data chunk. Must be a multiple\n                of 8 in the range `[48..1048576]`. In earlier versions of the module the\n                minimum value was different.\n            duplicate_policy:\n                Policy for handling multiple samples with identical timestamps. Can be\n                one of:\n\n                - 'block': An error will occur and the new value will be ignored.\n                - 'first': Ignore the new value.\n                - 'last': Override with the latest value.\n                - 'min': Only override if the value is lower than the existing value.\n                - 'max': Only override if the value is higher than the existing value.\n                - 'sum': If a previous sample exists, add the new sample to it so\n                  that the updated value is equal to (previous + new). If no\n                  previous sample exists, set the updated value equal to the new\n                  value.\n\n            ignore_max_time_diff:\n                A non-negative integer value, in milliseconds, that sets an ignore\n                threshold for added timestamps. If the difference between the last\n                timestamp and the new timestamp is lower than this threshold, the new\n                entry is ignored. Only applicable if `duplicate_policy` is set to\n                `last`, and if `ignore_max_val_diff` is also set. Available since\n                RedisTimeSeries version 1.12.0.\n            ignore_max_val_diff:\n                A non-negative floating point value, that sets an ignore threshold for\n                added values. If the difference between the last value and the new value\n                is lower than this threshold, the new entry is ignored. Only applicable\n                if `duplicate_policy` is set to `last`, and if `ignore_max_time_diff` is\n                also set. Available since RedisTimeSeries version 1.12.0.\n            on_duplicate:\n                Use a specific duplicate policy for the specified timestamp. Overrides\n                the duplicate policy set by `duplicate_policy`.\n        \"\"\"\n        params = [key, timestamp, value]\n        self._append_retention(params, retention_msecs)\n        self._append_uncompressed(params, uncompressed)\n        self._append_chunk_size(params, chunk_size)\n        self._append_duplicate_policy(params, duplicate_policy)\n        self._append_labels(params, labels)\n        self._append_insertion_filters(\n            params, ignore_max_time_diff, ignore_max_val_diff\n        )\n        self._append_on_duplicate(params, on_duplicate)\n\n        return self.execute_command(ADD_CMD, *params)\n\n    def madd(self, ktv_tuples: List[Tuple[KeyT, Union[int, str], Union[Number, str]]]):\n        \"\"\"\n        Append new samples to one or more time series.\n\n        Each time series must already exist.\n\n        The method expects a list of tuples. Each tuple should contain three elements:\n        (`key`, `timestamp`, `value`). The `value` will be appended to the time series\n        identified by 'key', at the given 'timestamp'.\n\n        For more information see https://redis.io/commands/ts.madd/\n\n        Args:\n            ktv_tuples:\n                A list of tuples, where each tuple contains:\n                    - `key`: The key of the time series.\n                    - `timestamp`: The timestamp at which the value should be appended.\n                    - `value`: The value to append to the time series.\n\n        Returns:\n            A list that contains, for each sample, either the timestamp that was used,\n            or an error, if the sample could not be added.\n        \"\"\"\n        params = []\n        for ktv in ktv_tuples:\n            params.extend(ktv)\n\n        return self.execute_command(MADD_CMD, *params)\n\n    def incrby(\n        self,\n        key: KeyT,\n        value: Number,\n        timestamp: Optional[Union[int, str]] = None,\n        retention_msecs: Optional[int] = None,\n        uncompressed: Optional[bool] = False,\n        labels: Optional[Dict[str, str]] = None,\n        chunk_size: Optional[int] = None,\n        duplicate_policy: Optional[str] = None,\n        ignore_max_time_diff: Optional[int] = None,\n        ignore_max_val_diff: Optional[Number] = None,\n    ):\n        \"\"\"\n        Increment the latest sample's of a series. When the specified key does not\n        exist, a new time series is created.\n\n        This command can be used as a counter or gauge that automatically gets history\n        as a time series.\n\n        For more information see https://redis.io/commands/ts.incrby/\n\n        Args:\n            key:\n                The time-series key.\n            value:\n                Numeric value to be added (addend).\n            timestamp:\n                Timestamp of the sample. `*` can be used for automatic timestamp (using\n                the system clock). `timestamp` must be equal to or higher than the\n                maximum existing timestamp in the series. When equal, the value of the\n                sample with the maximum existing timestamp is increased. If it is\n                higher, a new sample with a timestamp set to `timestamp` is created, and\n                its value is set to the value of the sample with the maximum existing\n                timestamp plus the addend.\n            retention_msecs:\n                Maximum age for samples, compared to the highest reported timestamp in\n                milliseconds. If `None` or `0` is passed, the series is not trimmed at\n                all.\n            uncompressed:\n                Changes data storage from compressed (default) to uncompressed.\n            labels:\n                A dictionary of label-value pairs that represent metadata labels of the\n                key.\n            chunk_size:\n                Memory size, in bytes, allocated for each data chunk. Must be a multiple\n                of 8 in the range `[48..1048576]`. In earlier versions of the module the\n                minimum value was different.\n            duplicate_policy:\n                Policy for handling multiple samples with identical timestamps. Can be\n                one of:\n\n                - 'block': An error will occur and the new value will be ignored.\n                - 'first': Ignore the new value.\n                - 'last': Override with the latest value.\n                - 'min': Only override if the value is lower than the existing value.\n                - 'max': Only override if the value is higher than the existing value.\n                - 'sum': If a previous sample exists, add the new sample to it so\n                  that the updated value is equal to (previous + new). If no\n                  previous sample exists, set the updated value equal to the new\n                  value.\n\n            ignore_max_time_diff:\n                A non-negative integer value, in milliseconds, that sets an ignore\n                threshold for added timestamps. If the difference between the last\n                timestamp and the new timestamp is lower than this threshold, the new\n                entry is ignored. Only applicable if `duplicate_policy` is set to\n                `last`, and if `ignore_max_val_diff` is also set. Available since\n                RedisTimeSeries version 1.12.0.\n            ignore_max_val_diff:\n                A non-negative floating point value, that sets an ignore threshold for\n                added values. If the difference between the last value and the new value\n                is lower than this threshold, the new entry is ignored. Only applicable\n                if `duplicate_policy` is set to `last`, and if `ignore_max_time_diff` is\n                also set. Available since RedisTimeSeries version 1.12.0.\n\n        Returns:\n            The timestamp of the sample that was modified or added.\n        \"\"\"\n        params = [key, value]\n        self._append_timestamp(params, timestamp)\n        self._append_retention(params, retention_msecs)\n        self._append_uncompressed(params, uncompressed)\n        self._append_chunk_size(params, chunk_size)\n        self._append_duplicate_policy(params, duplicate_policy)\n        self._append_labels(params, labels)\n        self._append_insertion_filters(\n            params, ignore_max_time_diff, ignore_max_val_diff\n        )\n\n        return self.execute_command(INCRBY_CMD, *params)\n\n    def decrby(\n        self,\n        key: KeyT,\n        value: Number,\n        timestamp: Optional[Union[int, str]] = None,\n        retention_msecs: Optional[int] = None,\n        uncompressed: Optional[bool] = False,\n        labels: Optional[Dict[str, str]] = None,\n        chunk_size: Optional[int] = None,\n        duplicate_policy: Optional[str] = None,\n        ignore_max_time_diff: Optional[int] = None,\n        ignore_max_val_diff: Optional[Number] = None,\n    ):\n        \"\"\"\n        Decrement the latest sample's of a series. When the specified key does not\n        exist, a new time series is created.\n\n        This command can be used as a counter or gauge that automatically gets history\n        as a time series.\n\n        For more information see https://redis.io/commands/ts.decrby/\n\n        Args:\n            key:\n                The time-series key.\n            value:\n                Numeric value to subtract (subtrahend).\n            timestamp:\n                Timestamp of the sample. `*` can be used for automatic timestamp (using\n                the system clock). `timestamp` must be equal to or higher than the\n                maximum existing timestamp in the series. When equal, the value of the\n                sample with the maximum existing timestamp is decreased. If it is\n                higher, a new sample with a timestamp set to `timestamp` is created, and\n                its value is set to the value of the sample with the maximum existing\n                timestamp minus subtrahend.\n            retention_msecs:\n                Maximum age for samples, compared to the highest reported timestamp in\n                milliseconds. If `None` or `0` is passed, the series is not trimmed at\n                all.\n            uncompressed:\n                Changes data storage from compressed (default) to uncompressed.\n            labels:\n                A dictionary of label-value pairs that represent metadata labels of the\n                key.\n            chunk_size:\n                Memory size, in bytes, allocated for each data chunk. Must be a multiple\n                of 8 in the range `[48..1048576]`. In earlier versions of the module the\n                minimum value was different.\n            duplicate_policy:\n                Policy for handling multiple samples with identical timestamps. Can be\n                one of:\n\n                - 'block': An error will occur and the new value will be ignored.\n                - 'first': Ignore the new value.\n                - 'last': Override with the latest value.\n                - 'min': Only override if the value is lower than the existing value.\n                - 'max': Only override if the value is higher than the existing value.\n                - 'sum': If a previous sample exists, add the new sample to it so\n                  that the updated value is equal to (previous + new). If no\n                  previous sample exists, set the updated value equal to the new\n                  value.\n\n            ignore_max_time_diff:\n                A non-negative integer value, in milliseconds, that sets an ignore\n                threshold for added timestamps. If the difference between the last\n                timestamp and the new timestamp is lower than this threshold, the new\n                entry is ignored. Only applicable if `duplicate_policy` is set to\n                `last`, and if `ignore_max_val_diff` is also set. Available since\n                RedisTimeSeries version 1.12.0.\n            ignore_max_val_diff:\n                A non-negative floating point value, that sets an ignore threshold for\n                added values. If the difference between the last value and the new value\n                is lower than this threshold, the new entry is ignored. Only applicable\n                if `duplicate_policy` is set to `last`, and if `ignore_max_time_diff` is\n                also set. Available since RedisTimeSeries version 1.12.0.\n\n        Returns:\n            The timestamp of the sample that was modified or added.\n        \"\"\"\n        params = [key, value]\n        self._append_timestamp(params, timestamp)\n        self._append_retention(params, retention_msecs)\n        self._append_uncompressed(params, uncompressed)\n        self._append_chunk_size(params, chunk_size)\n        self._append_duplicate_policy(params, duplicate_policy)\n        self._append_labels(params, labels)\n        self._append_insertion_filters(\n            params, ignore_max_time_diff, ignore_max_val_diff\n        )\n\n        return self.execute_command(DECRBY_CMD, *params)\n\n    def delete(self, key: KeyT, from_time: int, to_time: int):\n        \"\"\"\n        Delete all samples between two timestamps for a given time series.\n\n        The given timestamp interval is closed (inclusive), meaning that samples whose\n        timestamp equals `from_time` or `to_time` are also deleted.\n\n        For more information see https://redis.io/commands/ts.del/\n\n        Args:\n            key:\n                The time-series key.\n            from_time:\n                Start timestamp for the range deletion.\n            to_time:\n                End timestamp for the range deletion.\n\n        Returns:\n            The number of samples deleted.\n        \"\"\"\n        return self.execute_command(DEL_CMD, key, from_time, to_time)\n\n    def createrule(\n        self,\n        source_key: KeyT,\n        dest_key: KeyT,\n        aggregation_type: str,\n        bucket_size_msec: int,\n        align_timestamp: Optional[int] = None,\n    ):\n        \"\"\"\n        Create a compaction rule from values added to `source_key` into `dest_key`.\n\n        For more information see https://redis.io/commands/ts.createrule/\n\n        Args:\n            source_key:\n                Key name for source time series.\n            dest_key:\n                Key name for destination (compacted) time series.\n            aggregation_type:\n                Aggregation type: One of the following:\n                [`avg`, `sum`, `min`, `max`, `range`, `count`, `first`, `last`, `std.p`,\n                `std.s`, `var.p`, `var.s`, `twa`, 'countNaN', 'countAll']\n            bucket_size_msec:\n                Duration of each bucket, in milliseconds.\n            align_timestamp:\n                Assure that there is a bucket that starts at exactly align_timestamp and\n                align all other buckets accordingly.\n        \"\"\"\n        params = [source_key, dest_key]\n        self._append_aggregation(params, aggregation_type, bucket_size_msec)\n        if align_timestamp is not None:\n            params.append(align_timestamp)\n\n        return self.execute_command(CREATERULE_CMD, *params)\n\n    def deleterule(self, source_key: KeyT, dest_key: KeyT):\n        \"\"\"\n        Delete a compaction rule from `source_key` to `dest_key`.\n\n        For more information see https://redis.io/commands/ts.deleterule/\n        \"\"\"\n        return self.execute_command(DELETERULE_CMD, source_key, dest_key)\n\n    def __range_params(\n        self,\n        key: KeyT,\n        from_time: Union[int, str],\n        to_time: Union[int, str],\n        count: Optional[int],\n        aggregation_type: Optional[str],\n        bucket_size_msec: Optional[int],\n        filter_by_ts: Optional[List[int]],\n        filter_by_min_value: Optional[int],\n        filter_by_max_value: Optional[int],\n        align: Optional[Union[int, str]],\n        latest: Optional[bool],\n        bucket_timestamp: Optional[str],\n        empty: Optional[bool],\n    ):\n        \"\"\"Create TS.RANGE and TS.REVRANGE arguments.\"\"\"\n        params = [key, from_time, to_time]\n        self._append_latest(params, latest)\n        self._append_filer_by_ts(params, filter_by_ts)\n        self._append_filer_by_value(params, filter_by_min_value, filter_by_max_value)\n        self._append_count(params, count)\n        self._append_align(params, align)\n        self._append_aggregation(params, aggregation_type, bucket_size_msec)\n        self._append_bucket_timestamp(params, bucket_timestamp)\n        self._append_empty(params, empty)\n\n        return params\n\n    def range(\n        self,\n        key: KeyT,\n        from_time: Union[int, str],\n        to_time: Union[int, str],\n        count: Optional[int] = None,\n        aggregation_type: Optional[str] = None,\n        bucket_size_msec: Optional[int] = 0,\n        filter_by_ts: Optional[List[int]] = None,\n        filter_by_min_value: Optional[int] = None,\n        filter_by_max_value: Optional[int] = None,\n        align: Optional[Union[int, str]] = None,\n        latest: Optional[bool] = False,\n        bucket_timestamp: Optional[str] = None,\n        empty: Optional[bool] = False,\n    ):\n        \"\"\"\n        Query a range in forward direction for a specific time-series.\n\n        For more information see https://redis.io/commands/ts.range/\n\n        Args:\n            key:\n                Key name for timeseries.\n            from_time:\n                Start timestamp for the range query. `-` can be used to express the\n                minimum possible timestamp (0).\n            to_time:\n                End timestamp for range query, `+` can be used to express the maximum\n                possible timestamp.\n            count:\n                Limits the number of returned samples.\n            aggregation_type:\n                Optional aggregation type. Can be one of [`avg`, `sum`, `min`, `max`,\n                `range`, `count`, `first`, `last`, `std.p`, `std.s`, `var.p`, `var.s`,\n                `twa`, 'countNaN', 'countAll']\n            bucket_size_msec:\n                Time bucket for aggregation in milliseconds.\n            filter_by_ts:\n                List of timestamps to filter the result by specific timestamps.\n            filter_by_min_value:\n                Filter result by minimum value (must mention also\n                `filter by_max_value`).\n            filter_by_max_value:\n                Filter result by maximum value (must mention also\n                `filter by_min_value`).\n            align:\n                Timestamp for alignment control for aggregation.\n            latest:\n                Used when a time series is a compaction, reports the compacted value of\n                the latest possibly partial bucket.\n            bucket_timestamp:\n                Controls how bucket timestamps are reported. Can be one of [`-`, `low`,\n                `+`, `high`, `~`, `mid`].\n            empty:\n                Reports aggregations for empty buckets.\n        \"\"\"\n        params = self.__range_params(\n            key,\n            from_time,\n            to_time,\n            count,\n            aggregation_type,\n            bucket_size_msec,\n            filter_by_ts,\n            filter_by_min_value,\n            filter_by_max_value,\n            align,\n            latest,\n            bucket_timestamp,\n            empty,\n        )\n        return self.execute_command(RANGE_CMD, *params, keys=[key])\n\n    def revrange(\n        self,\n        key: KeyT,\n        from_time: Union[int, str],\n        to_time: Union[int, str],\n        count: Optional[int] = None,\n        aggregation_type: Optional[str] = None,\n        bucket_size_msec: Optional[int] = 0,\n        filter_by_ts: Optional[List[int]] = None,\n        filter_by_min_value: Optional[int] = None,\n        filter_by_max_value: Optional[int] = None,\n        align: Optional[Union[int, str]] = None,\n        latest: Optional[bool] = False,\n        bucket_timestamp: Optional[str] = None,\n        empty: Optional[bool] = False,\n    ):\n        \"\"\"\n        Query a range in reverse direction for a specific time-series.\n\n        **Note**: This command is only available since RedisTimeSeries >= v1.4\n\n        For more information see https://redis.io/commands/ts.revrange/\n\n        Args:\n            key:\n                Key name for timeseries.\n            from_time:\n                Start timestamp for the range query. `-` can be used to express the\n                minimum possible timestamp (0).\n            to_time:\n                End timestamp for range query, `+` can be used to express the maximum\n                possible timestamp.\n            count:\n                Limits the number of returned samples.\n            aggregation_type:\n                Optional aggregation type. Can be one of [`avg`, `sum`, `min`, `max`,\n                `range`, `count`, `first`, `last`, `std.p`, `std.s`, `var.p`, `var.s`,\n                `twa`, 'countNaN', 'countAll']\n            bucket_size_msec:\n                Time bucket for aggregation in milliseconds.\n            filter_by_ts:\n                List of timestamps to filter the result by specific timestamps.\n            filter_by_min_value:\n                Filter result by minimum value (must mention also\n                `filter_by_max_value`).\n            filter_by_max_value:\n                Filter result by maximum value (must mention also\n                `filter_by_min_value`).\n            align:\n                Timestamp for alignment control for aggregation.\n            latest:\n                Used when a time series is a compaction, reports the compacted value of\n                the latest possibly partial bucket.\n            bucket_timestamp:\n                Controls how bucket timestamps are reported. Can be one of [`-`, `low`,\n                `+`, `high`, `~`, `mid`].\n            empty:\n                Reports aggregations for empty buckets.\n        \"\"\"\n        params = self.__range_params(\n            key,\n            from_time,\n            to_time,\n            count,\n            aggregation_type,\n            bucket_size_msec,\n            filter_by_ts,\n            filter_by_min_value,\n            filter_by_max_value,\n            align,\n            latest,\n            bucket_timestamp,\n            empty,\n        )\n        return self.execute_command(REVRANGE_CMD, *params, keys=[key])\n\n    def __mrange_params(\n        self,\n        aggregation_type: Optional[str],\n        bucket_size_msec: Optional[int],\n        count: Optional[int],\n        filters: List[str],\n        from_time: Union[int, str],\n        to_time: Union[int, str],\n        with_labels: Optional[bool],\n        filter_by_ts: Optional[List[int]],\n        filter_by_min_value: Optional[int],\n        filter_by_max_value: Optional[int],\n        groupby: Optional[str],\n        reduce: Optional[str],\n        select_labels: Optional[List[str]],\n        align: Optional[Union[int, str]],\n        latest: Optional[bool],\n        bucket_timestamp: Optional[str],\n        empty: Optional[bool],\n    ):\n        \"\"\"Create TS.MRANGE and TS.MREVRANGE arguments.\"\"\"\n        params = [from_time, to_time]\n        self._append_latest(params, latest)\n        self._append_filer_by_ts(params, filter_by_ts)\n        self._append_filer_by_value(params, filter_by_min_value, filter_by_max_value)\n        self._append_with_labels(params, with_labels, select_labels)\n        self._append_count(params, count)\n        self._append_align(params, align)\n        self._append_aggregation(params, aggregation_type, bucket_size_msec)\n        self._append_bucket_timestamp(params, bucket_timestamp)\n        self._append_empty(params, empty)\n        params.extend([\"FILTER\"])\n        params += filters\n        self._append_groupby_reduce(params, groupby, reduce)\n        return params\n\n    def mrange(\n        self,\n        from_time: Union[int, str],\n        to_time: Union[int, str],\n        filters: List[str],\n        count: Optional[int] = None,\n        aggregation_type: Optional[str] = None,\n        bucket_size_msec: Optional[int] = 0,\n        with_labels: Optional[bool] = False,\n        filter_by_ts: Optional[List[int]] = None,\n        filter_by_min_value: Optional[int] = None,\n        filter_by_max_value: Optional[int] = None,\n        groupby: Optional[str] = None,\n        reduce: Optional[str] = None,\n        select_labels: Optional[List[str]] = None,\n        align: Optional[Union[int, str]] = None,\n        latest: Optional[bool] = False,\n        bucket_timestamp: Optional[str] = None,\n        empty: Optional[bool] = False,\n    ):\n        \"\"\"\n        Query a range across multiple time-series by filters in forward direction.\n\n        For more information see https://redis.io/commands/ts.mrange/\n\n        Args:\n            from_time:\n                Start timestamp for the range query. `-` can be used to express the\n                minimum possible timestamp (0).\n            to_time:\n                End timestamp for range query, `+` can be used to express the maximum\n                possible timestamp.\n            filters:\n                Filter to match the time-series labels.\n            count:\n                Limits the number of returned samples.\n            aggregation_type:\n                Optional aggregation type. Can be one of [`avg`, `sum`, `min`, `max`,\n                `range`, `count`, `first`, `last`, `std.p`, `std.s`, `var.p`, `var.s`,\n                `twa`, 'countNaN', 'countAll']\n            bucket_size_msec:\n                Time bucket for aggregation in milliseconds.\n            with_labels:\n                Include in the reply all label-value pairs representing metadata labels\n                of the time series.\n            filter_by_ts:\n                List of timestamps to filter the result by specific timestamps.\n            filter_by_min_value:\n                Filter result by minimum value (must mention also\n                `filter_by_max_value`).\n            filter_by_max_value:\n                Filter result by maximum value (must mention also\n                `filter_by_min_value`).\n            groupby:\n                Grouping by fields the results (must mention also `reduce`).\n            reduce:\n                Applying reducer functions on each group. Can be one of [`avg` `sum`,\n                `min`, `max`, `range`, `count`, `std.p`, `std.s`, `var.p`, `var.s`].\n            select_labels:\n                Include in the reply only a subset of the key-value pair labels of a\n                series.\n            align:\n                Timestamp for alignment control for aggregation.\n            latest:\n                Used when a time series is a compaction, reports the compacted value of\n                the latest possibly partial bucket.\n            bucket_timestamp:\n                Controls how bucket timestamps are reported. Can be one of [`-`, `low`,\n                `+`, `high`, `~`, `mid`].\n            empty:\n                Reports aggregations for empty buckets.\n        \"\"\"\n        params = self.__mrange_params(\n            aggregation_type,\n            bucket_size_msec,\n            count,\n            filters,\n            from_time,\n            to_time,\n            with_labels,\n            filter_by_ts,\n            filter_by_min_value,\n            filter_by_max_value,\n            groupby,\n            reduce,\n            select_labels,\n            align,\n            latest,\n            bucket_timestamp,\n            empty,\n        )\n\n        return self.execute_command(MRANGE_CMD, *params)\n\n    def mrevrange(\n        self,\n        from_time: Union[int, str],\n        to_time: Union[int, str],\n        filters: List[str],\n        count: Optional[int] = None,\n        aggregation_type: Optional[str] = None,\n        bucket_size_msec: Optional[int] = 0,\n        with_labels: Optional[bool] = False,\n        filter_by_ts: Optional[List[int]] = None,\n        filter_by_min_value: Optional[int] = None,\n        filter_by_max_value: Optional[int] = None,\n        groupby: Optional[str] = None,\n        reduce: Optional[str] = None,\n        select_labels: Optional[List[str]] = None,\n        align: Optional[Union[int, str]] = None,\n        latest: Optional[bool] = False,\n        bucket_timestamp: Optional[str] = None,\n        empty: Optional[bool] = False,\n    ):\n        \"\"\"\n        Query a range across multiple time-series by filters in reverse direction.\n\n        For more information see https://redis.io/commands/ts.mrevrange/\n\n        Args:\n            from_time:\n                Start timestamp for the range query. '-' can be used to express the\n                minimum possible timestamp (0).\n            to_time:\n                End timestamp for range query, '+' can be used to express the maximum\n                possible timestamp.\n            filters:\n                Filter to match the time-series labels.\n            count:\n                Limits the number of returned samples.\n            aggregation_type:\n                Optional aggregation type. Can be one of [`avg`, `sum`, `min`, `max`,\n                `range`, `count`, `first`, `last`, `std.p`, `std.s`, `var.p`, `var.s`,\n                `twa`, 'countNaN', 'countAll'].\n            bucket_size_msec:\n                Time bucket for aggregation in milliseconds.\n            with_labels:\n                Include in the reply all label-value pairs representing metadata labels\n                of the time series.\n            filter_by_ts:\n                List of timestamps to filter the result by specific timestamps.\n            filter_by_min_value:\n                Filter result by minimum value (must mention also\n                `filter_by_max_value`).\n            filter_by_max_value:\n                Filter result by maximum value (must mention also\n                `filter_by_min_value`).\n            groupby:\n                Grouping by fields the results (must mention also `reduce`).\n            reduce:\n                Applying reducer functions on each group. Can be one of [`avg` `sum`,\n                `min`, `max`, `range`, `count`, `std.p`, `std.s`, `var.p`, `var.s`].\n            select_labels:\n                Include in the reply only a subset of the key-value pair labels of a\n                series.\n            align:\n                Timestamp for alignment control for aggregation.\n            latest:\n                Used when a time series is a compaction, reports the compacted value of\n                the latest possibly partial bucket.\n            bucket_timestamp:\n                Controls how bucket timestamps are reported. Can be one of [`-`, `low`,\n                `+`, `high`, `~`, `mid`].\n            empty:\n                Reports aggregations for empty buckets.\n        \"\"\"\n        params = self.__mrange_params(\n            aggregation_type,\n            bucket_size_msec,\n            count,\n            filters,\n            from_time,\n            to_time,\n            with_labels,\n            filter_by_ts,\n            filter_by_min_value,\n            filter_by_max_value,\n            groupby,\n            reduce,\n            select_labels,\n            align,\n            latest,\n            bucket_timestamp,\n            empty,\n        )\n\n        return self.execute_command(MREVRANGE_CMD, *params)\n\n    def get(self, key: KeyT, latest: Optional[bool] = False):\n        \"\"\"\n        Get the last sample of `key`.\n\n        For more information see https://redis.io/commands/ts.get/\n\n        Args:\n            latest:\n                Used when a time series is a compaction, reports the compacted value of\n                the latest (possibly partial) bucket.\n        \"\"\"\n        params = [key]\n        self._append_latest(params, latest)\n        return self.execute_command(GET_CMD, *params, keys=[key])\n\n    def mget(\n        self,\n        filters: List[str],\n        with_labels: Optional[bool] = False,\n        select_labels: Optional[List[str]] = None,\n        latest: Optional[bool] = False,\n    ):\n        \"\"\"\n        Get the last samples matching the specific `filter`.\n\n        For more information see https://redis.io/commands/ts.mget/\n\n        Args:\n            filters:\n                Filter to match the time-series labels.\n            with_labels:\n                Include in the reply all label-value pairs representing metadata labels\n                of the time series.\n            select_labels:\n                Include in the reply only a subset of the key-value pair labels o the\n                time series.\n            latest:\n                Used when a time series is a compaction, reports the compacted value of\n                the latest possibly partial bucket.\n        \"\"\"\n        params = []\n        self._append_latest(params, latest)\n        self._append_with_labels(params, with_labels, select_labels)\n        params.extend([\"FILTER\"])\n        params += filters\n        return self.execute_command(MGET_CMD, *params)\n\n    def info(self, key: KeyT):\n        \"\"\"\n        Get information of `key`.\n\n        For more information see https://redis.io/commands/ts.info/\n        \"\"\"\n        return self.execute_command(INFO_CMD, key, keys=[key])\n\n    def queryindex(self, filters: List[str]):\n        \"\"\"\n        Get all time series keys matching the `filter` list.\n\n        For more information see https://redis.io/commands/ts.queryindex/\n        \"\"\"\n        return self.execute_command(QUERYINDEX_CMD, *filters)\n\n    @staticmethod\n    def _append_uncompressed(params: List[str], uncompressed: Optional[bool]):\n        \"\"\"Append UNCOMPRESSED tag to params.\"\"\"\n        if uncompressed:\n            params.extend([\"ENCODING\", \"UNCOMPRESSED\"])\n\n    @staticmethod\n    def _append_with_labels(\n        params: List[str],\n        with_labels: Optional[bool],\n        select_labels: Optional[List[str]],\n    ):\n        \"\"\"Append labels behavior to params.\"\"\"\n        if with_labels and select_labels:\n            raise DataError(\n                \"with_labels and select_labels cannot be provided together.\"\n            )\n\n        if with_labels:\n            params.extend([\"WITHLABELS\"])\n        if select_labels:\n            params.extend([\"SELECTED_LABELS\", *select_labels])\n\n    @staticmethod\n    def _append_groupby_reduce(\n        params: List[str], groupby: Optional[str], reduce: Optional[str]\n    ):\n        \"\"\"Append GROUPBY REDUCE property to params.\"\"\"\n        if groupby is not None and reduce is not None:\n            params.extend([\"GROUPBY\", groupby, \"REDUCE\", reduce.upper()])\n\n    @staticmethod\n    def _append_retention(params: List[str], retention: Optional[int]):\n        \"\"\"Append RETENTION property to params.\"\"\"\n        if retention is not None:\n            params.extend([\"RETENTION\", retention])\n\n    @staticmethod\n    def _append_labels(params: List[str], labels: Optional[List[str]]):\n        \"\"\"Append LABELS property to params.\"\"\"\n        if labels:\n            params.append(\"LABELS\")\n            for k, v in labels.items():\n                params.extend([k, v])\n\n    @staticmethod\n    def _append_count(params: List[str], count: Optional[int]):\n        \"\"\"Append COUNT property to params.\"\"\"\n        if count is not None:\n            params.extend([\"COUNT\", count])\n\n    @staticmethod\n    def _append_timestamp(params: List[str], timestamp: Optional[int]):\n        \"\"\"Append TIMESTAMP property to params.\"\"\"\n        if timestamp is not None:\n            params.extend([\"TIMESTAMP\", timestamp])\n\n    @staticmethod\n    def _append_align(params: List[str], align: Optional[Union[int, str]]):\n        \"\"\"Append ALIGN property to params.\"\"\"\n        if align is not None:\n            params.extend([\"ALIGN\", align])\n\n    @staticmethod\n    def _append_aggregation(\n        params: List[str],\n        aggregation_type: Optional[str],\n        bucket_size_msec: Optional[int],\n    ):\n        \"\"\"Append AGGREGATION property to params.\"\"\"\n        if aggregation_type is not None:\n            params.extend([\"AGGREGATION\", aggregation_type, bucket_size_msec])\n\n    @staticmethod\n    def _append_chunk_size(params: List[str], chunk_size: Optional[int]):\n        \"\"\"Append CHUNK_SIZE property to params.\"\"\"\n        if chunk_size is not None:\n            params.extend([\"CHUNK_SIZE\", chunk_size])\n\n    @staticmethod\n    def _append_duplicate_policy(params: List[str], duplicate_policy: Optional[str]):\n        \"\"\"Append DUPLICATE_POLICY property to params.\"\"\"\n        if duplicate_policy is not None:\n            params.extend([\"DUPLICATE_POLICY\", duplicate_policy])\n\n    @staticmethod\n    def _append_on_duplicate(params: List[str], on_duplicate: Optional[str]):\n        \"\"\"Append ON_DUPLICATE property to params.\"\"\"\n        if on_duplicate is not None:\n            params.extend([\"ON_DUPLICATE\", on_duplicate])\n\n    @staticmethod\n    def _append_filer_by_ts(params: List[str], ts_list: Optional[List[int]]):\n        \"\"\"Append FILTER_BY_TS property to params.\"\"\"\n        if ts_list is not None:\n            params.extend([\"FILTER_BY_TS\", *ts_list])\n\n    @staticmethod\n    def _append_filer_by_value(\n        params: List[str], min_value: Optional[int], max_value: Optional[int]\n    ):\n        \"\"\"Append FILTER_BY_VALUE property to params.\"\"\"\n        if min_value is not None and max_value is not None:\n            params.extend([\"FILTER_BY_VALUE\", min_value, max_value])\n\n    @staticmethod\n    def _append_latest(params: List[str], latest: Optional[bool]):\n        \"\"\"Append LATEST property to params.\"\"\"\n        if latest:\n            params.append(\"LATEST\")\n\n    @staticmethod\n    def _append_bucket_timestamp(params: List[str], bucket_timestamp: Optional[str]):\n        \"\"\"Append BUCKET_TIMESTAMP property to params.\"\"\"\n        if bucket_timestamp is not None:\n            params.extend([\"BUCKETTIMESTAMP\", bucket_timestamp])\n\n    @staticmethod\n    def _append_empty(params: List[str], empty: Optional[bool]):\n        \"\"\"Append EMPTY property to params.\"\"\"\n        if empty:\n            params.append(\"EMPTY\")\n\n    @staticmethod\n    def _append_insertion_filters(\n        params: List[str],\n        ignore_max_time_diff: Optional[int] = None,\n        ignore_max_val_diff: Optional[Number] = None,\n    ):\n        \"\"\"Append insertion filters to params.\"\"\"\n        if (ignore_max_time_diff is None) != (ignore_max_val_diff is None):\n            raise ValueError(\n                \"Both ignore_max_time_diff and ignore_max_val_diff must be set.\"\n            )\n\n        if ignore_max_time_diff is not None and ignore_max_val_diff is not None:\n            params.extend(\n                [\"IGNORE\", str(ignore_max_time_diff), str(ignore_max_val_diff)]\n            )\n"
  },
  {
    "path": "redis/commands/timeseries/info.py",
    "content": "from ..helpers import nativestr\nfrom .utils import list_to_dict\n\n\nclass TSInfo:\n    \"\"\"\n    Hold information and statistics on the time-series.\n    Can be created using ``tsinfo`` command\n    https://redis.io/docs/latest/commands/ts.info/\n    \"\"\"\n\n    rules = []\n    labels = []\n    sourceKey = None\n    chunk_count = None\n    memory_usage = None\n    total_samples = None\n    retention_msecs = None\n    last_time_stamp = None\n    first_time_stamp = None\n\n    max_samples_per_chunk = None\n    chunk_size = None\n    duplicate_policy = None\n\n    def __init__(self, args):\n        \"\"\"\n        Hold information and statistics on the time-series.\n\n        The supported params that can be passed as args:\n\n        rules:\n            A list of compaction rules of the time series.\n        sourceKey:\n            Key name for source time series in case the current series\n            is a target of a rule.\n        chunkCount:\n            Number of Memory Chunks used for the time series.\n        memoryUsage:\n            Total number of bytes allocated for the time series.\n        totalSamples:\n            Total number of samples in the time series.\n        labels:\n            A list of label-value pairs that represent the metadata\n            labels of the time series.\n        retentionTime:\n            Retention time, in milliseconds, for the time series.\n        lastTimestamp:\n            Last timestamp present in the time series.\n        firstTimestamp:\n            First timestamp present in the time series.\n        maxSamplesPerChunk:\n            Deprecated.\n        chunkSize:\n            Amount of memory, in bytes, allocated for data.\n        duplicatePolicy:\n            Policy that will define handling of duplicate samples.\n\n        Can read more about on\n        https://redis.io/docs/latest/develop/data-types/timeseries/configuration/#duplicate_policy\n        \"\"\"\n        response = dict(zip(map(nativestr, args[::2]), args[1::2]))\n        self.rules = response.get(\"rules\")\n        self.source_key = response.get(\"sourceKey\")\n        self.chunk_count = response.get(\"chunkCount\")\n        self.memory_usage = response.get(\"memoryUsage\")\n        self.total_samples = response.get(\"totalSamples\")\n        self.labels = list_to_dict(response.get(\"labels\"))\n        self.retention_msecs = response.get(\"retentionTime\")\n        self.last_timestamp = response.get(\"lastTimestamp\")\n        self.first_timestamp = response.get(\"firstTimestamp\")\n        if \"maxSamplesPerChunk\" in response:\n            self.max_samples_per_chunk = response[\"maxSamplesPerChunk\"]\n            self.chunk_size = (\n                self.max_samples_per_chunk * 16\n            )  # backward compatible changes\n        if \"chunkSize\" in response:\n            self.chunk_size = response[\"chunkSize\"]\n        if \"duplicatePolicy\" in response:\n            self.duplicate_policy = response[\"duplicatePolicy\"]\n            if isinstance(self.duplicate_policy, bytes):\n                self.duplicate_policy = self.duplicate_policy.decode()\n\n    def get(self, item):\n        try:\n            return self.__getitem__(item)\n        except AttributeError:\n            return None\n\n    def __getitem__(self, item):\n        return getattr(self, item)\n"
  },
  {
    "path": "redis/commands/timeseries/utils.py",
    "content": "from ..helpers import nativestr\n\n\ndef list_to_dict(aList):\n    return {nativestr(aList[i][0]): nativestr(aList[i][1]) for i in range(len(aList))}\n\n\ndef parse_range(response, **kwargs):\n    \"\"\"Parse range response. Used by TS.RANGE and TS.REVRANGE.\"\"\"\n    return [tuple((r[0], float(r[1]))) for r in response]\n\n\ndef parse_m_range(response):\n    \"\"\"Parse multi range response. Used by TS.MRANGE and TS.MREVRANGE.\"\"\"\n    res = []\n    for item in response:\n        res.append({nativestr(item[0]): [list_to_dict(item[1]), parse_range(item[2])]})\n    return sorted(res, key=lambda d: list(d.keys()))\n\n\ndef parse_get(response):\n    \"\"\"Parse get response. Used by TS.GET.\"\"\"\n    if not response:\n        return None\n    return int(response[0]), float(response[1])\n\n\ndef parse_m_get(response):\n    \"\"\"Parse multi get response. Used by TS.MGET.\"\"\"\n    res = []\n    for item in response:\n        if not item[2]:\n            res.append({nativestr(item[0]): [list_to_dict(item[1]), None, None]})\n        else:\n            res.append(\n                {\n                    nativestr(item[0]): [\n                        list_to_dict(item[1]),\n                        int(item[2][0]),\n                        float(item[2][1]),\n                    ]\n                }\n            )\n    return sorted(res, key=lambda d: list(d.keys()))\n"
  },
  {
    "path": "redis/commands/vectorset/__init__.py",
    "content": "import json\nfrom typing import Literal\n\nfrom redis._parsers.helpers import pairs_to_dict\nfrom redis.commands.vectorset.utils import (\n    parse_vemb_result,\n    parse_vlinks_result,\n    parse_vsim_result,\n)\n\nfrom ..helpers import get_protocol_version\nfrom .commands import (\n    VEMB_CMD,\n    VGETATTR_CMD,\n    VINFO_CMD,\n    VLINKS_CMD,\n    VSIM_CMD,\n    VectorSetCommands,\n)\n\n\nclass _VectorSetBase(VectorSetCommands):\n    \"\"\"Base class with shared initialization logic for VectorSet clients.\"\"\"\n\n    def __init__(self, client, **kwargs):\n        \"\"\"Initialize VectorSet client with callbacks.\"\"\"\n        # Set the module commands' callbacks\n        self._MODULE_CALLBACKS = {\n            VEMB_CMD: parse_vemb_result,\n            VSIM_CMD: parse_vsim_result,\n            VGETATTR_CMD: lambda r: r and json.loads(r) or None,\n        }\n\n        self._RESP2_MODULE_CALLBACKS = {\n            VINFO_CMD: lambda r: r and pairs_to_dict(r) or None,\n            VLINKS_CMD: parse_vlinks_result,\n        }\n        self._RESP3_MODULE_CALLBACKS = {}\n\n        self.client = client\n        self.execute_command = client.execute_command\n\n        if get_protocol_version(self.client) in [\"3\", 3]:\n            self._MODULE_CALLBACKS.update(self._RESP3_MODULE_CALLBACKS)\n        else:\n            self._MODULE_CALLBACKS.update(self._RESP2_MODULE_CALLBACKS)\n\n        for k, v in self._MODULE_CALLBACKS.items():\n            self.client.set_response_callback(k, v)\n\n\nclass VectorSet(_VectorSetBase):\n    \"\"\"Sync VectorSet client.\"\"\"\n\n    _is_async_client: Literal[False] = False\n\n\nclass AsyncVectorSet(_VectorSetBase):\n    \"\"\"Async VectorSet client.\n\n    Note: Inherits from _VectorSetBase (not VectorSet) to maintain proper\n    type discrimination. If AsyncVectorSet inherited from VectorSet, the\n    type system would see it as a subtype of SyncClientProtocol, causing\n    @overload resolution to incorrectly infer sync return types.\n    \"\"\"\n\n    _is_async_client: Literal[True] = True\n"
  },
  {
    "path": "redis/commands/vectorset/commands.py",
    "content": "from __future__ import annotations\n\nimport json\nfrom enum import Enum\nfrom typing import Any, Awaitable, overload\n\nfrom redis.client import NEVER_DECODE\nfrom redis.commands.helpers import get_protocol_version\nfrom redis.exceptions import DataError\nfrom redis.typing import (\n    AsyncClientProtocol,\n    CommandsProtocol,\n    EncodableT,\n    KeyT,\n    Number,\n    SyncClientProtocol,\n)\n\nVADD_CMD = \"VADD\"\nVSIM_CMD = \"VSIM\"\nVREM_CMD = \"VREM\"\nVDIM_CMD = \"VDIM\"\nVCARD_CMD = \"VCARD\"\nVEMB_CMD = \"VEMB\"\nVLINKS_CMD = \"VLINKS\"\nVINFO_CMD = \"VINFO\"\nVSETATTR_CMD = \"VSETATTR\"\nVGETATTR_CMD = \"VGETATTR\"\nVRANDMEMBER_CMD = \"VRANDMEMBER\"\nVRANGE_CMD = \"VRANGE\"\n\n# Return type for vsim command\nVSimResult = (\n    list[list[EncodableT] | dict[EncodableT, Number] | dict[EncodableT, dict[str, Any]]]\n    | None\n)\n\n# Return type for vemb command\nVEmbResult = list[EncodableT] | dict[str, EncodableT] | None\n\n# Return type for vlinks command\nVLinksResult = list[list[str | bytes] | dict[str | bytes, Number]] | None\n\n# Return type for vrandmember command\nVRandMemberResult = list[str] | str | None\n\n# Return type for vgetattr command\nVGetAttrResult = dict | None\n\n\nclass QuantizationOptions(Enum):\n    \"\"\"Quantization options for the VADD command.\"\"\"\n\n    NOQUANT = \"NOQUANT\"\n    BIN = \"BIN\"\n    Q8 = \"Q8\"\n\n\nclass CallbacksOptions(Enum):\n    \"\"\"Options that can be set for the commands callbacks\"\"\"\n\n    RAW = \"RAW\"\n    WITHSCORES = \"WITHSCORES\"\n    WITHATTRIBS = \"WITHATTRIBS\"\n    ALLOW_DECODING = \"ALLOW_DECODING\"\n    RESP3 = \"RESP3\"\n\n\nclass VectorSetCommands(CommandsProtocol):\n    \"\"\"Redis VectorSet commands\"\"\"\n\n    @overload\n    def vadd(\n        self: SyncClientProtocol,\n        key: KeyT,\n        vector: list[float] | bytes,\n        element: str,\n        reduce_dim: int | None = None,\n        cas: bool | None = False,\n        quantization: QuantizationOptions | None = None,\n        ef: Number | None = None,\n        attributes: dict | str | None = None,\n        numlinks: int | None = None,\n    ) -> int: ...\n\n    @overload\n    def vadd(\n        self: AsyncClientProtocol,\n        key: KeyT,\n        vector: list[float] | bytes,\n        element: str,\n        reduce_dim: int | None = None,\n        cas: bool | None = False,\n        quantization: QuantizationOptions | None = None,\n        ef: Number | None = None,\n        attributes: dict | str | None = None,\n        numlinks: int | None = None,\n    ) -> Awaitable[int]: ...\n\n    def vadd(\n        self,\n        key: KeyT,\n        vector: list[float] | bytes,\n        element: str,\n        reduce_dim: int | None = None,\n        cas: bool | None = False,\n        quantization: QuantizationOptions | None = None,\n        ef: Number | None = None,\n        attributes: dict | str | None = None,\n        numlinks: int | None = None,\n    ) -> Awaitable[int] | int:\n        \"\"\"\n        Add vector ``vector`` for element ``element`` to a vector set ``key``.\n\n        ``reduce_dim`` sets the dimensions to reduce the vector to.\n                If not provided, the vector is not reduced.\n\n        ``cas`` is a boolean flag that indicates whether to use CAS (check-and-set style)\n                when adding the vector. If not provided, CAS is not used.\n\n        ``quantization`` sets the quantization type to use.\n                If not provided, int8 quantization is used.\n                The options are:\n                - NOQUANT: No quantization\n                - BIN: Binary quantization\n                - Q8: Signed 8-bit quantization\n\n        ``ef`` sets the exploration factor to use.\n                If not provided, the default exploration factor is used.\n\n        ``attributes`` is a dictionary or json string that contains the attributes to set for the vector.\n                If not provided, no attributes are set.\n\n        ``numlinks`` sets the number of links to create for the vector.\n                If not provided, the default number of links is used.\n\n        For more information, see https://redis.io/commands/vadd.\n        \"\"\"\n        if not vector or not element:\n            raise DataError(\"Both vector and element must be provided\")\n\n        pieces = []\n        if reduce_dim:\n            pieces.extend([\"REDUCE\", reduce_dim])\n\n        values_pieces = []\n        if isinstance(vector, bytes):\n            values_pieces.extend([\"FP32\", vector])\n        else:\n            values_pieces.extend([\"VALUES\", len(vector)])\n            values_pieces.extend(vector)\n        pieces.extend(values_pieces)\n\n        pieces.append(element)\n\n        if cas:\n            pieces.append(\"CAS\")\n\n        if quantization:\n            pieces.append(quantization.value)\n\n        if ef:\n            pieces.extend([\"EF\", ef])\n\n        if attributes:\n            if isinstance(attributes, dict):\n                # transform attributes to json string\n                attributes_json = json.dumps(attributes)\n            else:\n                attributes_json = attributes\n            pieces.extend([\"SETATTR\", attributes_json])\n\n        if numlinks:\n            pieces.extend([\"M\", numlinks])\n\n        return self.execute_command(VADD_CMD, key, *pieces)\n\n    @overload\n    def vsim(\n        self: SyncClientProtocol,\n        key: KeyT,\n        input: list[float] | bytes | str,\n        with_scores: bool | None = False,\n        with_attribs: bool | None = False,\n        count: int | None = None,\n        ef: Number | None = None,\n        filter: str | None = None,\n        filter_ef: str | None = None,\n        truth: bool | None = False,\n        no_thread: bool | None = False,\n        epsilon: Number | None = None,\n    ) -> VSimResult: ...\n\n    @overload\n    def vsim(\n        self: AsyncClientProtocol,\n        key: KeyT,\n        input: list[float] | bytes | str,\n        with_scores: bool | None = False,\n        with_attribs: bool | None = False,\n        count: int | None = None,\n        ef: Number | None = None,\n        filter: str | None = None,\n        filter_ef: str | None = None,\n        truth: bool | None = False,\n        no_thread: bool | None = False,\n        epsilon: Number | None = None,\n    ) -> Awaitable[VSimResult]: ...\n\n    def vsim(\n        self,\n        key: KeyT,\n        input: list[float] | bytes | str,\n        with_scores: bool | None = False,\n        with_attribs: bool | None = False,\n        count: int | None = None,\n        ef: Number | None = None,\n        filter: str | None = None,\n        filter_ef: str | None = None,\n        truth: bool | None = False,\n        no_thread: bool | None = False,\n        epsilon: Number | None = None,\n    ) -> Awaitable[VSimResult] | VSimResult:\n        \"\"\"\n        Compare a vector or element ``input``  with the other vectors in a vector set ``key``.\n\n        ``with_scores`` sets if similarity scores should be returned for each element in the result.\n\n        ``with_attribs`` ``with_attribs`` sets if the results should be returned with the\n                attributes of the elements in the result, or None when no attributes are present.\n\n        ``count`` sets the number of results to return.\n\n        ``ef`` sets the exploration factor.\n\n        ``filter`` sets the filter that should be applied for the search.\n\n        ``filter_ef`` sets the max filtering effort.\n\n        ``truth`` when enabled, forces the command to perform a linear scan.\n\n        ``no_thread`` when enabled forces the command to execute the search\n                on the data structure in the main thread.\n\n        ``epsilon`` floating point between 0 and 1, if specified will return\n                only elements with distance no further than the specified one.\n\n        For more information, see https://redis.io/commands/vsim.\n        \"\"\"\n\n        if not input:\n            raise DataError(\"'input' should be provided\")\n\n        pieces = []\n        options = {}\n\n        if isinstance(input, bytes):\n            pieces.extend([\"FP32\", input])\n        elif isinstance(input, list):\n            pieces.extend([\"VALUES\", len(input)])\n            pieces.extend(input)\n        else:\n            pieces.extend([\"ELE\", input])\n\n        if with_scores or with_attribs:\n            if get_protocol_version(self.client) in [\"3\", 3]:\n                options[CallbacksOptions.RESP3.value] = True\n\n            if with_scores:\n                pieces.append(\"WITHSCORES\")\n                options[CallbacksOptions.WITHSCORES.value] = True\n\n            if with_attribs:\n                pieces.append(\"WITHATTRIBS\")\n                options[CallbacksOptions.WITHATTRIBS.value] = True\n\n        if count:\n            pieces.extend([\"COUNT\", count])\n\n        if epsilon:\n            pieces.extend([\"EPSILON\", epsilon])\n\n        if ef:\n            pieces.extend([\"EF\", ef])\n\n        if filter:\n            pieces.extend([\"FILTER\", filter])\n\n        if filter_ef:\n            pieces.extend([\"FILTER-EF\", filter_ef])\n\n        if truth:\n            pieces.append(\"TRUTH\")\n\n        if no_thread:\n            pieces.append(\"NOTHREAD\")\n\n        return self.execute_command(VSIM_CMD, key, *pieces, **options)\n\n    @overload\n    def vdim(self: SyncClientProtocol, key: KeyT) -> int: ...\n\n    @overload\n    def vdim(self: AsyncClientProtocol, key: KeyT) -> Awaitable[int]: ...\n\n    def vdim(self, key: KeyT) -> Awaitable[int] | int:\n        \"\"\"\n        Get the dimension of a vector set.\n\n        In the case of vectors that were populated using the `REDUCE`\n        option, for random projection, the vector set will report the size of\n        the projected (reduced) dimension.\n\n        Raises `redis.exceptions.ResponseError` if the vector set doesn't exist.\n\n        For more information, see https://redis.io/commands/vdim.\n        \"\"\"\n        return self.execute_command(VDIM_CMD, key)\n\n    @overload\n    def vcard(self: SyncClientProtocol, key: KeyT) -> int: ...\n\n    @overload\n    def vcard(self: AsyncClientProtocol, key: KeyT) -> Awaitable[int]: ...\n\n    def vcard(self, key: KeyT) -> Awaitable[int] | int:\n        \"\"\"\n        Get the cardinality(the number of elements) of a vector set with key ``key``.\n\n        Raises `redis.exceptions.ResponseError` if the vector set doesn't exist.\n\n        For more information, see https://redis.io/commands/vcard.\n        \"\"\"\n        return self.execute_command(VCARD_CMD, key)\n\n    @overload\n    def vrem(self: SyncClientProtocol, key: KeyT, element: str) -> int: ...\n\n    @overload\n    def vrem(self: AsyncClientProtocol, key: KeyT, element: str) -> Awaitable[int]: ...\n\n    def vrem(self, key: KeyT, element: str) -> Awaitable[int] | int:\n        \"\"\"\n        Remove an element from a vector set.\n\n        For more information, see https://redis.io/commands/vrem.\n        \"\"\"\n        return self.execute_command(VREM_CMD, key, element)\n\n    @overload\n    def vemb(\n        self: SyncClientProtocol,\n        key: KeyT,\n        element: str,\n        raw: bool | None = False,\n    ) -> VEmbResult: ...\n\n    @overload\n    def vemb(\n        self: AsyncClientProtocol,\n        key: KeyT,\n        element: str,\n        raw: bool | None = False,\n    ) -> Awaitable[VEmbResult]: ...\n\n    def vemb(\n        self, key: KeyT, element: str, raw: bool | None = False\n    ) -> Awaitable[VEmbResult] | VEmbResult:\n        \"\"\"\n        Get the approximated vector of an element ``element`` from vector set ``key``.\n\n        ``raw`` is a boolean flag that indicates whether to return the\n                internal representation used by the vector.\n\n\n        For more information, see https://redis.io/commands/vemb.\n        \"\"\"\n        options = {}\n        pieces = []\n        pieces.extend([key, element])\n\n        if get_protocol_version(self.client) in [\"3\", 3]:\n            options[CallbacksOptions.RESP3.value] = True\n\n        if raw:\n            pieces.append(\"RAW\")\n\n            options[NEVER_DECODE] = True\n            if (\n                hasattr(self.client, \"connection_pool\")\n                and self.client.connection_pool.connection_kwargs[\"decode_responses\"]\n            ) or (\n                hasattr(self.client, \"nodes_manager\")\n                and self.client.nodes_manager.connection_kwargs[\"decode_responses\"]\n            ):\n                # allow decoding in the postprocessing callback\n                # if the user set decode_responses=True\n                # in the connection pool\n                options[CallbacksOptions.ALLOW_DECODING.value] = True\n\n            options[CallbacksOptions.RAW.value] = True\n\n        return self.execute_command(VEMB_CMD, *pieces, **options)\n\n    @overload\n    def vlinks(\n        self: SyncClientProtocol,\n        key: KeyT,\n        element: str,\n        with_scores: bool | None = False,\n    ) -> VLinksResult: ...\n\n    @overload\n    def vlinks(\n        self: AsyncClientProtocol,\n        key: KeyT,\n        element: str,\n        with_scores: bool | None = False,\n    ) -> Awaitable[VLinksResult]: ...\n\n    def vlinks(\n        self, key: KeyT, element: str, with_scores: bool | None = False\n    ) -> Awaitable[VLinksResult] | VLinksResult:\n        \"\"\"\n        Returns the neighbors for each level the element ``element`` exists in the vector set ``key``.\n\n        The result is a list of lists, where each list contains the neighbors for one level.\n        If the element does not exist, or if the vector set does not exist, None is returned.\n\n        If the ``WITHSCORES`` option is provided, the result is a list of dicts,\n        where each dict contains the neighbors for one level, with the scores as values.\n\n        For more information, see https://redis.io/commands/vlinks\n        \"\"\"\n        options = {}\n        pieces = []\n        pieces.extend([key, element])\n\n        if with_scores:\n            pieces.append(\"WITHSCORES\")\n            options[CallbacksOptions.WITHSCORES.value] = True\n\n        return self.execute_command(VLINKS_CMD, *pieces, **options)\n\n    @overload\n    def vinfo(self: SyncClientProtocol, key: KeyT) -> dict | None: ...\n\n    @overload\n    def vinfo(self: AsyncClientProtocol, key: KeyT) -> Awaitable[dict | None]: ...\n\n    def vinfo(self, key: KeyT) -> (dict | None) | Awaitable[dict | None]:\n        \"\"\"\n        Get information about a vector set.\n\n        For more information, see https://redis.io/commands/vinfo.\n        \"\"\"\n        return self.execute_command(VINFO_CMD, key)\n\n    @overload\n    def vsetattr(\n        self: SyncClientProtocol,\n        key: KeyT,\n        element: str,\n        attributes: dict | str | None = None,\n    ) -> int: ...\n\n    @overload\n    def vsetattr(\n        self: AsyncClientProtocol,\n        key: KeyT,\n        element: str,\n        attributes: dict | str | None = None,\n    ) -> Awaitable[int]: ...\n\n    def vsetattr(\n        self, key: KeyT, element: str, attributes: dict | str | None = None\n    ) -> Awaitable[int] | int:\n        \"\"\"\n        Associate or remove JSON attributes ``attributes`` of element ``element``\n        for vector set ``key``.\n\n        For more information, see https://redis.io/commands/vsetattr\n        \"\"\"\n        if attributes is None:\n            attributes_json = \"{}\"\n        elif isinstance(attributes, dict):\n            # transform attributes to json string\n            attributes_json = json.dumps(attributes)\n        else:\n            attributes_json = attributes\n\n        return self.execute_command(VSETATTR_CMD, key, element, attributes_json)\n\n    @overload\n    def vgetattr(\n        self: SyncClientProtocol, key: KeyT, element: str\n    ) -> VGetAttrResult: ...\n\n    @overload\n    def vgetattr(\n        self: AsyncClientProtocol, key: KeyT, element: str\n    ) -> Awaitable[VGetAttrResult]: ...\n\n    def vgetattr(\n        self, key: KeyT, element: str\n    ) -> Awaitable[VGetAttrResult] | VGetAttrResult:\n        \"\"\"\n        Retrieve the JSON attributes of an element ``element `` for vector set ``key``.\n\n        If the element does not exist, or if the vector set does not exist, None is\n        returned.\n\n        For more information, see https://redis.io/commands/vgetattr.\n        \"\"\"\n        return self.execute_command(VGETATTR_CMD, key, element)\n\n    @overload\n    def vrandmember(\n        self: SyncClientProtocol, key: KeyT, count: int | None = None\n    ) -> VRandMemberResult: ...\n\n    @overload\n    def vrandmember(\n        self: AsyncClientProtocol, key: KeyT, count: int | None = None\n    ) -> Awaitable[VRandMemberResult]: ...\n\n    def vrandmember(\n        self, key: KeyT, count: int | None = None\n    ) -> Awaitable[VRandMemberResult] | VRandMemberResult:\n        \"\"\"\n        Returns random elements from a vector set ``key``.\n\n        ``count`` is the number of elements to return.\n                If ``count`` is not provided, a single element is returned as a single string.\n                If ``count`` is positive(smaller than the number of elements\n                            in the vector set), the command returns a list with up to ``count``\n                            distinct elements from the vector set\n                If ``count`` is negative, the command returns a list with ``count`` random elements,\n                            potentially with duplicates.\n                If ``count`` is greater than the number of elements in the vector set,\n                            only the entire set is returned as a list.\n\n        If the vector set does not exist, ``None`` is returned.\n\n        For more information, see https://redis.io/commands/vrandmember.\n        \"\"\"\n        pieces = []\n        pieces.append(key)\n        if count is not None:\n            pieces.append(count)\n        return self.execute_command(VRANDMEMBER_CMD, *pieces)\n\n    @overload\n    def vrange(\n        self: SyncClientProtocol,\n        key: KeyT,\n        start: str,\n        end: str,\n        count: int | None = None,\n    ) -> list[str]: ...\n\n    @overload\n    def vrange(\n        self: AsyncClientProtocol,\n        key: KeyT,\n        start: str,\n        end: str,\n        count: int | None = None,\n    ) -> Awaitable[list[str]]: ...\n\n    def vrange(\n        self, key: KeyT, start: str, end: str, count: int | None = None\n    ) -> Awaitable[list[str]] | list[str]:\n        \"\"\"\n        Return elements in a lexicographical range from a vector set ``key``.\n\n        ``start`` is the starting point of the lexicographical range. Can be:\n                - A string prefixed with '[' for inclusive range (e.g., '[Redis')\n                - A string prefixed with '(' for exclusive range (e.g., '(a7')\n                - The special symbol '-' to indicate the minimum element\n\n        ``end`` is the ending point of the lexicographical range. Can be:\n                - A string prefixed with '[' for inclusive range\n                - A string prefixed with '(' for exclusive range\n                - The special symbol '+' to indicate the maximum element\n\n        ``count`` is the maximum number of elements to return.\n                If ``count`` is not provided or negative, all elements in the range are returned.\n                If ``count`` is positive, at most ``count`` elements are returned.\n\n        Returns an array of elements in lexicographical order within the specified range.\n        Returns an empty array if the key doesn't exist.\n\n        For more information, see https://redis.io/commands/vrange.\n        \"\"\"\n        pieces = [key, start, end]\n        if count is not None:\n            pieces.append(count)\n        return self.execute_command(VRANGE_CMD, *pieces)\n"
  },
  {
    "path": "redis/commands/vectorset/utils.py",
    "content": "import json\n\nfrom redis._parsers.helpers import pairs_to_dict\nfrom redis.commands.vectorset.commands import CallbacksOptions\n\n\ndef parse_vemb_result(response, **options):\n    \"\"\"\n    Handle VEMB result since the command can returning different result\n    structures depending on input options and on quantization type of the vector set.\n\n    Parsing VEMB result into:\n    - List[Union[bytes, Union[int, float]]]\n    - Dict[str, Union[bytes, str, float]]\n    \"\"\"\n    if response is None:\n        return response\n\n    if options.get(CallbacksOptions.RAW.value):\n        result = {}\n        result[\"quantization\"] = (\n            response[0].decode(\"utf-8\")\n            if options.get(CallbacksOptions.ALLOW_DECODING.value)\n            else response[0]\n        )\n        result[\"raw\"] = response[1]\n        result[\"l2\"] = float(response[2])\n        if len(response) > 3:\n            result[\"range\"] = float(response[3])\n        return result\n    else:\n        if options.get(CallbacksOptions.RESP3.value):\n            return response\n\n        result = []\n        for i in range(len(response)):\n            try:\n                result.append(int(response[i]))\n            except ValueError:\n                # if the value is not an integer, it should be a float\n                result.append(float(response[i]))\n\n        return result\n\n\ndef parse_vlinks_result(response, **options):\n    \"\"\"\n    Handle VLINKS result since the command can be returning different result\n    structures depending on input options.\n    Parsing VLINKS result into:\n    - List[List[str]]\n    - List[Dict[str, Number]]\n    \"\"\"\n    if response is None:\n        return response\n\n    if options.get(CallbacksOptions.WITHSCORES.value):\n        result = []\n        # Redis will return a list of list of strings.\n        # This list have to be transformed to list of dicts\n        for level_item in response:\n            level_data_dict = {}\n            for key, value in pairs_to_dict(level_item).items():\n                value = float(value)\n                level_data_dict[key] = value\n            result.append(level_data_dict)\n        return result\n    else:\n        # return the list of elements for each level\n        # list of lists\n        return response\n\n\ndef parse_vsim_result(response, **options):\n    \"\"\"\n    Handle VSIM result since the command can be returning different result\n    structures depending on input options.\n    Parsing VSIM result into:\n    - List[List[str]]\n    - List[Dict[str, Number]] - when with_scores is used (without attributes)\n    - List[Dict[str, Mapping[str, Any]]] - when with_attribs is used (without scores)\n    - List[Dict[str, Union[Number, Mapping[str, Any]]]] - when with_scores and with_attribs are used\n\n    \"\"\"\n    if response is None:\n        return response\n\n    withscores = bool(options.get(CallbacksOptions.WITHSCORES.value))\n    withattribs = bool(options.get(CallbacksOptions.WITHATTRIBS.value))\n\n    # Exactly one of withscores or withattribs is True\n    if (withscores and not withattribs) or (not withscores and withattribs):\n        # Redis will return a list of list of pairs.\n        # This list have to be transformed to dict\n        result_dict = {}\n        if options.get(CallbacksOptions.RESP3.value):\n            resp_dict = response\n        else:\n            resp_dict = pairs_to_dict(response)\n        for key, value in resp_dict.items():\n            if withscores:\n                value = float(value)\n            else:\n                value = json.loads(value) if value else None\n\n            result_dict[key] = value\n        return result_dict\n    elif withscores and withattribs:\n        it = iter(response)\n        result_dict = {}\n        if options.get(CallbacksOptions.RESP3.value):\n            for elem, data in response.items():\n                if data[1] is not None:\n                    attribs_dict = json.loads(data[1])\n                else:\n                    attribs_dict = None\n                result_dict[elem] = {\"score\": data[0], \"attributes\": attribs_dict}\n        else:\n            for elem, score, attribs in zip(it, it, it):\n                if attribs is not None:\n                    attribs_dict = json.loads(attribs)\n                else:\n                    attribs_dict = None\n\n                result_dict[elem] = {\"score\": float(score), \"attributes\": attribs_dict}\n        return result_dict\n    else:\n        # return the list of elements for each level\n        # list of lists\n        return response\n"
  },
  {
    "path": "redis/connection.py",
    "content": "import copy\nimport os\nimport socket\nimport sys\nimport threading\nimport time\nimport weakref\nfrom abc import ABC, abstractmethod\nfrom itertools import chain\nfrom queue import Empty, Full, LifoQueue\nfrom typing import (\n    Any,\n    Callable,\n    Dict,\n    Iterable,\n    List,\n    Literal,\n    Optional,\n    Type,\n    TypeVar,\n    Union,\n)\nfrom urllib.parse import parse_qs, unquote, urlparse\n\nfrom redis.cache import (\n    CacheEntry,\n    CacheEntryStatus,\n    CacheFactory,\n    CacheFactoryInterface,\n    CacheInterface,\n    CacheKey,\n    CacheProxy,\n)\n\nfrom ._parsers import Encoder, _HiredisParser, _RESP2Parser, _RESP3Parser\nfrom ._parsers.socket import SENTINEL\nfrom .auth.token import TokenInterface\nfrom .backoff import NoBackoff\nfrom .credentials import CredentialProvider, UsernamePasswordCredentialProvider\nfrom .driver_info import DriverInfo, resolve_driver_info\nfrom .event import AfterConnectionReleasedEvent, EventDispatcher\nfrom .exceptions import (\n    AuthenticationError,\n    AuthenticationWrongNumberOfArgsError,\n    ChildDeadlockedError,\n    ConnectionError,\n    DataError,\n    MaxConnectionsError,\n    RedisError,\n    ResponseError,\n    TimeoutError,\n)\nfrom .maint_notifications import (\n    MaintenanceState,\n    MaintNotificationsConfig,\n    MaintNotificationsConnectionHandler,\n    MaintNotificationsPoolHandler,\n    OSSMaintNotificationsHandler,\n)\nfrom .observability.attributes import (\n    DB_CLIENT_CONNECTION_POOL_NAME,\n    DB_CLIENT_CONNECTION_STATE,\n    AttributeBuilder,\n    ConnectionState,\n    CSCReason,\n    CSCResult,\n    get_pool_name,\n)\nfrom .observability.metrics import CloseReason\nfrom .observability.recorder import (\n    init_csc_items,\n    record_connection_closed,\n    record_connection_count,\n    record_connection_create_time,\n    record_connection_wait_time,\n    record_csc_eviction,\n    record_csc_network_saved,\n    record_csc_request,\n    record_error_count,\n    register_csc_items_callback,\n)\nfrom .retry import Retry\nfrom .utils import (\n    CRYPTOGRAPHY_AVAILABLE,\n    HIREDIS_AVAILABLE,\n    SSL_AVAILABLE,\n    check_protocol_version,\n    compare_versions,\n    deprecated_args,\n    ensure_string,\n    format_error_message,\n    str_if_bytes,\n)\n\nif SSL_AVAILABLE:\n    import ssl\n    from ssl import VerifyFlags\nelse:\n    ssl = None\n    VerifyFlags = None\n\nif HIREDIS_AVAILABLE:\n    import hiredis\n\nSYM_STAR = b\"*\"\nSYM_DOLLAR = b\"$\"\nSYM_CRLF = b\"\\r\\n\"\nSYM_EMPTY = b\"\"\n\nDEFAULT_RESP_VERSION = 2\n\nDefaultParser: Type[Union[_RESP2Parser, _RESP3Parser, _HiredisParser]]\nif HIREDIS_AVAILABLE:\n    DefaultParser = _HiredisParser\nelse:\n    DefaultParser = _RESP2Parser\n\n\nclass HiredisRespSerializer:\n    def pack(self, *args: List):\n        \"\"\"Pack a series of arguments into the Redis protocol\"\"\"\n        output = []\n\n        if isinstance(args[0], str):\n            args = tuple(args[0].encode().split()) + args[1:]\n        elif b\" \" in args[0]:\n            args = tuple(args[0].split()) + args[1:]\n        try:\n            output.append(hiredis.pack_command(args))\n        except TypeError:\n            _, value, traceback = sys.exc_info()\n            raise DataError(value).with_traceback(traceback)\n\n        return output\n\n\nclass PythonRespSerializer:\n    def __init__(self, buffer_cutoff, encode) -> None:\n        self._buffer_cutoff = buffer_cutoff\n        self.encode = encode\n\n    def pack(self, *args):\n        \"\"\"Pack a series of arguments into the Redis protocol\"\"\"\n        output = []\n        # the client might have included 1 or more literal arguments in\n        # the command name, e.g., 'CONFIG GET'. The Redis server expects these\n        # arguments to be sent separately, so split the first argument\n        # manually. These arguments should be bytestrings so that they are\n        # not encoded.\n        if isinstance(args[0], str):\n            args = tuple(args[0].encode().split()) + args[1:]\n        elif b\" \" in args[0]:\n            args = tuple(args[0].split()) + args[1:]\n\n        buff = SYM_EMPTY.join((SYM_STAR, str(len(args)).encode(), SYM_CRLF))\n\n        buffer_cutoff = self._buffer_cutoff\n        for arg in map(self.encode, args):\n            # to avoid large string mallocs, chunk the command into the\n            # output list if we're sending large values or memoryviews\n            arg_length = len(arg)\n            if (\n                len(buff) > buffer_cutoff\n                or arg_length > buffer_cutoff\n                or isinstance(arg, memoryview)\n            ):\n                buff = SYM_EMPTY.join(\n                    (buff, SYM_DOLLAR, str(arg_length).encode(), SYM_CRLF)\n                )\n                output.append(buff)\n                output.append(arg)\n                buff = SYM_CRLF\n            else:\n                buff = SYM_EMPTY.join(\n                    (\n                        buff,\n                        SYM_DOLLAR,\n                        str(arg_length).encode(),\n                        SYM_CRLF,\n                        arg,\n                        SYM_CRLF,\n                    )\n                )\n        output.append(buff)\n        return output\n\n\nclass ConnectionInterface:\n    @abstractmethod\n    def repr_pieces(self):\n        pass\n\n    @abstractmethod\n    def register_connect_callback(self, callback):\n        pass\n\n    @abstractmethod\n    def deregister_connect_callback(self, callback):\n        pass\n\n    @abstractmethod\n    def set_parser(self, parser_class):\n        pass\n\n    @abstractmethod\n    def get_protocol(self):\n        pass\n\n    @abstractmethod\n    def connect(self):\n        pass\n\n    @abstractmethod\n    def on_connect(self):\n        pass\n\n    @abstractmethod\n    def disconnect(self, *args, **kwargs):\n        pass\n\n    @abstractmethod\n    def check_health(self):\n        pass\n\n    @abstractmethod\n    def send_packed_command(self, command, check_health=True):\n        pass\n\n    @abstractmethod\n    def send_command(self, *args, **kwargs):\n        pass\n\n    @abstractmethod\n    def can_read(self, timeout=0):\n        pass\n\n    @abstractmethod\n    def read_response(\n        self,\n        disable_decoding=False,\n        *,\n        timeout: Union[float, object] = SENTINEL,\n        disconnect_on_error=True,\n        push_request=False,\n    ):\n        pass\n\n    @abstractmethod\n    def pack_command(self, *args):\n        pass\n\n    @abstractmethod\n    def pack_commands(self, commands):\n        pass\n\n    @property\n    @abstractmethod\n    def handshake_metadata(self) -> Union[Dict[bytes, bytes], Dict[str, str]]:\n        pass\n\n    @abstractmethod\n    def set_re_auth_token(self, token: TokenInterface):\n        pass\n\n    @abstractmethod\n    def re_auth(self):\n        pass\n\n    @abstractmethod\n    def mark_for_reconnect(self):\n        \"\"\"\n        Mark the connection to be reconnected on the next command.\n        This is useful when a connection is moved to a different node.\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def should_reconnect(self):\n        \"\"\"\n        Returns True if the connection should be reconnected.\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def reset_should_reconnect(self):\n        \"\"\"\n        Reset the internal flag to False.\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def extract_connection_details(self) -> str:\n        pass\n\n\nclass MaintNotificationsAbstractConnection:\n    \"\"\"\n    Abstract class for handling maintenance notifications logic.\n    This class is expected to be used as base class together with ConnectionInterface.\n\n    This class is intended to be used with multiple inheritance!\n\n    All logic related to maintenance notifications is encapsulated in this class.\n    \"\"\"\n\n    def __init__(\n        self,\n        maint_notifications_config: Optional[MaintNotificationsConfig],\n        maint_notifications_pool_handler: Optional[\n            MaintNotificationsPoolHandler\n        ] = None,\n        maintenance_state: \"MaintenanceState\" = MaintenanceState.NONE,\n        maintenance_notification_hash: Optional[int] = None,\n        orig_host_address: Optional[str] = None,\n        orig_socket_timeout: Optional[float] = None,\n        orig_socket_connect_timeout: Optional[float] = None,\n        oss_cluster_maint_notifications_handler: Optional[\n            OSSMaintNotificationsHandler\n        ] = None,\n        parser: Optional[Union[_HiredisParser, _RESP3Parser]] = None,\n        event_dispatcher: Optional[EventDispatcher] = None,\n    ):\n        \"\"\"\n        Initialize the maintenance notifications for the connection.\n\n        Args:\n            maint_notifications_config (MaintNotificationsConfig): The configuration for maintenance notifications.\n            maint_notifications_pool_handler (Optional[MaintNotificationsPoolHandler]): The pool handler for maintenance notifications.\n            maintenance_state (MaintenanceState): The current maintenance state of the connection.\n            maintenance_notification_hash (Optional[int]): The current maintenance notification hash of the connection.\n            orig_host_address (Optional[str]): The original host address of the connection.\n            orig_socket_timeout (Optional[float]): The original socket timeout of the connection.\n            orig_socket_connect_timeout (Optional[float]): The original socket connect timeout of the connection.\n            oss_cluster_maint_notifications_handler (Optional[OSSMaintNotificationsHandler]): The OSS cluster handler for maintenance notifications.\n            parser (Optional[Union[_HiredisParser, _RESP3Parser]]): The parser to use for maintenance notifications.\n                    If not provided, the parser from the connection is used.\n                    This is useful when the parser is created after this object.\n        \"\"\"\n        self.maint_notifications_config = maint_notifications_config\n        self.maintenance_state = maintenance_state\n        self.maintenance_notification_hash = maintenance_notification_hash\n\n        if event_dispatcher is not None:\n            self.event_dispatcher = event_dispatcher\n        else:\n            self.event_dispatcher = EventDispatcher()\n\n        self._configure_maintenance_notifications(\n            maint_notifications_pool_handler,\n            orig_host_address,\n            orig_socket_timeout,\n            orig_socket_connect_timeout,\n            oss_cluster_maint_notifications_handler,\n            parser,\n        )\n        self._processed_start_maint_notifications = set()\n        self._skipped_end_maint_notifications = set()\n\n    @abstractmethod\n    def _get_parser(self) -> Union[_HiredisParser, _RESP3Parser]:\n        pass\n\n    @abstractmethod\n    def _get_socket(self) -> Optional[socket.socket]:\n        pass\n\n    @abstractmethod\n    def get_protocol(self) -> Union[int, str]:\n        \"\"\"\n        Returns:\n            The RESP protocol version, or ``None`` if the protocol is not specified,\n            in which case the server default will be used.\n        \"\"\"\n        pass\n\n    @property\n    @abstractmethod\n    def host(self) -> str:\n        pass\n\n    @host.setter\n    @abstractmethod\n    def host(self, value: str):\n        pass\n\n    @property\n    @abstractmethod\n    def socket_timeout(self) -> Optional[Union[float, int]]:\n        pass\n\n    @socket_timeout.setter\n    @abstractmethod\n    def socket_timeout(self, value: Optional[Union[float, int]]):\n        pass\n\n    @property\n    @abstractmethod\n    def socket_connect_timeout(self) -> Optional[Union[float, int]]:\n        pass\n\n    @socket_connect_timeout.setter\n    @abstractmethod\n    def socket_connect_timeout(self, value: Optional[Union[float, int]]):\n        pass\n\n    @abstractmethod\n    def send_command(self, *args, **kwargs):\n        pass\n\n    @abstractmethod\n    def read_response(\n        self,\n        disable_decoding=False,\n        *,\n        timeout: Union[float, object] = SENTINEL,\n        disconnect_on_error=True,\n        push_request=False,\n    ):\n        pass\n\n    @abstractmethod\n    def disconnect(self, *args, **kwargs):\n        pass\n\n    def _configure_maintenance_notifications(\n        self,\n        maint_notifications_pool_handler: Optional[\n            MaintNotificationsPoolHandler\n        ] = None,\n        orig_host_address=None,\n        orig_socket_timeout=None,\n        orig_socket_connect_timeout=None,\n        oss_cluster_maint_notifications_handler: Optional[\n            OSSMaintNotificationsHandler\n        ] = None,\n        parser: Optional[Union[_HiredisParser, _RESP3Parser]] = None,\n    ):\n        \"\"\"\n        Enable maintenance notifications by setting up\n        handlers and storing original connection parameters.\n\n        Should be used ONLY with parsers that support push notifications.\n        \"\"\"\n        if (\n            not self.maint_notifications_config\n            or not self.maint_notifications_config.enabled\n        ):\n            self._maint_notifications_pool_handler = None\n            self._maint_notifications_connection_handler = None\n            self._oss_cluster_maint_notifications_handler = None\n            return\n\n        if not parser:\n            raise RedisError(\n                \"To configure maintenance notifications, a parser must be provided!\"\n            )\n\n        if not isinstance(parser, _HiredisParser) and not isinstance(\n            parser, _RESP3Parser\n        ):\n            raise RedisError(\n                \"Maintenance notifications are only supported with hiredis and RESP3 parsers!\"\n            )\n\n        if maint_notifications_pool_handler:\n            # Extract a reference to a new pool handler that copies all properties\n            # of the original one and has a different connection reference\n            # This is needed because when we attach the handler to the parser\n            # we need to make sure that the handler has a reference to the\n            # connection that the parser is attached to.\n            self._maint_notifications_pool_handler = (\n                maint_notifications_pool_handler.get_handler_for_connection()\n            )\n            self._maint_notifications_pool_handler.set_connection(self)\n        else:\n            self._maint_notifications_pool_handler = None\n\n        self._maint_notifications_connection_handler = (\n            MaintNotificationsConnectionHandler(self, self.maint_notifications_config)\n        )\n\n        if oss_cluster_maint_notifications_handler:\n            self._oss_cluster_maint_notifications_handler = (\n                oss_cluster_maint_notifications_handler\n            )\n        else:\n            self._oss_cluster_maint_notifications_handler = None\n\n        # Set up OSS cluster handler to parser if available\n        if self._oss_cluster_maint_notifications_handler:\n            parser.set_oss_cluster_maint_push_handler(\n                self._oss_cluster_maint_notifications_handler.handle_notification\n            )\n\n        # Set up pool handler to parser if available\n        if self._maint_notifications_pool_handler:\n            parser.set_node_moving_push_handler(\n                self._maint_notifications_pool_handler.handle_notification\n            )\n\n        # Set up connection handler\n        parser.set_maintenance_push_handler(\n            self._maint_notifications_connection_handler.handle_notification\n        )\n\n        # Store original connection parameters\n        self.orig_host_address = orig_host_address if orig_host_address else self.host\n        self.orig_socket_timeout = (\n            orig_socket_timeout if orig_socket_timeout else self.socket_timeout\n        )\n        self.orig_socket_connect_timeout = (\n            orig_socket_connect_timeout\n            if orig_socket_connect_timeout\n            else self.socket_connect_timeout\n        )\n\n    def set_maint_notifications_pool_handler_for_connection(\n        self, maint_notifications_pool_handler: MaintNotificationsPoolHandler\n    ):\n        # Deep copy the pool handler to avoid sharing the same pool handler\n        # between multiple connections, because otherwise each connection will override\n        # the connection reference and the pool handler will only hold a reference\n        # to the last connection that was set.\n        maint_notifications_pool_handler_copy = (\n            maint_notifications_pool_handler.get_handler_for_connection()\n        )\n\n        maint_notifications_pool_handler_copy.set_connection(self)\n        self._get_parser().set_node_moving_push_handler(\n            maint_notifications_pool_handler_copy.handle_notification\n        )\n\n        self._maint_notifications_pool_handler = maint_notifications_pool_handler_copy\n\n        # Update maintenance notification connection handler if it doesn't exist\n        if not self._maint_notifications_connection_handler:\n            self._maint_notifications_connection_handler = (\n                MaintNotificationsConnectionHandler(\n                    self, maint_notifications_pool_handler.config\n                )\n            )\n            self._get_parser().set_maintenance_push_handler(\n                self._maint_notifications_connection_handler.handle_notification\n            )\n        else:\n            self._maint_notifications_connection_handler.config = (\n                maint_notifications_pool_handler.config\n            )\n\n    def set_maint_notifications_cluster_handler_for_connection(\n        self, oss_cluster_maint_notifications_handler: OSSMaintNotificationsHandler\n    ):\n        self._get_parser().set_oss_cluster_maint_push_handler(\n            oss_cluster_maint_notifications_handler.handle_notification\n        )\n\n        self._oss_cluster_maint_notifications_handler = (\n            oss_cluster_maint_notifications_handler\n        )\n\n        # Update maintenance notification connection handler if it doesn't exist\n        if not self._maint_notifications_connection_handler:\n            self._maint_notifications_connection_handler = (\n                MaintNotificationsConnectionHandler(\n                    self, oss_cluster_maint_notifications_handler.config\n                )\n            )\n            self._get_parser().set_maintenance_push_handler(\n                self._maint_notifications_connection_handler.handle_notification\n            )\n        else:\n            self._maint_notifications_connection_handler.config = (\n                oss_cluster_maint_notifications_handler.config\n            )\n\n    def activate_maint_notifications_handling_if_enabled(self, check_health=True):\n        # Send maintenance notifications handshake if RESP3 is active\n        # and maintenance notifications are enabled\n        # and we have a host to determine the endpoint type from\n        # When the maint_notifications_config enabled mode is \"auto\",\n        # we just log a warning if the handshake fails\n        # When the mode is enabled=True, we raise an exception in case of failure\n        if (\n            self.get_protocol() not in [2, \"2\"]\n            and self.maint_notifications_config\n            and self.maint_notifications_config.enabled\n            and self._maint_notifications_connection_handler\n            and hasattr(self, \"host\")\n        ):\n            self._enable_maintenance_notifications(\n                maint_notifications_config=self.maint_notifications_config,\n                check_health=check_health,\n            )\n\n    def _enable_maintenance_notifications(\n        self, maint_notifications_config: MaintNotificationsConfig, check_health=True\n    ):\n        try:\n            host = getattr(self, \"host\", None)\n            if host is None:\n                raise ValueError(\n                    \"Cannot enable maintenance notifications for connection\"\n                    \" object that doesn't have a host attribute.\"\n                )\n            else:\n                endpoint_type = maint_notifications_config.get_endpoint_type(host, self)\n                self.send_command(\n                    \"CLIENT\",\n                    \"MAINT_NOTIFICATIONS\",\n                    \"ON\",\n                    \"moving-endpoint-type\",\n                    endpoint_type.value,\n                    check_health=check_health,\n                )\n                response = self.read_response()\n                if not response or str_if_bytes(response) != \"OK\":\n                    raise ResponseError(\n                        \"The server doesn't support maintenance notifications\"\n                    )\n        except Exception as e:\n            if (\n                isinstance(e, ResponseError)\n                and maint_notifications_config.enabled == \"auto\"\n            ):\n                # Log warning but don't fail the connection\n                import logging\n\n                logger = logging.getLogger(__name__)\n                logger.debug(f\"Failed to enable maintenance notifications: {e}\")\n            else:\n                raise\n\n    def get_resolved_ip(self) -> Optional[str]:\n        \"\"\"\n        Extract the resolved IP address from an\n        established connection or resolve it from the host.\n\n        First tries to get the actual IP from the socket (most accurate),\n        then falls back to DNS resolution if needed.\n\n        Args:\n            connection: The connection object to extract the IP from\n\n        Returns:\n            str: The resolved IP address, or None if it cannot be determined\n        \"\"\"\n\n        # Method 1: Try to get the actual IP from the established socket connection\n        # This is most accurate as it shows the exact IP being used\n        try:\n            conn_socket = self._get_socket()\n            if conn_socket is not None:\n                peer_addr = conn_socket.getpeername()\n                if peer_addr and len(peer_addr) >= 1:\n                    # For TCP sockets, peer_addr is typically (host, port) tuple\n                    # Return just the host part\n                    return peer_addr[0]\n        except (AttributeError, OSError):\n            # Socket might not be connected or getpeername() might fail\n            pass\n\n        # Method 2: Fallback to DNS resolution of the host\n        # This is less accurate but works when socket is not available\n        try:\n            host = getattr(self, \"host\", \"localhost\")\n            port = getattr(self, \"port\", 6379)\n            if host:\n                # Use getaddrinfo to resolve the hostname to IP\n                # This mimics what the connection would do during _connect()\n                addr_info = socket.getaddrinfo(\n                    host, port, socket.AF_UNSPEC, socket.SOCK_STREAM\n                )\n                if addr_info:\n                    # Return the IP from the first result\n                    # addr_info[0] is (family, socktype, proto, canonname, sockaddr)\n                    # sockaddr[0] is the IP address\n                    return str(addr_info[0][4][0])\n        except (AttributeError, OSError, socket.gaierror):\n            # DNS resolution might fail\n            pass\n\n        return None\n\n    @property\n    def maintenance_state(self) -> MaintenanceState:\n        return self._maintenance_state\n\n    @maintenance_state.setter\n    def maintenance_state(self, state: \"MaintenanceState\"):\n        self._maintenance_state = state\n\n    def add_maint_start_notification(self, id: int):\n        self._processed_start_maint_notifications.add(id)\n\n    def get_processed_start_notifications(self) -> set:\n        return self._processed_start_maint_notifications\n\n    def add_skipped_end_notification(self, id: int):\n        self._skipped_end_maint_notifications.add(id)\n\n    def get_skipped_end_notifications(self) -> set:\n        return self._skipped_end_maint_notifications\n\n    def reset_received_notifications(self):\n        self._processed_start_maint_notifications.clear()\n        self._skipped_end_maint_notifications.clear()\n\n    def getpeername(self):\n        \"\"\"\n        Returns the peer name of the connection.\n        \"\"\"\n        conn_socket = self._get_socket()\n        if conn_socket:\n            return conn_socket.getpeername()[0]\n        return None\n\n    def update_current_socket_timeout(self, relaxed_timeout: Optional[float] = None):\n        conn_socket = self._get_socket()\n        if conn_socket:\n            timeout = relaxed_timeout if relaxed_timeout != -1 else self.socket_timeout\n            # if the current timeout is 0 it means we are in the middle of a can_read call\n            # in this case we don't want to change the timeout because the operation\n            # is non-blocking and should return immediately\n            # Changing the state from non-blocking to blocking in the middle of a read operation\n            # will lead to a deadlock\n            if conn_socket.gettimeout() != 0:\n                conn_socket.settimeout(timeout)\n            self.update_parser_timeout(timeout)\n\n    def update_parser_timeout(self, timeout: Optional[float] = None):\n        parser = self._get_parser()\n        if parser and parser._buffer:\n            if isinstance(parser, _RESP3Parser) and timeout:\n                parser._buffer.socket_timeout = timeout\n            elif isinstance(parser, _HiredisParser):\n                parser._socket_timeout = timeout\n\n    def set_tmp_settings(\n        self,\n        tmp_host_address: Optional[Union[str, object]] = SENTINEL,\n        tmp_relaxed_timeout: Optional[float] = None,\n    ):\n        \"\"\"\n        The value of SENTINEL is used to indicate that the property should not be updated.\n        \"\"\"\n        if tmp_host_address and tmp_host_address != SENTINEL:\n            self.host = str(tmp_host_address)\n        if tmp_relaxed_timeout != -1:\n            self.socket_timeout = tmp_relaxed_timeout\n            self.socket_connect_timeout = tmp_relaxed_timeout\n\n    def reset_tmp_settings(\n        self,\n        reset_host_address: bool = False,\n        reset_relaxed_timeout: bool = False,\n    ):\n        if reset_host_address:\n            self.host = self.orig_host_address\n        if reset_relaxed_timeout:\n            self.socket_timeout = self.orig_socket_timeout\n            self.socket_connect_timeout = self.orig_socket_connect_timeout\n\n\nclass AbstractConnection(MaintNotificationsAbstractConnection, ConnectionInterface):\n    \"Manages communication to and from a Redis server\"\n\n    @deprecated_args(\n        args_to_warn=[\"lib_name\", \"lib_version\"],\n        reason=\"Use 'driver_info' parameter instead. \"\n        \"lib_name and lib_version will be removed in a future version.\",\n    )\n    def __init__(\n        self,\n        db: int = 0,\n        password: Optional[str] = None,\n        socket_timeout: Optional[float] = None,\n        socket_connect_timeout: Optional[float] = None,\n        retry_on_timeout: bool = False,\n        retry_on_error: Union[Iterable[Type[Exception]], object] = SENTINEL,\n        encoding: str = \"utf-8\",\n        encoding_errors: str = \"strict\",\n        decode_responses: bool = False,\n        parser_class=DefaultParser,\n        socket_read_size: int = 65536,\n        health_check_interval: int = 0,\n        client_name: Optional[str] = None,\n        lib_name: Optional[str] = None,\n        lib_version: Optional[str] = None,\n        driver_info: Optional[DriverInfo] = None,\n        username: Optional[str] = None,\n        retry: Union[Any, None] = None,\n        redis_connect_func: Optional[Callable[[], None]] = None,\n        credential_provider: Optional[CredentialProvider] = None,\n        protocol: Optional[int] = 2,\n        command_packer: Optional[Callable[[], None]] = None,\n        event_dispatcher: Optional[EventDispatcher] = None,\n        maint_notifications_config: Optional[MaintNotificationsConfig] = None,\n        maint_notifications_pool_handler: Optional[\n            MaintNotificationsPoolHandler\n        ] = None,\n        maintenance_state: \"MaintenanceState\" = MaintenanceState.NONE,\n        maintenance_notification_hash: Optional[int] = None,\n        orig_host_address: Optional[str] = None,\n        orig_socket_timeout: Optional[float] = None,\n        orig_socket_connect_timeout: Optional[float] = None,\n        oss_cluster_maint_notifications_handler: Optional[\n            OSSMaintNotificationsHandler\n        ] = None,\n    ):\n        \"\"\"\n        Initialize a new Connection.\n\n        To specify a retry policy for specific errors, first set\n        `retry_on_error` to a list of the error/s to retry on, then set\n        `retry` to a valid `Retry` object.\n        To retry on TimeoutError, `retry_on_timeout` can also be set to `True`.\n\n        Parameters\n        ----------\n        driver_info : DriverInfo, optional\n            Driver metadata for CLIENT SETINFO. If provided, lib_name and lib_version\n            are ignored. If not provided, a DriverInfo will be created from lib_name\n            and lib_version (or defaults if those are also None).\n        lib_name : str, optional\n            **Deprecated.** Use driver_info instead. Library name for CLIENT SETINFO.\n        lib_version : str, optional\n            **Deprecated.** Use driver_info instead. Library version for CLIENT SETINFO.\n        \"\"\"\n        if (username or password) and credential_provider is not None:\n            raise DataError(\n                \"'username' and 'password' cannot be passed along with 'credential_\"\n                \"provider'. Please provide only one of the following arguments: \\n\"\n                \"1. 'password' and (optional) 'username'\\n\"\n                \"2. 'credential_provider'\"\n            )\n        if event_dispatcher is None:\n            self._event_dispatcher = EventDispatcher()\n        else:\n            self._event_dispatcher = event_dispatcher\n        self.pid = os.getpid()\n        self.db = db\n        self.client_name = client_name\n\n        # Handle driver_info: if provided, use it; otherwise create from lib_name/lib_version\n        self.driver_info = resolve_driver_info(driver_info, lib_name, lib_version)\n\n        self.credential_provider = credential_provider\n        self.password = password\n        self.username = username\n        self._socket_timeout = socket_timeout\n        if socket_connect_timeout is None:\n            socket_connect_timeout = socket_timeout\n        self._socket_connect_timeout = socket_connect_timeout\n        self.retry_on_timeout = retry_on_timeout\n        if retry_on_error is SENTINEL:\n            retry_on_errors_list = []\n        else:\n            retry_on_errors_list = list(retry_on_error)\n        if retry_on_timeout:\n            # Add TimeoutError to the errors list to retry on\n            retry_on_errors_list.append(TimeoutError)\n        self.retry_on_error = retry_on_errors_list\n        if retry or self.retry_on_error:\n            if retry is None:\n                self.retry = Retry(NoBackoff(), 1)\n            else:\n                # deep-copy the Retry object as it is mutable\n                self.retry = copy.deepcopy(retry)\n            if self.retry_on_error:\n                # Update the retry's supported errors with the specified errors\n                self.retry.update_supported_errors(self.retry_on_error)\n        else:\n            self.retry = Retry(NoBackoff(), 0)\n        self.health_check_interval = health_check_interval\n        self.next_health_check = 0\n        self.redis_connect_func = redis_connect_func\n        self.encoder = Encoder(encoding, encoding_errors, decode_responses)\n        self.handshake_metadata = None\n        self._sock = None\n        self._socket_read_size = socket_read_size\n        self._connect_callbacks = []\n        self._buffer_cutoff = 6000\n        self._re_auth_token: Optional[TokenInterface] = None\n        try:\n            p = int(protocol)\n        except TypeError:\n            p = DEFAULT_RESP_VERSION\n        except ValueError:\n            raise ConnectionError(\"protocol must be an integer\")\n        else:\n            if p < 2 or p > 3:\n                raise ConnectionError(\"protocol must be either 2 or 3\")\n        self.protocol = p\n        if self.protocol == 3 and parser_class == _RESP2Parser:\n            # If the protocol is 3 but the parser is RESP2, change it to RESP3\n            # This is needed because the parser might be set before the protocol\n            # or might be provided as a kwarg to the constructor\n            # We need to react on discrepancy only for RESP2 and RESP3\n            # as hiredis supports both\n            parser_class = _RESP3Parser\n        self.set_parser(parser_class)\n\n        self._command_packer = self._construct_command_packer(command_packer)\n        self._should_reconnect = False\n\n        # Set up maintenance notifications\n        MaintNotificationsAbstractConnection.__init__(\n            self,\n            maint_notifications_config,\n            maint_notifications_pool_handler,\n            maintenance_state,\n            maintenance_notification_hash,\n            orig_host_address,\n            orig_socket_timeout,\n            orig_socket_connect_timeout,\n            oss_cluster_maint_notifications_handler,\n            self._parser,\n            event_dispatcher=self._event_dispatcher,\n        )\n\n    def __repr__(self):\n        repr_args = \",\".join([f\"{k}={v}\" for k, v in self.repr_pieces()])\n        return f\"<{self.__class__.__module__}.{self.__class__.__name__}({repr_args})>\"\n\n    @abstractmethod\n    def repr_pieces(self):\n        pass\n\n    def __del__(self):\n        try:\n            self.disconnect()\n        except Exception:\n            pass\n\n    def _construct_command_packer(self, packer):\n        if packer is not None:\n            return packer\n        elif HIREDIS_AVAILABLE:\n            return HiredisRespSerializer()\n        else:\n            return PythonRespSerializer(self._buffer_cutoff, self.encoder.encode)\n\n    def register_connect_callback(self, callback):\n        \"\"\"\n        Register a callback to be called when the connection is established either\n        initially or reconnected.  This allows listeners to issue commands that\n        are ephemeral to the connection, for example pub/sub subscription or\n        key tracking.  The callback must be a _method_ and will be kept as\n        a weak reference.\n        \"\"\"\n        wm = weakref.WeakMethod(callback)\n        if wm not in self._connect_callbacks:\n            self._connect_callbacks.append(wm)\n\n    def deregister_connect_callback(self, callback):\n        \"\"\"\n        De-register a previously registered callback.  It will no-longer receive\n        notifications on connection events.  Calling this is not required when the\n        listener goes away, since the callbacks are kept as weak methods.\n        \"\"\"\n        try:\n            self._connect_callbacks.remove(weakref.WeakMethod(callback))\n        except ValueError:\n            pass\n\n    def set_parser(self, parser_class):\n        \"\"\"\n        Creates a new instance of parser_class with socket size:\n        _socket_read_size and assigns it to the parser for the connection\n        :param parser_class: The required parser class\n        \"\"\"\n        self._parser = parser_class(socket_read_size=self._socket_read_size)\n\n    def _get_parser(self) -> Union[_HiredisParser, _RESP3Parser, _RESP2Parser]:\n        return self._parser\n\n    def connect(self):\n        \"Connects to the Redis server if not already connected\"\n        # try once the socket connect with the handshake, retry the whole\n        # connect/handshake flow based on retry policy\n        self.retry.call_with_retry(\n            lambda: self.connect_check_health(\n                check_health=True, retry_socket_connect=False\n            ),\n            lambda error: self.disconnect(error),\n        )\n\n    def connect_check_health(\n        self, check_health: bool = True, retry_socket_connect: bool = True\n    ):\n        if self._sock:\n            return\n        # Track actual retry attempts for error reporting\n        actual_retry_attempts = [0]\n\n        def failure_callback(error, failure_count):\n            actual_retry_attempts[0] = failure_count\n            self.disconnect(error=error, failure_count=failure_count)\n\n        try:\n            if retry_socket_connect:\n                sock = self.retry.call_with_retry(\n                    self._connect,\n                    failure_callback,\n                    with_failure_count=True,\n                )\n            else:\n                sock = self._connect()\n        except socket.timeout:\n            e = TimeoutError(\"Timeout connecting to server\")\n            record_error_count(\n                server_address=self.host,\n                server_port=self.port,\n                network_peer_address=self.host,\n                network_peer_port=self.port,\n                error_type=e,\n                retry_attempts=actual_retry_attempts[0],\n            )\n            raise e\n        except OSError as e:\n            e = ConnectionError(self._error_message(e))\n            record_error_count(\n                server_address=getattr(self, \"host\", None),\n                server_port=getattr(self, \"port\", None),\n                network_peer_address=getattr(self, \"host\", None),\n                network_peer_port=getattr(self, \"port\", None),\n                error_type=e,\n                retry_attempts=actual_retry_attempts[0],\n            )\n            raise e\n\n        self._sock = sock\n        try:\n            if self.redis_connect_func is None:\n                # Use the default on_connect function\n                self.on_connect_check_health(check_health=check_health)\n            else:\n                # Use the passed function redis_connect_func\n                self.redis_connect_func(self)\n        except RedisError:\n            # clean up after any error in on_connect\n            self.disconnect()\n            raise\n\n        # run any user callbacks. right now the only internal callback\n        # is for pubsub channel/pattern resubscription\n        # first, remove any dead weakrefs\n        self._connect_callbacks = [ref for ref in self._connect_callbacks if ref()]\n        for ref in self._connect_callbacks:\n            callback = ref()\n            if callback:\n                callback(self)\n\n    @abstractmethod\n    def _connect(self):\n        pass\n\n    @abstractmethod\n    def _host_error(self):\n        pass\n\n    def _error_message(self, exception):\n        return format_error_message(self._host_error(), exception)\n\n    def on_connect(self):\n        self.on_connect_check_health(check_health=True)\n\n    def on_connect_check_health(self, check_health: bool = True):\n        \"Initialize the connection, authenticate and select a database\"\n        self._parser.on_connect(self)\n        parser = self._parser\n\n        auth_args = None\n        # if credential provider or username and/or password are set, authenticate\n        if self.credential_provider or (self.username or self.password):\n            cred_provider = (\n                self.credential_provider\n                or UsernamePasswordCredentialProvider(self.username, self.password)\n            )\n            auth_args = cred_provider.get_credentials()\n\n        # if resp version is specified and we have auth args,\n        # we need to send them via HELLO\n        if auth_args and self.protocol not in [2, \"2\"]:\n            if isinstance(self._parser, _RESP2Parser):\n                self.set_parser(_RESP3Parser)\n                # update cluster exception classes\n                self._parser.EXCEPTION_CLASSES = parser.EXCEPTION_CLASSES\n                self._parser.on_connect(self)\n            if len(auth_args) == 1:\n                auth_args = [\"default\", auth_args[0]]\n            # avoid checking health here -- PING will fail if we try\n            # to check the health prior to the AUTH\n            self.send_command(\n                \"HELLO\", self.protocol, \"AUTH\", *auth_args, check_health=False\n            )\n            self.handshake_metadata = self.read_response()\n            # if response.get(b\"proto\") != self.protocol and response.get(\n            #     \"proto\"\n            # ) != self.protocol:\n            #     raise ConnectionError(\"Invalid RESP version\")\n        elif auth_args:\n            # avoid checking health here -- PING will fail if we try\n            # to check the health prior to the AUTH\n            self.send_command(\"AUTH\", *auth_args, check_health=False)\n\n            try:\n                auth_response = self.read_response()\n            except AuthenticationWrongNumberOfArgsError:\n                # a username and password were specified but the Redis\n                # server seems to be < 6.0.0 which expects a single password\n                # arg. retry auth with just the password.\n                # https://github.com/andymccurdy/redis-py/issues/1274\n                self.send_command(\"AUTH\", auth_args[-1], check_health=False)\n                auth_response = self.read_response()\n\n            if str_if_bytes(auth_response) != \"OK\":\n                raise AuthenticationError(\"Invalid Username or Password\")\n\n        # if resp version is specified, switch to it\n        elif self.protocol not in [2, \"2\"]:\n            if isinstance(self._parser, _RESP2Parser):\n                self.set_parser(_RESP3Parser)\n                # update cluster exception classes\n                self._parser.EXCEPTION_CLASSES = parser.EXCEPTION_CLASSES\n                self._parser.on_connect(self)\n            self.send_command(\"HELLO\", self.protocol, check_health=check_health)\n            self.handshake_metadata = self.read_response()\n            if (\n                self.handshake_metadata.get(b\"proto\") != self.protocol\n                and self.handshake_metadata.get(\"proto\") != self.protocol\n            ):\n                raise ConnectionError(\"Invalid RESP version\")\n\n        # Activate maintenance notifications for this connection\n        # if enabled in the configuration\n        # This is a no-op if maintenance notifications are not enabled\n        self.activate_maint_notifications_handling_if_enabled(check_health=check_health)\n\n        # if a client_name is given, set it\n        if self.client_name:\n            self.send_command(\n                \"CLIENT\",\n                \"SETNAME\",\n                self.client_name,\n                check_health=check_health,\n            )\n            if str_if_bytes(self.read_response()) != \"OK\":\n                raise ConnectionError(\"Error setting client name\")\n\n        # Set the library name and version from driver_info\n        try:\n            if self.driver_info and self.driver_info.formatted_name:\n                self.send_command(\n                    \"CLIENT\",\n                    \"SETINFO\",\n                    \"LIB-NAME\",\n                    self.driver_info.formatted_name,\n                    check_health=check_health,\n                )\n                self.read_response()\n        except ResponseError:\n            pass\n\n        try:\n            if self.driver_info and self.driver_info.lib_version:\n                self.send_command(\n                    \"CLIENT\",\n                    \"SETINFO\",\n                    \"LIB-VER\",\n                    self.driver_info.lib_version,\n                    check_health=check_health,\n                )\n                self.read_response()\n        except ResponseError:\n            pass\n\n        # if a database is specified, switch to it\n        if self.db:\n            self.send_command(\"SELECT\", self.db, check_health=check_health)\n            if str_if_bytes(self.read_response()) != \"OK\":\n                raise ConnectionError(\"Invalid Database\")\n\n    def disconnect(self, *args, **kwargs):\n        \"Disconnects from the Redis server\"\n        self._parser.on_disconnect()\n\n        conn_sock = self._sock\n        self._sock = None\n        # reset the reconnect flag\n        self.reset_should_reconnect()\n\n        if conn_sock is None:\n            return\n\n        if os.getpid() == self.pid:\n            try:\n                conn_sock.shutdown(socket.SHUT_RDWR)\n            except (OSError, TypeError):\n                pass\n\n        try:\n            conn_sock.close()\n        except OSError:\n            pass\n\n        error = kwargs.get(\"error\")\n        failure_count = kwargs.get(\"failure_count\")\n        health_check_failed = kwargs.get(\"health_check_failed\")\n\n        if error:\n            if health_check_failed:\n                close_reason = CloseReason.HEALTHCHECK_FAILED\n            else:\n                close_reason = CloseReason.ERROR\n\n            if failure_count is not None and failure_count > self.retry.get_retries():\n                record_error_count(\n                    server_address=self.host,\n                    server_port=self.port,\n                    network_peer_address=self.host,\n                    network_peer_port=self.port,\n                    error_type=error,\n                    retry_attempts=failure_count,\n                )\n\n            record_connection_closed(\n                close_reason=close_reason,\n                error_type=error,\n            )\n        else:\n            record_connection_closed(\n                close_reason=CloseReason.APPLICATION_CLOSE,\n            )\n\n        if self.maintenance_state == MaintenanceState.MAINTENANCE:\n            # this block will be executed only if the connection was in maintenance state\n            # and the connection was closed.\n            # The state change won't be applied on connections that are in Moving state\n            # because their state and configurations will be handled when the moving ttl expires.\n            self.reset_tmp_settings(reset_relaxed_timeout=True)\n            self.maintenance_state = MaintenanceState.NONE\n            # reset the sets that keep track of received start maint\n            # notifications and skipped end maint notifications\n            self.reset_received_notifications()\n\n    def mark_for_reconnect(self):\n        self._should_reconnect = True\n\n    def should_reconnect(self):\n        return self._should_reconnect\n\n    def reset_should_reconnect(self):\n        self._should_reconnect = False\n\n    def _send_ping(self):\n        \"\"\"Send PING, expect PONG in return\"\"\"\n        self.send_command(\"PING\", check_health=False)\n        if str_if_bytes(self.read_response()) != \"PONG\":\n            raise ConnectionError(\"Bad response from PING health check\")\n\n    def _ping_failed(self, error, failure_count):\n        \"\"\"Function to call when PING fails\"\"\"\n        self.disconnect(\n            error=error, failure_count=failure_count, health_check_failed=True\n        )\n\n    def check_health(self):\n        \"\"\"Check the health of the connection with a PING/PONG\"\"\"\n        if self.health_check_interval and time.monotonic() > self.next_health_check:\n            self.retry.call_with_retry(\n                self._send_ping,\n                self._ping_failed,\n                with_failure_count=True,\n            )\n\n    def send_packed_command(self, command, check_health=True):\n        \"\"\"Send an already packed command to the Redis server\"\"\"\n        if not self._sock:\n            self.connect_check_health(check_health=False)\n        # guard against health check recursion\n        if check_health:\n            self.check_health()\n        try:\n            if isinstance(command, str):\n                command = [command]\n            for item in command:\n                self._sock.sendall(item)\n        except socket.timeout:\n            self.disconnect()\n            raise TimeoutError(\"Timeout writing to socket\")\n        except OSError as e:\n            self.disconnect()\n            if len(e.args) == 1:\n                errno, errmsg = \"UNKNOWN\", e.args[0]\n            else:\n                errno = e.args[0]\n                errmsg = e.args[1]\n            raise ConnectionError(f\"Error {errno} while writing to socket. {errmsg}.\")\n        except BaseException:\n            # BaseExceptions can be raised when a socket send operation is not\n            # finished, e.g. due to a timeout.  Ideally, a caller could then re-try\n            # to send un-sent data. However, the send_packed_command() API\n            # does not support it so there is no point in keeping the connection open.\n            self.disconnect()\n            raise\n\n    def send_command(self, *args, **kwargs):\n        \"\"\"Pack and send a command to the Redis server\"\"\"\n        self.send_packed_command(\n            self._command_packer.pack(*args),\n            check_health=kwargs.get(\"check_health\", True),\n        )\n\n    def can_read(self, timeout=0):\n        \"\"\"Poll the socket to see if there's data that can be read.\"\"\"\n        sock = self._sock\n        if not sock:\n            self.connect()\n\n        host_error = self._host_error()\n\n        try:\n            return self._parser.can_read(timeout)\n\n        except OSError as e:\n            self.disconnect()\n            raise ConnectionError(f\"Error while reading from {host_error}: {e.args}\")\n\n    def read_response(\n        self,\n        disable_decoding=False,\n        *,\n        timeout: Union[float, object] = SENTINEL,\n        disconnect_on_error=True,\n        push_request=False,\n    ):\n        \"\"\"Read the response from a previously sent command\"\"\"\n\n        host_error = self._host_error()\n\n        try:\n            if self.protocol in [\"3\", 3]:\n                response = self._parser.read_response(\n                    disable_decoding=disable_decoding,\n                    push_request=push_request,\n                    timeout=timeout,\n                )\n            else:\n                response = self._parser.read_response(\n                    disable_decoding=disable_decoding, timeout=timeout\n                )\n        except socket.timeout:\n            if disconnect_on_error:\n                self.disconnect()\n            raise TimeoutError(f\"Timeout reading from {host_error}\")\n        except OSError as e:\n            if disconnect_on_error:\n                self.disconnect()\n            raise ConnectionError(f\"Error while reading from {host_error} : {e.args}\")\n        except BaseException:\n            # Also by default close in case of BaseException.  A lot of code\n            # relies on this behaviour when doing Command/Response pairs.\n            # See #1128.\n            if disconnect_on_error:\n                self.disconnect()\n            raise\n\n        if self.health_check_interval:\n            self.next_health_check = time.monotonic() + self.health_check_interval\n\n        if isinstance(response, ResponseError):\n            try:\n                raise response\n            finally:\n                del response  # avoid creating ref cycles\n        return response\n\n    def pack_command(self, *args):\n        \"\"\"Pack a series of arguments into the Redis protocol\"\"\"\n        return self._command_packer.pack(*args)\n\n    def pack_commands(self, commands):\n        \"\"\"Pack multiple commands into the Redis protocol\"\"\"\n        output = []\n        pieces = []\n        buffer_length = 0\n        buffer_cutoff = self._buffer_cutoff\n\n        for cmd in commands:\n            for chunk in self._command_packer.pack(*cmd):\n                chunklen = len(chunk)\n                if (\n                    buffer_length > buffer_cutoff\n                    or chunklen > buffer_cutoff\n                    or isinstance(chunk, memoryview)\n                ):\n                    if pieces:\n                        output.append(SYM_EMPTY.join(pieces))\n                    buffer_length = 0\n                    pieces = []\n\n                if chunklen > buffer_cutoff or isinstance(chunk, memoryview):\n                    output.append(chunk)\n                else:\n                    pieces.append(chunk)\n                    buffer_length += chunklen\n\n        if pieces:\n            output.append(SYM_EMPTY.join(pieces))\n        return output\n\n    def get_protocol(self) -> Union[int, str]:\n        return self.protocol\n\n    @property\n    def handshake_metadata(self) -> Union[Dict[bytes, bytes], Dict[str, str]]:\n        return self._handshake_metadata\n\n    @handshake_metadata.setter\n    def handshake_metadata(self, value: Union[Dict[bytes, bytes], Dict[str, str]]):\n        self._handshake_metadata = value\n\n    def set_re_auth_token(self, token: TokenInterface):\n        self._re_auth_token = token\n\n    def re_auth(self):\n        if self._re_auth_token is not None:\n            self.send_command(\n                \"AUTH\",\n                self._re_auth_token.try_get(\"oid\"),\n                self._re_auth_token.get_value(),\n            )\n            self.read_response()\n            self._re_auth_token = None\n\n    def _get_socket(self) -> Optional[socket.socket]:\n        return self._sock\n\n    @property\n    def socket_timeout(self) -> Optional[Union[float, int]]:\n        return self._socket_timeout\n\n    @socket_timeout.setter\n    def socket_timeout(self, value: Optional[Union[float, int]]):\n        self._socket_timeout = value\n\n    @property\n    def socket_connect_timeout(self) -> Optional[Union[float, int]]:\n        return self._socket_connect_timeout\n\n    @socket_connect_timeout.setter\n    def socket_connect_timeout(self, value: Optional[Union[float, int]]):\n        self._socket_connect_timeout = value\n\n    def extract_connection_details(self) -> str:\n        socket_address = None\n        if self._sock is None:\n            return \"not connected\"\n        try:\n            socket_address = self._sock.getsockname() if self._sock else None\n            socket_address = socket_address[1] if socket_address else None\n        except (AttributeError, OSError):\n            pass\n\n        return f\"connected to ip {self.get_resolved_ip()}, local socket port: {socket_address}\"\n\n\nclass Connection(AbstractConnection):\n    \"Manages TCP communication to and from a Redis server\"\n\n    def __init__(\n        self,\n        host=\"localhost\",\n        port=6379,\n        socket_keepalive=False,\n        socket_keepalive_options=None,\n        socket_type=0,\n        **kwargs,\n    ):\n        self._host = host\n        self.port = int(port)\n        self.socket_keepalive = socket_keepalive\n        self.socket_keepalive_options = socket_keepalive_options or {}\n        self.socket_type = socket_type\n        super().__init__(**kwargs)\n\n    def repr_pieces(self):\n        pieces = [(\"host\", self.host), (\"port\", self.port), (\"db\", self.db)]\n        if self.client_name:\n            pieces.append((\"client_name\", self.client_name))\n        return pieces\n\n    def _connect(self):\n        \"Create a TCP socket connection\"\n        # we want to mimic what socket.create_connection does to support\n        # ipv4/ipv6, but we want to set options prior to calling\n        # socket.connect()\n        err = None\n\n        for res in socket.getaddrinfo(\n            self.host, self.port, self.socket_type, socket.SOCK_STREAM\n        ):\n            family, socktype, proto, canonname, socket_address = res\n            sock = None\n            try:\n                sock = socket.socket(family, socktype, proto)\n                # TCP_NODELAY\n                sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)\n\n                # TCP_KEEPALIVE\n                if self.socket_keepalive:\n                    sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)\n                    for k, v in self.socket_keepalive_options.items():\n                        sock.setsockopt(socket.IPPROTO_TCP, k, v)\n\n                # set the socket_connect_timeout before we connect\n                sock.settimeout(self.socket_connect_timeout)\n\n                # connect\n                sock.connect(socket_address)\n\n                # set the socket_timeout now that we're connected\n                sock.settimeout(self.socket_timeout)\n                return sock\n\n            except OSError as _:\n                err = _\n                if sock is not None:\n                    try:\n                        sock.shutdown(socket.SHUT_RDWR)  # ensure a clean close\n                    except OSError:\n                        pass\n                    sock.close()\n\n        if err is not None:\n            raise err\n        raise OSError(\"socket.getaddrinfo returned an empty list\")\n\n    def _host_error(self):\n        return f\"{self.host}:{self.port}\"\n\n    @property\n    def host(self) -> str:\n        return self._host\n\n    @host.setter\n    def host(self, value: str):\n        self._host = value\n\n\nclass CacheProxyConnection(MaintNotificationsAbstractConnection, ConnectionInterface):\n    DUMMY_CACHE_VALUE = b\"foo\"\n    MIN_ALLOWED_VERSION = \"7.4.0\"\n    DEFAULT_SERVER_NAME = \"redis\"\n\n    def __init__(\n        self,\n        conn: ConnectionInterface,\n        cache: CacheInterface,\n        pool_lock: threading.RLock,\n    ):\n        self.pid = os.getpid()\n        self._conn = conn\n        self.retry = self._conn.retry\n        self.host = self._conn.host\n        self.port = self._conn.port\n        self.db = self._conn.db\n        self._event_dispatcher = self._conn._event_dispatcher\n        self.credential_provider = conn.credential_provider\n        self._pool_lock = pool_lock\n        self._cache = cache\n        self._cache_lock = threading.RLock()\n        self._current_command_cache_key = None\n        self._current_options = None\n        self.register_connect_callback(self._enable_tracking_callback)\n\n        if isinstance(self._conn, MaintNotificationsAbstractConnection):\n            MaintNotificationsAbstractConnection.__init__(\n                self,\n                self._conn.maint_notifications_config,\n                self._conn._maint_notifications_pool_handler,\n                self._conn.maintenance_state,\n                self._conn.maintenance_notification_hash,\n                self._conn.host,\n                self._conn.socket_timeout,\n                self._conn.socket_connect_timeout,\n                self._conn._oss_cluster_maint_notifications_handler,\n                self._conn._get_parser(),\n                event_dispatcher=self._conn.event_dispatcher,\n            )\n\n    def repr_pieces(self):\n        return self._conn.repr_pieces()\n\n    def register_connect_callback(self, callback):\n        self._conn.register_connect_callback(callback)\n\n    def deregister_connect_callback(self, callback):\n        self._conn.deregister_connect_callback(callback)\n\n    def set_parser(self, parser_class):\n        self._conn.set_parser(parser_class)\n\n    def set_maint_notifications_pool_handler_for_connection(\n        self, maint_notifications_pool_handler\n    ):\n        if isinstance(self._conn, MaintNotificationsAbstractConnection):\n            self._conn.set_maint_notifications_pool_handler_for_connection(\n                maint_notifications_pool_handler\n            )\n\n    def set_maint_notifications_cluster_handler_for_connection(\n        self, oss_cluster_maint_notifications_handler\n    ):\n        if isinstance(self._conn, MaintNotificationsAbstractConnection):\n            self._conn.set_maint_notifications_cluster_handler_for_connection(\n                oss_cluster_maint_notifications_handler\n            )\n\n    def get_protocol(self):\n        return self._conn.get_protocol()\n\n    def connect(self):\n        self._conn.connect()\n\n        server_name = self._conn.handshake_metadata.get(b\"server\", None)\n        if server_name is None:\n            server_name = self._conn.handshake_metadata.get(\"server\", None)\n        server_ver = self._conn.handshake_metadata.get(b\"version\", None)\n        if server_ver is None:\n            server_ver = self._conn.handshake_metadata.get(\"version\", None)\n        if server_ver is None or server_name is None:\n            raise ConnectionError(\"Cannot retrieve information about server version\")\n\n        server_ver = ensure_string(server_ver)\n        server_name = ensure_string(server_name)\n\n        if (\n            server_name != self.DEFAULT_SERVER_NAME\n            or compare_versions(server_ver, self.MIN_ALLOWED_VERSION) == 1\n        ):\n            raise ConnectionError(\n                \"To maximize compatibility with all Redis products, client-side caching is supported by Redis 7.4 or later\"  # noqa: E501\n            )\n\n    def on_connect(self):\n        self._conn.on_connect()\n\n    def disconnect(self, *args, **kwargs):\n        with self._cache_lock:\n            self._cache.flush()\n        self._conn.disconnect(*args, **kwargs)\n\n    def check_health(self):\n        self._conn.check_health()\n\n    def send_packed_command(self, command, check_health=True):\n        # TODO: Investigate if it's possible to unpack command\n        #  or extract keys from packed command\n        self._conn.send_packed_command(command)\n\n    def send_command(self, *args, **kwargs):\n        self._process_pending_invalidations()\n\n        with self._cache_lock:\n            # Command is write command or not allowed\n            # to be cached.\n            if not self._cache.is_cachable(\n                CacheKey(command=args[0], redis_keys=(), redis_args=())\n            ):\n                self._current_command_cache_key = None\n                self._conn.send_command(*args, **kwargs)\n                return\n\n        if kwargs.get(\"keys\") is None:\n            raise ValueError(\"Cannot create cache key.\")\n\n        # Creates cache key.\n        self._current_command_cache_key = CacheKey(\n            command=args[0], redis_keys=tuple(kwargs.get(\"keys\")), redis_args=args\n        )\n\n        with self._cache_lock:\n            # We have to trigger invalidation processing in case if\n            # it was cached by another connection to avoid\n            # queueing invalidations in stale connections.\n            if self._cache.get(self._current_command_cache_key):\n                entry = self._cache.get(self._current_command_cache_key)\n\n                if entry.connection_ref != self._conn:\n                    with self._pool_lock:\n                        while entry.connection_ref.can_read():\n                            entry.connection_ref.read_response(push_request=True)\n\n                return\n\n            # Set temporary entry value to prevent\n            # race condition from another connection.\n            self._cache.set(\n                CacheEntry(\n                    cache_key=self._current_command_cache_key,\n                    cache_value=self.DUMMY_CACHE_VALUE,\n                    status=CacheEntryStatus.IN_PROGRESS,\n                    connection_ref=self._conn,\n                )\n            )\n\n        # Send command over socket only if it's allowed\n        # read-only command that not yet cached.\n        self._conn.send_command(*args, **kwargs)\n\n    def can_read(self, timeout=0):\n        return self._conn.can_read(timeout)\n\n    def read_response(\n        self,\n        disable_decoding=False,\n        *,\n        timeout: Union[float, object] = SENTINEL,\n        disconnect_on_error=True,\n        push_request=False,\n    ):\n        with self._cache_lock:\n            # Check if command response exists in a cache and it's not in progress.\n            if self._current_command_cache_key is not None:\n                if (\n                    self._cache.get(self._current_command_cache_key) is not None\n                    and self._cache.get(self._current_command_cache_key).status\n                    != CacheEntryStatus.IN_PROGRESS\n                ):\n                    res = copy.deepcopy(\n                        self._cache.get(self._current_command_cache_key).cache_value\n                    )\n                    self._current_command_cache_key = None\n                    record_csc_request(\n                        result=CSCResult.HIT,\n                    )\n                    record_csc_network_saved(\n                        bytes_saved=len(res),\n                    )\n                    return res\n                record_csc_request(\n                    result=CSCResult.MISS,\n                )\n\n        response = self._conn.read_response(\n            disable_decoding=disable_decoding,\n            timeout=timeout,\n            disconnect_on_error=disconnect_on_error,\n            push_request=push_request,\n        )\n\n        with self._cache_lock:\n            # Prevent not-allowed command from caching.\n            if self._current_command_cache_key is None:\n                return response\n            # If response is None prevent from caching.\n            if response is None:\n                self._cache.delete_by_cache_keys([self._current_command_cache_key])\n                return response\n\n            cache_entry = self._cache.get(self._current_command_cache_key)\n\n            # Cache only responses that still valid\n            # and wasn't invalidated by another connection in meantime.\n            if cache_entry is not None:\n                cache_entry.status = CacheEntryStatus.VALID\n                cache_entry.cache_value = response\n                self._cache.set(cache_entry)\n\n            self._current_command_cache_key = None\n\n        return response\n\n    def pack_command(self, *args):\n        return self._conn.pack_command(*args)\n\n    def pack_commands(self, commands):\n        return self._conn.pack_commands(commands)\n\n    @property\n    def handshake_metadata(self) -> Union[Dict[bytes, bytes], Dict[str, str]]:\n        return self._conn.handshake_metadata\n\n    def set_re_auth_token(self, token: TokenInterface):\n        self._conn.set_re_auth_token(token)\n\n    def re_auth(self):\n        self._conn.re_auth()\n\n    def mark_for_reconnect(self):\n        self._conn.mark_for_reconnect()\n\n    def should_reconnect(self):\n        return self._conn.should_reconnect()\n\n    def reset_should_reconnect(self):\n        self._conn.reset_should_reconnect()\n\n    @property\n    def host(self) -> str:\n        return self._conn.host\n\n    @host.setter\n    def host(self, value: str):\n        self._conn.host = value\n\n    @property\n    def socket_timeout(self) -> Optional[Union[float, int]]:\n        return self._conn.socket_timeout\n\n    @socket_timeout.setter\n    def socket_timeout(self, value: Optional[Union[float, int]]):\n        self._conn.socket_timeout = value\n\n    @property\n    def socket_connect_timeout(self) -> Optional[Union[float, int]]:\n        return self._conn.socket_connect_timeout\n\n    @socket_connect_timeout.setter\n    def socket_connect_timeout(self, value: Optional[Union[float, int]]):\n        self._conn.socket_connect_timeout = value\n\n    @property\n    def _maint_notifications_connection_handler(\n        self,\n    ) -> Optional[MaintNotificationsConnectionHandler]:\n        if isinstance(self._conn, MaintNotificationsAbstractConnection):\n            return self._conn._maint_notifications_connection_handler\n\n    @_maint_notifications_connection_handler.setter\n    def _maint_notifications_connection_handler(\n        self, value: Optional[MaintNotificationsConnectionHandler]\n    ):\n        self._conn._maint_notifications_connection_handler = value\n\n    def _get_socket(self) -> Optional[socket.socket]:\n        if isinstance(self._conn, MaintNotificationsAbstractConnection):\n            return self._conn._get_socket()\n        else:\n            raise NotImplementedError(\n                \"Maintenance notifications are not supported by this connection type\"\n            )\n\n    def _get_maint_notifications_connection_instance(\n        self, connection\n    ) -> MaintNotificationsAbstractConnection:\n        \"\"\"\n        Validate that connection instance supports maintenance notifications.\n        With this helper method we ensure that we are working\n        with the correct connection type.\n        After twe validate that connection instance supports maintenance notifications\n        we can safely return the connection instance\n        as MaintNotificationsAbstractConnection.\n        \"\"\"\n        if not isinstance(connection, MaintNotificationsAbstractConnection):\n            raise NotImplementedError(\n                \"Maintenance notifications are not supported by this connection type\"\n            )\n        else:\n            return connection\n\n    @property\n    def maintenance_state(self) -> MaintenanceState:\n        con = self._get_maint_notifications_connection_instance(self._conn)\n        return con.maintenance_state\n\n    @maintenance_state.setter\n    def maintenance_state(self, state: MaintenanceState):\n        con = self._get_maint_notifications_connection_instance(self._conn)\n        con.maintenance_state = state\n\n    def getpeername(self):\n        con = self._get_maint_notifications_connection_instance(self._conn)\n        return con.getpeername()\n\n    def get_resolved_ip(self):\n        con = self._get_maint_notifications_connection_instance(self._conn)\n        return con.get_resolved_ip()\n\n    def update_current_socket_timeout(self, relaxed_timeout: Optional[float] = None):\n        con = self._get_maint_notifications_connection_instance(self._conn)\n        con.update_current_socket_timeout(relaxed_timeout)\n\n    def set_tmp_settings(\n        self,\n        tmp_host_address: Optional[str] = None,\n        tmp_relaxed_timeout: Optional[float] = None,\n    ):\n        con = self._get_maint_notifications_connection_instance(self._conn)\n        con.set_tmp_settings(tmp_host_address, tmp_relaxed_timeout)\n\n    def reset_tmp_settings(\n        self,\n        reset_host_address: bool = False,\n        reset_relaxed_timeout: bool = False,\n    ):\n        con = self._get_maint_notifications_connection_instance(self._conn)\n        con.reset_tmp_settings(reset_host_address, reset_relaxed_timeout)\n\n    def _connect(self):\n        self._conn._connect()\n\n    def _host_error(self):\n        self._conn._host_error()\n\n    def _enable_tracking_callback(self, conn: ConnectionInterface) -> None:\n        conn.send_command(\"CLIENT\", \"TRACKING\", \"ON\")\n        conn.read_response()\n        conn._parser.set_invalidation_push_handler(self._on_invalidation_callback)\n\n    def _process_pending_invalidations(self):\n        while self.can_read():\n            self._conn.read_response(push_request=True)\n\n    def _on_invalidation_callback(self, data: List[Union[str, Optional[List[bytes]]]]):\n        with self._cache_lock:\n            # Flush cache when DB flushed on server-side\n            if data[1] is None:\n                self._cache.flush()\n            else:\n                keys_deleted = self._cache.delete_by_redis_keys(data[1])\n\n                if len(keys_deleted) > 0:\n                    record_csc_eviction(\n                        count=len(keys_deleted),\n                        reason=CSCReason.INVALIDATION,\n                    )\n\n    def extract_connection_details(self) -> str:\n        return self._conn.extract_connection_details()\n\n\nclass SSLConnection(Connection):\n    \"\"\"Manages SSL connections to and from the Redis server(s).\n    This class extends the Connection class, adding SSL functionality, and making\n    use of ssl.SSLContext (https://docs.python.org/3/library/ssl.html#ssl.SSLContext)\n    \"\"\"  # noqa\n\n    def __init__(\n        self,\n        ssl_keyfile=None,\n        ssl_certfile=None,\n        ssl_cert_reqs=\"required\",\n        ssl_include_verify_flags: Optional[List[\"VerifyFlags\"]] = None,\n        ssl_exclude_verify_flags: Optional[List[\"VerifyFlags\"]] = None,\n        ssl_ca_certs=None,\n        ssl_ca_data=None,\n        ssl_check_hostname=True,\n        ssl_ca_path=None,\n        ssl_password=None,\n        ssl_validate_ocsp=False,\n        ssl_validate_ocsp_stapled=False,\n        ssl_ocsp_context=None,\n        ssl_ocsp_expected_cert=None,\n        ssl_min_version=None,\n        ssl_ciphers=None,\n        **kwargs,\n    ):\n        \"\"\"Constructor\n\n        Args:\n            ssl_keyfile: Path to an ssl private key. Defaults to None.\n            ssl_certfile: Path to an ssl certificate. Defaults to None.\n            ssl_cert_reqs: The string value for the SSLContext.verify_mode (none, optional, required),\n                           or an ssl.VerifyMode. Defaults to \"required\".\n            ssl_include_verify_flags: A list of flags to be included in the SSLContext.verify_flags. Defaults to None.\n            ssl_exclude_verify_flags: A list of flags to be excluded from the SSLContext.verify_flags. Defaults to None.\n            ssl_ca_certs: The path to a file of concatenated CA certificates in PEM format. Defaults to None.\n            ssl_ca_data: Either an ASCII string of one or more PEM-encoded certificates or a bytes-like object of DER-encoded certificates.\n            ssl_check_hostname: If set, match the hostname during the SSL handshake. Defaults to True.\n            ssl_ca_path: The path to a directory containing several CA certificates in PEM format. Defaults to None.\n            ssl_password: Password for unlocking an encrypted private key. Defaults to None.\n\n            ssl_validate_ocsp: If set, perform a full ocsp validation (i.e not a stapled verification)\n            ssl_validate_ocsp_stapled: If set, perform a validation on a stapled ocsp response\n            ssl_ocsp_context: A fully initialized OpenSSL.SSL.Context object to be used in verifying the ssl_ocsp_expected_cert\n            ssl_ocsp_expected_cert: A PEM armoured string containing the expected certificate to be returned from the ocsp verification service.\n            ssl_min_version: The lowest supported SSL version. It affects the supported SSL versions of the SSLContext. None leaves the default provided by ssl module.\n            ssl_ciphers: A string listing the ciphers that are allowed to be used. Defaults to None, which means that the default ciphers are used. See https://docs.python.org/3/library/ssl.html#ssl.SSLContext.set_ciphers for more information.\n\n        Raises:\n            RedisError\n        \"\"\"  # noqa\n        if not SSL_AVAILABLE:\n            raise RedisError(\"Python wasn't built with SSL support\")\n\n        self.keyfile = ssl_keyfile\n        self.certfile = ssl_certfile\n        if ssl_cert_reqs is None:\n            ssl_cert_reqs = ssl.CERT_NONE\n        elif isinstance(ssl_cert_reqs, str):\n            CERT_REQS = {  # noqa: N806\n                \"none\": ssl.CERT_NONE,\n                \"optional\": ssl.CERT_OPTIONAL,\n                \"required\": ssl.CERT_REQUIRED,\n            }\n            if ssl_cert_reqs not in CERT_REQS:\n                raise RedisError(\n                    f\"Invalid SSL Certificate Requirements Flag: {ssl_cert_reqs}\"\n                )\n            ssl_cert_reqs = CERT_REQS[ssl_cert_reqs]\n        self.cert_reqs = ssl_cert_reqs\n        self.ssl_include_verify_flags = ssl_include_verify_flags\n        self.ssl_exclude_verify_flags = ssl_exclude_verify_flags\n        self.ca_certs = ssl_ca_certs\n        self.ca_data = ssl_ca_data\n        self.ca_path = ssl_ca_path\n        self.check_hostname = (\n            ssl_check_hostname if self.cert_reqs != ssl.CERT_NONE else False\n        )\n        self.certificate_password = ssl_password\n        self.ssl_validate_ocsp = ssl_validate_ocsp\n        self.ssl_validate_ocsp_stapled = ssl_validate_ocsp_stapled\n        self.ssl_ocsp_context = ssl_ocsp_context\n        self.ssl_ocsp_expected_cert = ssl_ocsp_expected_cert\n        self.ssl_min_version = ssl_min_version\n        self.ssl_ciphers = ssl_ciphers\n        super().__init__(**kwargs)\n\n    def _connect(self):\n        \"\"\"\n        Wrap the socket with SSL support, handling potential errors.\n        \"\"\"\n        sock = super()._connect()\n        try:\n            return self._wrap_socket_with_ssl(sock)\n        except (OSError, RedisError):\n            sock.close()\n            raise\n\n    def _wrap_socket_with_ssl(self, sock):\n        \"\"\"\n        Wraps the socket with SSL support.\n\n        Args:\n            sock: The plain socket to wrap with SSL.\n\n        Returns:\n            An SSL wrapped socket.\n        \"\"\"\n        context = ssl.create_default_context()\n        context.check_hostname = self.check_hostname\n        context.verify_mode = self.cert_reqs\n        if self.ssl_include_verify_flags:\n            for flag in self.ssl_include_verify_flags:\n                context.verify_flags |= flag\n        if self.ssl_exclude_verify_flags:\n            for flag in self.ssl_exclude_verify_flags:\n                context.verify_flags &= ~flag\n        if self.certfile or self.keyfile:\n            context.load_cert_chain(\n                certfile=self.certfile,\n                keyfile=self.keyfile,\n                password=self.certificate_password,\n            )\n        if (\n            self.ca_certs is not None\n            or self.ca_path is not None\n            or self.ca_data is not None\n        ):\n            context.load_verify_locations(\n                cafile=self.ca_certs, capath=self.ca_path, cadata=self.ca_data\n            )\n        if self.ssl_min_version is not None:\n            context.minimum_version = self.ssl_min_version\n        if self.ssl_ciphers:\n            context.set_ciphers(self.ssl_ciphers)\n        if self.ssl_validate_ocsp is True and CRYPTOGRAPHY_AVAILABLE is False:\n            raise RedisError(\"cryptography is not installed.\")\n\n        if self.ssl_validate_ocsp_stapled and self.ssl_validate_ocsp:\n            raise RedisError(\n                \"Either an OCSP staple or pure OCSP connection must be validated \"\n                \"- not both.\"\n            )\n\n        sslsock = context.wrap_socket(sock, server_hostname=self.host)\n\n        # validation for the stapled case\n        if self.ssl_validate_ocsp_stapled:\n            import OpenSSL\n\n            from .ocsp import ocsp_staple_verifier\n\n            # if a context is provided use it - otherwise, a basic context\n            if self.ssl_ocsp_context is None:\n                staple_ctx = OpenSSL.SSL.Context(OpenSSL.SSL.SSLv23_METHOD)\n                staple_ctx.use_certificate_file(self.certfile)\n                staple_ctx.use_privatekey_file(self.keyfile)\n            else:\n                staple_ctx = self.ssl_ocsp_context\n\n            staple_ctx.set_ocsp_client_callback(\n                ocsp_staple_verifier, self.ssl_ocsp_expected_cert\n            )\n\n            #  need another socket\n            con = OpenSSL.SSL.Connection(staple_ctx, socket.socket())\n            con.request_ocsp()\n            con.connect((self.host, self.port))\n            con.do_handshake()\n            con.shutdown()\n            return sslsock\n\n        # pure ocsp validation\n        if self.ssl_validate_ocsp is True and CRYPTOGRAPHY_AVAILABLE:\n            from .ocsp import OCSPVerifier\n\n            o = OCSPVerifier(sslsock, self.host, self.port, self.ca_certs)\n            if o.is_valid():\n                return sslsock\n            else:\n                raise ConnectionError(\"ocsp validation error\")\n        return sslsock\n\n\nclass UnixDomainSocketConnection(AbstractConnection):\n    \"Manages UDS communication to and from a Redis server\"\n\n    def __init__(self, path=\"\", socket_timeout=None, **kwargs):\n        super().__init__(**kwargs)\n        self.path = path\n        self.socket_timeout = socket_timeout\n\n    def repr_pieces(self):\n        pieces = [(\"path\", self.path), (\"db\", self.db)]\n        if self.client_name:\n            pieces.append((\"client_name\", self.client_name))\n        return pieces\n\n    def _connect(self):\n        \"Create a Unix domain socket connection\"\n        sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)\n        sock.settimeout(self.socket_connect_timeout)\n        try:\n            sock.connect(self.path)\n        except OSError:\n            # Prevent ResourceWarnings for unclosed sockets.\n            try:\n                sock.shutdown(socket.SHUT_RDWR)  # ensure a clean close\n            except OSError:\n                pass\n            sock.close()\n            raise\n        sock.settimeout(self.socket_timeout)\n        return sock\n\n    def _host_error(self):\n        return self.path\n\n\nFALSE_STRINGS = (\"0\", \"F\", \"FALSE\", \"N\", \"NO\")\n\n\ndef to_bool(value):\n    if value is None or value == \"\":\n        return None\n    if isinstance(value, str) and value.upper() in FALSE_STRINGS:\n        return False\n    return bool(value)\n\n\ndef parse_ssl_verify_flags(value):\n    # flags are passed in as a string representation of a list,\n    # e.g. VERIFY_X509_STRICT, VERIFY_X509_PARTIAL_CHAIN\n    verify_flags_str = value.replace(\"[\", \"\").replace(\"]\", \"\")\n\n    verify_flags = []\n    for flag in verify_flags_str.split(\",\"):\n        flag = flag.strip()\n        if not hasattr(VerifyFlags, flag):\n            raise ValueError(f\"Invalid ssl verify flag: {flag}\")\n        verify_flags.append(getattr(VerifyFlags, flag))\n    return verify_flags\n\n\nURL_QUERY_ARGUMENT_PARSERS = {\n    \"db\": int,\n    \"socket_timeout\": float,\n    \"socket_connect_timeout\": float,\n    \"socket_keepalive\": to_bool,\n    \"retry_on_timeout\": to_bool,\n    \"retry_on_error\": list,\n    \"max_connections\": int,\n    \"health_check_interval\": int,\n    \"ssl_check_hostname\": to_bool,\n    \"ssl_include_verify_flags\": parse_ssl_verify_flags,\n    \"ssl_exclude_verify_flags\": parse_ssl_verify_flags,\n    \"timeout\": float,\n}\n\n\ndef parse_url(url):\n    if not (\n        url.startswith(\"redis://\")\n        or url.startswith(\"rediss://\")\n        or url.startswith(\"unix://\")\n    ):\n        raise ValueError(\n            \"Redis URL must specify one of the following \"\n            \"schemes (redis://, rediss://, unix://)\"\n        )\n\n    url = urlparse(url)\n    kwargs = {}\n\n    for name, value in parse_qs(url.query).items():\n        if value and len(value) > 0:\n            value = unquote(value[0])\n            parser = URL_QUERY_ARGUMENT_PARSERS.get(name)\n            if parser:\n                try:\n                    kwargs[name] = parser(value)\n                except (TypeError, ValueError):\n                    raise ValueError(f\"Invalid value for '{name}' in connection URL.\")\n            else:\n                kwargs[name] = value\n\n    if url.username:\n        kwargs[\"username\"] = unquote(url.username)\n    if url.password:\n        kwargs[\"password\"] = unquote(url.password)\n\n    # We only support redis://, rediss:// and unix:// schemes.\n    if url.scheme == \"unix\":\n        if url.path:\n            kwargs[\"path\"] = unquote(url.path)\n        kwargs[\"connection_class\"] = UnixDomainSocketConnection\n\n    else:  # implied:  url.scheme in (\"redis\", \"rediss\"):\n        if url.hostname:\n            kwargs[\"host\"] = unquote(url.hostname)\n        if url.port:\n            kwargs[\"port\"] = int(url.port)\n\n        # If there's a path argument, use it as the db argument if a\n        # querystring value wasn't specified\n        if url.path and \"db\" not in kwargs:\n            try:\n                kwargs[\"db\"] = int(unquote(url.path).replace(\"/\", \"\"))\n            except (AttributeError, ValueError):\n                pass\n\n        if url.scheme == \"rediss\":\n            kwargs[\"connection_class\"] = SSLConnection\n\n    return kwargs\n\n\n_CP = TypeVar(\"_CP\", bound=\"ConnectionPool\")\n\n\nclass ConnectionPoolInterface(ABC):\n    @abstractmethod\n    def get_protocol(self):\n        pass\n\n    @abstractmethod\n    def reset(self):\n        pass\n\n    @abstractmethod\n    @deprecated_args(\n        args_to_warn=[\"*\"],\n        reason=\"Use get_connection() without args instead\",\n        version=\"5.3.0\",\n    )\n    def get_connection(\n        self, command_name: Optional[str], *keys, **options\n    ) -> ConnectionInterface:\n        pass\n\n    @abstractmethod\n    def get_encoder(self):\n        pass\n\n    @abstractmethod\n    def release(self, connection: ConnectionInterface):\n        pass\n\n    @abstractmethod\n    def disconnect(self, inuse_connections: bool = True):\n        pass\n\n    @abstractmethod\n    def close(self):\n        pass\n\n    @abstractmethod\n    def set_retry(self, retry: Retry):\n        pass\n\n    @abstractmethod\n    def re_auth_callback(self, token: TokenInterface):\n        pass\n\n    @abstractmethod\n    def get_connection_count(self) -> list[tuple[int, dict]]:\n        \"\"\"\n        Returns a connection count (both idle and in use).\n        \"\"\"\n        pass\n\n\nclass MaintNotificationsAbstractConnectionPool:\n    \"\"\"\n    Abstract class for handling maintenance notifications logic.\n    This class is mixed into the ConnectionPool classes.\n\n    This class is not intended to be used directly!\n\n    All logic related to maintenance notifications and\n    connection pool handling is encapsulated in this class.\n    \"\"\"\n\n    def __init__(\n        self,\n        maint_notifications_config: Optional[MaintNotificationsConfig] = None,\n        oss_cluster_maint_notifications_handler: Optional[\n            OSSMaintNotificationsHandler\n        ] = None,\n        **kwargs,\n    ):\n        # Initialize maintenance notifications\n        is_protocol_supported = check_protocol_version(kwargs.get(\"protocol\"), 3)\n\n        if maint_notifications_config is None and is_protocol_supported:\n            maint_notifications_config = MaintNotificationsConfig()\n\n        if maint_notifications_config and maint_notifications_config.enabled:\n            if not is_protocol_supported:\n                raise RedisError(\n                    \"Maintenance notifications handlers on connection are only supported with RESP version 3\"\n                )\n\n            self._event_dispatcher = kwargs.get(\"event_dispatcher\", None)\n            if self._event_dispatcher is None:\n                self._event_dispatcher = EventDispatcher()\n\n            self._maint_notifications_pool_handler = MaintNotificationsPoolHandler(\n                self, maint_notifications_config\n            )\n            if oss_cluster_maint_notifications_handler:\n                self._oss_cluster_maint_notifications_handler = (\n                    oss_cluster_maint_notifications_handler\n                )\n                self._update_connection_kwargs_for_maint_notifications(\n                    oss_cluster_maint_notifications_handler=self._oss_cluster_maint_notifications_handler\n                )\n                self._maint_notifications_pool_handler = None\n            else:\n                self._oss_cluster_maint_notifications_handler = None\n                self._maint_notifications_pool_handler = MaintNotificationsPoolHandler(\n                    self, maint_notifications_config\n                )\n\n                self._update_connection_kwargs_for_maint_notifications(\n                    maint_notifications_pool_handler=self._maint_notifications_pool_handler\n                )\n        else:\n            self._maint_notifications_pool_handler = None\n            self._oss_cluster_maint_notifications_handler = None\n\n    @property\n    @abstractmethod\n    def connection_kwargs(self) -> Dict[str, Any]:\n        pass\n\n    @connection_kwargs.setter\n    @abstractmethod\n    def connection_kwargs(self, value: Dict[str, Any]):\n        pass\n\n    @abstractmethod\n    def _get_pool_lock(self) -> threading.RLock:\n        pass\n\n    @abstractmethod\n    def _get_free_connections(self) -> Iterable[\"MaintNotificationsAbstractConnection\"]:\n        pass\n\n    @abstractmethod\n    def _get_in_use_connections(\n        self,\n    ) -> Iterable[\"MaintNotificationsAbstractConnection\"]:\n        pass\n\n    def maint_notifications_enabled(self):\n        \"\"\"\n        Returns:\n            True if the maintenance notifications are enabled, False otherwise.\n            The maintenance notifications config is stored in the pool handler.\n            If the pool handler is not set, the maintenance notifications are not enabled.\n        \"\"\"\n        if self._oss_cluster_maint_notifications_handler:\n            maint_notifications_config = (\n                self._oss_cluster_maint_notifications_handler.config\n            )\n        else:\n            maint_notifications_config = (\n                self._maint_notifications_pool_handler.config\n                if self._maint_notifications_pool_handler\n                else None\n            )\n\n        return maint_notifications_config and maint_notifications_config.enabled\n\n    def update_maint_notifications_config(\n        self,\n        maint_notifications_config: MaintNotificationsConfig,\n        oss_cluster_maint_notifications_handler: Optional[\n            OSSMaintNotificationsHandler\n        ] = None,\n    ):\n        \"\"\"\n        Updates the maintenance notifications configuration.\n        This method should be called only if the pool was created\n        without enabling the maintenance notifications and\n        in a later point in time maintenance notifications\n        are requested to be enabled.\n        \"\"\"\n        if (\n            self.maint_notifications_enabled()\n            and not maint_notifications_config.enabled\n        ):\n            raise ValueError(\n                \"Cannot disable maintenance notifications after enabling them\"\n            )\n        if oss_cluster_maint_notifications_handler:\n            self._oss_cluster_maint_notifications_handler = (\n                oss_cluster_maint_notifications_handler\n            )\n        else:\n            # first update pool settings\n            if not self._maint_notifications_pool_handler:\n                self._maint_notifications_pool_handler = MaintNotificationsPoolHandler(\n                    self, maint_notifications_config\n                )\n            else:\n                self._maint_notifications_pool_handler.config = (\n                    maint_notifications_config\n                )\n\n        # then update connection kwargs and existing connections\n        self._update_connection_kwargs_for_maint_notifications(\n            maint_notifications_pool_handler=self._maint_notifications_pool_handler,\n            oss_cluster_maint_notifications_handler=self._oss_cluster_maint_notifications_handler,\n        )\n        self._update_maint_notifications_configs_for_connections(\n            maint_notifications_pool_handler=self._maint_notifications_pool_handler,\n            oss_cluster_maint_notifications_handler=self._oss_cluster_maint_notifications_handler,\n        )\n\n    def _update_connection_kwargs_for_maint_notifications(\n        self,\n        maint_notifications_pool_handler: Optional[\n            MaintNotificationsPoolHandler\n        ] = None,\n        oss_cluster_maint_notifications_handler: Optional[\n            OSSMaintNotificationsHandler\n        ] = None,\n    ):\n        \"\"\"\n        Update the connection kwargs for all future connections.\n        \"\"\"\n        if not self.maint_notifications_enabled():\n            return\n        if maint_notifications_pool_handler:\n            self.connection_kwargs.update(\n                {\n                    \"maint_notifications_pool_handler\": maint_notifications_pool_handler,\n                    \"maint_notifications_config\": maint_notifications_pool_handler.config,\n                }\n            )\n        if oss_cluster_maint_notifications_handler:\n            self.connection_kwargs.update(\n                {\n                    \"oss_cluster_maint_notifications_handler\": oss_cluster_maint_notifications_handler,\n                    \"maint_notifications_config\": oss_cluster_maint_notifications_handler.config,\n                }\n            )\n\n        # Store original connection parameters for maintenance notifications.\n        if self.connection_kwargs.get(\"orig_host_address\", None) is None:\n            # If orig_host_address is None it means we haven't\n            # configured the original values yet\n            self.connection_kwargs.update(\n                {\n                    \"orig_host_address\": self.connection_kwargs.get(\"host\"),\n                    \"orig_socket_timeout\": self.connection_kwargs.get(\n                        \"socket_timeout\", None\n                    ),\n                    \"orig_socket_connect_timeout\": self.connection_kwargs.get(\n                        \"socket_connect_timeout\", None\n                    ),\n                }\n            )\n\n    def _update_maint_notifications_configs_for_connections(\n        self,\n        maint_notifications_pool_handler: Optional[\n            MaintNotificationsPoolHandler\n        ] = None,\n        oss_cluster_maint_notifications_handler: Optional[\n            OSSMaintNotificationsHandler\n        ] = None,\n    ):\n        \"\"\"Update the maintenance notifications config for all connections in the pool.\"\"\"\n        with self._get_pool_lock():\n            for conn in self._get_free_connections():\n                if oss_cluster_maint_notifications_handler:\n                    # set cluster handler for conn\n                    conn.set_maint_notifications_cluster_handler_for_connection(\n                        oss_cluster_maint_notifications_handler\n                    )\n                    conn.maint_notifications_config = (\n                        oss_cluster_maint_notifications_handler.config\n                    )\n                elif maint_notifications_pool_handler:\n                    conn.set_maint_notifications_pool_handler_for_connection(\n                        maint_notifications_pool_handler\n                    )\n                    conn.maint_notifications_config = (\n                        maint_notifications_pool_handler.config\n                    )\n                else:\n                    raise ValueError(\n                        \"Either maint_notifications_pool_handler or oss_cluster_maint_notifications_handler must be set\"\n                    )\n                conn.disconnect()\n            for conn in self._get_in_use_connections():\n                if oss_cluster_maint_notifications_handler:\n                    conn.maint_notifications_config = (\n                        oss_cluster_maint_notifications_handler.config\n                    )\n                    conn._configure_maintenance_notifications(\n                        oss_cluster_maint_notifications_handler=oss_cluster_maint_notifications_handler\n                    )\n                elif maint_notifications_pool_handler:\n                    conn.set_maint_notifications_pool_handler_for_connection(\n                        maint_notifications_pool_handler\n                    )\n                    conn.maint_notifications_config = (\n                        maint_notifications_pool_handler.config\n                    )\n                else:\n                    raise ValueError(\n                        \"Either maint_notifications_pool_handler or oss_cluster_maint_notifications_handler must be set\"\n                    )\n                conn.mark_for_reconnect()\n\n    def _should_update_connection(\n        self,\n        conn: \"MaintNotificationsAbstractConnection\",\n        matching_pattern: Literal[\n            \"connected_address\", \"configured_address\", \"notification_hash\"\n        ] = \"connected_address\",\n        matching_address: Optional[str] = None,\n        matching_notification_hash: Optional[int] = None,\n    ) -> bool:\n        \"\"\"\n        Check if the connection should be updated based on the matching criteria.\n        \"\"\"\n        if matching_pattern == \"connected_address\":\n            if matching_address and conn.getpeername() != matching_address:\n                return False\n        elif matching_pattern == \"configured_address\":\n            if matching_address and conn.host != matching_address:\n                return False\n        elif matching_pattern == \"notification_hash\":\n            if (\n                matching_notification_hash\n                and conn.maintenance_notification_hash != matching_notification_hash\n            ):\n                return False\n        return True\n\n    def update_connection_settings(\n        self,\n        conn: \"MaintNotificationsAbstractConnection\",\n        state: Optional[\"MaintenanceState\"] = None,\n        maintenance_notification_hash: Optional[int] = None,\n        host_address: Optional[str] = None,\n        relaxed_timeout: Optional[float] = None,\n        update_notification_hash: bool = False,\n        reset_host_address: bool = False,\n        reset_relaxed_timeout: bool = False,\n    ):\n        \"\"\"\n        Update the settings for a single connection.\n        \"\"\"\n        if state:\n            conn.maintenance_state = state\n\n        if update_notification_hash:\n            # update the notification hash only if requested\n            conn.maintenance_notification_hash = maintenance_notification_hash\n\n        if host_address is not None:\n            conn.set_tmp_settings(tmp_host_address=host_address)\n\n        if relaxed_timeout is not None:\n            conn.set_tmp_settings(tmp_relaxed_timeout=relaxed_timeout)\n\n        if reset_relaxed_timeout or reset_host_address:\n            conn.reset_tmp_settings(\n                reset_host_address=reset_host_address,\n                reset_relaxed_timeout=reset_relaxed_timeout,\n            )\n\n        conn.update_current_socket_timeout(relaxed_timeout)\n\n    def update_connections_settings(\n        self,\n        state: Optional[\"MaintenanceState\"] = None,\n        maintenance_notification_hash: Optional[int] = None,\n        host_address: Optional[str] = None,\n        relaxed_timeout: Optional[float] = None,\n        matching_address: Optional[str] = None,\n        matching_notification_hash: Optional[int] = None,\n        matching_pattern: Literal[\n            \"connected_address\", \"configured_address\", \"notification_hash\"\n        ] = \"connected_address\",\n        update_notification_hash: bool = False,\n        reset_host_address: bool = False,\n        reset_relaxed_timeout: bool = False,\n        include_free_connections: bool = True,\n    ):\n        \"\"\"\n        Update the settings for all matching connections in the pool.\n\n        This method does not create new connections.\n        This method does not affect the connection kwargs.\n\n        :param state: The maintenance state to set for the connection.\n        :param maintenance_notification_hash: The hash of the maintenance notification\n                                               to set for the connection.\n        :param host_address: The host address to set for the connection.\n        :param relaxed_timeout: The relaxed timeout to set for the connection.\n        :param matching_address: The address to match for the connection.\n        :param matching_notification_hash: The notification hash to match for the connection.\n        :param matching_pattern: The pattern to match for the connection.\n        :param update_notification_hash: Whether to update the notification hash for the connection.\n        :param reset_host_address: Whether to reset the host address to the original address.\n        :param reset_relaxed_timeout: Whether to reset the relaxed timeout to the original timeout.\n        :param include_free_connections: Whether to include free/available connections.\n        \"\"\"\n        with self._get_pool_lock():\n            for conn in self._get_in_use_connections():\n                if self._should_update_connection(\n                    conn,\n                    matching_pattern,\n                    matching_address,\n                    matching_notification_hash,\n                ):\n                    self.update_connection_settings(\n                        conn,\n                        state=state,\n                        maintenance_notification_hash=maintenance_notification_hash,\n                        host_address=host_address,\n                        relaxed_timeout=relaxed_timeout,\n                        update_notification_hash=update_notification_hash,\n                        reset_host_address=reset_host_address,\n                        reset_relaxed_timeout=reset_relaxed_timeout,\n                    )\n\n            if include_free_connections:\n                for conn in self._get_free_connections():\n                    if self._should_update_connection(\n                        conn,\n                        matching_pattern,\n                        matching_address,\n                        matching_notification_hash,\n                    ):\n                        self.update_connection_settings(\n                            conn,\n                            state=state,\n                            maintenance_notification_hash=maintenance_notification_hash,\n                            host_address=host_address,\n                            relaxed_timeout=relaxed_timeout,\n                            update_notification_hash=update_notification_hash,\n                            reset_host_address=reset_host_address,\n                            reset_relaxed_timeout=reset_relaxed_timeout,\n                        )\n\n    def update_connection_kwargs(\n        self,\n        **kwargs,\n    ):\n        \"\"\"\n        Update the connection kwargs for all future connections.\n\n        This method updates the connection kwargs for all future connections created by the pool.\n        Existing connections are not affected.\n        \"\"\"\n        self.connection_kwargs.update(kwargs)\n\n    def update_active_connections_for_reconnect(\n        self,\n        moving_address_src: Optional[str] = None,\n    ):\n        \"\"\"\n        Mark all active connections for reconnect.\n        This is used when a cluster node is migrated to a different address.\n\n        :param moving_address_src: The address of the node that is being moved.\n        \"\"\"\n        with self._get_pool_lock():\n            for conn in self._get_in_use_connections():\n                if self._should_update_connection(\n                    conn, \"connected_address\", moving_address_src\n                ):\n                    conn.mark_for_reconnect()\n\n    def disconnect_free_connections(\n        self,\n        moving_address_src: Optional[str] = None,\n    ):\n        \"\"\"\n        Disconnect all free/available connections.\n        This is used when a cluster node is migrated to a different address.\n\n        :param moving_address_src: The address of the node that is being moved.\n        \"\"\"\n        with self._get_pool_lock():\n            for conn in self._get_free_connections():\n                if self._should_update_connection(\n                    conn, \"connected_address\", moving_address_src\n                ):\n                    conn.disconnect()\n\n\nclass ConnectionPool(MaintNotificationsAbstractConnectionPool, ConnectionPoolInterface):\n    \"\"\"\n    Create a connection pool. ``If max_connections`` is set, then this\n    object raises :py:class:`~redis.exceptions.ConnectionError` when the pool's\n    limit is reached.\n\n    By default, TCP connections are created unless ``connection_class``\n    is specified. Use class:`.UnixDomainSocketConnection` for\n    unix sockets.\n    :py:class:`~redis.SSLConnection` can be used for SSL enabled connections.\n\n    If ``maint_notifications_config`` is provided, the connection pool will support\n    maintenance notifications.\n    Maintenance notifications are supported only with RESP3.\n    If the ``maint_notifications_config`` is not provided but the ``protocol`` is 3,\n    the maintenance notifications will be enabled by default.\n\n    Any additional keyword arguments are passed to the constructor of\n    ``connection_class``.\n    \"\"\"\n\n    @classmethod\n    def from_url(cls: Type[_CP], url: str, **kwargs) -> _CP:\n        \"\"\"\n        Return a connection pool configured from the given URL.\n\n        For example::\n\n            redis://[[username]:[password]]@localhost:6379/0\n            rediss://[[username]:[password]]@localhost:6379/0\n            unix://[username@]/path/to/socket.sock?db=0[&password=password]\n\n        Three URL schemes are supported:\n\n        - `redis://` creates a TCP socket connection. See more at:\n          <https://www.iana.org/assignments/uri-schemes/prov/redis>\n        - `rediss://` creates a SSL wrapped TCP socket connection. See more at:\n          <https://www.iana.org/assignments/uri-schemes/prov/rediss>\n        - ``unix://``: creates a Unix Domain Socket connection.\n\n        The username, password, hostname, path and all querystring values\n        are passed through urllib.parse.unquote in order to replace any\n        percent-encoded values with their corresponding characters.\n\n        There are several ways to specify a database number. The first value\n        found will be used:\n\n            1. A ``db`` querystring option, e.g. redis://localhost?db=0\n            2. If using the redis:// or rediss:// schemes, the path argument\n               of the url, e.g. redis://localhost/0\n            3. A ``db`` keyword argument to this function.\n\n        If none of these options are specified, the default db=0 is used.\n\n        All querystring options are cast to their appropriate Python types.\n        Boolean arguments can be specified with string values \"True\"/\"False\"\n        or \"Yes\"/\"No\". Values that cannot be properly cast cause a\n        ``ValueError`` to be raised. Once parsed, the querystring arguments\n        and keyword arguments are passed to the ``ConnectionPool``'s\n        class initializer. In the case of conflicting arguments, querystring\n        arguments always win.\n        \"\"\"\n        url_options = parse_url(url)\n\n        if \"connection_class\" in kwargs:\n            url_options[\"connection_class\"] = kwargs[\"connection_class\"]\n\n        kwargs.update(url_options)\n        return cls(**kwargs)\n\n    def __init__(\n        self,\n        connection_class=Connection,\n        max_connections: Optional[int] = None,\n        cache_factory: Optional[CacheFactoryInterface] = None,\n        maint_notifications_config: Optional[MaintNotificationsConfig] = None,\n        **connection_kwargs,\n    ):\n        max_connections = max_connections or 2**31\n        if not isinstance(max_connections, int) or max_connections < 0:\n            raise ValueError('\"max_connections\" must be a positive integer')\n\n        self.connection_class = connection_class\n        self._connection_kwargs = connection_kwargs\n        self.max_connections = max_connections\n        self.cache = None\n        self._cache_factory = cache_factory\n\n        self._event_dispatcher = self._connection_kwargs.get(\"event_dispatcher\", None)\n        if self._event_dispatcher is None:\n            self._event_dispatcher = EventDispatcher()\n\n        if connection_kwargs.get(\"cache_config\") or connection_kwargs.get(\"cache\"):\n            if not check_protocol_version(self._connection_kwargs.get(\"protocol\"), 3):\n                raise RedisError(\"Client caching is only supported with RESP version 3\")\n\n            cache = self._connection_kwargs.get(\"cache\")\n\n            if cache is not None:\n                if not isinstance(cache, CacheInterface):\n                    raise ValueError(\"Cache must implement CacheInterface\")\n\n                self.cache = cache\n            else:\n                if self._cache_factory is not None:\n                    self.cache = CacheProxy(self._cache_factory.get_cache())\n                else:\n                    self.cache = CacheFactory(\n                        self._connection_kwargs.get(\"cache_config\")\n                    ).get_cache()\n\n            init_csc_items()\n            register_csc_items_callback(\n                callback=lambda: self.cache.size,\n                pool_name=get_pool_name(self),\n            )\n\n        connection_kwargs.pop(\"cache\", None)\n        connection_kwargs.pop(\"cache_config\", None)\n\n        # a lock to protect the critical section in _checkpid().\n        # this lock is acquired when the process id changes, such as\n        # after a fork. during this time, multiple threads in the child\n        # process could attempt to acquire this lock. the first thread\n        # to acquire the lock will reset the data structures and lock\n        # object of this pool. subsequent threads acquiring this lock\n        # will notice the first thread already did the work and simply\n        # release the lock.\n\n        self._fork_lock = threading.RLock()\n        self._lock = threading.RLock()\n\n        # Generate unique pool ID for observability (matches go-redis behavior)\n        import secrets\n\n        self._pool_id = secrets.token_hex(4)\n\n        MaintNotificationsAbstractConnectionPool.__init__(\n            self,\n            maint_notifications_config=maint_notifications_config,\n            **connection_kwargs,\n        )\n\n        self.reset()\n\n    # Keys that should be redacted in __repr__ to avoid exposing sensitive information\n    SENSITIVE_REPR_KEYS = frozenset(\n        {\n            \"password\",\n            \"username\",\n            \"ssl_password\",\n            \"credential_provider\",\n        }\n    )\n\n    def __repr__(self) -> str:\n        conn_kwargs = \",\".join(\n            [\n                f\"{k}={'<REDACTED>' if k in self.SENSITIVE_REPR_KEYS else v}\"\n                for k, v in self.connection_kwargs.items()\n            ]\n        )\n        return (\n            f\"<{self.__class__.__module__}.{self.__class__.__name__}\"\n            f\"(<{self.connection_class.__module__}.{self.connection_class.__name__}\"\n            f\"({conn_kwargs})>)>\"\n        )\n\n    @property\n    def connection_kwargs(self) -> Dict[str, Any]:\n        return self._connection_kwargs\n\n    @connection_kwargs.setter\n    def connection_kwargs(self, value: Dict[str, Any]):\n        self._connection_kwargs = value\n\n    def get_protocol(self):\n        \"\"\"\n        Returns:\n            The RESP protocol version, or ``None`` if the protocol is not specified,\n            in which case the server default will be used.\n        \"\"\"\n        return self.connection_kwargs.get(\"protocol\", None)\n\n    def reset(self) -> None:\n        # Record metrics for connections being removed before clearing\n        # (only if attributes exist - they won't during __init__)\n        if hasattr(self, \"_available_connections\") and hasattr(\n            self, \"_in_use_connections\"\n        ):\n            with self._lock:\n                idle_count = len(self._available_connections)\n                in_use_count = len(self._in_use_connections)\n                if idle_count > 0 or in_use_count > 0:\n                    pool_name = get_pool_name(self)\n                    if idle_count > 0:\n                        record_connection_count(\n                            pool_name=pool_name,\n                            connection_state=ConnectionState.IDLE,\n                            counter=-idle_count,\n                        )\n                    if in_use_count > 0:\n                        record_connection_count(\n                            pool_name=pool_name,\n                            connection_state=ConnectionState.USED,\n                            counter=-in_use_count,\n                        )\n\n        self._created_connections = 0\n        self._available_connections = []\n        self._in_use_connections = set()\n\n        # this must be the last operation in this method. while reset() is\n        # called when holding _fork_lock, other threads in this process\n        # can call _checkpid() which compares self.pid and os.getpid() without\n        # holding any lock (for performance reasons). keeping this assignment\n        # as the last operation ensures that those other threads will also\n        # notice a pid difference and block waiting for the first thread to\n        # release _fork_lock. when each of these threads eventually acquire\n        # _fork_lock, they will notice that another thread already called\n        # reset() and they will immediately release _fork_lock and continue on.\n        self.pid = os.getpid()\n\n    def __del__(self) -> None:\n        \"\"\"Clean up connection pool and record metrics when garbage collected.\"\"\"\n        try:\n            if not hasattr(self, \"_available_connections\") or not hasattr(\n                self, \"_in_use_connections\"\n            ):\n                return\n            # Record metrics for all connections being removed\n            idle_count = len(self._available_connections)\n            in_use_count = len(self._in_use_connections)\n            if idle_count > 0 or in_use_count > 0:\n                pool_name = get_pool_name(self)\n                if idle_count > 0:\n                    record_connection_count(\n                        pool_name=pool_name,\n                        connection_state=ConnectionState.IDLE,\n                        counter=-idle_count,\n                    )\n                if in_use_count > 0:\n                    record_connection_count(\n                        pool_name=pool_name,\n                        connection_state=ConnectionState.USED,\n                        counter=-in_use_count,\n                    )\n        except Exception:\n            pass\n\n    def _checkpid(self) -> None:\n        # _checkpid() attempts to keep ConnectionPool fork-safe on modern\n        # systems. this is called by all ConnectionPool methods that\n        # manipulate the pool's state such as get_connection() and release().\n        #\n        # _checkpid() determines whether the process has forked by comparing\n        # the current process id to the process id saved on the ConnectionPool\n        # instance. if these values are the same, _checkpid() simply returns.\n        #\n        # when the process ids differ, _checkpid() assumes that the process\n        # has forked and that we're now running in the child process. the child\n        # process cannot use the parent's file descriptors (e.g., sockets).\n        # therefore, when _checkpid() sees the process id change, it calls\n        # reset() in order to reinitialize the child's ConnectionPool. this\n        # will cause the child to make all new connection objects.\n        #\n        # _checkpid() is protected by self._fork_lock to ensure that multiple\n        # threads in the child process do not call reset() multiple times.\n        #\n        # there is an extremely small chance this could fail in the following\n        # scenario:\n        #   1. process A calls _checkpid() for the first time and acquires\n        #      self._fork_lock.\n        #   2. while holding self._fork_lock, process A forks (the fork()\n        #      could happen in a different thread owned by process A)\n        #   3. process B (the forked child process) inherits the\n        #      ConnectionPool's state from the parent. that state includes\n        #      a locked _fork_lock. process B will not be notified when\n        #      process A releases the _fork_lock and will thus never be\n        #      able to acquire the _fork_lock.\n        #\n        # to mitigate this possible deadlock, _checkpid() will only wait 5\n        # seconds to acquire _fork_lock. if _fork_lock cannot be acquired in\n        # that time it is assumed that the child is deadlocked and a\n        # redis.ChildDeadlockedError error is raised.\n        if self.pid != os.getpid():\n            acquired = self._fork_lock.acquire(timeout=5)\n            if not acquired:\n                raise ChildDeadlockedError\n            # reset() the instance for the new process if another thread\n            # hasn't already done so\n            try:\n                if self.pid != os.getpid():\n                    self.reset()\n            finally:\n                self._fork_lock.release()\n\n    @deprecated_args(\n        args_to_warn=[\"*\"],\n        reason=\"Use get_connection() without args instead\",\n        version=\"5.3.0\",\n    )\n    def get_connection(self, command_name=None, *keys, **options) -> \"Connection\":\n        \"Get a connection from the pool\"\n\n        # Start timing for observability\n        self._checkpid()\n        is_created = False\n\n        with self._lock:\n            try:\n                connection = self._available_connections.pop()\n            except IndexError:\n                # Start timing for observability\n                start_time_created = time.monotonic()\n\n                connection = self.make_connection()\n                is_created = True\n            self._in_use_connections.add(connection)\n\n        # Record state transition: IDLE -> USED\n        # (make_connection already recorded IDLE +1 for new connections)\n        # This ensures counters stay balanced if connect() fails and release() is called\n        pool_name = get_pool_name(self)\n        record_connection_count(\n            pool_name=pool_name,\n            connection_state=ConnectionState.IDLE,\n            counter=-1,\n        )\n        record_connection_count(\n            pool_name=pool_name,\n            connection_state=ConnectionState.USED,\n            counter=1,\n        )\n\n        try:\n            # ensure this connection is connected to Redis\n            connection.connect()\n            # connections that the pool provides should be ready to send\n            # a command. if not, the connection was either returned to the\n            # pool before all data has been read or the socket has been\n            # closed. either way, reconnect and verify everything is good.\n            try:\n                if (\n                    connection.can_read()\n                    and self.cache is None\n                    and not self.maint_notifications_enabled()\n                ):\n                    raise ConnectionError(\"Connection has data\")\n            except (ConnectionError, TimeoutError, OSError):\n                connection.disconnect()\n                connection.connect()\n                if connection.can_read():\n                    raise ConnectionError(\"Connection not ready\")\n        except BaseException:\n            # release the connection back to the pool so that we don't\n            # leak it\n            self.release(connection)\n            raise\n\n        if is_created:\n            record_connection_create_time(\n                connection_pool=self,\n                duration_seconds=time.monotonic() - start_time_created,\n            )\n\n        return connection\n\n    def get_encoder(self) -> Encoder:\n        \"Return an encoder based on encoding settings\"\n        kwargs = self.connection_kwargs\n        return Encoder(\n            encoding=kwargs.get(\"encoding\", \"utf-8\"),\n            encoding_errors=kwargs.get(\"encoding_errors\", \"strict\"),\n            decode_responses=kwargs.get(\"decode_responses\", False),\n        )\n\n    def make_connection(self) -> \"ConnectionInterface\":\n        \"Create a new connection\"\n        if self._created_connections >= self.max_connections:\n            raise MaxConnectionsError(\"Too many connections\")\n        self._created_connections += 1\n\n        kwargs = dict(self.connection_kwargs)\n\n        # Create the connection first, then record metrics only on success\n        if self.cache is not None:\n            connection = CacheProxyConnection(\n                self.connection_class(**kwargs), self.cache, self._lock\n            )\n        else:\n            connection = self.connection_class(**kwargs)\n\n        # Record new connection created (starts as IDLE) - only after successful construction\n        record_connection_count(\n            pool_name=get_pool_name(self),\n            connection_state=ConnectionState.IDLE,\n            counter=1,\n        )\n\n        return connection\n\n    def release(self, connection: \"Connection\") -> None:\n        \"Releases the connection back to the pool\"\n        self._checkpid()\n        with self._lock:\n            try:\n                self._in_use_connections.remove(connection)\n            except KeyError:\n                # Gracefully fail when a connection is returned to this pool\n                # that the pool doesn't actually own\n                return\n\n            if self.owns_connection(connection):\n                if connection.should_reconnect():\n                    connection.disconnect()\n                self._available_connections.append(connection)\n                self._event_dispatcher.dispatch(\n                    AfterConnectionReleasedEvent(connection)\n                )\n\n                # Record state transition: USED -> IDLE\n                pool_name = get_pool_name(self)\n                record_connection_count(\n                    pool_name=pool_name,\n                    connection_state=ConnectionState.USED,\n                    counter=-1,\n                )\n                record_connection_count(\n                    pool_name=pool_name,\n                    connection_state=ConnectionState.IDLE,\n                    counter=1,\n                )\n            else:\n                # Pool doesn't own this connection, do not add it back\n                # to the pool.\n                # The created connections count should not be changed,\n                # because the connection was not created by the pool.\n                # Still need to decrement USED since it was counted in get_connection()\n                connection.disconnect()\n                record_connection_count(\n                    pool_name=\"unknown_pool\",\n                    connection_state=ConnectionState.USED,\n                    counter=-1,\n                )\n                return\n\n    def owns_connection(self, connection: \"Connection\") -> int:\n        return connection.pid == self.pid\n\n    def disconnect(self, inuse_connections: bool = True) -> None:\n        \"\"\"\n        Disconnects connections in the pool\n\n        If ``inuse_connections`` is True, disconnect connections that are\n        currently in use, potentially by other threads. Otherwise only disconnect\n        connections that are idle in the pool.\n        \"\"\"\n        self._checkpid()\n        with self._lock:\n            if inuse_connections:\n                connections = chain(\n                    self._available_connections, self._in_use_connections\n                )\n            else:\n                connections = self._available_connections\n\n            for connection in connections:\n                connection.disconnect()\n\n    def close(self) -> None:\n        \"\"\"Close the pool, disconnecting all connections\"\"\"\n        self.disconnect()\n\n    def set_retry(self, retry: Retry) -> None:\n        self.connection_kwargs.update({\"retry\": retry})\n        for conn in self._available_connections:\n            conn.retry = retry\n        for conn in self._in_use_connections:\n            conn.retry = retry\n\n    def re_auth_callback(self, token: TokenInterface):\n        with self._lock:\n            for conn in self._available_connections:\n                conn.retry.call_with_retry(\n                    lambda: conn.send_command(\n                        \"AUTH\", token.try_get(\"oid\"), token.get_value()\n                    ),\n                    lambda error: self._mock(error),\n                )\n                conn.retry.call_with_retry(\n                    lambda: conn.read_response(), lambda error: self._mock(error)\n                )\n            for conn in self._in_use_connections:\n                conn.set_re_auth_token(token)\n\n    def _get_pool_lock(self):\n        return self._lock\n\n    def _get_free_connections(self):\n        with self._lock:\n            return list(self._available_connections)\n\n    def _get_in_use_connections(self):\n        with self._lock:\n            return set(self._in_use_connections)\n\n    def _mock(self, error: RedisError):\n        \"\"\"\n        Dummy functions, needs to be passed as error callback to retry object.\n        :param error:\n        :return:\n        \"\"\"\n        pass\n\n    def get_connection_count(self) -> List[tuple[int, dict]]:\n        from redis.observability.attributes import get_pool_name\n\n        attributes = AttributeBuilder.build_base_attributes()\n        attributes[DB_CLIENT_CONNECTION_POOL_NAME] = get_pool_name(self)\n        free_connections_attributes = attributes.copy()\n        in_use_connections_attributes = attributes.copy()\n\n        free_connections_attributes[DB_CLIENT_CONNECTION_STATE] = (\n            ConnectionState.IDLE.value\n        )\n        in_use_connections_attributes[DB_CLIENT_CONNECTION_STATE] = (\n            ConnectionState.USED.value\n        )\n\n        return [\n            (len(self._get_free_connections()), free_connections_attributes),\n            (len(self._get_in_use_connections()), in_use_connections_attributes),\n        ]\n\n\nclass BlockingConnectionPool(ConnectionPool):\n    \"\"\"\n    Thread-safe blocking connection pool::\n\n        >>> from redis.client import Redis\n        >>> client = Redis(connection_pool=BlockingConnectionPool())\n\n    It performs the same function as the default\n    :py:class:`~redis.ConnectionPool` implementation, in that,\n    it maintains a pool of reusable connections that can be shared by\n    multiple redis clients (safely across threads if required).\n\n    The difference is that, in the event that a client tries to get a\n    connection from the pool when all of connections are in use, rather than\n    raising a :py:class:`~redis.ConnectionError` (as the default\n    :py:class:`~redis.ConnectionPool` implementation does), it\n    makes the client wait (\"blocks\") for a specified number of seconds until\n    a connection becomes available.\n\n    Use ``max_connections`` to increase / decrease the pool size::\n\n        >>> pool = BlockingConnectionPool(max_connections=10)\n\n    Use ``timeout`` to tell it either how many seconds to wait for a connection\n    to become available, or to block forever:\n\n        >>> # Block forever.\n        >>> pool = BlockingConnectionPool(timeout=None)\n\n        >>> # Raise a ``ConnectionError`` after five seconds if a connection is\n        >>> # not available.\n        >>> pool = BlockingConnectionPool(timeout=5)\n    \"\"\"\n\n    def __init__(\n        self,\n        max_connections=50,\n        timeout=20,\n        connection_class=Connection,\n        queue_class=LifoQueue,\n        **connection_kwargs,\n    ):\n        self.queue_class = queue_class\n        self.timeout = timeout\n        self._in_maintenance = False\n        self._locked = False\n        super().__init__(\n            connection_class=connection_class,\n            max_connections=max_connections,\n            **connection_kwargs,\n        )\n\n    def reset(self):\n        # Create and fill up a thread safe queue with ``None`` values.\n        try:\n            if self._in_maintenance:\n                self._lock.acquire()\n                self._locked = True\n\n            # Record metrics for connections being removed before clearing\n            # Note: Access pool.queue directly to avoid deadlock since we may\n            # already hold self._lock (which is non-reentrant)\n            if (\n                hasattr(self, \"_connections\")\n                and self._connections\n                and hasattr(self, \"pool\")\n            ):\n                with self._lock:\n                    connections_in_queue = {conn for conn in self.pool.queue if conn}\n                    idle_count = len(connections_in_queue)\n                    in_use_count = len(self._connections) - idle_count\n                    if idle_count > 0 or in_use_count > 0:\n                        pool_name = get_pool_name(self)\n                        if idle_count > 0:\n                            record_connection_count(\n                                pool_name=pool_name,\n                                connection_state=ConnectionState.IDLE,\n                                counter=-idle_count,\n                            )\n                        if in_use_count > 0:\n                            record_connection_count(\n                                pool_name=pool_name,\n                                connection_state=ConnectionState.USED,\n                                counter=-in_use_count,\n                            )\n\n            self.pool = self.queue_class(self.max_connections)\n            while True:\n                try:\n                    self.pool.put_nowait(None)\n                except Full:\n                    break\n\n            # Keep a list of actual connection instances so that we can\n            # disconnect them later.\n            self._connections = []\n        finally:\n            if self._locked:\n                try:\n                    self._lock.release()\n                except Exception:\n                    pass\n                self._locked = False\n\n        # this must be the last operation in this method. while reset() is\n        # called when holding _fork_lock, other threads in this process\n        # can call _checkpid() which compares self.pid and os.getpid() without\n        # holding any lock (for performance reasons). keeping this assignment\n        # as the last operation ensures that those other threads will also\n        # notice a pid difference and block waiting for the first thread to\n        # release _fork_lock. when each of these threads eventually acquire\n        # _fork_lock, they will notice that another thread already called\n        # reset() and they will immediately release _fork_lock and continue on.\n        self.pid = os.getpid()\n\n    def __del__(self) -> None:\n        \"\"\"Clean up connection pool and record metrics when garbage collected.\"\"\"\n        try:\n            # Note: Access pool.queue directly to avoid potential deadlock\n            # if GC runs while the lock is held by the same thread\n            if (\n                hasattr(self, \"_connections\")\n                and self._connections\n                and hasattr(self, \"pool\")\n            ):\n                connections_in_queue = {conn for conn in self.pool.queue if conn}\n                idle_count = len(connections_in_queue)\n                in_use_count = len(self._connections) - idle_count\n                if idle_count > 0 or in_use_count > 0:\n                    pool_name = get_pool_name(self)\n                    if idle_count > 0:\n                        record_connection_count(\n                            pool_name=pool_name,\n                            connection_state=ConnectionState.IDLE,\n                            counter=-idle_count,\n                        )\n                    if in_use_count > 0:\n                        record_connection_count(\n                            pool_name=pool_name,\n                            connection_state=ConnectionState.USED,\n                            counter=-in_use_count,\n                        )\n        except Exception:\n            pass\n\n    def make_connection(self):\n        \"Make a fresh connection.\"\n        try:\n            if self._in_maintenance:\n                self._lock.acquire()\n                self._locked = True\n\n            if self.cache is not None:\n                connection = CacheProxyConnection(\n                    self.connection_class(**self.connection_kwargs),\n                    self.cache,\n                    self._lock,\n                )\n            else:\n                connection = self.connection_class(**self.connection_kwargs)\n            self._connections.append(connection)\n\n            # Record new connection created (starts as IDLE)\n            record_connection_count(\n                pool_name=get_pool_name(self),\n                connection_state=ConnectionState.IDLE,\n                counter=1,\n            )\n\n            return connection\n        finally:\n            if self._locked:\n                try:\n                    self._lock.release()\n                except Exception:\n                    pass\n                self._locked = False\n\n    @deprecated_args(\n        args_to_warn=[\"*\"],\n        reason=\"Use get_connection() without args instead\",\n        version=\"5.3.0\",\n    )\n    def get_connection(self, command_name=None, *keys, **options):\n        \"\"\"\n        Get a connection, blocking for ``self.timeout`` until a connection\n        is available from the pool.\n\n        If the connection returned is ``None`` then creates a new connection.\n        Because we use a last-in first-out queue, the existing connections\n        (having been returned to the pool after the initial ``None`` values\n        were added) will be returned before ``None`` values. This means we only\n        create new connections when we need to, i.e.: the actual number of\n        connections will only increase in response to demand.\n        \"\"\"\n        start_time_acquired = time.monotonic()\n        # Make sure we haven't changed process.\n        self._checkpid()\n        is_created = False\n\n        # Try and get a connection from the pool. If one isn't available within\n        # self.timeout then raise a ``ConnectionError``.\n        connection = None\n        try:\n            if self._in_maintenance:\n                self._lock.acquire()\n                self._locked = True\n            try:\n                connection = self.pool.get(block=True, timeout=self.timeout)\n            except Empty:\n                # Note that this is not caught by the redis client and will be\n                # raised unless handled by application code. If you want never to\n                raise ConnectionError(\"No connection available.\")\n\n            # If the ``connection`` is actually ``None`` then that's a cue to make\n            # a new connection to add to the pool.\n            if connection is None:\n                # Start timing for observability\n                start_time_created = time.monotonic()\n                connection = self.make_connection()\n                is_created = True\n        finally:\n            if self._locked:\n                try:\n                    self._lock.release()\n                except Exception:\n                    pass\n                self._locked = False\n\n        # Record state transition: IDLE -> USED\n        # (make_connection already recorded IDLE +1 for new connections)\n        # This ensures counters stay balanced if connect() fails and release() is called\n        pool_name = get_pool_name(self)\n        record_connection_count(\n            pool_name=pool_name,\n            connection_state=ConnectionState.IDLE,\n            counter=-1,\n        )\n        record_connection_count(\n            pool_name=pool_name,\n            connection_state=ConnectionState.USED,\n            counter=1,\n        )\n\n        try:\n            # ensure this connection is connected to Redis\n            connection.connect()\n            # connections that the pool provides should be ready to send\n            # a command. if not, the connection was either returned to the\n            # pool before all data has been read or the socket has been\n            # closed. either way, reconnect and verify everything is good.\n            try:\n                if connection.can_read():\n                    raise ConnectionError(\"Connection has data\")\n            except (ConnectionError, TimeoutError, OSError):\n                connection.disconnect()\n                connection.connect()\n                if connection.can_read():\n                    raise ConnectionError(\"Connection not ready\")\n        except BaseException:\n            # release the connection back to the pool so that we don't leak it\n            self.release(connection)\n            raise\n\n        if is_created:\n            record_connection_create_time(\n                connection_pool=self,\n                duration_seconds=time.monotonic() - start_time_created,\n            )\n\n        record_connection_wait_time(\n            pool_name=pool_name,\n            duration_seconds=time.monotonic() - start_time_acquired,\n        )\n\n        return connection\n\n    def release(self, connection):\n        \"Releases the connection back to the pool.\"\n        # Make sure we haven't changed process.\n        self._checkpid()\n\n        try:\n            if self._in_maintenance:\n                self._lock.acquire()\n                self._locked = True\n            if not self.owns_connection(connection):\n                # pool doesn't own this connection. do not add it back\n                # to the pool. instead add a None value which is a placeholder\n                # that will cause the pool to recreate the connection if\n                # its needed.\n                connection.disconnect()\n                self.pool.put_nowait(None)\n                # Still need to decrement USED since it was counted in get_connection()\n                record_connection_count(\n                    pool_name=\"unknown_pool\",\n                    connection_state=ConnectionState.USED,\n                    counter=-1,\n                )\n                return\n            if connection.should_reconnect():\n                connection.disconnect()\n            # Put the connection back into the pool.\n            pool_name = get_pool_name(self)\n            try:\n                self.pool.put_nowait(connection)\n\n                # Record state transition: USED -> IDLE\n                record_connection_count(\n                    pool_name=pool_name,\n                    connection_state=ConnectionState.USED,\n                    counter=-1,\n                )\n                record_connection_count(\n                    pool_name=pool_name,\n                    connection_state=ConnectionState.IDLE,\n                    counter=1,\n                )\n            except Full:\n                pass\n        finally:\n            if self._locked:\n                try:\n                    self._lock.release()\n                except Exception:\n                    pass\n                self._locked = False\n\n    def disconnect(self, inuse_connections: bool = True):\n        \"\"\"\n        Disconnects either all connections in the pool or just the free connections.\n        \"\"\"\n        self._checkpid()\n        try:\n            if self._in_maintenance:\n                self._lock.acquire()\n                self._locked = True\n\n            if inuse_connections:\n                connections = self._connections\n            else:\n                connections = self._get_free_connections()\n\n            for connection in connections:\n                connection.disconnect()\n        finally:\n            if self._locked:\n                try:\n                    self._lock.release()\n                except Exception:\n                    pass\n                self._locked = False\n\n    def _get_free_connections(self):\n        with self._lock:\n            return {conn for conn in self.pool.queue if conn}\n\n    def _get_in_use_connections(self):\n        with self._lock:\n            # free connections\n            connections_in_queue = {conn for conn in self.pool.queue if conn}\n            # in self._connections we keep all created connections\n            # so the ones that are not in the queue are the in use ones\n            return {\n                conn for conn in self._connections if conn not in connections_in_queue\n            }\n\n    def set_in_maintenance(self, in_maintenance: bool):\n        \"\"\"\n        Sets a flag that this Blocking ConnectionPool is in maintenance mode.\n\n        This is used to prevent new connections from being created while we are in maintenance mode.\n        The pool will be in maintenance mode only when we are processing a MOVING notification.\n        \"\"\"\n        self._in_maintenance = in_maintenance\n"
  },
  {
    "path": "redis/crc.py",
    "content": "from binascii import crc_hqx\n\nfrom redis.typing import EncodedT\n\n# Redis Cluster's key space is divided into 16384 slots.\n# For more information see: https://github.com/redis/redis/issues/2576\nREDIS_CLUSTER_HASH_SLOTS = 16384\n\n__all__ = [\"key_slot\", \"REDIS_CLUSTER_HASH_SLOTS\"]\n\n\ndef key_slot(key: EncodedT, bucket: int = REDIS_CLUSTER_HASH_SLOTS) -> int:\n    \"\"\"Calculate key slot for a given key.\n    See Keys distribution model in https://redis.io/topics/cluster-spec\n    :param key - bytes\n    :param bucket - int\n    \"\"\"\n    start = key.find(b\"{\")\n    if start > -1:\n        end = key.find(b\"}\", start + 1)\n        if end > -1 and end != start + 1:\n            key = key[start + 1 : end]\n    return crc_hqx(key, 0) % bucket\n"
  },
  {
    "path": "redis/credentials.py",
    "content": "import logging\nfrom abc import ABC, abstractmethod\nfrom typing import Any, Callable, Optional, Tuple, Union\n\nlogger = logging.getLogger(__name__)\n\n\nclass CredentialProvider:\n    \"\"\"\n    Credentials Provider.\n    \"\"\"\n\n    def get_credentials(self) -> Union[Tuple[str], Tuple[str, str]]:\n        raise NotImplementedError(\"get_credentials must be implemented\")\n\n    async def get_credentials_async(self) -> Union[Tuple[str], Tuple[str, str]]:\n        logger.warning(\n            \"This method is added for backward compatability. \"\n            \"Please override it in your implementation.\"\n        )\n        return self.get_credentials()\n\n\nclass StreamingCredentialProvider(CredentialProvider, ABC):\n    \"\"\"\n    Credential provider that streams credentials in the background.\n    \"\"\"\n\n    @abstractmethod\n    def on_next(self, callback: Callable[[Any], None]):\n        \"\"\"\n        Specifies the callback that should be invoked\n        when the next credentials will be retrieved.\n\n        :param callback: Callback with\n        :return:\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def on_error(self, callback: Callable[[Exception], None]):\n        pass\n\n    @abstractmethod\n    def is_streaming(self) -> bool:\n        pass\n\n\nclass UsernamePasswordCredentialProvider(CredentialProvider):\n    \"\"\"\n    Simple implementation of CredentialProvider that just wraps static\n    username and password.\n    \"\"\"\n\n    def __init__(self, username: Optional[str] = None, password: Optional[str] = None):\n        self.username = username or \"\"\n        self.password = password or \"\"\n\n    def get_credentials(self):\n        if self.username:\n            return self.username, self.password\n        return (self.password,)\n\n    async def get_credentials_async(self) -> Union[Tuple[str], Tuple[str, str]]:\n        return self.get_credentials()\n"
  },
  {
    "path": "redis/data_structure.py",
    "content": "import threading\nfrom typing import Any, Generic, List, TypeVar\n\nfrom redis.typing import Number\n\nT = TypeVar(\"T\")\n\n\nclass WeightedList(Generic[T]):\n    \"\"\"\n    Thread-safe weighted list.\n    \"\"\"\n\n    def __init__(self):\n        self._items: List[tuple[Any, Number]] = []\n        self._lock = threading.RLock()\n\n    def add(self, item: Any, weight: float) -> None:\n        \"\"\"Add item with weight, maintaining sorted order\"\"\"\n        with self._lock:\n            # Find insertion point using binary search\n            left, right = 0, len(self._items)\n            while left < right:\n                mid = (left + right) // 2\n                if self._items[mid][1] < weight:\n                    right = mid\n                else:\n                    left = mid + 1\n\n            self._items.insert(left, (item, weight))\n\n    def remove(self, item):\n        \"\"\"Remove first occurrence of item\"\"\"\n        with self._lock:\n            for i, (stored_item, weight) in enumerate(self._items):\n                if stored_item == item:\n                    self._items.pop(i)\n                    return weight\n            raise ValueError(\"Item not found\")\n\n    def get_by_weight_range(\n        self, min_weight: float, max_weight: float\n    ) -> List[tuple[Any, Number]]:\n        \"\"\"Get all items within weight range\"\"\"\n        with self._lock:\n            result = []\n            for item, weight in self._items:\n                if min_weight <= weight <= max_weight:\n                    result.append((item, weight))\n            return result\n\n    def get_top_n(self, n: int) -> List[tuple[Any, Number]]:\n        \"\"\"Get top N the highest weighted items\"\"\"\n        with self._lock:\n            return [(item, weight) for item, weight in self._items[:n]]\n\n    def update_weight(self, item, new_weight: float):\n        with self._lock:\n            \"\"\"Update weight of an item\"\"\"\n            old_weight = self.remove(item)\n            self.add(item, new_weight)\n            return old_weight\n\n    def __iter__(self):\n        \"\"\"Iterate in descending weight order\"\"\"\n        with self._lock:\n            items_copy = (\n                self._items.copy()\n            )  # Create snapshot as lock released after each 'yield'\n\n        for item, weight in items_copy:\n            yield item, weight\n\n    def __len__(self):\n        with self._lock:\n            return len(self._items)\n\n    def __getitem__(self, index) -> tuple[Any, Number]:\n        with self._lock:\n            item, weight = self._items[index]\n            return item, weight\n"
  },
  {
    "path": "redis/driver_info.py",
    "content": "from __future__ import annotations\n\nfrom dataclasses import dataclass, field\nfrom typing import List, Optional\n\n_BRACES = {\"(\", \")\", \"[\", \"]\", \"{\", \"}\"}\n\n\ndef _validate_no_invalid_chars(value: str, field_name: str) -> None:\n    \"\"\"Ensure value contains only printable ASCII without spaces or braces.\n\n    This mirrors the constraints enforced by other Redis clients for values that\n    will appear in CLIENT LIST / CLIENT INFO output.\n    \"\"\"\n\n    for ch in value:\n        # printable ASCII without space: '!' (0x21) to '~' (0x7E)\n        if ord(ch) < 0x21 or ord(ch) > 0x7E or ch in _BRACES:\n            raise ValueError(\n                f\"{field_name} must not contain spaces, newlines, non-printable characters, or braces\"\n            )\n\n\ndef _validate_driver_name(name: str) -> None:\n    \"\"\"Validate an upstream driver name.\n\n    The name should look like a typical Python distribution or package name,\n    following a simplified form of PEP 503 normalisation rules:\n\n    * start with a lowercase ASCII letter\n    * contain only lowercase letters, digits, hyphens and underscores\n\n    Examples of valid names: ``\"django-redis\"``, ``\"celery\"``, ``\"rq\"``.\n    \"\"\"\n\n    import re\n\n    _validate_no_invalid_chars(name, \"Driver name\")\n    if not re.match(r\"^[a-z][a-z0-9_-]*$\", name):\n        raise ValueError(\n            \"Upstream driver name must use a Python package-style name: \"\n            \"start with a lowercase letter and contain only lowercase letters, \"\n            \"digits, hyphens, and underscores (e.g., 'django-redis').\"\n        )\n\n\ndef _validate_driver_version(version: str) -> None:\n    _validate_no_invalid_chars(version, \"Driver version\")\n\n\ndef _format_driver_entry(driver_name: str, driver_version: str) -> str:\n    return f\"{driver_name}_v{driver_version}\"\n\n\n@dataclass\nclass DriverInfo:\n    \"\"\"Driver information used to build the CLIENT SETINFO LIB-NAME and LIB-VER values.\n\n    This class consolidates all driver metadata (redis-py version and upstream drivers)\n    into a single object that is propagated through connection pools and connections.\n\n    The formatted name follows the pattern::\n\n        name(driver1_vVersion1;driver2_vVersion2)\n\n    Parameters\n    ----------\n    name : str, optional\n        The base library name (default: \"redis-py\")\n    lib_version : str, optional\n        The redis-py library version. If None, the version will be determined\n        automatically from the installed package.\n\n    Examples\n    --------\n    >>> info = DriverInfo()\n    >>> info.formatted_name\n    'redis-py'\n\n    >>> info = DriverInfo().add_upstream_driver(\"django-redis\", \"5.4.0\")\n    >>> info.formatted_name\n    'redis-py(django-redis_v5.4.0)'\n\n    >>> info = DriverInfo(lib_version=\"5.0.0\")\n    >>> info.lib_version\n    '5.0.0'\n    \"\"\"\n\n    name: str = \"redis-py\"\n    lib_version: Optional[str] = None\n    _upstream: List[str] = field(default_factory=list)\n\n    def __post_init__(self):\n        \"\"\"Initialize lib_version if not provided.\"\"\"\n        if self.lib_version is None:\n            from redis.utils import get_lib_version\n\n            self.lib_version = get_lib_version()\n\n    @property\n    def upstream_drivers(self) -> List[str]:\n        \"\"\"Return a copy of the upstream driver entries.\n\n        Each entry is in the form ``\"driver-name_vversion\"``.\n        \"\"\"\n\n        return list(self._upstream)\n\n    def add_upstream_driver(\n        self, driver_name: str, driver_version: str\n    ) -> \"DriverInfo\":\n        \"\"\"Add an upstream driver to this instance and return self.\n\n        The most recently added driver appears first in :pyattr:`formatted_name`.\n        \"\"\"\n\n        if driver_name is None:\n            raise ValueError(\"Driver name must not be None\")\n        if driver_version is None:\n            raise ValueError(\"Driver version must not be None\")\n\n        _validate_driver_name(driver_name)\n        _validate_driver_version(driver_version)\n\n        entry = _format_driver_entry(driver_name, driver_version)\n        # insert at the beginning so latest is first\n        self._upstream.insert(0, entry)\n        return self\n\n    @property\n    def formatted_name(self) -> str:\n        \"\"\"Return the base name with upstream drivers encoded, if any.\n\n        With no upstream drivers, this is just :pyattr:`name`. Otherwise::\n\n            name(driver1_vX;driver2_vY)\n        \"\"\"\n\n        if not self._upstream:\n            return self.name\n        return f\"{self.name}({';'.join(self._upstream)})\"\n\n\ndef resolve_driver_info(\n    driver_info: Optional[DriverInfo],\n    lib_name: Optional[str],\n    lib_version: Optional[str],\n) -> DriverInfo:\n    \"\"\"Resolve driver_info from parameters.\n\n    If driver_info is provided, use it. Otherwise, create DriverInfo from\n    lib_name and lib_version (using defaults if not provided).\n\n    Parameters\n    ----------\n    driver_info : DriverInfo, optional\n        The DriverInfo instance to use\n    lib_name : str, optional\n        The library name (default: \"redis-py\")\n    lib_version : str, optional\n        The library version (default: auto-detected)\n\n    Returns\n    -------\n    DriverInfo\n        The resolved DriverInfo instance\n    \"\"\"\n    if driver_info is not None:\n        return driver_info\n\n    # Fallback: create DriverInfo from lib_name and lib_version\n    from redis.utils import get_lib_version\n\n    name = lib_name if lib_name is not None else \"redis-py\"\n    version = lib_version if lib_version is not None else get_lib_version()\n    return DriverInfo(name=name, lib_version=version)\n"
  },
  {
    "path": "redis/event.py",
    "content": "import asyncio\nimport threading\nfrom abc import ABC, abstractmethod\nfrom enum import Enum\nfrom typing import Dict, List, Optional, Type, Union\n\nfrom redis.auth.token import TokenInterface\nfrom redis.credentials import CredentialProvider, StreamingCredentialProvider\nfrom redis.observability.recorder import (\n    init_connection_count,\n    register_pools_connection_count,\n)\nfrom redis.utils import check_protocol_version, deprecated_function\n\n\nclass EventListenerInterface(ABC):\n    \"\"\"\n    Represents a listener for given event object.\n    \"\"\"\n\n    @abstractmethod\n    def listen(self, event: object):\n        pass\n\n\nclass AsyncEventListenerInterface(ABC):\n    \"\"\"\n    Represents an async listener for given event object.\n    \"\"\"\n\n    @abstractmethod\n    async def listen(self, event: object):\n        pass\n\n\nclass EventDispatcherInterface(ABC):\n    \"\"\"\n    Represents a dispatcher that dispatches events to listeners\n    associated with given event.\n    \"\"\"\n\n    @abstractmethod\n    def dispatch(self, event: object):\n        pass\n\n    @abstractmethod\n    async def dispatch_async(self, event: object):\n        pass\n\n    @abstractmethod\n    def register_listeners(\n        self,\n        mappings: Dict[\n            Type[object],\n            List[Union[EventListenerInterface, AsyncEventListenerInterface]],\n        ],\n    ):\n        \"\"\"Register additional listeners.\"\"\"\n        pass\n\n\nclass EventException(Exception):\n    \"\"\"\n    Exception wrapper that adds an event object into exception context.\n    \"\"\"\n\n    def __init__(self, exception: Exception, event: object):\n        self.exception = exception\n        self.event = event\n        super().__init__(exception)\n\n\nclass EventDispatcher(EventDispatcherInterface):\n    # TODO: Make dispatcher to accept external mappings.\n    def __init__(\n        self,\n        event_listeners: Optional[\n            Dict[Type[object], List[EventListenerInterface]]\n        ] = None,\n    ):\n        \"\"\"\n        Dispatcher that dispatches events to listeners associated with given event.\n        \"\"\"\n        self._event_listeners_mapping: Dict[\n            Type[object], List[EventListenerInterface]\n        ] = {\n            AfterConnectionReleasedEvent: [\n                ReAuthConnectionListener(),\n            ],\n            AfterPooledConnectionsInstantiationEvent: [\n                RegisterReAuthForPooledConnections(),\n            ],\n            AfterSingleConnectionInstantiationEvent: [\n                RegisterReAuthForSingleConnection()\n            ],\n            AfterPubSubConnectionInstantiationEvent: [RegisterReAuthForPubSub()],\n            AfterAsyncClusterInstantiationEvent: [RegisterReAuthForAsyncClusterNodes()],\n            AsyncAfterConnectionReleasedEvent: [\n                AsyncReAuthConnectionListener(),\n            ],\n        }\n\n        self._lock = threading.Lock()\n        self._async_lock = None\n\n        if event_listeners:\n            self.register_listeners(event_listeners)\n\n    def dispatch(self, event: object):\n        with self._lock:\n            listeners = self._event_listeners_mapping.get(type(event), [])\n\n            for listener in listeners:\n                listener.listen(event)\n\n    async def dispatch_async(self, event: object):\n        if self._async_lock is None:\n            self._async_lock = asyncio.Lock()\n\n        async with self._async_lock:\n            listeners = self._event_listeners_mapping.get(type(event), [])\n\n            for listener in listeners:\n                await listener.listen(event)\n\n    def register_listeners(\n        self,\n        mappings: Dict[\n            Type[object],\n            List[Union[EventListenerInterface, AsyncEventListenerInterface]],\n        ],\n    ):\n        with self._lock:\n            for event_type in mappings:\n                if event_type in self._event_listeners_mapping:\n                    self._event_listeners_mapping[event_type] = list(\n                        set(\n                            self._event_listeners_mapping[event_type]\n                            + mappings[event_type]\n                        )\n                    )\n                else:\n                    self._event_listeners_mapping[event_type] = mappings[event_type]\n\n\nclass AfterConnectionReleasedEvent:\n    \"\"\"\n    Event that will be fired before each command execution.\n    \"\"\"\n\n    def __init__(self, connection):\n        self._connection = connection\n\n    @property\n    def connection(self):\n        return self._connection\n\n\nclass AsyncAfterConnectionReleasedEvent(AfterConnectionReleasedEvent):\n    pass\n\n\nclass ClientType(Enum):\n    SYNC = (\"sync\",)\n    ASYNC = (\"async\",)\n\n\nclass AfterPooledConnectionsInstantiationEvent:\n    \"\"\"\n    Event that will be fired after pooled connection instances was created.\n    \"\"\"\n\n    def __init__(\n        self,\n        connection_pools: List,\n        client_type: ClientType,\n        credential_provider: Optional[CredentialProvider] = None,\n    ):\n        self._connection_pools = connection_pools\n        self._client_type = client_type\n        self._credential_provider = credential_provider\n\n    @property\n    def connection_pools(self):\n        return self._connection_pools\n\n    @property\n    def client_type(self) -> ClientType:\n        return self._client_type\n\n    @property\n    def credential_provider(self) -> Union[CredentialProvider, None]:\n        return self._credential_provider\n\n\nclass AfterSingleConnectionInstantiationEvent:\n    \"\"\"\n    Event that will be fired after single connection instances was created.\n\n    :param connection_lock: For sync client thread-lock should be provided,\n    for async asyncio.Lock\n    \"\"\"\n\n    def __init__(\n        self,\n        connection,\n        client_type: ClientType,\n        connection_lock: Union[threading.RLock, asyncio.Lock],\n    ):\n        self._connection = connection\n        self._client_type = client_type\n        self._connection_lock = connection_lock\n\n    @property\n    def connection(self):\n        return self._connection\n\n    @property\n    def client_type(self) -> ClientType:\n        return self._client_type\n\n    @property\n    def connection_lock(self) -> Union[threading.RLock, asyncio.Lock]:\n        return self._connection_lock\n\n\nclass AfterPubSubConnectionInstantiationEvent:\n    def __init__(\n        self,\n        pubsub_connection,\n        connection_pool,\n        client_type: ClientType,\n        connection_lock: Union[threading.RLock, asyncio.Lock],\n    ):\n        self._pubsub_connection = pubsub_connection\n        self._connection_pool = connection_pool\n        self._client_type = client_type\n        self._connection_lock = connection_lock\n\n    @property\n    def pubsub_connection(self):\n        return self._pubsub_connection\n\n    @property\n    def connection_pool(self):\n        return self._connection_pool\n\n    @property\n    def client_type(self) -> ClientType:\n        return self._client_type\n\n    @property\n    def connection_lock(self) -> Union[threading.RLock, asyncio.Lock]:\n        return self._connection_lock\n\n\nclass AfterAsyncClusterInstantiationEvent:\n    \"\"\"\n    Event that will be fired after async cluster instance was created.\n\n    Async cluster doesn't use connection pools,\n    instead ClusterNode object manages connections.\n    \"\"\"\n\n    def __init__(\n        self,\n        nodes: dict,\n        credential_provider: Optional[CredentialProvider] = None,\n    ):\n        self._nodes = nodes\n        self._credential_provider = credential_provider\n\n    @property\n    def nodes(self) -> dict:\n        return self._nodes\n\n    @property\n    def credential_provider(self) -> Union[CredentialProvider, None]:\n        return self._credential_provider\n\n\nclass OnCommandsFailEvent:\n    \"\"\"\n    Event fired whenever a command fails during the execution.\n    \"\"\"\n\n    def __init__(\n        self,\n        commands: tuple,\n        exception: Exception,\n    ):\n        self._commands = commands\n        self._exception = exception\n\n    @property\n    def commands(self) -> tuple:\n        return self._commands\n\n    @property\n    def exception(self) -> Exception:\n        return self._exception\n\n\nclass AsyncOnCommandsFailEvent(OnCommandsFailEvent):\n    pass\n\n\nclass ReAuthConnectionListener(EventListenerInterface):\n    \"\"\"\n    Listener that performs re-authentication of given connection.\n    \"\"\"\n\n    def listen(self, event: AfterConnectionReleasedEvent):\n        event.connection.re_auth()\n\n\nclass AsyncReAuthConnectionListener(AsyncEventListenerInterface):\n    \"\"\"\n    Async listener that performs re-authentication of given connection.\n    \"\"\"\n\n    async def listen(self, event: AsyncAfterConnectionReleasedEvent):\n        await event.connection.re_auth()\n\n\nclass RegisterReAuthForPooledConnections(EventListenerInterface):\n    \"\"\"\n    Listener that registers a re-authentication callback for pooled connections.\n    Required by :class:`StreamingCredentialProvider`.\n    \"\"\"\n\n    def __init__(self):\n        self._event = None\n\n    def listen(self, event: AfterPooledConnectionsInstantiationEvent):\n        if isinstance(event.credential_provider, StreamingCredentialProvider):\n            self._event = event\n\n            if event.client_type == ClientType.SYNC:\n                event.credential_provider.on_next(self._re_auth)\n                event.credential_provider.on_error(self._raise_on_error)\n            else:\n                event.credential_provider.on_next(self._re_auth_async)\n                event.credential_provider.on_error(self._raise_on_error_async)\n\n    def _re_auth(self, token):\n        for pool in self._event.connection_pools:\n            pool.re_auth_callback(token)\n\n    async def _re_auth_async(self, token):\n        for pool in self._event.connection_pools:\n            await pool.re_auth_callback(token)\n\n    def _raise_on_error(self, error: Exception):\n        raise EventException(error, self._event)\n\n    async def _raise_on_error_async(self, error: Exception):\n        raise EventException(error, self._event)\n\n\nclass RegisterReAuthForSingleConnection(EventListenerInterface):\n    \"\"\"\n    Listener that registers a re-authentication callback for single connection.\n    Required by :class:`StreamingCredentialProvider`.\n    \"\"\"\n\n    def __init__(self):\n        self._event = None\n\n    def listen(self, event: AfterSingleConnectionInstantiationEvent):\n        if isinstance(\n            event.connection.credential_provider, StreamingCredentialProvider\n        ):\n            self._event = event\n\n            if event.client_type == ClientType.SYNC:\n                event.connection.credential_provider.on_next(self._re_auth)\n                event.connection.credential_provider.on_error(self._raise_on_error)\n            else:\n                event.connection.credential_provider.on_next(self._re_auth_async)\n                event.connection.credential_provider.on_error(\n                    self._raise_on_error_async\n                )\n\n    def _re_auth(self, token):\n        with self._event.connection_lock:\n            self._event.connection.send_command(\n                \"AUTH\", token.try_get(\"oid\"), token.get_value()\n            )\n            self._event.connection.read_response()\n\n    async def _re_auth_async(self, token):\n        async with self._event.connection_lock:\n            await self._event.connection.send_command(\n                \"AUTH\", token.try_get(\"oid\"), token.get_value()\n            )\n            await self._event.connection.read_response()\n\n    def _raise_on_error(self, error: Exception):\n        raise EventException(error, self._event)\n\n    async def _raise_on_error_async(self, error: Exception):\n        raise EventException(error, self._event)\n\n\nclass RegisterReAuthForAsyncClusterNodes(EventListenerInterface):\n    def __init__(self):\n        self._event = None\n\n    def listen(self, event: AfterAsyncClusterInstantiationEvent):\n        if isinstance(event.credential_provider, StreamingCredentialProvider):\n            self._event = event\n            event.credential_provider.on_next(self._re_auth)\n            event.credential_provider.on_error(self._raise_on_error)\n\n    async def _re_auth(self, token: TokenInterface):\n        for key in self._event.nodes:\n            await self._event.nodes[key].re_auth_callback(token)\n\n    async def _raise_on_error(self, error: Exception):\n        raise EventException(error, self._event)\n\n\nclass RegisterReAuthForPubSub(EventListenerInterface):\n    def __init__(self):\n        self._connection = None\n        self._connection_pool = None\n        self._client_type = None\n        self._connection_lock = None\n        self._event = None\n\n    def listen(self, event: AfterPubSubConnectionInstantiationEvent):\n        if isinstance(\n            event.pubsub_connection.credential_provider, StreamingCredentialProvider\n        ) and check_protocol_version(event.pubsub_connection.get_protocol(), 3):\n            self._event = event\n            self._connection = event.pubsub_connection\n            self._connection_pool = event.connection_pool\n            self._client_type = event.client_type\n            self._connection_lock = event.connection_lock\n\n            if self._client_type == ClientType.SYNC:\n                self._connection.credential_provider.on_next(self._re_auth)\n                self._connection.credential_provider.on_error(self._raise_on_error)\n            else:\n                self._connection.credential_provider.on_next(self._re_auth_async)\n                self._connection.credential_provider.on_error(\n                    self._raise_on_error_async\n                )\n\n    def _re_auth(self, token: TokenInterface):\n        with self._connection_lock:\n            self._connection.send_command(\n                \"AUTH\", token.try_get(\"oid\"), token.get_value()\n            )\n            self._connection.read_response()\n\n        self._connection_pool.re_auth_callback(token)\n\n    async def _re_auth_async(self, token: TokenInterface):\n        async with self._connection_lock:\n            await self._connection.send_command(\n                \"AUTH\", token.try_get(\"oid\"), token.get_value()\n            )\n            await self._connection.read_response()\n\n        await self._connection_pool.re_auth_callback(token)\n\n    def _raise_on_error(self, error: Exception):\n        raise EventException(error, self._event)\n\n    async def _raise_on_error_async(self, error: Exception):\n        raise EventException(error, self._event)\n\n\nclass InitializeConnectionCountObservability(EventListenerInterface):\n    \"\"\"\n    Listener that initializes connection count observability.\n    \"\"\"\n\n    @deprecated_function(\n        reason=\"Connection count is now tracked via record_connection_count(). \"\n        \"This functionality will be removed in the next major version\",\n        version=\"7.4.0\",\n    )\n    def listen(self, event: AfterPooledConnectionsInstantiationEvent):\n        # Initialize gauge only once, subsequent calls won't have an affect.\n        # Note: init_connection_count() and register_pools_connection_count()\n        # are deprecated and will emit their own warnings.\n        init_connection_count()\n\n        # Register pools for connection count observability.\n        register_pools_connection_count(event.connection_pools)\n"
  },
  {
    "path": "redis/exceptions.py",
    "content": "from enum import Enum\n\n\"Core exceptions raised by the Redis client\"\n\n\nclass ExceptionType(Enum):\n    NETWORK = \"network\"\n    TLS = \"tls\"\n    AUTH = \"auth\"\n    SERVER = \"server\"\n\n\nclass RedisError(Exception):\n    def __init__(self, *args, status_code: str = None):\n        super().__init__(*args)\n        self.error_type = ExceptionType.SERVER\n        self.status_code = status_code\n\n    def __repr__(self):\n        return f\"{self.error_type.value}:{self.__class__.__name__}\"\n\n\nclass ConnectionError(RedisError):\n    def __init__(self, *args, status_code: str = None):\n        super().__init__(*args, status_code=status_code)\n        self.error_type = ExceptionType.NETWORK\n\n\nclass TimeoutError(RedisError):\n    def __init__(self, *args, status_code: str = None):\n        super().__init__(*args, status_code=status_code)\n        self.error_type = ExceptionType.NETWORK\n\n\nclass AuthenticationError(ConnectionError):\n    def __init__(self, *args, status_code: str = None):\n        super().__init__(*args, status_code=status_code)\n        self.error_type = ExceptionType.AUTH\n\n\nclass AuthorizationError(ConnectionError):\n    def __init__(self, *args, status_code: str = None):\n        super().__init__(*args, status_code=status_code)\n        self.error_type = ExceptionType.AUTH\n\n\nclass BusyLoadingError(ConnectionError):\n    def __init__(self, *args, status_code: str = None):\n        super().__init__(*args, status_code=status_code)\n        self.error_type = ExceptionType.NETWORK\n\n\nclass InvalidResponse(RedisError):\n    pass\n\n\nclass ResponseError(RedisError):\n    pass\n\n\nclass DataError(RedisError):\n    pass\n\n\nclass PubSubError(RedisError):\n    pass\n\n\nclass WatchError(RedisError):\n    pass\n\n\nclass NoScriptError(ResponseError):\n    pass\n\n\nclass OutOfMemoryError(ResponseError):\n    \"\"\"\n    Indicates the database is full. Can only occur when either:\n      * Redis maxmemory-policy=noeviction\n      * Redis maxmemory-policy=volatile* and there are no evictable keys\n\n    For more information see `Memory optimization in Redis <https://redis.io/docs/management/optimization/memory-optimization/#memory-allocation>`_. # noqa\n    \"\"\"\n\n    pass\n\n\nclass ExecAbortError(ResponseError):\n    pass\n\n\nclass ReadOnlyError(ResponseError):\n    pass\n\n\nclass NoPermissionError(ResponseError):\n    def __init__(self, *args, status_code: str = None):\n        super().__init__(*args, status_code=status_code)\n        self.error_type = ExceptionType.AUTH\n\n\nclass ModuleError(ResponseError):\n    pass\n\n\nclass LockError(RedisError, ValueError):\n    \"Errors acquiring or releasing a lock\"\n\n    # NOTE: For backwards compatibility, this class derives from ValueError.\n    # This was originally chosen to behave like threading.Lock.\n\n    def __init__(self, message=None, lock_name=None):\n        super().__init__(message)\n        self.message = message\n        self.lock_name = lock_name\n\n\nclass LockNotOwnedError(LockError):\n    \"Error trying to extend or release a lock that is not owned (anymore)\"\n\n    pass\n\n\nclass ChildDeadlockedError(Exception):\n    \"Error indicating that a child process is deadlocked after a fork()\"\n\n    pass\n\n\nclass AuthenticationWrongNumberOfArgsError(ResponseError):\n    \"\"\"\n    An error to indicate that the wrong number of args\n    were sent to the AUTH command\n    \"\"\"\n\n    def __init__(self, *args, status_code: str = None):\n        super().__init__(*args, status_code=status_code)\n        self.error_type = ExceptionType.AUTH\n\n\nclass RedisClusterException(Exception):\n    \"\"\"\n    Base exception for the RedisCluster client\n    \"\"\"\n\n    def __init__(self, *args):\n        super().__init__(*args)\n        self.error_type = ExceptionType.SERVER\n\n    def __repr__(self):\n        return f\"{self.error_type.value}:{self.__class__.__name__}\"\n\n\nclass ClusterError(RedisError):\n    \"\"\"\n    Cluster errors occurred multiple times, resulting in an exhaustion of the\n    command execution TTL\n    \"\"\"\n\n    def __init__(self, *args, status_code: str = None):\n        super().__init__(*args, status_code=status_code)\n        self.error_type = ExceptionType.SERVER\n\n\nclass ClusterDownError(ClusterError, ResponseError):\n    \"\"\"\n    Error indicated CLUSTERDOWN error received from cluster.\n    By default Redis Cluster nodes stop accepting queries if they detect there\n    is at least a hash slot uncovered (no available node is serving it).\n    This way if the cluster is partially down (for example a range of hash\n    slots are no longer covered) the entire cluster eventually becomes\n    unavailable. It automatically returns available as soon as all the slots\n    are covered again.\n    \"\"\"\n\n    def __init__(self, resp, status_code: str = None):\n        self.args = (resp,)\n        self.message = resp\n        self.error_type = ExceptionType.SERVER\n        self.status_code = status_code\n\n\nclass AskError(ResponseError):\n    \"\"\"\n    Error indicated ASK error received from cluster.\n    When a slot is set as MIGRATING, the node will accept all queries that\n    pertain to this hash slot, but only if the key in question exists,\n    otherwise the query is forwarded using a -ASK redirection to the node that\n    is target of the migration.\n\n    src node: MIGRATING to dst node\n        get > ASK error\n        ask dst node > ASKING command\n    dst node: IMPORTING from src node\n        asking command only affects next command\n        any op will be allowed after asking command\n    \"\"\"\n\n    def __init__(self, resp, status_code: str = None):\n        \"\"\"should only redirect to master node\"\"\"\n        super().__init__(resp, status_code=status_code)\n        self.args = (resp,)\n        self.message = resp\n        slot_id, new_node = resp.split(\" \")\n        host, port = new_node.rsplit(\":\", 1)\n        self.slot_id = int(slot_id)\n        self.node_addr = self.host, self.port = host, int(port)\n\n\nclass TryAgainError(ResponseError):\n    \"\"\"\n    Error indicated TRYAGAIN error received from cluster.\n    Operations on keys that don't exist or are - during resharding - split\n    between the source and destination nodes, will generate a -TRYAGAIN error.\n    \"\"\"\n\n    def __init__(self, *args, status_code: str = None, **kwargs):\n        super().__init__(*args, status_code=status_code)\n\n\nclass ClusterCrossSlotError(ResponseError):\n    \"\"\"\n    Error indicated CROSSSLOT error received from cluster.\n    A CROSSSLOT error is generated when keys in a request don't hash to the\n    same slot.\n    \"\"\"\n\n    message = \"Keys in request don't hash to the same slot\"\n\n    def __init__(self, *args, status_code: str = None):\n        super().__init__(*args, status_code=status_code)\n        self.error_type = ExceptionType.SERVER\n\n\nclass MovedError(AskError):\n    \"\"\"\n    Error indicated MOVED error received from cluster.\n    A request sent to a node that doesn't serve this key will be replayed with\n    a MOVED error that points to the correct node.\n    \"\"\"\n\n    pass\n\n\nclass MasterDownError(ClusterDownError):\n    \"\"\"\n    Error indicated MASTERDOWN error received from cluster.\n    Link with MASTER is down and replica-serve-stale-data is set to 'no'.\n    \"\"\"\n\n    pass\n\n\nclass SlotNotCoveredError(RedisClusterException):\n    \"\"\"\n    This error only happens in the case where the connection pool will try to\n    fetch what node that is covered by a given slot.\n\n    If this error is raised the client should drop the current node layout and\n    attempt to reconnect and refresh the node layout again\n    \"\"\"\n\n    pass\n\n\nclass MaxConnectionsError(ConnectionError):\n    \"\"\"\n    Raised when a connection pool has reached its max_connections limit.\n    This indicates pool exhaustion rather than an actual connection failure.\n    \"\"\"\n\n    pass\n\n\nclass CrossSlotTransactionError(RedisClusterException):\n    \"\"\"\n    Raised when a transaction or watch is triggered in a pipeline\n    and not all keys or all commands belong to the same slot.\n    \"\"\"\n\n    pass\n\n\nclass InvalidPipelineStack(RedisClusterException):\n    \"\"\"\n    Raised on unexpected response length on pipelines. This is\n    most likely a handling error on the stack.\n    \"\"\"\n\n    pass\n\n\nclass ExternalAuthProviderError(ConnectionError):\n    \"\"\"\n    Raised when an external authentication provider returns an error.\n    \"\"\"\n\n    pass\n\n\nclass IncorrectPolicyType(Exception):\n    \"\"\"\n    Raised when a policy type isn't matching to any known policy types.\n    \"\"\"\n\n    pass\n"
  },
  {
    "path": "redis/http/__init__.py",
    "content": ""
  },
  {
    "path": "redis/http/http_client.py",
    "content": "from __future__ import annotations\n\nimport base64\nimport gzip\nimport json\nimport ssl\nimport zlib\nfrom dataclasses import dataclass\nfrom typing import Any, Dict, Mapping, Optional, Tuple, Union\nfrom urllib.error import HTTPError, URLError\nfrom urllib.parse import urlencode, urljoin\nfrom urllib.request import Request, urlopen\n\n__all__ = [\"HttpClient\", \"HttpResponse\", \"HttpError\", \"DEFAULT_TIMEOUT\"]\n\nfrom redis.backoff import ExponentialWithJitterBackoff\nfrom redis.retry import Retry\nfrom redis.utils import dummy_fail\n\nDEFAULT_USER_AGENT = \"HttpClient/1.0 (+https://example.invalid)\"\nDEFAULT_TIMEOUT = 30.0\nRETRY_STATUS_CODES = {429, 500, 502, 503, 504}\n\n\n@dataclass\nclass HttpResponse:\n    status: int\n    headers: Dict[str, str]\n    url: str\n    content: bytes\n\n    def text(self, encoding: Optional[str] = None) -> str:\n        enc = encoding or self._get_encoding()\n        return self.content.decode(enc, errors=\"replace\")\n\n    def json(self) -> Any:\n        return json.loads(self.text(encoding=self._get_encoding()))\n\n    def _get_encoding(self) -> str:\n        # Try to infer encoding from headers; default to utf-8\n        ctype = self.headers.get(\"content-type\", \"\")\n        # Example: application/json; charset=utf-8\n        for part in ctype.split(\";\"):\n            p = part.strip()\n            if p.lower().startswith(\"charset=\"):\n                return p.split(\"=\", 1)[1].strip() or \"utf-8\"\n        return \"utf-8\"\n\n\nclass HttpError(Exception):\n    def __init__(self, status: int, url: str, message: Optional[str] = None):\n        self.status = status\n        self.url = url\n        self.message = message or f\"HTTP {status} for {url}\"\n        super().__init__(self.message)\n\n\nclass HttpClient:\n    \"\"\"\n    A lightweight HTTP client for REST API calls.\n    \"\"\"\n\n    def __init__(\n        self,\n        base_url: str = \"\",\n        headers: Optional[Mapping[str, str]] = None,\n        timeout: float = DEFAULT_TIMEOUT,\n        retry: Retry = Retry(\n            backoff=ExponentialWithJitterBackoff(base=1, cap=10), retries=3\n        ),\n        verify_tls: bool = True,\n        # TLS verification (server) options\n        ca_file: Optional[str] = None,\n        ca_path: Optional[str] = None,\n        ca_data: Optional[Union[str, bytes]] = None,\n        # Mutual TLS (client cert) options\n        client_cert_file: Optional[str] = None,\n        client_key_file: Optional[str] = None,\n        client_key_password: Optional[str] = None,\n        auth_basic: Optional[Tuple[str, str]] = None,  # (username, password)\n        user_agent: str = DEFAULT_USER_AGENT,\n    ) -> None:\n        \"\"\"\n        Initialize a new HTTP client instance.\n\n        Args:\n            base_url: Base URL for all requests. Will be prefixed to all paths.\n            headers: Default headers to include in all requests.\n            timeout: Default timeout in seconds for requests.\n            retry: Retry configuration for failed requests.\n            verify_tls: Whether to verify TLS certificates.\n            ca_file: Path to CA certificate file for TLS verification.\n            ca_path: Path to a directory containing CA certificates.\n            ca_data: CA certificate data as string or bytes.\n            client_cert_file: Path to client certificate for mutual TLS.\n            client_key_file: Path to a client private key for mutual TLS.\n            client_key_password: Password for an encrypted client private key.\n            auth_basic: Tuple of (username, password) for HTTP basic auth.\n            user_agent: User-Agent header value for requests.\n\n        The client supports both regular HTTPS with server verification and mutual TLS\n        authentication. For server verification, provide CA certificate information via\n        ca_file, ca_path or ca_data. For mutual TLS, additionally provide a client\n        certificate and key via client_cert_file and client_key_file.\n        \"\"\"\n        self.base_url = (\n            base_url.rstrip() + \"/\"\n            if base_url and not base_url.endswith(\"/\")\n            else base_url\n        )\n        self._default_headers = {k.lower(): v for k, v in (headers or {}).items()}\n        self.timeout = timeout\n        self.retry = retry\n        self.retry.update_supported_errors((HTTPError, URLError, ssl.SSLError))\n        self.verify_tls = verify_tls\n\n        # TLS settings\n        self.ca_file = ca_file\n        self.ca_path = ca_path\n        self.ca_data = ca_data\n        self.client_cert_file = client_cert_file\n        self.client_key_file = client_key_file\n        self.client_key_password = client_key_password\n\n        self.auth_basic = auth_basic\n        self.user_agent = user_agent\n\n    # Public JSON-centric helpers\n    def get(\n        self,\n        path: str,\n        params: Optional[\n            Mapping[str, Union[None, str, int, float, bool, list, tuple]]\n        ] = None,\n        headers: Optional[Mapping[str, str]] = None,\n        timeout: Optional[float] = None,\n        expect_json: bool = True,\n    ) -> Union[HttpResponse, Any]:\n        return self._json_call(\n            \"GET\",\n            path,\n            params=params,\n            headers=headers,\n            timeout=timeout,\n            body=None,\n            expect_json=expect_json,\n        )\n\n    def delete(\n        self,\n        path: str,\n        params: Optional[\n            Mapping[str, Union[None, str, int, float, bool, list, tuple]]\n        ] = None,\n        headers: Optional[Mapping[str, str]] = None,\n        timeout: Optional[float] = None,\n        expect_json: bool = True,\n    ) -> Union[HttpResponse, Any]:\n        return self._json_call(\n            \"DELETE\",\n            path,\n            params=params,\n            headers=headers,\n            timeout=timeout,\n            body=None,\n            expect_json=expect_json,\n        )\n\n    def post(\n        self,\n        path: str,\n        json_body: Optional[Any] = None,\n        data: Optional[Union[bytes, str]] = None,\n        params: Optional[\n            Mapping[str, Union[None, str, int, float, bool, list, tuple]]\n        ] = None,\n        headers: Optional[Mapping[str, str]] = None,\n        timeout: Optional[float] = None,\n        expect_json: bool = True,\n    ) -> Union[HttpResponse, Any]:\n        return self._json_call(\n            \"POST\",\n            path,\n            params=params,\n            headers=headers,\n            timeout=timeout,\n            body=self._prepare_body(json_body=json_body, data=data),\n            expect_json=expect_json,\n        )\n\n    def put(\n        self,\n        path: str,\n        json_body: Optional[Any] = None,\n        data: Optional[Union[bytes, str]] = None,\n        params: Optional[\n            Mapping[str, Union[None, str, int, float, bool, list, tuple]]\n        ] = None,\n        headers: Optional[Mapping[str, str]] = None,\n        timeout: Optional[float] = None,\n        expect_json: bool = True,\n    ) -> Union[HttpResponse, Any]:\n        return self._json_call(\n            \"PUT\",\n            path,\n            params=params,\n            headers=headers,\n            timeout=timeout,\n            body=self._prepare_body(json_body=json_body, data=data),\n            expect_json=expect_json,\n        )\n\n    def patch(\n        self,\n        path: str,\n        json_body: Optional[Any] = None,\n        data: Optional[Union[bytes, str]] = None,\n        params: Optional[\n            Mapping[str, Union[None, str, int, float, bool, list, tuple]]\n        ] = None,\n        headers: Optional[Mapping[str, str]] = None,\n        timeout: Optional[float] = None,\n        expect_json: bool = True,\n    ) -> Union[HttpResponse, Any]:\n        return self._json_call(\n            \"PATCH\",\n            path,\n            params=params,\n            headers=headers,\n            timeout=timeout,\n            body=self._prepare_body(json_body=json_body, data=data),\n            expect_json=expect_json,\n        )\n\n    # Low-level request\n    def request(\n        self,\n        method: str,\n        path: str,\n        params: Optional[\n            Mapping[str, Union[None, str, int, float, bool, list, tuple]]\n        ] = None,\n        headers: Optional[Mapping[str, str]] = None,\n        body: Optional[Union[bytes, str]] = None,\n        timeout: Optional[float] = None,\n    ) -> HttpResponse:\n        url = self._build_url(path, params)\n        all_headers = self._prepare_headers(headers, body)\n        data = body.encode(\"utf-8\") if isinstance(body, str) else body\n\n        req = Request(url=url, method=method.upper(), data=data, headers=all_headers)\n\n        context: Optional[ssl.SSLContext] = None\n        if url.lower().startswith(\"https\"):\n            if self.verify_tls:\n                # Use provided CA material if any; fall back to system defaults\n                context = ssl.create_default_context(\n                    cafile=self.ca_file,\n                    capath=self.ca_path,\n                    cadata=self.ca_data,\n                )\n                # Load client certificate for mTLS if configured\n                if self.client_cert_file:\n                    context.load_cert_chain(\n                        certfile=self.client_cert_file,\n                        keyfile=self.client_key_file,\n                        password=self.client_key_password,\n                    )\n            else:\n                # Verification disabled\n                context = ssl.create_default_context()\n                context.check_hostname = False\n                context.verify_mode = ssl.CERT_NONE\n\n        try:\n            return self.retry.call_with_retry(\n                lambda: self._make_request(req, context=context, timeout=timeout),\n                lambda _: dummy_fail(),\n                lambda error: self._is_retryable_http_error(error),\n            )\n        except HTTPError as e:\n            # Read error body, build response, and decide on retry\n            err_body = b\"\"\n            try:\n                err_body = e.read()\n            except Exception:\n                pass\n            headers_map = {k.lower(): v for k, v in (e.headers or {}).items()}\n            err_body = self._maybe_decompress(err_body, headers_map)\n            status = getattr(e, \"code\", 0) or 0\n            response = HttpResponse(\n                status=status,\n                headers=headers_map,\n                url=url,\n                content=err_body,\n            )\n            return response\n\n    def _make_request(\n        self,\n        request: Request,\n        context: Optional[ssl.SSLContext] = None,\n        timeout: Optional[float] = None,\n    ):\n        with urlopen(request, timeout=timeout or self.timeout, context=context) as resp:\n            raw = resp.read()\n            headers_map = {k.lower(): v for k, v in resp.headers.items()}\n            raw = self._maybe_decompress(raw, headers_map)\n            return HttpResponse(\n                status=resp.status,\n                headers=headers_map,\n                url=resp.geturl(),\n                content=raw,\n            )\n\n    def _is_retryable_http_error(self, error: Exception) -> bool:\n        if isinstance(error, HTTPError):\n            return self._should_retry_status(error.code)\n        return False\n\n    # Internal utilities\n    def _json_call(\n        self,\n        method: str,\n        path: str,\n        params: Optional[\n            Mapping[str, Union[None, str, int, float, bool, list, tuple]]\n        ] = None,\n        headers: Optional[Mapping[str, str]] = None,\n        timeout: Optional[float] = None,\n        body: Optional[Union[bytes, str]] = None,\n        expect_json: bool = True,\n    ) -> Union[HttpResponse, Any]:\n        resp = self.request(\n            method=method,\n            path=path,\n            params=params,\n            headers=headers,\n            body=body,\n            timeout=timeout,\n        )\n        if not (200 <= resp.status < 400):\n            raise HttpError(resp.status, resp.url, resp.text())\n        if expect_json:\n            return resp.json()\n        return resp\n\n    def _prepare_body(\n        self, json_body: Optional[Any] = None, data: Optional[Union[bytes, str]] = None\n    ) -> Optional[Union[bytes, str]]:\n        if json_body is not None and data is not None:\n            raise ValueError(\"Provide either json_body or data, not both.\")\n        if json_body is not None:\n            return json.dumps(json_body, ensure_ascii=False, separators=(\",\", \":\"))\n        return data\n\n    def _build_url(\n        self,\n        path: str,\n        params: Optional[\n            Mapping[str, Union[None, str, int, float, bool, list, tuple]]\n        ] = None,\n    ) -> str:\n        url = urljoin(self.base_url or \"\", path)\n        if params:\n            # urlencode with doseq=True supports list/tuple values\n            query = urlencode(\n                {k: v for k, v in params.items() if v is not None}, doseq=True\n            )\n            separator = \"&\" if (\"?\" in url) else \"?\"\n            url = f\"{url}{separator}{query}\" if query else url\n        return url\n\n    def _prepare_headers(\n        self, headers: Optional[Mapping[str, str]], body: Optional[Union[bytes, str]]\n    ) -> Dict[str, str]:\n        # Start with defaults\n        prepared: Dict[str, str] = {}\n        prepared.update(self._default_headers)\n\n        # Standard defaults for JSON REST usage\n        prepared.setdefault(\"accept\", \"application/json\")\n        prepared.setdefault(\"user-agent\", self.user_agent)\n        # We will send gzip accept-encoding; handle decompression manually\n        prepared.setdefault(\"accept-encoding\", \"gzip, deflate\")\n\n        # If we have a string body and content-type not specified, assume JSON\n        if body is not None and isinstance(body, str):\n            prepared.setdefault(\"content-type\", \"application/json; charset=utf-8\")\n\n        # Basic authentication if provided and not overridden\n        if self.auth_basic and \"authorization\" not in prepared:\n            user, pwd = self.auth_basic\n            token = base64.b64encode(f\"{user}:{pwd}\".encode(\"utf-8\")).decode(\"ascii\")\n            prepared[\"authorization\"] = f\"Basic {token}\"\n\n        # Merge per-call headers (case-insensitive)\n        if headers:\n            for k, v in headers.items():\n                prepared[k.lower()] = v\n\n        # urllib expects header keys in canonical capitalization sometimes; but it’s tolerant.\n        # We'll return as provided; urllib will handle it.\n        return prepared\n\n    def _should_retry_status(self, status: int) -> bool:\n        return status in RETRY_STATUS_CODES\n\n    def _maybe_decompress(self, content: bytes, headers: Mapping[str, str]) -> bytes:\n        if not content:\n            return content\n        encoding = (headers.get(\"content-encoding\") or \"\").lower()\n        try:\n            if \"gzip\" in encoding:\n                return gzip.decompress(content)\n            if \"deflate\" in encoding:\n                # Try raw deflate, then zlib-wrapped\n                try:\n                    return zlib.decompress(content, -zlib.MAX_WBITS)\n                except zlib.error:\n                    return zlib.decompress(content)\n        except Exception:\n            # If decompression fails, return original bytes\n            return content\n        return content\n"
  },
  {
    "path": "redis/lock.py",
    "content": "import logging\nimport threading\nimport time as mod_time\nimport uuid\nfrom types import SimpleNamespace, TracebackType\nfrom typing import Optional, Type\n\nfrom redis.exceptions import LockError, LockNotOwnedError\nfrom redis.typing import Number\n\nlogger = logging.getLogger(__name__)\n\n\nclass Lock:\n    \"\"\"\n    A shared, distributed Lock. Using Redis for locking allows the Lock\n    to be shared across processes and/or machines.\n\n    It's left to the user to resolve deadlock issues and make sure\n    multiple clients play nicely together.\n    \"\"\"\n\n    lua_release = None\n    lua_extend = None\n    lua_reacquire = None\n\n    # KEYS[1] - lock name\n    # ARGV[1] - token\n    # return 1 if the lock was released, otherwise 0\n    LUA_RELEASE_SCRIPT = \"\"\"\n        local token = redis.call('get', KEYS[1])\n        if not token or token ~= ARGV[1] then\n            return 0\n        end\n        redis.call('del', KEYS[1])\n        return 1\n    \"\"\"\n\n    # KEYS[1] - lock name\n    # ARGV[1] - token\n    # ARGV[2] - additional milliseconds\n    # ARGV[3] - \"0\" if the additional time should be added to the lock's\n    #           existing ttl or \"1\" if the existing ttl should be replaced\n    # return 1 if the locks time was extended, otherwise 0\n    LUA_EXTEND_SCRIPT = \"\"\"\n        local token = redis.call('get', KEYS[1])\n        if not token or token ~= ARGV[1] then\n            return 0\n        end\n        local expiration = redis.call('pttl', KEYS[1])\n        if not expiration then\n            expiration = 0\n        end\n        if expiration < 0 then\n            return 0\n        end\n\n        local newttl = ARGV[2]\n        if ARGV[3] == \"0\" then\n            newttl = ARGV[2] + expiration\n        end\n        redis.call('pexpire', KEYS[1], newttl)\n        return 1\n    \"\"\"\n\n    # KEYS[1] - lock name\n    # ARGV[1] - token\n    # ARGV[2] - milliseconds\n    # return 1 if the locks time was reacquired, otherwise 0\n    LUA_REACQUIRE_SCRIPT = \"\"\"\n        local token = redis.call('get', KEYS[1])\n        if not token or token ~= ARGV[1] then\n            return 0\n        end\n        redis.call('pexpire', KEYS[1], ARGV[2])\n        return 1\n    \"\"\"\n\n    def __init__(\n        self,\n        redis,\n        name: str,\n        timeout: Optional[Number] = None,\n        sleep: Number = 0.1,\n        blocking: bool = True,\n        blocking_timeout: Optional[Number] = None,\n        thread_local: bool = True,\n        raise_on_release_error: bool = True,\n    ):\n        \"\"\"\n        Create a new Lock instance named ``name`` using the Redis client\n        supplied by ``redis``.\n\n        ``timeout`` indicates a maximum life for the lock in seconds.\n        By default, it will remain locked until release() is called.\n        ``timeout`` can be specified as a float or integer, both representing\n        the number of seconds to wait.\n\n        ``sleep`` indicates the amount of time to sleep in seconds per loop\n        iteration when the lock is in blocking mode and another client is\n        currently holding the lock.\n\n        ``blocking`` indicates whether calling ``acquire`` should block until\n        the lock has been acquired or to fail immediately, causing ``acquire``\n        to return False and the lock not being acquired. Defaults to True.\n        Note this value can be overridden by passing a ``blocking``\n        argument to ``acquire``.\n\n        ``blocking_timeout`` indicates the maximum amount of time in seconds to\n        spend trying to acquire the lock. A value of ``None`` indicates\n        continue trying forever. ``blocking_timeout`` can be specified as a\n        float or integer, both representing the number of seconds to wait.\n\n        ``thread_local`` indicates whether the lock token is placed in\n        thread-local storage. By default, the token is placed in thread local\n        storage so that a thread only sees its token, not a token set by\n        another thread. Consider the following timeline:\n\n            time: 0, thread-1 acquires `my-lock`, with a timeout of 5 seconds.\n                     thread-1 sets the token to \"abc\"\n            time: 1, thread-2 blocks trying to acquire `my-lock` using the\n                     Lock instance.\n            time: 5, thread-1 has not yet completed. redis expires the lock\n                     key.\n            time: 5, thread-2 acquired `my-lock` now that it's available.\n                     thread-2 sets the token to \"xyz\"\n            time: 6, thread-1 finishes its work and calls release(). if the\n                     token is *not* stored in thread local storage, then\n                     thread-1 would see the token value as \"xyz\" and would be\n                     able to successfully release the thread-2's lock.\n\n        ``raise_on_release_error`` indicates whether to raise an exception when\n        the lock is no longer owned when exiting the context manager. By default,\n        this is True, meaning an exception will be raised. If False, the warning\n        will be logged and the exception will be suppressed.\n\n        In some use cases it's necessary to disable thread local storage. For\n        example, if you have code where one thread acquires a lock and passes\n        that lock instance to a worker thread to release later. If thread\n        local storage isn't disabled in this case, the worker thread won't see\n        the token set by the thread that acquired the lock. Our assumption\n        is that these cases aren't common and as such default to using\n        thread local storage.\n        \"\"\"\n        self.redis = redis\n        self.name = name\n        self.timeout = timeout\n        self.sleep = sleep\n        self.blocking = blocking\n        self.blocking_timeout = blocking_timeout\n        self.thread_local = bool(thread_local)\n        self.raise_on_release_error = raise_on_release_error\n        self.local = threading.local() if self.thread_local else SimpleNamespace()\n        self.local.token = None\n        self.register_scripts()\n\n    def register_scripts(self) -> None:\n        cls = self.__class__\n        client = self.redis\n        if cls.lua_release is None:\n            cls.lua_release = client.register_script(cls.LUA_RELEASE_SCRIPT)\n        if cls.lua_extend is None:\n            cls.lua_extend = client.register_script(cls.LUA_EXTEND_SCRIPT)\n        if cls.lua_reacquire is None:\n            cls.lua_reacquire = client.register_script(cls.LUA_REACQUIRE_SCRIPT)\n\n    def __enter__(self) -> \"Lock\":\n        if self.acquire():\n            return self\n        raise LockError(\n            \"Unable to acquire lock within the time specified\",\n            lock_name=self.name,\n        )\n\n    def __exit__(\n        self,\n        exc_type: Optional[Type[BaseException]],\n        exc_value: Optional[BaseException],\n        traceback: Optional[TracebackType],\n    ) -> None:\n        try:\n            self.release()\n        except LockError:\n            if self.raise_on_release_error:\n                raise\n            logger.warning(\n                \"Lock was unlocked or no longer owned when exiting context manager.\"\n            )\n\n    def acquire(\n        self,\n        sleep: Optional[Number] = None,\n        blocking: Optional[bool] = None,\n        blocking_timeout: Optional[Number] = None,\n        token: Optional[str] = None,\n    ):\n        \"\"\"\n        Use Redis to hold a shared, distributed lock named ``name``.\n        Returns True once the lock is acquired.\n\n        If ``blocking`` is False, always return immediately. If the lock\n        was acquired, return True, otherwise return False.\n\n        ``blocking_timeout`` specifies the maximum number of seconds to\n        wait trying to acquire the lock.\n\n        ``token`` specifies the token value to be used. If provided, token\n        must be a bytes object or a string that can be encoded to a bytes\n        object with the default encoding. If a token isn't specified, a UUID\n        will be generated.\n        \"\"\"\n        if sleep is None:\n            sleep = self.sleep\n        if token is None:\n            token = uuid.uuid1().hex.encode()\n        else:\n            encoder = self.redis.get_encoder()\n            token = encoder.encode(token)\n        if blocking is None:\n            blocking = self.blocking\n        if blocking_timeout is None:\n            blocking_timeout = self.blocking_timeout\n        stop_trying_at = None\n        if blocking_timeout is not None:\n            stop_trying_at = mod_time.monotonic() + blocking_timeout\n        while True:\n            if self.do_acquire(token):\n                self.local.token = token\n                return True\n            if not blocking:\n                return False\n            next_try_at = mod_time.monotonic() + sleep\n            if stop_trying_at is not None and next_try_at > stop_trying_at:\n                return False\n            mod_time.sleep(sleep)\n\n    def do_acquire(self, token: str) -> bool:\n        if self.timeout:\n            # convert to milliseconds\n            timeout = int(self.timeout * 1000)\n        else:\n            timeout = None\n        if self.redis.set(self.name, token, nx=True, px=timeout):\n            return True\n        return False\n\n    def locked(self) -> bool:\n        \"\"\"\n        Returns True if this key is locked by any process, otherwise False.\n        \"\"\"\n        return self.redis.get(self.name) is not None\n\n    def owned(self) -> bool:\n        \"\"\"\n        Returns True if this key is locked by this lock, otherwise False.\n        \"\"\"\n        stored_token = self.redis.get(self.name)\n        # need to always compare bytes to bytes\n        # TODO: this can be simplified when the context manager is finished\n        if stored_token and not isinstance(stored_token, bytes):\n            encoder = self.redis.get_encoder()\n            stored_token = encoder.encode(stored_token)\n        return self.local.token is not None and stored_token == self.local.token\n\n    def release(self) -> None:\n        \"\"\"\n        Releases the already acquired lock\n        \"\"\"\n        expected_token = self.local.token\n        if expected_token is None:\n            raise LockError(\n                \"Cannot release a lock that's not owned or is already unlocked.\",\n                lock_name=self.name,\n            )\n        self.local.token = None\n        self.do_release(expected_token)\n\n    def do_release(self, expected_token: str) -> None:\n        if not bool(\n            self.lua_release(keys=[self.name], args=[expected_token], client=self.redis)\n        ):\n            raise LockNotOwnedError(\n                \"Cannot release a lock that's no longer owned\",\n                lock_name=self.name,\n            )\n\n    def extend(self, additional_time: Number, replace_ttl: bool = False) -> bool:\n        \"\"\"\n        Adds more time to an already acquired lock.\n\n        ``additional_time`` can be specified as an integer or a float, both\n        representing the number of seconds to add.\n\n        ``replace_ttl`` if False (the default), add `additional_time` to\n        the lock's existing ttl. If True, replace the lock's ttl with\n        `additional_time`.\n        \"\"\"\n        if self.local.token is None:\n            raise LockError(\"Cannot extend an unlocked lock\", lock_name=self.name)\n        if self.timeout is None:\n            raise LockError(\"Cannot extend a lock with no timeout\", lock_name=self.name)\n        return self.do_extend(additional_time, replace_ttl)\n\n    def do_extend(self, additional_time: Number, replace_ttl: bool) -> bool:\n        additional_time = int(additional_time * 1000)\n        if not bool(\n            self.lua_extend(\n                keys=[self.name],\n                args=[self.local.token, additional_time, \"1\" if replace_ttl else \"0\"],\n                client=self.redis,\n            )\n        ):\n            raise LockNotOwnedError(\n                \"Cannot extend a lock that's no longer owned\",\n                lock_name=self.name,\n            )\n        return True\n\n    def reacquire(self) -> bool:\n        \"\"\"\n        Resets a TTL of an already acquired lock back to a timeout value.\n        \"\"\"\n        if self.local.token is None:\n            raise LockError(\"Cannot reacquire an unlocked lock\", lock_name=self.name)\n        if self.timeout is None:\n            raise LockError(\n                \"Cannot reacquire a lock with no timeout\",\n                lock_name=self.name,\n            )\n        return self.do_reacquire()\n\n    def do_reacquire(self) -> bool:\n        timeout = int(self.timeout * 1000)\n        if not bool(\n            self.lua_reacquire(\n                keys=[self.name], args=[self.local.token, timeout], client=self.redis\n            )\n        ):\n            raise LockNotOwnedError(\n                \"Cannot reacquire a lock that's no longer owned\",\n                lock_name=self.name,\n            )\n        return True\n"
  },
  {
    "path": "redis/maint_notifications.py",
    "content": "import enum\nimport ipaddress\nimport logging\nimport re\nimport threading\nimport time\nfrom abc import ABC, abstractmethod\nfrom typing import TYPE_CHECKING, Dict, List, Literal, Optional, Union\n\nfrom redis.observability.attributes import get_pool_name\nfrom redis.observability.recorder import (\n    record_connection_handoff,\n    record_connection_relaxed_timeout,\n    record_maint_notification_count,\n)\nfrom redis.typing import Number\n\nif TYPE_CHECKING:\n    from redis.cluster import MaintNotificationsAbstractRedisCluster\n\nlogger = logging.getLogger(__name__)\n\n\nclass MaintenanceState(enum.Enum):\n    NONE = \"none\"\n    MOVING = \"moving\"\n    MAINTENANCE = \"maintenance\"\n\n\nclass EndpointType(enum.Enum):\n    \"\"\"Valid endpoint types used in CLIENT MAINT_NOTIFICATIONS command.\"\"\"\n\n    INTERNAL_IP = \"internal-ip\"\n    INTERNAL_FQDN = \"internal-fqdn\"\n    EXTERNAL_IP = \"external-ip\"\n    EXTERNAL_FQDN = \"external-fqdn\"\n    NONE = \"none\"\n\n    def __str__(self):\n        \"\"\"Return the string value of the enum.\"\"\"\n        return self.value\n\n\nif TYPE_CHECKING:\n    from redis.connection import (\n        MaintNotificationsAbstractConnection,\n        MaintNotificationsAbstractConnectionPool,\n    )\n\n\nclass MaintenanceNotification(ABC):\n    \"\"\"\n    Base class for maintenance notifications sent through push messages by Redis server.\n\n    This class provides common functionality for all maintenance notifications including\n    unique identification and TTL (Time-To-Live) functionality.\n\n    Attributes:\n        id (int): Unique identifier for this notification\n        ttl (int): Time-to-live in seconds for this notification\n        creation_time (float): Timestamp when the notification was created/read\n    \"\"\"\n\n    def __init__(self, id: int, ttl: int):\n        \"\"\"\n        Initialize a new MaintenanceNotification with unique ID and TTL functionality.\n\n        Args:\n            id (int): Unique identifier for this notification\n            ttl (int): Time-to-live in seconds for this notification\n        \"\"\"\n        self.id = id\n        self.ttl = ttl\n        self.creation_time = time.monotonic()\n        self.expire_at = self.creation_time + self.ttl\n\n    def is_expired(self) -> bool:\n        \"\"\"\n        Check if this notification has expired based on its TTL\n        and creation time.\n\n        Returns:\n            bool: True if the notification has expired, False otherwise\n        \"\"\"\n        return time.monotonic() > (self.creation_time + self.ttl)\n\n    @abstractmethod\n    def __repr__(self) -> str:\n        \"\"\"\n        Return a string representation of the maintenance notification.\n\n        This method must be implemented by all concrete subclasses.\n\n        Returns:\n            str: String representation of the notification\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def __eq__(self, other) -> bool:\n        \"\"\"\n        Compare two maintenance notifications for equality.\n\n        This method must be implemented by all concrete subclasses.\n        Notifications are typically considered equal if they have the same id\n        and are of the same type.\n\n        Args:\n            other: The other object to compare with\n\n        Returns:\n            bool: True if the notifications are equal, False otherwise\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def __hash__(self) -> int:\n        \"\"\"\n        Return a hash value for the maintenance notification.\n\n        This method must be implemented by all concrete subclasses to allow\n        instances to be used in sets and as dictionary keys.\n\n        Returns:\n            int: Hash value for the notification\n        \"\"\"\n        pass\n\n\nclass NodeMovingNotification(MaintenanceNotification):\n    \"\"\"\n    This notification is received when a node is replaced with a new node\n    during cluster rebalancing or maintenance operations.\n    \"\"\"\n\n    def __init__(\n        self,\n        id: int,\n        new_node_host: Optional[str],\n        new_node_port: Optional[int],\n        ttl: int,\n    ):\n        \"\"\"\n        Initialize a new NodeMovingNotification.\n\n        Args:\n            id (int): Unique identifier for this notification\n            new_node_host (str): Hostname or IP address of the new replacement node\n            new_node_port (int): Port number of the new replacement node\n            ttl (int): Time-to-live in seconds for this notification\n        \"\"\"\n        super().__init__(id, ttl)\n        self.new_node_host = new_node_host\n        self.new_node_port = new_node_port\n\n    def __repr__(self) -> str:\n        expiry_time = self.expire_at\n        remaining = max(0, expiry_time - time.monotonic())\n\n        return (\n            f\"{self.__class__.__name__}(\"\n            f\"id={self.id}, \"\n            f\"new_node_host='{self.new_node_host}', \"\n            f\"new_node_port={self.new_node_port}, \"\n            f\"ttl={self.ttl}, \"\n            f\"creation_time={self.creation_time}, \"\n            f\"expires_at={expiry_time}, \"\n            f\"remaining={remaining:.1f}s, \"\n            f\"expired={self.is_expired()}\"\n            f\")\"\n        )\n\n    def __eq__(self, other) -> bool:\n        \"\"\"\n        Two NodeMovingNotification notifications are considered equal if they have the same\n        id, new_node_host, and new_node_port.\n        \"\"\"\n        if not isinstance(other, NodeMovingNotification):\n            return False\n        return (\n            self.id == other.id\n            and self.new_node_host == other.new_node_host\n            and self.new_node_port == other.new_node_port\n        )\n\n    def __hash__(self) -> int:\n        \"\"\"\n        Return a hash value for the notification to allow\n        instances to be used in sets and as dictionary keys.\n\n        Returns:\n            int: Hash value based on notification type class name, id,\n            new_node_host and new_node_port\n        \"\"\"\n        try:\n            node_port = int(self.new_node_port) if self.new_node_port else None\n        except ValueError:\n            node_port = 0\n\n        return hash(\n            (\n                self.__class__.__name__,\n                int(self.id),\n                str(self.new_node_host),\n                node_port,\n            )\n        )\n\n\nclass NodeMigratingNotification(MaintenanceNotification):\n    \"\"\"\n    Notification for when a Redis cluster node is in the process of migrating slots.\n\n    This notification is received when a node starts migrating its slots to another node\n    during cluster rebalancing or maintenance operations.\n\n    Args:\n        id (int): Unique identifier for this notification\n        ttl (int): Time-to-live in seconds for this notification\n    \"\"\"\n\n    def __init__(self, id: int, ttl: int):\n        super().__init__(id, ttl)\n\n    def __repr__(self) -> str:\n        expiry_time = self.creation_time + self.ttl\n        remaining = max(0, expiry_time - time.monotonic())\n        return (\n            f\"{self.__class__.__name__}(\"\n            f\"id={self.id}, \"\n            f\"ttl={self.ttl}, \"\n            f\"creation_time={self.creation_time}, \"\n            f\"expires_at={expiry_time}, \"\n            f\"remaining={remaining:.1f}s, \"\n            f\"expired={self.is_expired()}\"\n            f\")\"\n        )\n\n    def __eq__(self, other) -> bool:\n        \"\"\"\n        Two NodeMigratingNotification notifications are considered equal if they have the same\n        id and are of the same type.\n        \"\"\"\n        if not isinstance(other, NodeMigratingNotification):\n            return False\n        return self.id == other.id and type(self) is type(other)\n\n    def __hash__(self) -> int:\n        \"\"\"\n        Return a hash value for the notification to allow\n        instances to be used in sets and as dictionary keys.\n\n        Returns:\n            int: Hash value based on notification type and id\n        \"\"\"\n        return hash((self.__class__.__name__, int(self.id)))\n\n\nclass NodeMigratedNotification(MaintenanceNotification):\n    \"\"\"\n    Notification for when a Redis cluster node has completed migrating slots.\n\n    This notification is received when a node has finished migrating all its slots\n    to other nodes during cluster rebalancing or maintenance operations.\n\n    Args:\n        id (int): Unique identifier for this notification\n    \"\"\"\n\n    DEFAULT_TTL = 5\n\n    def __init__(self, id: int):\n        super().__init__(id, NodeMigratedNotification.DEFAULT_TTL)\n\n    def __repr__(self) -> str:\n        expiry_time = self.creation_time + self.ttl\n        remaining = max(0, expiry_time - time.monotonic())\n        return (\n            f\"{self.__class__.__name__}(\"\n            f\"id={self.id}, \"\n            f\"ttl={self.ttl}, \"\n            f\"creation_time={self.creation_time}, \"\n            f\"expires_at={expiry_time}, \"\n            f\"remaining={remaining:.1f}s, \"\n            f\"expired={self.is_expired()}\"\n            f\")\"\n        )\n\n    def __eq__(self, other) -> bool:\n        \"\"\"\n        Two NodeMigratedNotification notifications are considered equal if they have the same\n        id and are of the same type.\n        \"\"\"\n        if not isinstance(other, NodeMigratedNotification):\n            return False\n        return self.id == other.id and type(self) is type(other)\n\n    def __hash__(self) -> int:\n        \"\"\"\n        Return a hash value for the notification to allow\n        instances to be used in sets and as dictionary keys.\n\n        Returns:\n            int: Hash value based on notification type and id\n        \"\"\"\n        return hash((self.__class__.__name__, int(self.id)))\n\n\nclass NodeFailingOverNotification(MaintenanceNotification):\n    \"\"\"\n    Notification for when a Redis cluster node is in the process of failing over.\n\n    This notification is received when a node starts a failover process during\n    cluster maintenance operations or when handling node failures.\n\n    Args:\n        id (int): Unique identifier for this notification\n        ttl (int): Time-to-live in seconds for this notification\n    \"\"\"\n\n    def __init__(self, id: int, ttl: int):\n        super().__init__(id, ttl)\n\n    def __repr__(self) -> str:\n        expiry_time = self.creation_time + self.ttl\n        remaining = max(0, expiry_time - time.monotonic())\n        return (\n            f\"{self.__class__.__name__}(\"\n            f\"id={self.id}, \"\n            f\"ttl={self.ttl}, \"\n            f\"creation_time={self.creation_time}, \"\n            f\"expires_at={expiry_time}, \"\n            f\"remaining={remaining:.1f}s, \"\n            f\"expired={self.is_expired()}\"\n            f\")\"\n        )\n\n    def __eq__(self, other) -> bool:\n        \"\"\"\n        Two NodeFailingOverNotification notifications are considered equal if they have the same\n        id and are of the same type.\n        \"\"\"\n        if not isinstance(other, NodeFailingOverNotification):\n            return False\n        return self.id == other.id and type(self) is type(other)\n\n    def __hash__(self) -> int:\n        \"\"\"\n        Return a hash value for the notification to allow\n        instances to be used in sets and as dictionary keys.\n\n        Returns:\n            int: Hash value based on notification type and id\n        \"\"\"\n        return hash((self.__class__.__name__, int(self.id)))\n\n\nclass NodeFailedOverNotification(MaintenanceNotification):\n    \"\"\"\n    Notification for when a Redis cluster node has completed a failover.\n\n    This notification is received when a node has finished the failover process\n    during cluster maintenance operations or after handling node failures.\n\n    Args:\n        id (int): Unique identifier for this notification\n    \"\"\"\n\n    DEFAULT_TTL = 5\n\n    def __init__(self, id: int):\n        super().__init__(id, NodeFailedOverNotification.DEFAULT_TTL)\n\n    def __repr__(self) -> str:\n        expiry_time = self.creation_time + self.ttl\n        remaining = max(0, expiry_time - time.monotonic())\n        return (\n            f\"{self.__class__.__name__}(\"\n            f\"id={self.id}, \"\n            f\"ttl={self.ttl}, \"\n            f\"creation_time={self.creation_time}, \"\n            f\"expires_at={expiry_time}, \"\n            f\"remaining={remaining:.1f}s, \"\n            f\"expired={self.is_expired()}\"\n            f\")\"\n        )\n\n    def __eq__(self, other) -> bool:\n        \"\"\"\n        Two NodeFailedOverNotification notifications are considered equal if they have the same\n        id and are of the same type.\n        \"\"\"\n        if not isinstance(other, NodeFailedOverNotification):\n            return False\n        return self.id == other.id and type(self) is type(other)\n\n    def __hash__(self) -> int:\n        \"\"\"\n        Return a hash value for the notification to allow\n        instances to be used in sets and as dictionary keys.\n\n        Returns:\n            int: Hash value based on notification type and id\n        \"\"\"\n        return hash((self.__class__.__name__, int(self.id)))\n\n\nclass OSSNodeMigratingNotification(MaintenanceNotification):\n    \"\"\"\n    Notification for when a Redis OSS API client is used and a node is in the process of migrating slots.\n\n    This notification is received when a node starts migrating its slots to another node\n    during cluster rebalancing or maintenance operations.\n\n    Args:\n        id (int): Unique identifier for this notification\n        slots (Optional[List[int]]): List of slots being migrated\n    \"\"\"\n\n    DEFAULT_TTL = 30\n\n    def __init__(\n        self,\n        id: int,\n        slots: Optional[str] = None,\n    ):\n        super().__init__(id, OSSNodeMigratingNotification.DEFAULT_TTL)\n        self.slots = slots\n\n    def __repr__(self) -> str:\n        expiry_time = self.creation_time + self.ttl\n        remaining = max(0, expiry_time - time.monotonic())\n        return (\n            f\"{self.__class__.__name__}(\"\n            f\"id={self.id}, \"\n            f\"slots={self.slots}, \"\n            f\"ttl={self.ttl}, \"\n            f\"creation_time={self.creation_time}, \"\n            f\"expires_at={expiry_time}, \"\n            f\"remaining={remaining:.1f}s, \"\n            f\"expired={self.is_expired()}\"\n            f\")\"\n        )\n\n    def __eq__(self, other) -> bool:\n        \"\"\"\n        Two OSSNodeMigratingNotification notifications are considered equal if they have the same\n        id and are of the same type.\n        \"\"\"\n        if not isinstance(other, OSSNodeMigratingNotification):\n            return False\n        return self.id == other.id and type(self) is type(other)\n\n    def __hash__(self) -> int:\n        \"\"\"\n        Return a hash value for the notification to allow\n        instances to be used in sets and as dictionary keys.\n\n        Returns:\n            int: Hash value based on notification type and id\n        \"\"\"\n        return hash((self.__class__.__name__, int(self.id)))\n\n\nclass OSSNodeMigratedNotification(MaintenanceNotification):\n    \"\"\"\n    Notification for when a Redis OSS API client is used and a node has completed migrating slots.\n\n    This notification is received when a node has finished migrating all its slots\n    to other nodes during cluster rebalancing or maintenance operations.\n\n    Args:\n        id (int): Unique identifier for this notification\n        nodes_to_slots_mapping (Dict[str, List[Dict[str, str]]]): Map of source node address\n            to list of destination mappings. Each destination mapping is a dict with\n            the destination node address as key and the slot range as value.\n\n            Structure example:\n            {\n                \"127.0.0.1:6379\": [\n                    {\"127.0.0.1:6380\": \"1-100\"},\n                    {\"127.0.0.1:6381\": \"101-200\"}\n                ],\n                \"127.0.0.1:6382\": [\n                    {\"127.0.0.1:6383\": \"201-300\"}\n                ]\n            }\n\n            Where:\n            - Key (str): Source node address in \"host:port\" format\n            - Value (List[Dict[str, str]]): List of destination mappings where each dict\n              contains destination node address as key and slot range as value\n    \"\"\"\n\n    DEFAULT_TTL = 120\n\n    def __init__(\n        self,\n        id: int,\n        nodes_to_slots_mapping: Dict[str, List[Dict[str, str]]],\n    ):\n        super().__init__(id, OSSNodeMigratedNotification.DEFAULT_TTL)\n        self.nodes_to_slots_mapping = nodes_to_slots_mapping\n\n    def __repr__(self) -> str:\n        expiry_time = self.creation_time + self.ttl\n        remaining = max(0, expiry_time - time.monotonic())\n        return (\n            f\"{self.__class__.__name__}(\"\n            f\"id={self.id}, \"\n            f\"nodes_to_slots_mapping={self.nodes_to_slots_mapping}, \"\n            f\"ttl={self.ttl}, \"\n            f\"creation_time={self.creation_time}, \"\n            f\"expires_at={expiry_time}, \"\n            f\"remaining={remaining:.1f}s, \"\n            f\"expired={self.is_expired()}\"\n            f\")\"\n        )\n\n    def __eq__(self, other) -> bool:\n        \"\"\"\n        Two OSSNodeMigratedNotification notifications are considered equal if they have the same\n        id and are of the same type.\n        \"\"\"\n        if not isinstance(other, OSSNodeMigratedNotification):\n            return False\n        return self.id == other.id and type(self) is type(other)\n\n    def __hash__(self) -> int:\n        \"\"\"\n        Return a hash value for the notification to allow\n        instances to be used in sets and as dictionary keys.\n\n        Returns:\n            int: Hash value based on notification type and id\n        \"\"\"\n        return hash((self.__class__.__name__, int(self.id)))\n\n\ndef _is_private_fqdn(host: str) -> bool:\n    \"\"\"\n    Determine if an FQDN is likely to be internal/private.\n\n    This uses heuristics based on RFC 952 and RFC 1123 standards:\n    - .local domains (RFC 6762 - Multicast DNS)\n    - .internal domains (common internal convention)\n    - Single-label hostnames (no dots)\n    - Common internal TLDs\n\n    Args:\n        host (str): The FQDN to check\n\n    Returns:\n        bool: True if the FQDN appears to be internal/private\n    \"\"\"\n    host_lower = host.lower().rstrip(\".\")\n\n    # Single-label hostnames (no dots) are typically internal\n    if \".\" not in host_lower:\n        return True\n\n    # Common internal/private domain patterns\n    internal_patterns = [\n        r\"\\.local$\",  # mDNS/Bonjour domains\n        r\"\\.internal$\",  # Common internal convention\n        r\"\\.corp$\",  # Corporate domains\n        r\"\\.lan$\",  # Local area network\n        r\"\\.intranet$\",  # Intranet domains\n        r\"\\.private$\",  # Private domains\n    ]\n\n    for pattern in internal_patterns:\n        if re.search(pattern, host_lower):\n            return True\n\n    # If none of the internal patterns match, assume it's external\n    return False\n\n\nnotification_types_mapping: dict[type[MaintenanceNotification], str] = {\n    NodeMovingNotification: \"MOVING\",\n    NodeMigratingNotification: \"MIGRATING\",\n    NodeMigratedNotification: \"MIGRATED\",\n    NodeFailingOverNotification: \"FAILING_OVER\",\n    NodeFailedOverNotification: \"FAILED_OVER\",\n    OSSNodeMigratingNotification: \"SMIGRATING\",\n    OSSNodeMigratedNotification: \"SMIGRATED\",\n}\n\n\ndef add_debug_log_for_notification(\n    connection: \"MaintNotificationsAbstractConnection\",\n    notification: Union[str, MaintenanceNotification],\n):\n    if logger.isEnabledFor(logging.DEBUG):\n        socket_address = None\n        try:\n            socket_address = (\n                connection._sock.getsockname() if connection._sock else None\n            )\n            socket_address = socket_address[1] if socket_address else None\n        except (AttributeError, OSError):\n            pass\n\n        logger.debug(\n            f\"Handling maintenance notification: {notification}, \"\n            f\"with connection: {connection}, connected to ip {connection.get_resolved_ip()}, \"\n            f\"local socket port: {socket_address}\",\n        )\n\n\nclass MaintNotificationsConfig:\n    \"\"\"\n    Configuration class for maintenance notifications handling behaviour. Notifications are received through\n    push notifications.\n\n    This class defines how the Redis client should react to different push notifications\n    such as node moving, migrations, etc. in a Redis cluster.\n\n    \"\"\"\n\n    def __init__(\n        self,\n        enabled: Union[bool, Literal[\"auto\"]] = \"auto\",\n        proactive_reconnect: bool = True,\n        relaxed_timeout: Optional[Number] = 10,\n        endpoint_type: Optional[EndpointType] = None,\n    ):\n        \"\"\"\n        Initialize a new MaintNotificationsConfig.\n\n        Args:\n            enabled (bool | \"auto\"): Controls maintenance notifications handling behavior.\n                - True: The CLIENT MAINT_NOTIFICATIONS command must succeed during connection setup,\n                otherwise a ResponseError is raised.\n                - \"auto\": The CLIENT MAINT_NOTIFICATIONS command is attempted but failures are\n                gracefully handled - a warning is logged and normal operation continues.\n                - False: Maintenance notifications are completely disabled.\n                Defaults to \"auto\".\n            proactive_reconnect (bool): Whether to proactively reconnect when a node is replaced.\n                Defaults to True.\n            relaxed_timeout (Number): The relaxed timeout to use for the connection during maintenance.\n                If -1 is provided - the relaxed timeout is disabled. Defaults to 20.\n            endpoint_type (Optional[EndpointType]): Override for the endpoint type to use in CLIENT MAINT_NOTIFICATIONS.\n                If None, the endpoint type will be automatically determined based on the host and TLS configuration.\n                Defaults to None.\n\n        Raises:\n            ValueError: If endpoint_type is provided but is not a valid endpoint type.\n        \"\"\"\n        self.enabled = enabled\n        self.relaxed_timeout = relaxed_timeout\n        self.proactive_reconnect = proactive_reconnect\n        self.endpoint_type = endpoint_type\n\n    def __repr__(self) -> str:\n        return (\n            f\"{self.__class__.__name__}(\"\n            f\"enabled={self.enabled}, \"\n            f\"proactive_reconnect={self.proactive_reconnect}, \"\n            f\"relaxed_timeout={self.relaxed_timeout}, \"\n            f\"endpoint_type={self.endpoint_type!r}\"\n            f\")\"\n        )\n\n    def is_relaxed_timeouts_enabled(self) -> bool:\n        \"\"\"\n        Check if the relaxed_timeout is enabled. The '-1' value is used to disable the relaxed_timeout.\n        If relaxed_timeout is set to None, it will make the operation blocking\n        and waiting until any response is received.\n\n        Returns:\n            True if the relaxed_timeout is enabled, False otherwise.\n        \"\"\"\n        return self.relaxed_timeout != -1\n\n    def get_endpoint_type(\n        self, host: str, connection: \"MaintNotificationsAbstractConnection\"\n    ) -> EndpointType:\n        \"\"\"\n        Determine the appropriate endpoint type for CLIENT MAINT_NOTIFICATIONS command.\n\n        Logic:\n        1. If endpoint_type is explicitly set, use it\n        2. Otherwise, check the original host from connection.host:\n           - If host is an IP address, use it directly to determine internal-ip vs external-ip\n           - If host is an FQDN, get the resolved IP to determine internal-fqdn vs external-fqdn\n\n        Args:\n            host: User provided hostname to analyze\n            connection: The connection object to analyze for endpoint type determination\n\n        Returns:\n        \"\"\"\n\n        # If endpoint_type is explicitly set, use it\n        if self.endpoint_type is not None:\n            return self.endpoint_type\n\n        # Check if the host is an IP address\n        try:\n            ip_addr = ipaddress.ip_address(host)\n            # Host is an IP address - use it directly\n            is_private = ip_addr.is_private\n            return EndpointType.INTERNAL_IP if is_private else EndpointType.EXTERNAL_IP\n        except ValueError:\n            # Host is an FQDN - need to check resolved IP to determine internal vs external\n            pass\n\n        # Host is an FQDN, get the resolved IP to determine if it's internal or external\n        resolved_ip = connection.get_resolved_ip()\n\n        if resolved_ip:\n            try:\n                ip_addr = ipaddress.ip_address(resolved_ip)\n                is_private = ip_addr.is_private\n                # Use FQDN types since the original host was an FQDN\n                return (\n                    EndpointType.INTERNAL_FQDN\n                    if is_private\n                    else EndpointType.EXTERNAL_FQDN\n                )\n            except ValueError:\n                # This shouldn't happen since we got the IP from the socket, but fallback\n                pass\n\n        # Final fallback: use heuristics on the FQDN itself\n        is_private = _is_private_fqdn(host)\n        return EndpointType.INTERNAL_FQDN if is_private else EndpointType.EXTERNAL_FQDN\n\n\nclass MaintNotificationsPoolHandler:\n    def __init__(\n        self,\n        pool: \"MaintNotificationsAbstractConnectionPool\",\n        config: MaintNotificationsConfig,\n    ) -> None:\n        self.pool = pool\n        self.config = config\n        self._processed_notifications = set()\n        self._lock = threading.RLock()\n        self.connection = None\n\n    def set_connection(self, connection: \"MaintNotificationsAbstractConnection\"):\n        self.connection = connection\n\n    def get_handler_for_connection(self):\n        # Copy all data that should be shared between connections\n        # but each connection should have its own pool handler\n        # since each connection can be in a different state\n        copy = MaintNotificationsPoolHandler(self.pool, self.config)\n        copy._processed_notifications = self._processed_notifications\n        copy._lock = self._lock\n        copy.connection = None\n        return copy\n\n    def remove_expired_notifications(self):\n        with self._lock:\n            for notification in tuple(self._processed_notifications):\n                if notification.is_expired():\n                    self._processed_notifications.remove(notification)\n\n    def handle_notification(self, notification: MaintenanceNotification):\n        self.remove_expired_notifications()\n\n        if isinstance(notification, NodeMovingNotification):\n            return self.handle_node_moving_notification(notification)\n        else:\n            logger.error(f\"Unhandled notification type: {notification}\")\n\n    def handle_node_moving_notification(self, notification: NodeMovingNotification):\n        if (\n            not self.config.proactive_reconnect\n            and not self.config.is_relaxed_timeouts_enabled()\n        ):\n            return\n        with self._lock:\n            if notification in self._processed_notifications:\n                # nothing to do in the connection pool handling\n                # the notification has already been handled or is expired\n                # just return\n                return\n\n            with self.pool._lock:\n                logger.debug(\n                    f\"Handling node MOVING notification: {notification}, \"\n                    f\"with connection: {self.connection}, connected to ip \"\n                    f\"{self.connection.get_resolved_ip() if self.connection else None}\"\n                )\n                if (\n                    self.config.proactive_reconnect\n                    or self.config.is_relaxed_timeouts_enabled()\n                ):\n                    # Get the current connected address - if any\n                    # This is the address that is being moved\n                    # and we need to handle only connections\n                    # connected to the same address\n                    moving_address_src = (\n                        self.connection.getpeername() if self.connection else None\n                    )\n\n                    if getattr(self.pool, \"set_in_maintenance\", False):\n                        # Set pool in maintenance mode - executed only if\n                        # BlockingConnectionPool is used\n                        self.pool.set_in_maintenance(True)\n\n                    # Update maintenance state, timeout and optionally host address\n                    # connection settings for matching connections\n                    self.pool.update_connections_settings(\n                        state=MaintenanceState.MOVING,\n                        maintenance_notification_hash=hash(notification),\n                        relaxed_timeout=self.config.relaxed_timeout,\n                        host_address=notification.new_node_host,\n                        matching_address=moving_address_src,\n                        matching_pattern=\"connected_address\",\n                        update_notification_hash=True,\n                        include_free_connections=True,\n                    )\n\n                    if self.config.proactive_reconnect:\n                        if notification.new_node_host is not None:\n                            self.run_proactive_reconnect(moving_address_src)\n                        else:\n                            threading.Timer(\n                                notification.ttl / 2,\n                                self.run_proactive_reconnect,\n                                args=(moving_address_src,),\n                            ).start()\n\n                    # Update config for new connections:\n                    # Set state to MOVING\n                    # update host\n                    # if relax timeouts are enabled - update timeouts\n                    kwargs: dict = {\n                        \"maintenance_state\": MaintenanceState.MOVING,\n                        \"maintenance_notification_hash\": hash(notification),\n                    }\n                    if notification.new_node_host is not None:\n                        # the host is not updated if the new node host is None\n                        # this happens when the MOVING push notification does not contain\n                        # the new node host - in this case we only update the timeouts\n                        kwargs.update(\n                            {\n                                \"host\": notification.new_node_host,\n                            }\n                        )\n                    if self.config.is_relaxed_timeouts_enabled():\n                        kwargs.update(\n                            {\n                                \"socket_timeout\": self.config.relaxed_timeout,\n                                \"socket_connect_timeout\": self.config.relaxed_timeout,\n                            }\n                        )\n                    self.pool.update_connection_kwargs(**kwargs)\n\n                    if getattr(self.pool, \"set_in_maintenance\", False):\n                        self.pool.set_in_maintenance(False)\n\n            threading.Timer(\n                notification.ttl,\n                self.handle_node_moved_notification,\n                args=(notification,),\n            ).start()\n\n            record_connection_handoff(\n                pool_name=get_pool_name(self.pool),\n            )\n\n            self._processed_notifications.add(notification)\n\n    def run_proactive_reconnect(self, moving_address_src: Optional[str] = None):\n        \"\"\"\n        Run proactive reconnect for the pool.\n        Active connections are marked for reconnect after they complete the current command.\n        Inactive connections are disconnected and will be connected on next use.\n        \"\"\"\n        with self._lock:\n            with self.pool._lock:\n                # take care for the active connections in the pool\n                # mark them for reconnect after they complete the current command\n                self.pool.update_active_connections_for_reconnect(\n                    moving_address_src=moving_address_src,\n                )\n                # take care for the inactive connections in the pool\n                # delete them and create new ones\n                self.pool.disconnect_free_connections(\n                    moving_address_src=moving_address_src,\n                )\n\n    def handle_node_moved_notification(self, notification: NodeMovingNotification):\n        \"\"\"\n        Handle the cleanup after a node moving notification expires.\n        \"\"\"\n        notification_hash = hash(notification)\n\n        with self._lock:\n            logger.debug(\n                f\"Reverting temporary changes related to notification: {notification}, \"\n                f\"with connection: {self.connection}, connected to ip \"\n                f\"{self.connection.get_resolved_ip() if self.connection else None}\"\n            )\n            # if the current maintenance_notification_hash in kwargs is not matching the notification\n            # it means there has been a new moving notification after this one\n            # and we don't need to revert the kwargs yet\n            if (\n                self.pool.connection_kwargs.get(\"maintenance_notification_hash\")\n                == notification_hash\n            ):\n                orig_host = self.pool.connection_kwargs.get(\"orig_host_address\")\n                orig_socket_timeout = self.pool.connection_kwargs.get(\n                    \"orig_socket_timeout\"\n                )\n                orig_connect_timeout = self.pool.connection_kwargs.get(\n                    \"orig_socket_connect_timeout\"\n                )\n                kwargs: dict = {\n                    \"maintenance_state\": MaintenanceState.NONE,\n                    \"maintenance_notification_hash\": None,\n                    \"host\": orig_host,\n                    \"socket_timeout\": orig_socket_timeout,\n                    \"socket_connect_timeout\": orig_connect_timeout,\n                }\n                self.pool.update_connection_kwargs(**kwargs)\n\n            with self.pool._lock:\n                reset_relaxed_timeout = self.config.is_relaxed_timeouts_enabled()\n                reset_host_address = self.config.proactive_reconnect\n\n                self.pool.update_connections_settings(\n                    relaxed_timeout=-1,\n                    state=MaintenanceState.NONE,\n                    maintenance_notification_hash=None,\n                    matching_notification_hash=notification_hash,\n                    matching_pattern=\"notification_hash\",\n                    update_notification_hash=True,\n                    reset_relaxed_timeout=reset_relaxed_timeout,\n                    reset_host_address=reset_host_address,\n                    include_free_connections=True,\n                )\n\n\nclass MaintNotificationsConnectionHandler:\n    # 1 = \"starting maintenance\" notifications, 0 = \"completed maintenance\" notifications\n    _NOTIFICATION_TYPES: dict[type[\"MaintenanceNotification\"], int] = {\n        NodeMigratingNotification: 1,\n        NodeFailingOverNotification: 1,\n        OSSNodeMigratingNotification: 1,\n        NodeMigratedNotification: 0,\n        NodeFailedOverNotification: 0,\n        OSSNodeMigratedNotification: 0,\n    }\n\n    def __init__(\n        self,\n        connection: \"MaintNotificationsAbstractConnection\",\n        config: MaintNotificationsConfig,\n    ) -> None:\n        self.connection = connection\n        self.config = config\n\n    def _get_pool_name(self) -> str:\n        \"\"\"\n        Get the pool name from the connection's pool handler.\n        Falls back to connection representation if pool is not available.\n        \"\"\"\n        pool_handler = getattr(\n            self.connection, \"_maint_notifications_pool_handler\", None\n        )\n        if pool_handler and getattr(pool_handler, \"pool\", None):\n            return get_pool_name(pool_handler.pool)\n        # Fallback for standalone connections without a pool\n        return repr(self.connection)\n\n    def handle_notification(self, notification: MaintenanceNotification):\n        # get the notification type by checking its class in the _NOTIFICATION_TYPES dict\n        notification_type = self._NOTIFICATION_TYPES.get(notification.__class__, None)\n        maint_notification = notification_types_mapping.get(notification.__class__, \"\")\n\n        record_maint_notification_count(\n            server_address=self.connection.host,\n            server_port=self.connection.port,\n            network_peer_address=self.connection.host,\n            network_peer_port=self.connection.port,\n            maint_notification=maint_notification,\n        )\n\n        if notification_type is None:\n            logger.error(f\"Unhandled notification type: {notification}\")\n            return\n\n        if notification_type:\n            self.handle_maintenance_start_notification(\n                MaintenanceState.MAINTENANCE, notification\n            )\n        else:\n            self.handle_maintenance_completed_notification(notification=notification)\n\n    def handle_maintenance_start_notification(\n        self, maintenance_state: MaintenanceState, notification: MaintenanceNotification\n    ):\n        add_debug_log_for_notification(self.connection, notification)\n\n        if (\n            self.connection.maintenance_state == MaintenanceState.MOVING\n            or not self.config.is_relaxed_timeouts_enabled()\n        ):\n            return\n\n        self.connection.maintenance_state = maintenance_state\n        self.connection.set_tmp_settings(\n            tmp_relaxed_timeout=self.config.relaxed_timeout\n        )\n        # extend the timeout for all created connections\n        self.connection.update_current_socket_timeout(self.config.relaxed_timeout)\n        if isinstance(notification, OSSNodeMigratingNotification):\n            # add the notification id to the set of processed start maint notifications\n            # this is used to skip the unrelaxing of the timeouts if we have received more than\n            # one start notification before the the final end notification\n            self.connection.add_maint_start_notification(notification.id)\n\n        maint_notification = notification_types_mapping.get(notification.__class__, \"\")\n        record_connection_relaxed_timeout(\n            connection_name=self._get_pool_name(),\n            maint_notification=maint_notification,\n            relaxed=True,\n        )\n\n    def handle_maintenance_completed_notification(self, **kwargs):\n        # Only reset timeouts if state is not MOVING and relaxed timeouts are enabled\n        if (\n            self.connection.maintenance_state == MaintenanceState.MOVING\n            or not self.config.is_relaxed_timeouts_enabled()\n        ):\n            return\n        notification = None\n        if kwargs.get(\"notification\"):\n            notification = kwargs[\"notification\"]\n        add_debug_log_for_notification(\n            self.connection, notification if notification else \"MAINTENANCE_COMPLETED\"\n        )\n        self.connection.reset_tmp_settings(reset_relaxed_timeout=True)\n        # Maintenance completed - reset the connection\n        # timeouts by providing -1 as the relaxed timeout\n        self.connection.update_current_socket_timeout(-1)\n        self.connection.maintenance_state = MaintenanceState.NONE\n        # reset the sets that keep track of received start maint\n        # notifications and skipped end maint notifications\n        self.connection.reset_received_notifications()\n\n        if notification:\n            maint_notification = notification_types_mapping.get(\n                notification.__class__, \"\"\n            )\n            record_connection_relaxed_timeout(\n                connection_name=self._get_pool_name(),\n                maint_notification=maint_notification,\n                relaxed=False,\n            )\n\n\nclass OSSMaintNotificationsHandler:\n    def __init__(\n        self,\n        cluster_client: \"MaintNotificationsAbstractRedisCluster\",\n        config: MaintNotificationsConfig,\n    ) -> None:\n        self.cluster_client = cluster_client\n        self.config = config\n        self._processed_notifications = set()\n        self._in_progress = set()\n        self._lock = threading.RLock()\n\n    def get_handler_for_connection(self):\n        # Copy all data that should be shared between connections\n        # but each connection should have its own pool handler\n        # since each connection can be in a different state\n        copy = OSSMaintNotificationsHandler(self.cluster_client, self.config)\n        copy._processed_notifications = self._processed_notifications\n        copy._in_progress = self._in_progress\n        copy._lock = self._lock\n        return copy\n\n    def remove_expired_notifications(self):\n        with self._lock:\n            for notification in tuple(self._processed_notifications):\n                if notification.is_expired():\n                    self._processed_notifications.remove(notification)\n\n    def handle_notification(self, notification: MaintenanceNotification):\n        if isinstance(notification, OSSNodeMigratedNotification):\n            self.handle_oss_maintenance_completed_notification(notification)\n        else:\n            logger.error(f\"Unhandled notification type: {notification}\")\n\n    def handle_oss_maintenance_completed_notification(\n        self, notification: OSSNodeMigratedNotification\n    ):\n        self.remove_expired_notifications()\n\n        with self._lock:\n            if (\n                notification in self._in_progress\n                or notification in self._processed_notifications\n            ):\n                # we are already handling this notification or it has already been processed\n                # we should skip in_progress notification since when we reinitialize the cluster\n                # we execute a CLUSTER SLOTS command that can use a different connection\n                # that has also has the notification and we don't want to\n                # process the same notification twice\n                return\n\n            if logger.isEnabledFor(logging.DEBUG):\n                logger.debug(f\"Handling SMIGRATED notification: {notification}\")\n            self._in_progress.add(notification)\n\n            # Extract the information about the src and destination nodes that are affected\n            # by the maintenance. nodes_to_slots_mapping structure:\n            # {\n            #     \"src_host:port\": [\n            #         {\"dest_host:port\": \"slot_range\"},\n            #         ...\n            #     ],\n            #     ...\n            # }\n            additional_startup_nodes_info = []\n            affected_nodes = set()\n            for (\n                src_address,\n                dest_mappings,\n            ) in notification.nodes_to_slots_mapping.items():\n                src_host, src_port = src_address.split(\":\")\n                src_node = self.cluster_client.nodes_manager.get_node(\n                    host=src_host, port=src_port\n                )\n                if src_node is not None:\n                    affected_nodes.add(src_node)\n\n                for dest_mapping in dest_mappings:\n                    for dest_address in dest_mapping.keys():\n                        dest_host, dest_port = dest_address.split(\":\")\n                        additional_startup_nodes_info.append(\n                            (dest_host, int(dest_port))\n                        )\n\n            # Updates the cluster slots cache with the new slots mapping\n            # This will also update the nodes cache with the new nodes mapping\n            self.cluster_client.nodes_manager.initialize(\n                disconnect_startup_nodes_pools=False,\n                additional_startup_nodes_info=additional_startup_nodes_info,\n            )\n\n            all_nodes = set(affected_nodes)\n            all_nodes = all_nodes.union(\n                self.cluster_client.nodes_manager.nodes_cache.values()\n            )\n\n            for current_node in all_nodes:\n                if current_node.redis_connection is None:\n                    continue\n                with current_node.redis_connection.connection_pool._lock:\n                    handoff_recorded = False\n                    if current_node in affected_nodes:\n                        # mark for reconnect all in use connections to the node - this will force them to\n                        # disconnect after they complete their current commands\n                        # Some of them might be used by sub sub and we don't know which ones - so we disconnect\n                        # all in flight connections after they are done with current command execution\n                        for conn in current_node.redis_connection.connection_pool._get_in_use_connections():\n                            add_debug_log_for_notification(\n                                conn, \"SMIGRATED - mark for reconnect\"\n                            )\n                            conn.mark_for_reconnect()\n\n                        record_connection_handoff(\n                            pool_name=get_pool_name(\n                                current_node.redis_connection.connection_pool\n                            )\n                        )\n                        handoff_recorded = True\n                    else:\n                        if logger.isEnabledFor(logging.DEBUG):\n                            logger.debug(\n                                f\"SMIGRATED: Node {current_node.name} not affected by maintenance, \"\n                                f\"skipping mark for reconnect\"\n                            )\n\n                    if (\n                        current_node\n                        not in self.cluster_client.nodes_manager.nodes_cache.values()\n                    ):\n                        # disconnect all free connections to the node - this node will be dropped\n                        # from the cluster, so we don't need to revert the timeouts\n                        for conn in current_node.redis_connection.connection_pool._get_free_connections():\n                            conn.disconnect()\n\n                        # Only record handoff if not already recorded for this node\n                        if not handoff_recorded:\n                            record_connection_handoff(\n                                pool_name=get_pool_name(\n                                    current_node.redis_connection.connection_pool\n                                )\n                            )\n\n            # mark the notification as processed\n            self._processed_notifications.add(notification)\n            self._in_progress.remove(notification)\n"
  },
  {
    "path": "redis/multidb/__init__.py",
    "content": ""
  },
  {
    "path": "redis/multidb/circuit.py",
    "content": "from abc import ABC, abstractmethod\nfrom enum import Enum\nfrom typing import Callable\n\nimport pybreaker\n\nDEFAULT_GRACE_PERIOD = 60\n\n\nclass State(Enum):\n    CLOSED = \"closed\"\n    OPEN = \"open\"\n    HALF_OPEN = \"half-open\"\n\n\nclass CircuitBreaker(ABC):\n    @property\n    @abstractmethod\n    def grace_period(self) -> float:\n        \"\"\"The grace period in seconds when the circle should be kept open.\"\"\"\n        pass\n\n    @grace_period.setter\n    @abstractmethod\n    def grace_period(self, grace_period: float):\n        \"\"\"Set the grace period in seconds.\"\"\"\n\n    @property\n    @abstractmethod\n    def state(self) -> State:\n        \"\"\"The current state of the circuit.\"\"\"\n        pass\n\n    @state.setter\n    @abstractmethod\n    def state(self, state: State):\n        \"\"\"Set current state of the circuit.\"\"\"\n        pass\n\n    @property\n    @abstractmethod\n    def database(self):\n        \"\"\"Database associated with this circuit.\"\"\"\n        pass\n\n    @database.setter\n    @abstractmethod\n    def database(self, database):\n        \"\"\"Set database associated with this circuit.\"\"\"\n        pass\n\n    @abstractmethod\n    def on_state_changed(self, cb: Callable[[\"CircuitBreaker\", State, State], None]):\n        \"\"\"Callback called when the state of the circuit changes.\"\"\"\n        pass\n\n\nclass BaseCircuitBreaker(CircuitBreaker):\n    \"\"\"\n    Base implementation of Circuit Breaker interface.\n    \"\"\"\n\n    def __init__(self, cb: pybreaker.CircuitBreaker):\n        self._cb = cb\n        self._state_pb_mapper = {\n            State.CLOSED: self._cb.close,\n            State.OPEN: self._cb.open,\n            State.HALF_OPEN: self._cb.half_open,\n        }\n        self._database = None\n\n    @property\n    def grace_period(self) -> float:\n        return self._cb.reset_timeout\n\n    @grace_period.setter\n    def grace_period(self, grace_period: float):\n        self._cb.reset_timeout = grace_period\n\n    @property\n    def state(self) -> State:\n        return State(value=self._cb.state.name)\n\n    @state.setter\n    def state(self, state: State):\n        self._state_pb_mapper[state]()\n\n    @property\n    def database(self):\n        return self._database\n\n    @database.setter\n    def database(self, database):\n        self._database = database\n\n    @abstractmethod\n    def on_state_changed(self, cb: Callable[[\"CircuitBreaker\", State, State], None]):\n        \"\"\"Callback called when the state of the circuit changes.\"\"\"\n        pass\n\n\nclass PBListener(pybreaker.CircuitBreakerListener):\n    \"\"\"Wrapper for callback to be compatible with pybreaker implementation.\"\"\"\n\n    def __init__(\n        self,\n        cb: Callable[[CircuitBreaker, State, State], None],\n        database,\n    ):\n        \"\"\"\n        Initialize a PBListener instance.\n\n        Args:\n            cb: Callback function that will be called when the circuit breaker state changes.\n            database: Database instance associated with this circuit breaker.\n        \"\"\"\n\n        self._cb = cb\n        self._database = database\n\n    def state_change(self, cb, old_state, new_state):\n        cb = PBCircuitBreakerAdapter(cb)\n        cb.database = self._database\n        old_state = State(value=old_state.name)\n        new_state = State(value=new_state.name)\n        self._cb(cb, old_state, new_state)\n\n\nclass PBCircuitBreakerAdapter(BaseCircuitBreaker):\n    def __init__(self, cb: pybreaker.CircuitBreaker):\n        \"\"\"\n        Initialize a PBCircuitBreakerAdapter instance.\n\n        This adapter wraps pybreaker's CircuitBreaker implementation to make it compatible\n        with our CircuitBreaker interface.\n\n        Args:\n            cb: A pybreaker CircuitBreaker instance to be adapted.\n        \"\"\"\n        super().__init__(cb)\n\n    def on_state_changed(self, cb: Callable[[\"CircuitBreaker\", State, State], None]):\n        listener = PBListener(cb, self.database)\n        self._cb.add_listener(listener)\n"
  },
  {
    "path": "redis/multidb/client.py",
    "content": "import asyncio\nimport logging\nimport threading\nfrom typing import Any, Callable, List, Literal, Optional\n\nfrom redis.asyncio.multidb.healthcheck import HealthCheck, HealthCheckPolicy\nfrom redis.background import BackgroundScheduler\nfrom redis.backoff import NoBackoff\nfrom redis.client import PubSubWorkerThread\nfrom redis.commands import CoreCommands, RedisModuleCommands\nfrom redis.maint_notifications import MaintNotificationsConfig\nfrom redis.multidb.circuit import CircuitBreaker\nfrom redis.multidb.circuit import State as CBState\nfrom redis.multidb.command_executor import DefaultCommandExecutor\nfrom redis.multidb.config import (\n    DEFAULT_GRACE_PERIOD,\n    DatabaseConfig,\n    InitialHealthCheck,\n    MultiDbConfig,\n)\nfrom redis.multidb.database import Database, Databases, SyncDatabase\nfrom redis.multidb.exception import (\n    InitialHealthCheckFailedError,\n    NoValidDatabaseException,\n    UnhealthyDatabaseException,\n)\nfrom redis.multidb.failure_detector import FailureDetector\nfrom redis.observability.attributes import GeoFailoverReason\nfrom redis.retry import Retry\nfrom redis.utils import experimental\n\nlogger = logging.getLogger(__name__)\n\n\n@experimental\nclass MultiDBClient(RedisModuleCommands, CoreCommands):\n    \"\"\"\n    Client that operates on multiple logical Redis databases.\n    Should be used in Client-side geographic failover database setups.\n    \"\"\"\n\n    def __init__(self, config: MultiDbConfig):\n        self._databases = config.databases()\n        self._health_checks = (\n            config.default_health_checks()\n            if not config.health_checks\n            else config.health_checks\n        )\n        self._health_check_interval = config.health_check_interval\n        self._health_check_policy: HealthCheckPolicy = (\n            config.health_check_policy.value()\n        )\n        self._failure_detectors = (\n            config.default_failure_detectors()\n            if not config.failure_detectors\n            else config.failure_detectors\n        )\n\n        self._failover_strategy = (\n            config.default_failover_strategy()\n            if config.failover_strategy is None\n            else config.failover_strategy\n        )\n        self._failover_strategy.set_databases(self._databases)\n        self._auto_fallback_interval = config.auto_fallback_interval\n        self._event_dispatcher = config.event_dispatcher\n        self._command_retry = config.command_retry\n        self._command_retry.update_supported_errors((ConnectionRefusedError,))\n        self.command_executor = DefaultCommandExecutor(\n            failure_detectors=self._failure_detectors,\n            databases=self._databases,\n            command_retry=self._command_retry,\n            failover_strategy=self._failover_strategy,\n            failover_attempts=config.failover_attempts,\n            failover_delay=config.failover_delay,\n            event_dispatcher=self._event_dispatcher,\n            auto_fallback_interval=self._auto_fallback_interval,\n        )\n        self.initialized = False\n        self._bg_scheduler = BackgroundScheduler()\n        self._hc_lock = threading.Lock()\n        self._config = config\n\n    def __del__(self):\n        try:\n            self.close()\n        except Exception:\n            # Suppress exceptions during garbage collection.\n            # close() may fail if called during interpreter shutdown\n            # or while an event loop is already running.\n            pass\n\n    def initialize(self):\n        \"\"\"\n        Perform initialization of databases to define their initial state.\n        \"\"\"\n\n        # Initial databases check to define initial state.\n        # Uses run_coro_sync to run in the shared background loop - this ensures\n        # connection pools created during initial health check remain valid for\n        # subsequent recurring health checks (they use the same event loop).\n        self._bg_scheduler.run_coro_sync(self._perform_initial_health_check)\n\n        # Starts recurring health checks on the background.\n        # Uses run_recurring_coro which shares the same event loop as run_coro_sync\n        self._bg_scheduler.run_recurring_coro(\n            self._health_check_interval,\n            self._check_databases_health,\n        )\n\n        is_active_db_found = False\n\n        for database, weight in self._databases:\n            # Set on state changed callback for each circuit.\n            database.circuit.on_state_changed(self._on_circuit_state_change_callback)\n\n            # Set states according to a weights and circuit state\n            if database.circuit.state == CBState.CLOSED and not is_active_db_found:\n                # Directly set the active database during initialization\n                # without recording a geo failover metric\n                self.command_executor._active_database = database\n                is_active_db_found = True\n\n        if not is_active_db_found:\n            raise NoValidDatabaseException(\n                \"Initial connection failed - no active database found\"\n            )\n\n        self.initialized = True\n\n    def get_databases(self) -> Databases:\n        \"\"\"\n        Returns a sorted (by weight) list of all databases.\n        \"\"\"\n        return self._databases\n\n    def set_active_database(self, database: SyncDatabase) -> None:\n        \"\"\"\n        Promote one of the existing databases to become an active.\n        \"\"\"\n        exists = None\n\n        for existing_db, _ in self._databases:\n            if existing_db == database:\n                exists = True\n                break\n\n        if not exists:\n            raise ValueError(\"Given database is not a member of database list\")\n\n        self._bg_scheduler.run_coro_sync(self._check_db_health, database)\n\n        if database.circuit.state == CBState.CLOSED:\n            highest_weighted_db, _ = self._databases.get_top_n(1)[0]\n            self.command_executor.active_database = (\n                database,\n                GeoFailoverReason.MANUAL,\n            )\n            return\n\n        raise NoValidDatabaseException(\n            \"Cannot set active database, database is unhealthy\"\n        )\n\n    def add_database(\n        self, config: DatabaseConfig, skip_initial_health_check: bool = True\n    ):\n        \"\"\"\n        Adds a new database to the database list.\n\n        Args:\n            config: DatabaseConfig object that contains the database configuration.\n            skip_initial_health_check: If True, adds the database even if it is unhealthy.\n        \"\"\"\n        # The retry object is not used in the lower level clients, so we can safely remove it.\n        # We rely on command_retry in terms of global retries.\n        config.client_kwargs[\"retry\"] = Retry(retries=0, backoff=NoBackoff())\n\n        # Maintenance notifications are disabled by default in underlying clients,\n        # but user can override this by providing their own config.\n        if \"maint_notifications_config\" not in config.client_kwargs:\n            config.client_kwargs[\"maint_notifications_config\"] = (\n                MaintNotificationsConfig(enabled=False)\n            )\n\n        if config.from_url:\n            client = self._config.client_class.from_url(\n                config.from_url, **config.client_kwargs\n            )\n        elif config.from_pool:\n            config.from_pool.set_retry(Retry(retries=0, backoff=NoBackoff()))\n            client = self._config.client_class.from_pool(\n                connection_pool=config.from_pool\n            )\n        else:\n            client = self._config.client_class(**config.client_kwargs)\n\n        circuit = (\n            config.default_circuit_breaker()\n            if config.circuit is None\n            else config.circuit\n        )\n\n        database = Database(\n            client=client,\n            circuit=circuit,\n            weight=config.weight,\n            health_check_url=config.health_check_url,\n        )\n\n        try:\n            self._bg_scheduler.run_coro_sync(self._check_db_health, database)\n        except UnhealthyDatabaseException:\n            if not skip_initial_health_check:\n                raise\n\n        highest_weighted_db, highest_weight = self._databases.get_top_n(1)[0]\n        self._databases.add(database, database.weight)\n        self._change_active_database(database, highest_weighted_db)\n\n    def _change_active_database(\n        self, new_database: SyncDatabase, highest_weight_database: SyncDatabase\n    ):\n        if (\n            new_database.weight > highest_weight_database.weight\n            and new_database.circuit.state == CBState.CLOSED\n        ):\n            self.command_executor.active_database = (\n                new_database,\n                GeoFailoverReason.AUTOMATIC,\n            )\n\n    def remove_database(self, database: Database):\n        \"\"\"\n        Removes a database from the database list.\n        \"\"\"\n        weight = self._databases.remove(database)\n        highest_weighted_db, highest_weight = self._databases.get_top_n(1)[0]\n\n        if (\n            highest_weight <= weight\n            and highest_weighted_db.circuit.state == CBState.CLOSED\n        ):\n            self.command_executor.active_database = (\n                highest_weighted_db,\n                GeoFailoverReason.MANUAL,\n            )\n\n    def update_database_weight(self, database: SyncDatabase, weight: float):\n        \"\"\"\n        Updates a database from the database list.\n        \"\"\"\n        exists = None\n\n        for existing_db, _ in self._databases:\n            if existing_db == database:\n                exists = True\n                break\n\n        if not exists:\n            raise ValueError(\"Given database is not a member of database list\")\n\n        highest_weighted_db, highest_weight = self._databases.get_top_n(1)[0]\n        self._databases.update_weight(database, weight)\n        database.weight = weight\n        self._change_active_database(database, highest_weighted_db)\n\n    def add_failure_detector(self, failure_detector: FailureDetector):\n        \"\"\"\n        Adds a new failure detector to the database.\n        \"\"\"\n        self._failure_detectors.append(failure_detector)\n\n    def add_health_check(self, healthcheck: HealthCheck):\n        \"\"\"\n        Adds a new health check to the database.\n        \"\"\"\n        with self._hc_lock:\n            self._health_checks.append(healthcheck)\n\n    def execute_command(self, *args, **options):\n        \"\"\"\n        Executes a single command and return its result.\n        \"\"\"\n        if not self.initialized:\n            self.initialize()\n\n        return self.command_executor.execute_command(*args, **options)\n\n    def pipeline(self):\n        \"\"\"\n        Enters into pipeline mode of the client.\n        \"\"\"\n        return Pipeline(self)\n\n    def transaction(self, func: Callable[[\"Pipeline\"], None], *watches, **options):\n        \"\"\"\n        Executes callable as transaction.\n        \"\"\"\n        if not self.initialized:\n            self.initialize()\n\n        return self.command_executor.execute_transaction(func, *watches, *options)\n\n    def pubsub(self, **kwargs):\n        \"\"\"\n        Return a Publish/Subscribe object. With this object, you can\n        subscribe to channels and listen for messages that get published to\n        them.\n        \"\"\"\n        if not self.initialized:\n            self.initialize()\n\n        return PubSub(self, **kwargs)\n\n    async def _check_db_health(self, database: SyncDatabase) -> bool:\n        \"\"\"\n        Runs health checks on the given database until first failure.\n        \"\"\"\n        with self._hc_lock:\n            health_checks = list(self._health_checks)\n\n        # Health check will setup circuit state\n        is_healthy = await self._health_check_policy.execute(health_checks, database)\n\n        if not is_healthy:\n            if database.circuit.state != CBState.OPEN:\n                database.circuit.state = CBState.OPEN\n            return is_healthy\n        elif is_healthy and database.circuit.state != CBState.CLOSED:\n            database.circuit.state = CBState.CLOSED\n\n        return is_healthy\n\n    async def _check_databases_health(self) -> dict[Database, bool]:\n        \"\"\"\n        Runs health checks as a recurring task.\n        Runs health checks against all databases.\n        \"\"\"\n        task_to_db: dict[asyncio.Task, Database] = {}\n\n        self._hc_tasks = []\n        for database, _ in self._databases:\n            task = asyncio.create_task(self._check_db_health(database))\n            task_to_db[task] = database\n            self._hc_tasks.append(task)\n\n        results = await asyncio.gather(*self._hc_tasks, return_exceptions=True)\n\n        # Map end results to databases\n        db_results = {\n            task_to_db[task]: result for task, result in zip(self._hc_tasks, results)\n        }\n\n        for database, result in db_results.items():\n            if isinstance(result, UnhealthyDatabaseException):\n                unhealthy_db = result.database\n                unhealthy_db.circuit.state = CBState.OPEN\n\n                logger.debug(\n                    \"Health check failed, due to exception\",\n                    exc_info=result.original_exception,\n                )\n\n                db_results[unhealthy_db] = False\n\n        return db_results\n\n    async def _perform_initial_health_check(self):\n        \"\"\"\n        Runs initial health check and evaluate healthiness based on initial_health_check_policy.\n        \"\"\"\n        results = await self._check_databases_health()\n        is_healthy = True\n\n        if self._config.initial_health_check_policy == InitialHealthCheck.ALL_AVAILABLE:\n            is_healthy = False not in results.values()\n        elif (\n            self._config.initial_health_check_policy\n            == InitialHealthCheck.MAJORITY_AVAILABLE\n        ):\n            is_healthy = sum(results.values()) > len(results) / 2\n        elif (\n            self._config.initial_health_check_policy == InitialHealthCheck.ONE_AVAILABLE\n        ):\n            is_healthy = True in results.values()\n\n        if not is_healthy:\n            raise InitialHealthCheckFailedError(\n                f\"Initial health check failed. Initial health check policy: {self._config.initial_health_check_policy}\"\n            )\n\n    def _on_circuit_state_change_callback(\n        self, circuit: CircuitBreaker, old_state: CBState, new_state: CBState\n    ):\n        if new_state == CBState.HALF_OPEN:\n            self._bg_scheduler.run_coro_fire_and_forget(\n                self._check_db_health, circuit.database\n            )\n            return\n\n        if old_state == CBState.CLOSED and new_state == CBState.OPEN:\n            logger.warning(\n                f\"Database {circuit.database} is unreachable. Failover has been initiated.\"\n            )\n\n            self._bg_scheduler.run_once(\n                DEFAULT_GRACE_PERIOD, _half_open_circuit, circuit\n            )\n\n        if old_state != CBState.CLOSED and new_state == CBState.CLOSED:\n            logger.info(f\"Database {circuit.database} is reachable again.\")\n\n    def close(self):\n        \"\"\"\n        Closes the client and all its resources.\n        \"\"\"\n        # Close health check policy BEFORE stopping the scheduler.\n        # The policy's connection pools were created on the shared health check\n        # event loop, so they must be disconnected on that same loop to avoid\n        # leaking sockets/file descriptors.\n        if self._bg_scheduler:\n            try:\n                self._bg_scheduler.run_coro_sync(self._health_check_policy.close)\n            except Exception:\n                pass\n            self._bg_scheduler.stop()\n\n        if self.command_executor.active_database:\n            self.command_executor.active_database.client.close()\n\n\ndef _half_open_circuit(circuit: CircuitBreaker):\n    circuit.state = CBState.HALF_OPEN\n\n\nclass Pipeline(RedisModuleCommands, CoreCommands):\n    \"\"\"\n    Pipeline implementation for multiple logical Redis databases.\n    \"\"\"\n\n    _is_async_client: Literal[False] = False\n\n    def __init__(self, client: MultiDBClient):\n        self._command_stack = []\n        self._client = client\n\n    def __enter__(self) -> \"Pipeline\":\n        return self\n\n    def __exit__(self, exc_type, exc_value, traceback):\n        self.reset()\n\n    def __del__(self):\n        try:\n            self.reset()\n        except Exception:\n            pass\n\n    def __len__(self) -> int:\n        return len(self._command_stack)\n\n    def __bool__(self) -> bool:\n        \"\"\"Pipeline instances should always evaluate to True\"\"\"\n        return True\n\n    def reset(self) -> None:\n        self._command_stack = []\n\n    def close(self) -> None:\n        \"\"\"Close the pipeline\"\"\"\n        self.reset()\n\n    def pipeline_execute_command(self, *args, **options) -> \"Pipeline\":\n        \"\"\"\n        Stage a command to be executed when execute() is next called\n\n        Returns the current Pipeline object back so commands can be\n        chained together, such as:\n\n        pipe = pipe.set('foo', 'bar').incr('baz').decr('bang')\n\n        At some other point, you can then run: pipe.execute(),\n        which will execute all commands queued in the pipe.\n        \"\"\"\n        self._command_stack.append((args, options))\n        return self\n\n    def execute_command(self, *args, **kwargs):\n        \"\"\"Adds a command to the stack\"\"\"\n        return self.pipeline_execute_command(*args, **kwargs)\n\n    def execute(self) -> List[Any]:\n        \"\"\"Execute all the commands in the current pipeline\"\"\"\n        if not self._client.initialized:\n            self._client.initialize()\n\n        try:\n            return self._client.command_executor.execute_pipeline(\n                tuple(self._command_stack)\n            )\n        finally:\n            self.reset()\n\n\nclass PubSub:\n    \"\"\"\n    PubSub object for multi database client.\n    \"\"\"\n\n    def __init__(self, client: MultiDBClient, **kwargs):\n        \"\"\"Initialize the PubSub object for a multi-database client.\n\n        Args:\n            client: MultiDBClient instance to use for pub/sub operations\n            **kwargs: Additional keyword arguments to pass to the underlying pubsub implementation\n        \"\"\"\n\n        self._client = client\n        self._client.command_executor.pubsub(**kwargs)\n\n    def __enter__(self) -> \"PubSub\":\n        return self\n\n    def __del__(self) -> None:\n        try:\n            # if this object went out of scope prior to shutting down\n            # subscriptions, close the connection manually before\n            # returning it to the connection pool\n            self.reset()\n        except Exception:\n            pass\n\n    def reset(self) -> None:\n        return self._client.command_executor.execute_pubsub_method(\"reset\")\n\n    def close(self) -> None:\n        self.reset()\n\n    @property\n    def subscribed(self) -> bool:\n        return self._client.command_executor.active_pubsub.subscribed\n\n    def execute_command(self, *args):\n        return self._client.command_executor.execute_pubsub_method(\n            \"execute_command\", *args\n        )\n\n    def psubscribe(self, *args, **kwargs):\n        \"\"\"\n        Subscribe to channel patterns. Patterns supplied as keyword arguments\n        expect a pattern name as the key and a callable as the value. A\n        pattern's callable will be invoked automatically when a message is\n        received on that pattern rather than producing a message via\n        ``listen()``.\n        \"\"\"\n        return self._client.command_executor.execute_pubsub_method(\n            \"psubscribe\", *args, **kwargs\n        )\n\n    def punsubscribe(self, *args):\n        \"\"\"\n        Unsubscribe from the supplied patterns. If empty, unsubscribe from\n        all patterns.\n        \"\"\"\n        return self._client.command_executor.execute_pubsub_method(\n            \"punsubscribe\", *args\n        )\n\n    def subscribe(self, *args, **kwargs):\n        \"\"\"\n        Subscribe to channels. Channels supplied as keyword arguments expect\n        a channel name as the key and a callable as the value. A channel's\n        callable will be invoked automatically when a message is received on\n        that channel rather than producing a message via ``listen()`` or\n        ``get_message()``.\n        \"\"\"\n        return self._client.command_executor.execute_pubsub_method(\n            \"subscribe\", *args, **kwargs\n        )\n\n    def unsubscribe(self, *args):\n        \"\"\"\n        Unsubscribe from the supplied channels. If empty, unsubscribe from\n        all channels\n        \"\"\"\n        return self._client.command_executor.execute_pubsub_method(\"unsubscribe\", *args)\n\n    def ssubscribe(self, *args, **kwargs):\n        \"\"\"\n        Subscribes the client to the specified shard channels.\n        Channels supplied as keyword arguments expect a channel name as the key\n        and a callable as the value. A channel's callable will be invoked automatically\n        when a message is received on that channel rather than producing a message via\n        ``listen()`` or ``get_sharded_message()``.\n        \"\"\"\n        return self._client.command_executor.execute_pubsub_method(\n            \"ssubscribe\", *args, **kwargs\n        )\n\n    def sunsubscribe(self, *args):\n        \"\"\"\n        Unsubscribe from the supplied shard_channels. If empty, unsubscribe from\n        all shard_channels\n        \"\"\"\n        return self._client.command_executor.execute_pubsub_method(\n            \"sunsubscribe\", *args\n        )\n\n    def get_message(\n        self, ignore_subscribe_messages: bool = False, timeout: float = 0.0\n    ):\n        \"\"\"\n        Get the next message if one is available, otherwise None.\n\n        If timeout is specified, the system will wait for `timeout` seconds\n        before returning. Timeout should be specified as a floating point\n        number, or None, to wait indefinitely.\n        \"\"\"\n        return self._client.command_executor.execute_pubsub_method(\n            \"get_message\",\n            ignore_subscribe_messages=ignore_subscribe_messages,\n            timeout=timeout,\n        )\n\n    def get_sharded_message(\n        self, ignore_subscribe_messages: bool = False, timeout: float = 0.0\n    ):\n        \"\"\"\n        Get the next message if one is available in a sharded channel, otherwise None.\n\n        If timeout is specified, the system will wait for `timeout` seconds\n        before returning. Timeout should be specified as a floating point\n        number, or None, to wait indefinitely.\n        \"\"\"\n        return self._client.command_executor.execute_pubsub_method(\n            \"get_sharded_message\",\n            ignore_subscribe_messages=ignore_subscribe_messages,\n            timeout=timeout,\n        )\n\n    def run_in_thread(\n        self,\n        sleep_time: float = 0.0,\n        daemon: bool = False,\n        exception_handler: Optional[Callable] = None,\n        sharded_pubsub: bool = False,\n    ) -> \"PubSubWorkerThread\":\n        return self._client.command_executor.execute_pubsub_run(\n            sleep_time,\n            daemon=daemon,\n            exception_handler=exception_handler,\n            pubsub=self,\n            sharded_pubsub=sharded_pubsub,\n        )\n"
  },
  {
    "path": "redis/multidb/command_executor.py",
    "content": "from abc import ABC, abstractmethod\nfrom datetime import datetime, timedelta\nfrom typing import Any, Callable, List, Optional, Tuple\n\nfrom redis.client import Pipeline, PubSub, PubSubWorkerThread\nfrom redis.event import EventDispatcherInterface, OnCommandsFailEvent\nfrom redis.multidb.circuit import State as CBState\nfrom redis.multidb.config import DEFAULT_AUTO_FALLBACK_INTERVAL\nfrom redis.multidb.database import Database, Databases, SyncDatabase\nfrom redis.multidb.event import (\n    ActiveDatabaseChanged,\n    CloseConnectionOnActiveDatabaseChanged,\n    RegisterCommandFailure,\n    ResubscribeOnActiveDatabaseChanged,\n)\nfrom redis.multidb.failover import (\n    DEFAULT_FAILOVER_ATTEMPTS,\n    DEFAULT_FAILOVER_DELAY,\n    DefaultFailoverStrategyExecutor,\n    FailoverStrategy,\n    FailoverStrategyExecutor,\n)\nfrom redis.multidb.failure_detector import FailureDetector\nfrom redis.observability.attributes import GeoFailoverReason\nfrom redis.observability.recorder import record_geo_failover\nfrom redis.retry import Retry\n\n\nclass CommandExecutor(ABC):\n    @property\n    @abstractmethod\n    def auto_fallback_interval(self) -> float:\n        \"\"\"Returns auto-fallback interval.\"\"\"\n        pass\n\n    @auto_fallback_interval.setter\n    @abstractmethod\n    def auto_fallback_interval(self, auto_fallback_interval: float) -> None:\n        \"\"\"Sets auto-fallback interval.\"\"\"\n        pass\n\n\nclass BaseCommandExecutor(CommandExecutor):\n    def __init__(\n        self,\n        auto_fallback_interval: float = DEFAULT_AUTO_FALLBACK_INTERVAL,\n    ):\n        self._auto_fallback_interval = auto_fallback_interval\n        self._next_fallback_attempt: datetime\n\n    @property\n    def auto_fallback_interval(self) -> float:\n        return self._auto_fallback_interval\n\n    @auto_fallback_interval.setter\n    def auto_fallback_interval(self, auto_fallback_interval: int) -> None:\n        self._auto_fallback_interval = auto_fallback_interval\n\n    def _schedule_next_fallback(self) -> None:\n        if self._auto_fallback_interval < 0:\n            return\n\n        self._next_fallback_attempt = datetime.now() + timedelta(\n            seconds=self._auto_fallback_interval\n        )\n\n\nclass SyncCommandExecutor(CommandExecutor):\n    @property\n    @abstractmethod\n    def databases(self) -> Databases:\n        \"\"\"Returns a list of databases.\"\"\"\n        pass\n\n    @property\n    @abstractmethod\n    def failure_detectors(self) -> List[FailureDetector]:\n        \"\"\"Returns a list of failure detectors.\"\"\"\n        pass\n\n    @abstractmethod\n    def add_failure_detector(self, failure_detector: FailureDetector) -> None:\n        \"\"\"Adds a new failure detector to the list of failure detectors.\"\"\"\n        pass\n\n    @property\n    @abstractmethod\n    def active_database(self) -> Optional[Database]:\n        \"\"\"Returns currently active database.\"\"\"\n        pass\n\n    @active_database.setter\n    @abstractmethod\n    def active_database(self, value: Tuple[SyncDatabase, GeoFailoverReason]) -> None:\n        \"\"\"Sets the currently active database.\n\n        Args:\n            value: A tuple of (database, reason) where database is the new active\n                   database and reason is the GeoFailoverReason for the change.\n        \"\"\"\n        pass\n\n    @property\n    @abstractmethod\n    def active_pubsub(self) -> Optional[PubSub]:\n        \"\"\"Returns currently active pubsub.\"\"\"\n        pass\n\n    @active_pubsub.setter\n    @abstractmethod\n    def active_pubsub(self, pubsub: PubSub) -> None:\n        \"\"\"Sets currently active pubsub.\"\"\"\n        pass\n\n    @property\n    @abstractmethod\n    def failover_strategy_executor(self) -> FailoverStrategyExecutor:\n        \"\"\"Returns failover strategy executor.\"\"\"\n        pass\n\n    @property\n    @abstractmethod\n    def command_retry(self) -> Retry:\n        \"\"\"Returns command retry object.\"\"\"\n        pass\n\n    @abstractmethod\n    def pubsub(self, **kwargs):\n        \"\"\"Initializes a PubSub object on a currently active database\"\"\"\n        pass\n\n    @abstractmethod\n    def execute_command(self, *args, **options):\n        \"\"\"Executes a command and returns the result.\"\"\"\n        pass\n\n    @abstractmethod\n    def execute_pipeline(self, command_stack: tuple):\n        \"\"\"Executes a stack of commands in pipeline.\"\"\"\n        pass\n\n    @abstractmethod\n    def execute_transaction(\n        self, transaction: Callable[[Pipeline], None], *watches, **options\n    ):\n        \"\"\"Executes a transaction block wrapped in callback.\"\"\"\n        pass\n\n    @abstractmethod\n    def execute_pubsub_method(self, method_name: str, *args, **kwargs):\n        \"\"\"Executes a given method on active pub/sub.\"\"\"\n        pass\n\n    @abstractmethod\n    def execute_pubsub_run(self, sleep_time: float, **kwargs) -> Any:\n        \"\"\"Executes pub/sub run in a thread.\"\"\"\n        pass\n\n\nclass DefaultCommandExecutor(SyncCommandExecutor, BaseCommandExecutor):\n    def __init__(\n        self,\n        failure_detectors: List[FailureDetector],\n        databases: Databases,\n        command_retry: Retry,\n        failover_strategy: FailoverStrategy,\n        event_dispatcher: EventDispatcherInterface,\n        failover_attempts: int = DEFAULT_FAILOVER_ATTEMPTS,\n        failover_delay: float = DEFAULT_FAILOVER_DELAY,\n        auto_fallback_interval: float = DEFAULT_AUTO_FALLBACK_INTERVAL,\n    ):\n        \"\"\"\n        Initialize the DefaultCommandExecutor instance.\n\n        Args:\n            failure_detectors: List of failure detector instances to monitor database health\n            databases: Collection of available databases to execute commands on\n            command_retry: Retry policy for failed command execution\n            failover_strategy: Strategy for handling database failover\n            event_dispatcher: Interface for dispatching events\n            failover_attempts: Number of failover attempts\n            failover_delay: Delay between failover attempts\n            auto_fallback_interval: Time interval in seconds between attempts to fall back to a primary database\n        \"\"\"\n        super().__init__(auto_fallback_interval)\n\n        for fd in failure_detectors:\n            fd.set_command_executor(command_executor=self)\n\n        self._databases = databases\n        self._failure_detectors = failure_detectors\n        self._command_retry = command_retry\n        self._failover_strategy_executor = DefaultFailoverStrategyExecutor(\n            failover_strategy, failover_attempts, failover_delay\n        )\n        self._event_dispatcher = event_dispatcher\n        self._active_database: Optional[Database] = None\n        self._active_pubsub: Optional[PubSub] = None\n        self._active_pubsub_kwargs = {}\n        self._setup_event_dispatcher()\n        self._schedule_next_fallback()\n\n    @property\n    def databases(self) -> Databases:\n        return self._databases\n\n    @property\n    def failure_detectors(self) -> List[FailureDetector]:\n        return self._failure_detectors\n\n    def add_failure_detector(self, failure_detector: FailureDetector) -> None:\n        self._failure_detectors.append(failure_detector)\n\n    @property\n    def command_retry(self) -> Retry:\n        return self._command_retry\n\n    @property\n    def active_database(self) -> Optional[SyncDatabase]:\n        return self._active_database\n\n    @active_database.setter\n    def active_database(self, value: Tuple[SyncDatabase, GeoFailoverReason]) -> None:\n        database, reason = value\n        old_active = self._active_database\n        self._active_database = database\n\n        if old_active is not None and old_active is not database:\n            record_geo_failover(\n                fail_from=old_active,\n                fail_to=database,\n                reason=reason,\n            )\n            self._event_dispatcher.dispatch(\n                ActiveDatabaseChanged(\n                    old_active,\n                    self._active_database,\n                    self,\n                    **self._active_pubsub_kwargs,\n                )\n            )\n\n    @property\n    def active_pubsub(self) -> Optional[PubSub]:\n        return self._active_pubsub\n\n    @active_pubsub.setter\n    def active_pubsub(self, pubsub: PubSub) -> None:\n        self._active_pubsub = pubsub\n\n    @property\n    def failover_strategy_executor(self) -> FailoverStrategyExecutor:\n        return self._failover_strategy_executor\n\n    def execute_command(self, *args, **options):\n        def callback():\n            response = self._active_database.client.execute_command(*args, **options)\n            self._register_command_execution(args)\n            return response\n\n        return self._execute_with_failure_detection(callback, args)\n\n    def execute_pipeline(self, command_stack: tuple):\n        def callback():\n            with self._active_database.client.pipeline() as pipe:\n                for command, options in command_stack:\n                    pipe.execute_command(*command, **options)\n\n                response = pipe.execute()\n                self._register_command_execution(command_stack)\n                return response\n\n        return self._execute_with_failure_detection(callback, command_stack)\n\n    def execute_transaction(\n        self, transaction: Callable[[Pipeline], None], *watches, **options\n    ):\n        def callback():\n            response = self._active_database.client.transaction(\n                transaction, *watches, **options\n            )\n            self._register_command_execution(())\n            return response\n\n        return self._execute_with_failure_detection(callback)\n\n    def pubsub(self, **kwargs):\n        def callback():\n            if self._active_pubsub is None:\n                self._active_pubsub = self._active_database.client.pubsub(**kwargs)\n                self._active_pubsub_kwargs = kwargs\n            return None\n\n        return self._execute_with_failure_detection(callback)\n\n    def execute_pubsub_method(self, method_name: str, *args, **kwargs):\n        def callback():\n            method = getattr(self.active_pubsub, method_name)\n            response = method(*args, **kwargs)\n            self._register_command_execution(args)\n            return response\n\n        return self._execute_with_failure_detection(callback, *args)\n\n    def execute_pubsub_run(self, sleep_time, **kwargs) -> \"PubSubWorkerThread\":\n        def callback():\n            return self._active_pubsub.run_in_thread(sleep_time, **kwargs)\n\n        return self._execute_with_failure_detection(callback)\n\n    def _execute_with_failure_detection(self, callback: Callable, cmds: tuple = ()):\n        \"\"\"\n        Execute a commands execution callback with failure detection.\n        \"\"\"\n\n        def wrapper():\n            # On each retry we need to check active database as it might change.\n            self._check_active_database()\n            return callback()\n\n        return self._command_retry.call_with_retry(\n            lambda: wrapper(),\n            lambda error: self._on_command_fail(error, *cmds),\n        )\n\n    def _on_command_fail(self, error, *args):\n        self._event_dispatcher.dispatch(OnCommandsFailEvent(args, error))\n\n    def _check_active_database(self):\n        \"\"\"\n        Checks if active a database needs to be updated.\n        \"\"\"\n        if (\n            self._active_database is None\n            or self._active_database.circuit.state != CBState.CLOSED\n            or (\n                self._auto_fallback_interval > 0\n                and self._next_fallback_attempt <= datetime.now()\n            )\n        ):\n            self.active_database = (\n                self._failover_strategy_executor.execute(),\n                GeoFailoverReason.AUTOMATIC,\n            )\n            self._schedule_next_fallback()\n\n    def _register_command_execution(self, cmd: tuple):\n        for detector in self._failure_detectors:\n            detector.register_command_execution(cmd)\n\n    def _setup_event_dispatcher(self):\n        \"\"\"\n        Registers necessary listeners.\n        \"\"\"\n        failure_listener = RegisterCommandFailure(self._failure_detectors)\n        resubscribe_listener = ResubscribeOnActiveDatabaseChanged()\n        close_connection_listener = CloseConnectionOnActiveDatabaseChanged()\n        self._event_dispatcher.register_listeners(\n            {\n                OnCommandsFailEvent: [failure_listener],\n                ActiveDatabaseChanged: [\n                    close_connection_listener,\n                    resubscribe_listener,\n                ],\n            }\n        )\n"
  },
  {
    "path": "redis/multidb/config.py",
    "content": "from dataclasses import dataclass, field\nfrom enum import Enum\nfrom typing import List, Optional, Type, Union\n\nimport pybreaker\n\nfrom redis import ConnectionPool, Redis, RedisCluster\nfrom redis.asyncio.multidb.healthcheck import (\n    DEFAULT_HEALTH_CHECK_DELAY,\n    DEFAULT_HEALTH_CHECK_INTERVAL,\n    DEFAULT_HEALTH_CHECK_POLICY,\n    DEFAULT_HEALTH_CHECK_PROBES,\n    DEFAULT_HEALTH_CHECK_TIMEOUT,\n    HealthCheck,\n    HealthCheckPolicies,\n    PingHealthCheck,\n)\nfrom redis.backoff import ExponentialWithJitterBackoff, NoBackoff\nfrom redis.data_structure import WeightedList\nfrom redis.event import EventDispatcher, EventDispatcherInterface\nfrom redis.maint_notifications import MaintNotificationsConfig\nfrom redis.multidb.circuit import (\n    DEFAULT_GRACE_PERIOD,\n    CircuitBreaker,\n    PBCircuitBreakerAdapter,\n)\nfrom redis.multidb.database import Database, Databases\nfrom redis.multidb.failover import (\n    DEFAULT_FAILOVER_ATTEMPTS,\n    DEFAULT_FAILOVER_DELAY,\n    FailoverStrategy,\n    WeightBasedFailoverStrategy,\n)\nfrom redis.multidb.failure_detector import (\n    DEFAULT_FAILURE_RATE_THRESHOLD,\n    DEFAULT_FAILURES_DETECTION_WINDOW,\n    DEFAULT_MIN_NUM_FAILURES,\n    CommandFailureDetector,\n    FailureDetector,\n)\nfrom redis.retry import Retry\n\nDEFAULT_AUTO_FALLBACK_INTERVAL = 120\n\n\nclass InitialHealthCheck(Enum):\n    ALL_AVAILABLE = \"all_available\"\n    MAJORITY_AVAILABLE = \"majority_available\"\n    ONE_AVAILABLE = \"one_available\"\n\n\ndef default_event_dispatcher() -> EventDispatcherInterface:\n    return EventDispatcher()\n\n\n@dataclass\nclass DatabaseConfig:\n    \"\"\"\n    Dataclass representing the configuration for a database connection.\n\n    This class is used to store configuration settings for a database connection,\n    including client options, connection sourcing details, circuit breaker settings,\n    and cluster-specific properties. It provides a structure for defining these\n    attributes and allows for the creation of customized configurations for various\n    database setups.\n\n    Attributes:\n        weight (float): Weight of the database to define the active one.\n        client_kwargs (dict): Additional parameters for the database client connection.\n        from_url (Optional[str]): Redis URL way of connecting to the database.\n        from_pool (Optional[ConnectionPool]): A pre-configured connection pool to use.\n        circuit (Optional[CircuitBreaker]): Custom circuit breaker implementation.\n        grace_period (float): Grace period after which we need to check if the circuit could be closed again.\n        health_check_url (Optional[str]): URL for health checks. Cluster FQDN is typically used\n            on public Redis Enterprise endpoints.\n\n    Methods:\n        default_circuit_breaker:\n            Generates and returns a default CircuitBreaker instance adapted for use.\n    \"\"\"\n\n    weight: float = 1.0\n    client_kwargs: dict = field(default_factory=dict)\n    from_url: Optional[str] = None\n    from_pool: Optional[ConnectionPool] = None\n    circuit: Optional[CircuitBreaker] = None\n    grace_period: float = DEFAULT_GRACE_PERIOD\n    health_check_url: Optional[str] = None\n\n    def default_circuit_breaker(self) -> CircuitBreaker:\n        circuit_breaker = pybreaker.CircuitBreaker(reset_timeout=self.grace_period)\n        return PBCircuitBreakerAdapter(circuit_breaker)\n\n\n@dataclass\nclass MultiDbConfig:\n    \"\"\"\n    Configuration class for managing multiple database connections in a resilient and fail-safe manner.\n\n    Attributes:\n        databases_config: A list of database configurations.\n        client_class: The client class used to manage database connections.\n        command_retry: Retry strategy for executing database commands.\n        failure_detectors: Optional list of additional failure detectors for monitoring database failures.\n        min_num_failures: Minimal count of failures required for failover\n        failure_rate_threshold: Percentage of failures required for failover\n        failures_detection_window: Time interval for tracking database failures.\n        health_checks: Optional list of additional health checks performed on databases.\n        health_check_interval: Time interval for executing health checks.\n        health_check_probes: Number of attempts to evaluate the health of a database.\n        health_check_delay: Delay between health check attempts.\n        health_check_timeout: Timeout for the full health check operation (including all probes).\n        health_check_policy: Policy for determining database health based on health checks.\n        failover_strategy: Optional strategy for handling database failover scenarios.\n        failover_attempts: Number of retries allowed for failover operations.\n        failover_delay: Delay between failover attempts.\n        auto_fallback_interval: Time interval to trigger automatic fallback.\n        event_dispatcher: Interface for dispatching events related to database operations.\n        initial_health_check_policy: Defines the policy used to determine whether the databases setup is\n                                     healthy during the initial health check.\n\n    Methods:\n        databases:\n            Retrieves a collection of database clients managed by weighted configurations.\n            Initializes database clients based on the provided configuration and removes\n            redundant retry objects for lower-level clients to rely on global retry logic.\n\n        default_failure_detectors:\n            Returns the default list of failure detectors used to monitor database failures.\n\n        default_health_checks:\n            Returns the default list of health checks used to monitor database health\n            with specific retry and backoff strategies.\n\n        default_failover_strategy:\n            Provides the default failover strategy used for handling failover scenarios\n            with defined retry and backoff configurations.\n    \"\"\"\n\n    databases_config: List[DatabaseConfig]\n    client_class: Type[Union[Redis, RedisCluster]] = Redis\n    command_retry: Retry = Retry(\n        backoff=ExponentialWithJitterBackoff(base=1, cap=10), retries=3\n    )\n    failure_detectors: Optional[List[FailureDetector]] = None\n    min_num_failures: int = DEFAULT_MIN_NUM_FAILURES\n    failure_rate_threshold: float = DEFAULT_FAILURE_RATE_THRESHOLD\n    failures_detection_window: float = DEFAULT_FAILURES_DETECTION_WINDOW\n    health_checks: Optional[List[HealthCheck]] = None\n    health_check_interval: float = DEFAULT_HEALTH_CHECK_INTERVAL\n    health_check_probes: int = DEFAULT_HEALTH_CHECK_PROBES\n    health_check_delay: float = DEFAULT_HEALTH_CHECK_DELAY\n    health_check_timeout: float = DEFAULT_HEALTH_CHECK_TIMEOUT\n    health_check_policy: HealthCheckPolicies = DEFAULT_HEALTH_CHECK_POLICY\n    failover_strategy: Optional[FailoverStrategy] = None\n    failover_attempts: int = DEFAULT_FAILOVER_ATTEMPTS\n    failover_delay: float = DEFAULT_FAILOVER_DELAY\n    auto_fallback_interval: float = DEFAULT_AUTO_FALLBACK_INTERVAL\n    event_dispatcher: EventDispatcherInterface = field(\n        default_factory=default_event_dispatcher\n    )\n    initial_health_check_policy: InitialHealthCheck = InitialHealthCheck.ALL_AVAILABLE\n\n    def databases(self) -> Databases:\n        databases = WeightedList()\n\n        for database_config in self.databases_config:\n            # The retry object is not used in the lower level clients, so we can safely remove it.\n            # We rely on command_retry in terms of global retries.\n            database_config.client_kwargs[\"retry\"] = Retry(\n                retries=0, backoff=NoBackoff()\n            )\n\n            # Maintenance notifications are disabled by default in underlying clients,\n            # but user can override this by providing their own config.\n            if \"maint_notifications_config\" not in database_config.client_kwargs:\n                database_config.client_kwargs[\"maint_notifications_config\"] = (\n                    MaintNotificationsConfig(enabled=False)\n                )\n\n            if database_config.from_url:\n                client = self.client_class.from_url(\n                    database_config.from_url, **database_config.client_kwargs\n                )\n            elif database_config.from_pool:\n                database_config.from_pool.set_retry(\n                    Retry(retries=0, backoff=NoBackoff())\n                )\n                client = self.client_class.from_pool(\n                    connection_pool=database_config.from_pool\n                )\n            else:\n                client = self.client_class(**database_config.client_kwargs)\n\n            circuit = (\n                database_config.default_circuit_breaker()\n                if database_config.circuit is None\n                else database_config.circuit\n            )\n            databases.add(\n                Database(\n                    client=client,\n                    circuit=circuit,\n                    weight=database_config.weight,\n                    health_check_url=database_config.health_check_url,\n                ),\n                database_config.weight,\n            )\n\n        return databases\n\n    def default_failure_detectors(self) -> List[FailureDetector]:\n        return [\n            CommandFailureDetector(\n                min_num_failures=self.min_num_failures,\n                failure_rate_threshold=self.failure_rate_threshold,\n                failure_detection_window=self.failures_detection_window,\n            ),\n        ]\n\n    def default_health_checks(self) -> List[HealthCheck]:\n        return [\n            PingHealthCheck(\n                health_check_probes=self.health_check_probes,\n                health_check_delay=self.health_check_delay,\n                health_check_timeout=self.health_check_timeout,\n            ),\n        ]\n\n    def default_failover_strategy(self) -> FailoverStrategy:\n        return WeightBasedFailoverStrategy()\n"
  },
  {
    "path": "redis/multidb/database.py",
    "content": "from abc import ABC, abstractmethod\nfrom typing import Optional, Union\n\nimport redis\nfrom redis import RedisCluster\nfrom redis.data_structure import WeightedList\nfrom redis.multidb.circuit import CircuitBreaker\nfrom redis.typing import Number\n\n\nclass AbstractDatabase(ABC):\n    @property\n    @abstractmethod\n    def weight(self) -> float:\n        \"\"\"The weight of this database in compare to others. Used to determine the database failover to.\"\"\"\n        pass\n\n    @weight.setter\n    @abstractmethod\n    def weight(self, weight: float):\n        \"\"\"Set the weight of this database in compare to others.\"\"\"\n        pass\n\n    @property\n    @abstractmethod\n    def health_check_url(self) -> Optional[str]:\n        \"\"\"Health check URL associated with the current database.\"\"\"\n        pass\n\n    @health_check_url.setter\n    @abstractmethod\n    def health_check_url(self, health_check_url: Optional[str]):\n        \"\"\"Set the health check URL associated with the current database.\"\"\"\n        pass\n\n\nclass BaseDatabase(AbstractDatabase):\n    def __init__(\n        self,\n        weight: float,\n        health_check_url: Optional[str] = None,\n    ):\n        self._weight = weight\n        self._health_check_url = health_check_url\n\n    @property\n    def weight(self) -> float:\n        return self._weight\n\n    @weight.setter\n    def weight(self, weight: float):\n        self._weight = weight\n\n    @property\n    def health_check_url(self) -> Optional[str]:\n        return self._health_check_url\n\n    @health_check_url.setter\n    def health_check_url(self, health_check_url: Optional[str]):\n        self._health_check_url = health_check_url\n\n\nclass SyncDatabase(AbstractDatabase):\n    \"\"\"Database with an underlying synchronous redis client.\"\"\"\n\n    @property\n    @abstractmethod\n    def client(self) -> Union[redis.Redis, RedisCluster]:\n        \"\"\"The underlying redis client.\"\"\"\n        pass\n\n    @client.setter\n    @abstractmethod\n    def client(self, client: Union[redis.Redis, RedisCluster]):\n        \"\"\"Set the underlying redis client.\"\"\"\n        pass\n\n    @property\n    @abstractmethod\n    def circuit(self) -> CircuitBreaker:\n        \"\"\"Circuit breaker for the current database.\"\"\"\n        pass\n\n    @circuit.setter\n    @abstractmethod\n    def circuit(self, circuit: CircuitBreaker):\n        \"\"\"Set the circuit breaker for the current database.\"\"\"\n        pass\n\n\nDatabases = WeightedList[tuple[SyncDatabase, Number]]\n\n\nclass Database(BaseDatabase, SyncDatabase):\n    def __init__(\n        self,\n        client: Union[redis.Redis, RedisCluster],\n        circuit: CircuitBreaker,\n        weight: float,\n        health_check_url: Optional[str] = None,\n    ):\n        \"\"\"\n        Initialize a new Database instance.\n\n        Args:\n            client: Underlying Redis client instance for database operations\n            circuit: Circuit breaker for handling database failures\n            weight: Weight value used for database failover prioritization\n            health_check_url: Health check URL associated with the current database\n        \"\"\"\n        self._client = client\n        self._cb = circuit\n        self._cb.database = self\n        super().__init__(weight, health_check_url)\n\n    @property\n    def client(self) -> Union[redis.Redis, RedisCluster]:\n        return self._client\n\n    @client.setter\n    def client(self, client: Union[redis.Redis, RedisCluster]):\n        self._client = client\n\n    @property\n    def circuit(self) -> CircuitBreaker:\n        return self._cb\n\n    @circuit.setter\n    def circuit(self, circuit: CircuitBreaker):\n        self._cb = circuit\n\n    def __repr__(self):\n        return f\"Database(client={self.client}, weight={self.weight})\"\n"
  },
  {
    "path": "redis/multidb/event.py",
    "content": "from typing import List\n\nfrom redis.client import Redis\nfrom redis.event import EventListenerInterface, OnCommandsFailEvent\nfrom redis.multidb.database import SyncDatabase\nfrom redis.multidb.failure_detector import FailureDetector\n\n\nclass ActiveDatabaseChanged:\n    \"\"\"\n    Event fired when an active database has been changed.\n    \"\"\"\n\n    def __init__(\n        self,\n        old_database: SyncDatabase,\n        new_database: SyncDatabase,\n        command_executor,\n        **kwargs,\n    ):\n        self._old_database = old_database\n        self._new_database = new_database\n        self._command_executor = command_executor\n        self._kwargs = kwargs\n\n    @property\n    def old_database(self) -> SyncDatabase:\n        return self._old_database\n\n    @property\n    def new_database(self) -> SyncDatabase:\n        return self._new_database\n\n    @property\n    def command_executor(self):\n        return self._command_executor\n\n    @property\n    def kwargs(self):\n        return self._kwargs\n\n\nclass ResubscribeOnActiveDatabaseChanged(EventListenerInterface):\n    \"\"\"\n    Re-subscribe the currently active pub / sub to a new active database.\n    \"\"\"\n\n    def listen(self, event: ActiveDatabaseChanged):\n        old_pubsub = event.command_executor.active_pubsub\n\n        if old_pubsub is not None:\n            # Re-assign old channels and patterns so they will be automatically subscribed on connection.\n            new_pubsub = event.new_database.client.pubsub(**event.kwargs)\n            new_pubsub.channels = old_pubsub.channels\n            new_pubsub.patterns = old_pubsub.patterns\n            new_pubsub.shard_channels = old_pubsub.shard_channels\n            new_pubsub.on_connect(None)\n            event.command_executor.active_pubsub = new_pubsub\n            old_pubsub.close()\n\n\nclass CloseConnectionOnActiveDatabaseChanged(EventListenerInterface):\n    \"\"\"\n    Close connection to the old active database.\n    \"\"\"\n\n    def listen(self, event: ActiveDatabaseChanged):\n        event.old_database.client.close()\n\n        if isinstance(event.old_database.client, Redis):\n            event.old_database.client.connection_pool.update_active_connections_for_reconnect()\n            event.old_database.client.connection_pool.disconnect()\n        else:\n            for node in event.old_database.client.nodes_manager.nodes_cache.values():\n                node.redis_connection.connection_pool.update_active_connections_for_reconnect()\n                node.redis_connection.connection_pool.disconnect()\n\n\nclass RegisterCommandFailure(EventListenerInterface):\n    \"\"\"\n    Event listener that registers command failures and passing it to the failure detectors.\n    \"\"\"\n\n    def __init__(self, failure_detectors: List[FailureDetector]):\n        self._failure_detectors = failure_detectors\n\n    def listen(self, event: OnCommandsFailEvent) -> None:\n        for failure_detector in self._failure_detectors:\n            failure_detector.register_failure(event.exception, event.commands)\n"
  },
  {
    "path": "redis/multidb/exception.py",
    "content": "class NoValidDatabaseException(Exception):\n    pass\n\n\nclass UnhealthyDatabaseException(Exception):\n    \"\"\"Exception raised when a database is unhealthy due to an underlying exception.\"\"\"\n\n    def __init__(self, message, database, original_exception):\n        super().__init__(message)\n        self.database = database\n        self.original_exception = original_exception\n\n\nclass TemporaryUnavailableException(Exception):\n    \"\"\"Exception raised when all databases in setup are temporary unavailable.\"\"\"\n\n    pass\n\n\nclass InitialHealthCheckFailedError(Exception):\n    \"\"\"Exception raised when initial health check fails.\"\"\"\n\n    pass\n"
  },
  {
    "path": "redis/multidb/failover.py",
    "content": "import time\nfrom abc import ABC, abstractmethod\n\nfrom redis.data_structure import WeightedList\nfrom redis.multidb.circuit import State as CBState\nfrom redis.multidb.database import Databases, SyncDatabase\nfrom redis.multidb.exception import (\n    NoValidDatabaseException,\n    TemporaryUnavailableException,\n)\n\nDEFAULT_FAILOVER_ATTEMPTS = 10\nDEFAULT_FAILOVER_DELAY = 12\n\n\nclass FailoverStrategy(ABC):\n    @abstractmethod\n    def database(self) -> SyncDatabase:\n        \"\"\"Select the database according to the strategy.\"\"\"\n        pass\n\n    @abstractmethod\n    def set_databases(self, databases: Databases) -> None:\n        \"\"\"Set the database strategy operates on.\"\"\"\n        pass\n\n\nclass FailoverStrategyExecutor(ABC):\n    @property\n    @abstractmethod\n    def failover_attempts(self) -> int:\n        \"\"\"The number of failover attempts.\"\"\"\n        pass\n\n    @property\n    @abstractmethod\n    def failover_delay(self) -> float:\n        \"\"\"The delay between failover attempts.\"\"\"\n        pass\n\n    @property\n    @abstractmethod\n    def strategy(self) -> FailoverStrategy:\n        \"\"\"The strategy to execute.\"\"\"\n        pass\n\n    @abstractmethod\n    def execute(self) -> SyncDatabase:\n        \"\"\"Execute the failover strategy.\"\"\"\n        pass\n\n\nclass WeightBasedFailoverStrategy(FailoverStrategy):\n    \"\"\"\n    Failover strategy based on database weights.\n    \"\"\"\n\n    def __init__(self) -> None:\n        self._databases = WeightedList()\n\n    def database(self) -> SyncDatabase:\n        for database, _ in self._databases:\n            if database.circuit.state == CBState.CLOSED:\n                return database\n\n        raise NoValidDatabaseException(\"No valid database available for communication\")\n\n    def set_databases(self, databases: Databases) -> None:\n        self._databases = databases\n\n\nclass DefaultFailoverStrategyExecutor(FailoverStrategyExecutor):\n    \"\"\"\n    Executes given failover strategy.\n    \"\"\"\n\n    def __init__(\n        self,\n        strategy: FailoverStrategy,\n        failover_attempts: int = DEFAULT_FAILOVER_ATTEMPTS,\n        failover_delay: float = DEFAULT_FAILOVER_DELAY,\n    ):\n        self._strategy = strategy\n        self._failover_attempts = failover_attempts\n        self._failover_delay = failover_delay\n        self._next_attempt_ts: int = 0\n        self._failover_counter: int = 0\n\n    @property\n    def failover_attempts(self) -> int:\n        return self._failover_attempts\n\n    @property\n    def failover_delay(self) -> float:\n        return self._failover_delay\n\n    @property\n    def strategy(self) -> FailoverStrategy:\n        return self._strategy\n\n    def execute(self) -> SyncDatabase:\n        try:\n            database = self._strategy.database()\n            self._reset()\n            return database\n        except NoValidDatabaseException as e:\n            if self._next_attempt_ts == 0:\n                self._next_attempt_ts = time.time() + self._failover_delay\n                self._failover_counter += 1\n            elif time.time() >= self._next_attempt_ts:\n                self._next_attempt_ts += self._failover_delay\n                self._failover_counter += 1\n\n            if self._failover_counter > self._failover_attempts:\n                self._reset()\n                raise e\n            else:\n                raise TemporaryUnavailableException(\n                    \"No database connections currently available. \"\n                    \"This is a temporary condition - please retry the operation.\"\n                )\n\n    def _reset(self) -> None:\n        self._next_attempt_ts = 0\n        self._failover_counter = 0\n"
  },
  {
    "path": "redis/multidb/failure_detector.py",
    "content": "import math\nimport threading\nfrom abc import ABC, abstractmethod\nfrom datetime import datetime, timedelta\nfrom typing import List, Optional, Type\n\nfrom redis.multidb.circuit import State as CBState\n\nDEFAULT_MIN_NUM_FAILURES = 1000\nDEFAULT_FAILURE_RATE_THRESHOLD = 0.1\nDEFAULT_FAILURES_DETECTION_WINDOW = 2\n\n\nclass FailureDetector(ABC):\n    @abstractmethod\n    def register_failure(self, exception: Exception, cmd: tuple) -> None:\n        \"\"\"Register a failure that occurred during command execution.\"\"\"\n        pass\n\n    @abstractmethod\n    def register_command_execution(self, cmd: tuple) -> None:\n        \"\"\"Register a command execution.\"\"\"\n        pass\n\n    @abstractmethod\n    def set_command_executor(self, command_executor) -> None:\n        \"\"\"Set the command executor for this failure.\"\"\"\n        pass\n\n\nclass CommandFailureDetector(FailureDetector):\n    \"\"\"\n    Detects a failure based on a threshold of failed commands during a specific period of time.\n    \"\"\"\n\n    def __init__(\n        self,\n        min_num_failures: int = DEFAULT_MIN_NUM_FAILURES,\n        failure_rate_threshold: float = DEFAULT_FAILURE_RATE_THRESHOLD,\n        failure_detection_window: float = DEFAULT_FAILURES_DETECTION_WINDOW,\n        error_types: Optional[List[Type[Exception]]] = None,\n    ) -> None:\n        \"\"\"\n        Initialize a new CommandFailureDetector instance.\n\n        Args:\n            min_num_failures: Minimal count of failures required for failover\n            failure_rate_threshold: Percentage of failures required for failover\n            failure_detection_window: Time interval for executing health checks.\n            error_types: Optional list of exception types to trigger failover. If None, all exceptions are counted.\n\n        The detector tracks command failures within a sliding time window. When the number of failures\n        exceeds the threshold within the specified duration, it triggers failure detection.\n        \"\"\"\n        self._command_executor = None\n        self._min_num_failures = min_num_failures\n        self._failure_rate_threshold = failure_rate_threshold\n        self._failure_detection_window = failure_detection_window\n        self._error_types = error_types\n        self._commands_executed: int = 0\n        self._start_time: datetime = datetime.now()\n        self._end_time: datetime = self._start_time + timedelta(\n            seconds=self._failure_detection_window\n        )\n        self._failures_count: int = 0\n        self._lock = threading.RLock()\n\n    def register_failure(self, exception: Exception, cmd: tuple) -> None:\n        with self._lock:\n            if self._error_types:\n                if type(exception) in self._error_types:\n                    self._failures_count += 1\n            else:\n                self._failures_count += 1\n\n            self._check_threshold()\n\n    def set_command_executor(self, command_executor) -> None:\n        self._command_executor = command_executor\n\n    def register_command_execution(self, cmd: tuple) -> None:\n        with self._lock:\n            if not self._start_time < datetime.now() < self._end_time:\n                self._reset()\n\n            self._commands_executed += 1\n\n    def _check_threshold(self):\n        if self._failures_count >= self._min_num_failures and self._failures_count >= (\n            math.ceil(self._commands_executed * self._failure_rate_threshold)\n        ):\n            self._command_executor.active_database.circuit.state = CBState.OPEN\n            self._reset()\n\n    def _reset(self) -> None:\n        with self._lock:\n            self._start_time = datetime.now()\n            self._end_time = self._start_time + timedelta(\n                seconds=self._failure_detection_window\n            )\n            self._failures_count = 0\n            self._commands_executed = 0\n"
  },
  {
    "path": "redis/observability/__init__.py",
    "content": "\"\"\"\nOpenTelemetry observability module for redis-py.\n\nThis module provides APIs for collecting and exporting Redis metrics using OpenTelemetry.\n\nUsage:\n    from redis.observability import get_observability_instance, OTelConfig\n\n    otel = get_observability_instance()\n    otel.init(OTelConfig())\n\"\"\"\n\nfrom redis.observability.config import MetricGroup, OTelConfig, TelemetryOption\nfrom redis.observability.providers import (\n    ObservabilityInstance,\n    get_observability_instance,\n    reset_observability_instance,\n)\n\n__all__ = [\n    \"OTelConfig\",\n    \"MetricGroup\",\n    \"TelemetryOption\",\n    \"ObservabilityInstance\",\n    \"get_observability_instance\",\n    \"reset_observability_instance\",\n]\n"
  },
  {
    "path": "redis/observability/attributes.py",
    "content": "\"\"\"\nOpenTelemetry semantic convention attributes for Redis.\n\nThis module provides constants and helper functions for building OTel attributes\naccording to the semantic conventions for database clients.\n\nReference: https://opentelemetry.io/docs/specs/semconv/database/redis/\n\"\"\"\n\nfrom enum import Enum\nfrom typing import TYPE_CHECKING, Any, Dict, Optional, Union\n\nimport redis\n\nif TYPE_CHECKING:\n    from redis.asyncio.connection import ConnectionPool\n    from redis.asyncio.multidb.database import AsyncDatabase\n    from redis.connection import ConnectionPoolInterface\n    from redis.multidb.database import SyncDatabase\n\n# Database semantic convention attributes\nDB_SYSTEM = \"db.system\"\nDB_NAMESPACE = \"db.namespace\"\nDB_OPERATION_NAME = \"db.operation.name\"\nDB_RESPONSE_STATUS_CODE = \"db.response.status_code\"\nDB_STORED_PROCEDURE_NAME = \"db.stored_procedure.name\"\n\n# Error attributes\nERROR_TYPE = \"error.type\"\n\n# Network attributes\nNETWORK_PEER_ADDRESS = \"network.peer.address\"\nNETWORK_PEER_PORT = \"network.peer.port\"\n\n# Server attributes\nSERVER_ADDRESS = \"server.address\"\nSERVER_PORT = \"server.port\"\n\n# Connection pool attributes\nDB_CLIENT_CONNECTION_POOL_NAME = \"db.client.connection.pool.name\"\nDB_CLIENT_CONNECTION_STATE = \"db.client.connection.state\"\nDB_CLIENT_CONNECTION_NAME = \"db.client.connection.name\"\n\n# Geofailover attributes\nDB_CLIENT_GEOFAILOVER_FAIL_FROM = \"db.client.geofailover.fail_from\"\nDB_CLIENT_GEOFAILOVER_FAIL_TO = \"db.client.geofailover.fail_to\"\nDB_CLIENT_GEOFAILOVER_REASON = \"db.client.geofailover.reason\"\n\n# Redis-specific attributes\nREDIS_CLIENT_LIBRARY = \"redis.client.library\"\nREDIS_CLIENT_CONNECTION_PUBSUB = \"redis.client.connection.pubsub\"\nREDIS_CLIENT_CONNECTION_CLOSE_REASON = \"redis.client.connection.close.reason\"\nREDIS_CLIENT_CONNECTION_NOTIFICATION = \"redis.client.connection.notification\"\nREDIS_CLIENT_OPERATION_RETRY_ATTEMPTS = \"redis.client.operation.retry_attempts\"\nREDIS_CLIENT_OPERATION_BLOCKING = \"redis.client.operation.blocking\"\nREDIS_CLIENT_PUBSUB_MESSAGE_DIRECTION = \"redis.client.pubsub.message.direction\"\nREDIS_CLIENT_PUBSUB_CHANNEL = \"redis.client.pubsub.channel\"\nREDIS_CLIENT_PUBSUB_SHARDED = \"redis.client.pubsub.sharded\"\nREDIS_CLIENT_ERROR_INTERNAL = \"redis.client.errors.internal\"\nREDIS_CLIENT_ERROR_CATEGORY = \"redis.client.errors.category\"\nREDIS_CLIENT_STREAM_NAME = \"redis.client.stream.name\"\nREDIS_CLIENT_CONSUMER_GROUP = \"redis.client.consumer_group\"\nREDIS_CLIENT_CSC_RESULT = \"redis.client.csc.result\"\nREDIS_CLIENT_CSC_REASON = \"redis.client.csc.reason\"\n\n\nclass ConnectionState(Enum):\n    IDLE = \"idle\"\n    USED = \"used\"\n\n\nclass PubSubDirection(Enum):\n    PUBLISH = \"publish\"\n    RECEIVE = \"receive\"\n\n\nclass CSCResult(Enum):\n    HIT = \"hit\"\n    MISS = \"miss\"\n\n\nclass CSCReason(Enum):\n    FULL = \"full\"\n    INVALIDATION = \"invalidation\"\n\n\nclass GeoFailoverReason(Enum):\n    AUTOMATIC = \"automatic\"\n    MANUAL = \"manual\"\n\n\nclass AttributeBuilder:\n    \"\"\"\n    Helper class to build OTel semantic convention attributes for Redis operations.\n    \"\"\"\n\n    @staticmethod\n    def build_base_attributes(\n        server_address: Optional[str] = None,\n        server_port: Optional[int] = None,\n        db_namespace: Optional[int] = None,\n    ) -> Dict[str, Any]:\n        \"\"\"\n        Build base attributes common to all Redis operations.\n\n        Args:\n            server_address: Redis server address (FQDN or IP)\n            server_port: Redis server port\n            db_namespace: Redis database index\n\n        Returns:\n            Dictionary of base attributes\n        \"\"\"\n        attrs: Dict[str, Any] = {\n            DB_SYSTEM: \"redis\",\n            REDIS_CLIENT_LIBRARY: f\"redis-py:v{redis.__version__}\",\n        }\n\n        if server_address is not None:\n            attrs[SERVER_ADDRESS] = server_address\n\n        if server_port is not None:\n            attrs[SERVER_PORT] = server_port\n\n        if db_namespace is not None:\n            attrs[DB_NAMESPACE] = str(db_namespace)\n\n        return attrs\n\n    @staticmethod\n    def build_operation_attributes(\n        command_name: Optional[Union[str, bytes]] = None,\n        batch_size: Optional[int] = None,  # noqa\n        network_peer_address: Optional[str] = None,\n        network_peer_port: Optional[int] = None,\n        stored_procedure_name: Optional[str] = None,\n        retry_attempts: Optional[int] = None,\n        is_blocking: Optional[bool] = None,\n    ) -> Dict[str, Any]:\n        \"\"\"\n        Build attributes for a Redis operation (command execution).\n\n        Args:\n            command_name: Redis command name (e.g., 'GET', 'SET', 'MULTI'), can be str or bytes\n            batch_size: Number of commands in batch (for pipelines/transactions)\n            network_peer_address: Resolved peer address\n            network_peer_port: Peer port number\n            stored_procedure_name: Lua script name or SHA1 digest\n            retry_attempts: Number of retry attempts made\n            is_blocking: Whether the operation is a blocking command\n\n        Returns:\n            Dictionary of operation attributes\n        \"\"\"\n        attrs: Dict[str, Any] = {}\n\n        if command_name is not None:\n            # Ensure command_name is a string (it can be bytes from args[0])\n            if isinstance(command_name, bytes):\n                command_name = command_name.decode(\"utf-8\", errors=\"replace\")\n            attrs[DB_OPERATION_NAME] = command_name.upper()\n\n        if network_peer_address is not None:\n            attrs[NETWORK_PEER_ADDRESS] = network_peer_address\n\n        if network_peer_port is not None:\n            attrs[NETWORK_PEER_PORT] = network_peer_port\n\n        if stored_procedure_name is not None:\n            attrs[DB_STORED_PROCEDURE_NAME] = stored_procedure_name\n\n        if retry_attempts is not None and retry_attempts > 0:\n            attrs[REDIS_CLIENT_OPERATION_RETRY_ATTEMPTS] = retry_attempts\n\n        if is_blocking is not None:\n            attrs[REDIS_CLIENT_OPERATION_BLOCKING] = is_blocking\n\n        return attrs\n\n    @staticmethod\n    def build_connection_attributes(\n        pool_name: Optional[str] = None,\n        connection_state: Optional[ConnectionState] = None,\n        connection_name: Optional[str] = None,\n        is_pubsub: Optional[bool] = None,\n    ) -> Dict[str, Any]:\n        \"\"\"\n        Build attributes for connection pool metrics.\n\n        Args:\n            pool_name: Unique connection pool name\n            connection_state: Connection state ('idle' or 'used')\n            is_pubsub: Whether this is a PubSub connection\n            connection_name: Unique connection name\n\n        Returns:\n            Dictionary of connection pool attributes\n        \"\"\"\n        attrs: Dict[str, Any] = AttributeBuilder.build_base_attributes()\n\n        if pool_name is not None:\n            attrs[DB_CLIENT_CONNECTION_POOL_NAME] = pool_name\n\n        if connection_state is not None:\n            attrs[DB_CLIENT_CONNECTION_STATE] = connection_state.value\n\n        if is_pubsub is not None:\n            attrs[REDIS_CLIENT_CONNECTION_PUBSUB] = is_pubsub\n\n        if connection_name is not None:\n            attrs[DB_CLIENT_CONNECTION_NAME] = connection_name\n\n        return attrs\n\n    @staticmethod\n    def build_error_attributes(\n        error_type: Optional[Exception] = None,\n        is_internal: Optional[bool] = None,\n    ) -> Dict[str, Any]:\n        \"\"\"\n        Build error attributes.\n\n        Args:\n            is_internal: Whether the error is internal (e.g., timeout, network error)\n            error_type: The exception that occurred\n\n        Returns:\n            Dictionary of error attributes\n        \"\"\"\n        attrs: Dict[str, Any] = {}\n\n        if error_type is not None:\n            attrs[ERROR_TYPE] = error_type.__class__.__name__\n\n            if (\n                hasattr(error_type, \"status_code\")\n                and error_type.status_code is not None\n            ):\n                attrs[DB_RESPONSE_STATUS_CODE] = error_type.status_code\n            else:\n                attrs[DB_RESPONSE_STATUS_CODE] = \"error\"\n\n            if hasattr(error_type, \"error_type\") and error_type.error_type is not None:\n                attrs[REDIS_CLIENT_ERROR_CATEGORY] = error_type.error_type.value\n            else:\n                attrs[REDIS_CLIENT_ERROR_CATEGORY] = \"other\"\n\n        if is_internal is not None:\n            attrs[REDIS_CLIENT_ERROR_INTERNAL] = is_internal\n\n        return attrs\n\n    @staticmethod\n    def build_pubsub_message_attributes(\n        direction: PubSubDirection,\n        channel: Optional[str] = None,\n        sharded: Optional[bool] = None,\n    ) -> Dict[str, Any]:\n        \"\"\"\n        Build attributes for a PubSub message.\n\n        Args:\n            direction: Message direction ('publish' or 'receive')\n            channel: Pub/Sub channel name\n            sharded: True if sharded Pub/Sub channel\n\n        Returns:\n            Dictionary of PubSub message attributes\n        \"\"\"\n        attrs: Dict[str, Any] = AttributeBuilder.build_base_attributes()\n        attrs[REDIS_CLIENT_PUBSUB_MESSAGE_DIRECTION] = direction.value\n\n        if channel is not None:\n            attrs[REDIS_CLIENT_PUBSUB_CHANNEL] = channel\n\n        if sharded is not None:\n            attrs[REDIS_CLIENT_PUBSUB_SHARDED] = sharded\n\n        return attrs\n\n    @staticmethod\n    def build_streaming_attributes(\n        stream_name: Optional[str] = None,\n        consumer_group: Optional[str] = None,\n        consumer_name: Optional[str] = None,  # noqa\n    ) -> Dict[str, Any]:\n        \"\"\"\n        Build attributes for a streaming operation.\n\n        Args:\n            stream_name: Name of the stream\n            consumer_group: Name of the consumer group\n            consumer_name: Name of the consumer\n\n        Returns:\n            Dictionary of streaming attributes\n        \"\"\"\n        attrs: Dict[str, Any] = AttributeBuilder.build_base_attributes()\n\n        if stream_name is not None:\n            attrs[REDIS_CLIENT_STREAM_NAME] = stream_name\n\n        if consumer_group is not None:\n            attrs[REDIS_CLIENT_CONSUMER_GROUP] = consumer_group\n\n        return attrs\n\n    @staticmethod\n    def build_csc_attributes(\n        pool_name: Optional[str] = None,\n        result: Optional[CSCResult] = None,\n        reason: Optional[CSCReason] = None,\n    ) -> Dict[str, Any]:\n        \"\"\"\n        Build attributes for a Client Side Caching (CSC) operation.\n\n        Args:\n            pool_name: Connection pool name (used only for csc_items metric)\n            result: CSC result ('hit' or 'miss')\n            reason: Reason for CSC eviction ('full' or 'invalidation')\n\n        Returns:\n            Dictionary of CSC attributes\n        \"\"\"\n        attrs: Dict[str, Any] = AttributeBuilder.build_base_attributes()\n\n        if pool_name is not None:\n            attrs[DB_CLIENT_CONNECTION_POOL_NAME] = pool_name\n\n        if result is not None:\n            attrs[REDIS_CLIENT_CSC_RESULT] = result.value\n\n        if reason is not None:\n            attrs[REDIS_CLIENT_CSC_REASON] = reason.value\n\n        return attrs\n\n    @staticmethod\n    def build_geo_failover_attributes(\n        fail_from: Union[\"SyncDatabase\", \"AsyncDatabase\"],\n        fail_to: Union[\"SyncDatabase\", \"AsyncDatabase\"],\n        reason: GeoFailoverReason,\n    ) -> Dict[str, Any]:\n        \"\"\"\n        Build attributes for a geo failover.\n\n        Args:\n            fail_from: Database failed from\n            fail_to: Database failed to\n            reason: Reason for the failover\n\n        Returns:\n            Dictionary of geo failover attributes\n        \"\"\"\n        attrs: Dict[str, Any] = AttributeBuilder.build_base_attributes()\n\n        attrs[DB_CLIENT_GEOFAILOVER_FAIL_FROM] = get_db_name(fail_from)\n        attrs[DB_CLIENT_GEOFAILOVER_FAIL_TO] = get_db_name(fail_to)\n        attrs[DB_CLIENT_GEOFAILOVER_REASON] = reason.value\n\n        return attrs\n\n    @staticmethod\n    def build_pool_name(\n        server_address: str,\n        server_port: int,\n        db_namespace: int = 0,\n    ) -> str:\n        \"\"\"\n        Build a unique connection pool name.\n\n        Args:\n            server_address: Redis server address\n            server_port: Redis server port\n            db_namespace: Redis database index\n\n        Returns:\n            Unique pool name in format \"address:port/db\"\n        \"\"\"\n        return f\"{server_address}:{server_port}/{db_namespace}\"\n\n\ndef get_pool_name(pool: Union[\"ConnectionPoolInterface\", \"ConnectionPool\"]) -> str:\n    \"\"\"\n    Get a short string representation of a connection pool for observability.\n\n    This provides a concise pool identifier suitable for use as a metric attribute,\n    in the format: host:port_uniqueID (matching go-redis format)\n\n    Args:\n        pool: Connection pool instance\n\n    Returns:\n        Short pool name in format \"host:port_uniqueID\"\n\n    Example:\n        >>> pool = ConnectionPool(host='localhost', port=6379, db=0)\n        >>> get_pool_name(pool)\n        'localhost:6379_a1b2c3d4'\n    \"\"\"\n    host = pool.connection_kwargs.get(\"host\", \"unknown\")\n    port = pool.connection_kwargs.get(\"port\", 6379)\n\n    # Get unique pool ID if available (added for observability)\n    pool_id = getattr(pool, \"_pool_id\", \"\")\n\n    if pool_id:\n        return f\"{host}:{port}_{pool_id}\"\n    else:\n        return f\"{host}:{port}\"\n\n\ndef get_db_name(database: Union[\"SyncDatabase\", \"AsyncDatabase\"]):\n    \"\"\"\n    Get a short string representation of a database for observability.\n\n    Args:\n        database: Database instance\n\n    Returns:\n        Short database name in format \"{host}:{port}/{weight}\"\n    \"\"\"\n\n    host = database.client.get_connection_kwargs()[\"host\"]\n    port = database.client.get_connection_kwargs()[\"port\"]\n    weight = database.weight\n\n    return f\"{host}:{port}/{weight}\"\n"
  },
  {
    "path": "redis/observability/config.py",
    "content": "from enum import IntFlag, auto\nfrom typing import List, Optional, Sequence\n\n\"\"\"\nOpenTelemetry configuration for redis-py.\n\nThis module handles configuration for OTel observability features,\nincluding parsing environment variables and validating settings.\n\"\"\"\n\n\nclass MetricGroup(IntFlag):\n    \"\"\"Metric groups that can be enabled/disabled.\"\"\"\n\n    RESILIENCY = auto()\n    CONNECTION_BASIC = auto()\n    CONNECTION_ADVANCED = auto()\n    COMMAND = auto()\n    CSC = auto()\n    STREAMING = auto()\n    PUBSUB = auto()\n\n\nclass TelemetryOption(IntFlag):\n    \"\"\"Telemetry options to export.\"\"\"\n\n    METRICS = auto()\n\n\ndef default_operation_duration_buckets() -> Sequence[float]:\n    return [\n        0.0001,\n        0.00025,\n        0.0005,\n        0.001,\n        0.0025,\n        0.005,\n        0.01,\n        0.025,\n        0.05,\n        0.1,\n        0.25,\n        0.5,\n        1,\n        2.5,\n    ]\n\n\ndef default_histogram_buckets() -> Sequence[float]:\n    return [0.0001, 0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5, 10]\n\n\nclass OTelConfig:\n    \"\"\"\n    Configuration for OpenTelemetry observability in redis-py.\n\n    This class manages all OTel-related settings including metrics, traces (future),\n    and logs (future). Configuration can be provided via constructor parameters or\n    environment variables (OTEL_* spec).\n\n    Constructor parameters take precedence over environment variables.\n\n    Args:\n        enabled_telemetry: Enabled telemetry options to export (default: metrics). Traces and logs will be added\n                           in future phases.\n        metric_groups: Group of metrics that should be exported.\n        include_commands: Explicit allowlist of commands to track\n        exclude_commands: Blocklist of commands to track\n        hide_pubsub_channel_names: If True, hide PubSub channel names in metrics (default: False)\n        hide_stream_names: If True, hide stream names in streaming metrics (default: False)\n\n    Note:\n        Redis-py uses the global MeterProvider set by your application.\n        Set it up before initializing observability:\n\n            from opentelemetry import metrics\n            from opentelemetry.sdk.metrics import MeterProvider\n            from opentelemetry.sdk.metrics._internal.view import View\n            from opentelemetry.sdk.metrics._internal.aggregation import ExplicitBucketHistogramAggregation\n\n            # Configure histogram bucket boundaries via Views\n            views = [\n                View(\n                    instrument_name=\"db.client.operation.duration\",\n                    aggregation=ExplicitBucketHistogramAggregation(\n                        boundaries=[0.0001, 0.00025, 0.0005, 0.001, 0.0025, 0.005,\n                                    0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5]\n                    ),\n                ),\n                # Add more views for other histograms...\n            ]\n\n            provider = MeterProvider(views=views, metric_readers=[reader])\n            metrics.set_meter_provider(provider)\n\n            # Then initialize redis-py observability\n            from redis.observability import get_observability_instance, OTelConfig\n            otel = get_observability_instance()\n            otel.init(OTelConfig())\n    \"\"\"\n\n    DEFAULT_TELEMETRY = TelemetryOption.METRICS\n    DEFAULT_METRIC_GROUPS = MetricGroup.CONNECTION_BASIC | MetricGroup.RESILIENCY\n\n    def __init__(\n        self,\n        # Core enablement\n        enabled_telemetry: Optional[List[TelemetryOption]] = None,\n        # Metrics-specific\n        metric_groups: Optional[List[MetricGroup]] = None,\n        # Redis-specific telemetry controls\n        include_commands: Optional[List[str]] = None,\n        exclude_commands: Optional[List[str]] = None,\n        # Privacy controls\n        hide_pubsub_channel_names: bool = False,\n        hide_stream_names: bool = False,\n        # Bucket sizes\n        buckets_operation_duration: Sequence[\n            float\n        ] = default_operation_duration_buckets(),\n        buckets_stream_processing_duration: Sequence[\n            float\n        ] = default_histogram_buckets(),\n        buckets_connection_create_time: Sequence[float] = default_histogram_buckets(),\n        buckets_connection_wait_time: Sequence[float] = default_histogram_buckets(),\n    ):\n        # Core enablement\n        if enabled_telemetry is None:\n            self.enabled_telemetry = self.DEFAULT_TELEMETRY\n        else:\n            self.enabled_telemetry = TelemetryOption(0)\n            for option in enabled_telemetry:\n                self.enabled_telemetry |= option\n\n        # Enable default metrics if None given\n        if metric_groups is None:\n            self.metric_groups = self.DEFAULT_METRIC_GROUPS\n        else:\n            self.metric_groups = MetricGroup(0)\n            for metric_group in metric_groups:\n                self.metric_groups |= metric_group\n\n        # Redis-specific controls\n        self.include_commands = set(include_commands) if include_commands else None\n        self.exclude_commands = set(exclude_commands) if exclude_commands else set()\n\n        # Privacy controls for hiding sensitive names in metrics\n        self.hide_pubsub_channel_names = hide_pubsub_channel_names\n        self.hide_stream_names = hide_stream_names\n\n        # Bucket sizes\n        self.buckets_operation_duration = buckets_operation_duration\n        self.buckets_stream_processing_duration = buckets_stream_processing_duration\n        self.buckets_connection_create_time = buckets_connection_create_time\n        self.buckets_connection_wait_time = buckets_connection_wait_time\n\n    def is_enabled(self) -> bool:\n        \"\"\"Check if any observability feature is enabled.\"\"\"\n        return bool(self.enabled_telemetry)\n\n    def should_track_command(self, command_name: str) -> bool:\n        \"\"\"\n        Determine if a command should be tracked based on include/exclude lists.\n\n        Args:\n            command_name: The Redis command name (e.g., 'GET', 'SET')\n\n        Returns:\n            True if the command should be tracked, False otherwise\n        \"\"\"\n        command_upper = command_name.upper()\n\n        # If include list is specified, only track commands in the list\n        if self.include_commands is not None:\n            return command_upper in self.include_commands\n\n        # Otherwise, track all commands except those in exclude list\n        return command_upper not in self.exclude_commands\n\n    def __repr__(self) -> str:\n        return f\"OTelConfig(enabled_telemetry={self.enabled_telemetry}\"\n"
  },
  {
    "path": "redis/observability/metrics.py",
    "content": "\"\"\"\nOpenTelemetry metrics collector for redis-py.\n\nThis module defines and manages all metric instruments according to\nOTel semantic conventions for database clients.\n\"\"\"\n\nimport logging\nimport time\nfrom enum import Enum\nfrom typing import TYPE_CHECKING, Callable, Optional, Union\n\nif TYPE_CHECKING:\n    from redis.asyncio.connection import ConnectionPool\n    from redis.asyncio.multidb.database import AsyncDatabase\n    from redis.connection import ConnectionPoolInterface\n    from redis.multidb.database import SyncDatabase\n\nfrom redis.observability.attributes import (\n    REDIS_CLIENT_CONNECTION_CLOSE_REASON,\n    REDIS_CLIENT_CONNECTION_NOTIFICATION,\n    AttributeBuilder,\n    ConnectionState,\n    CSCReason,\n    CSCResult,\n    GeoFailoverReason,\n    PubSubDirection,\n    get_pool_name,\n)\nfrom redis.observability.config import MetricGroup, OTelConfig\nfrom redis.utils import deprecated_args, deprecated_function\n\nlogger = logging.getLogger(__name__)\n\n# Optional imports - OTel SDK may not be installed\ntry:\n    from opentelemetry.metrics import Meter\n\n    OTEL_AVAILABLE = True\nexcept ImportError:\n    OTEL_AVAILABLE = False\n    Counter = None\n    Histogram = None\n    Meter = None\n    UpDownCounter = None\n\n\nclass CloseReason(Enum):\n    \"\"\"\n    Enum representing the reason why a Redis client connection was closed.\n\n    Values:\n        APPLICATION_CLOSE: The connection was closed intentionally by the application\n            (for example, during normal shutdown or explicit cleanup).\n        ERROR: The connection was closed due to an unexpected error\n            (for example, network failure or protocol error).\n        HEALTHCHECK_FAILED: The connection was closed because a health check\n            or liveness check for the connection failed.\n    \"\"\"\n\n    APPLICATION_CLOSE = \"application_close\"\n    ERROR = \"error\"\n    HEALTHCHECK_FAILED = \"healthcheck_failed\"\n\n\nclass RedisMetricsCollector:\n    \"\"\"\n    Collects and records OpenTelemetry metrics for Redis operations.\n\n    This class manages all metric instruments and provides methods to record\n    various Redis operations including connection pool events, command execution,\n    and cluster-specific operations.\n\n    Args:\n        meter: OpenTelemetry Meter instance\n        config: OTel configuration object\n    \"\"\"\n\n    METER_NAME = \"redis-py\"\n    METER_VERSION = \"1.0.0\"\n\n    def __init__(self, meter: Meter, config: OTelConfig):\n        if not OTEL_AVAILABLE:\n            raise ImportError(\n                \"OpenTelemetry API is not installed. \"\n                \"Install it with: pip install opentelemetry-api\"\n            )\n\n        self.meter = meter\n        self.config = config\n        self.attr_builder = AttributeBuilder()\n\n        # Initialize enabled metric instruments\n\n        if MetricGroup.RESILIENCY in self.config.metric_groups:\n            self._init_resiliency_metrics()\n\n        if MetricGroup.COMMAND in self.config.metric_groups:\n            self._init_command_metrics()\n\n        if MetricGroup.CONNECTION_BASIC in self.config.metric_groups:\n            self._init_connection_basic_metrics()\n\n        if MetricGroup.CONNECTION_ADVANCED in self.config.metric_groups:\n            self._init_connection_advanced_metrics()\n\n        if MetricGroup.PUBSUB in self.config.metric_groups:\n            self._init_pubsub_metrics()\n\n        if MetricGroup.STREAMING in self.config.metric_groups:\n            self._init_streaming_metrics()\n\n        if MetricGroup.CSC in self.config.metric_groups:\n            self._init_csc_metrics()\n\n        logger.info(\"RedisMetricsCollector initialized\")\n\n    def _init_resiliency_metrics(self) -> None:\n        \"\"\"Initialize resiliency metrics.\"\"\"\n        self.client_errors = self.meter.create_counter(\n            name=\"redis.client.errors\",\n            unit=\"{error}\",\n            description=\"A counter of all errors (both returned to the user and handled internally in the client library)\",\n        )\n\n        self.maintenance_notifications = self.meter.create_counter(\n            name=\"redis.client.maintenance.notifications\",\n            unit=\"{notification}\",\n            description=\"Tracks server-side maintenance notifications\",\n        )\n\n        self.geo_failovers = self.meter.create_counter(\n            name=\"redis.client.geofailover.failovers\",\n            unit=\"{geofailover}\",\n            description=\"Total count of failovers happened using MultiDbClient.\",\n        )\n\n    def _init_connection_basic_metrics(self) -> None:\n        \"\"\"Initialize basic connection metrics.\"\"\"\n        self.connection_create_time = self.meter.create_histogram(\n            name=\"db.client.connection.create_time\",\n            unit=\"s\",\n            description=\"Time to create a new connection\",\n            explicit_bucket_boundaries_advisory=self.config.buckets_connection_create_time,\n        )\n\n        self.connection_relaxed_timeout = self.meter.create_up_down_counter(\n            name=\"redis.client.connection.relaxed_timeout\",\n            unit=\"{relaxation}\",\n            description=\"Counts up for relaxed timeout, counts down for unrelaxed timeout\",\n        )\n\n        self.connection_handoff = self.meter.create_counter(\n            name=\"redis.client.connection.handoff\",\n            unit=\"{handoff}\",\n            description=\"Connections that have been handed off (e.g., after a MOVING notification)\",\n        )\n\n        # DEPRECATED: This attribute is kept for backward compatibility.\n        # It requires manual initialization via init_connection_count() with a callback.\n        # Use connection_count_updown instead for push-based tracking.\n        # Will be removed in the next major version.\n        self.connection_count = None\n\n        # New push-based connection count tracking via UpDownCounter\n        self.connection_count_updown = self.meter.create_up_down_counter(\n            name=\"db.client.connection.count\",\n            unit=\"{connection}\",\n            description=\"Number of connections currently in the pool by state\",\n        )\n\n    def _init_connection_advanced_metrics(self) -> None:\n        \"\"\"Initialize advanced connection metrics.\"\"\"\n        self.connection_timeouts = self.meter.create_counter(\n            name=\"db.client.connection.timeouts\",\n            unit=\"{timeout}\",\n            description=\"The number of connection timeouts that have occurred trying to obtain a connection from the pool.\",\n        )\n\n        self.connection_wait_time = self.meter.create_histogram(\n            name=\"db.client.connection.wait_time\",\n            unit=\"s\",\n            description=\"Time to obtain an open connection from the pool\",\n            explicit_bucket_boundaries_advisory=self.config.buckets_connection_wait_time,\n        )\n\n        self.connection_closed = self.meter.create_counter(\n            name=\"redis.client.connection.closed\",\n            unit=\"{connection}\",\n            description=\"Total number of closed connections\",\n        )\n\n    def _init_command_metrics(self) -> None:\n        \"\"\"Initialize command execution metric instruments.\"\"\"\n        self.operation_duration = self.meter.create_histogram(\n            name=\"db.client.operation.duration\",\n            unit=\"s\",\n            description=\"Command execution duration\",\n            explicit_bucket_boundaries_advisory=self.config.buckets_operation_duration,\n        )\n\n    def _init_pubsub_metrics(self) -> None:\n        \"\"\"Initialize PubSub metric instruments.\"\"\"\n        self.pubsub_messages = self.meter.create_counter(\n            name=\"redis.client.pubsub.messages\",\n            unit=\"{message}\",\n            description=\"Tracks published and received messages\",\n        )\n\n    def _init_streaming_metrics(self) -> None:\n        \"\"\"Initialize Streaming metric instruments.\"\"\"\n        self.stream_lag = self.meter.create_histogram(\n            name=\"redis.client.stream.lag\",\n            unit=\"s\",\n            description=\"End-to-end lag per message, showing how stale are the messages when the application starts processing them.\",\n            explicit_bucket_boundaries_advisory=self.config.buckets_stream_processing_duration,\n        )\n\n    def _init_csc_metrics(self) -> None:\n        \"\"\"Initialize Client Side Caching (CSC) metric instruments.\"\"\"\n        self.csc_requests = self.meter.create_counter(\n            name=\"redis.client.csc.requests\",\n            unit=\"{request}\",\n            description=\"The total number of requests to the cache\",\n        )\n\n        self.csc_evictions = self.meter.create_counter(\n            name=\"redis.client.csc.evictions\",\n            unit=\"{eviction}\",\n            description=\"The total number of cache evictions\",\n        )\n\n        self.csc_network_saved = self.meter.create_counter(\n            name=\"redis.client.csc.network_saved\",\n            unit=\"By\",\n            description=\"The total number of bytes saved by using CSC\",\n        )\n\n    # Resiliency metric recording methods\n\n    def record_error_count(\n        self,\n        server_address: Optional[str] = None,\n        server_port: Optional[int] = None,\n        network_peer_address: Optional[str] = None,\n        network_peer_port: Optional[int] = None,\n        error_type: Optional[Exception] = None,\n        retry_attempts: Optional[int] = None,\n        is_internal: Optional[bool] = None,\n    ):\n        \"\"\"\n        Record error count\n\n        Args:\n            server_address: Server address\n            server_port: Server port\n            network_peer_address: Network peer address\n            network_peer_port: Network peer port\n            error_type: Error type\n            retry_attempts: Retry attempts\n            is_internal: Whether the error is internal (e.g., timeout, network error)\n        \"\"\"\n        if not hasattr(self, \"client_errors\"):\n            return\n\n        attrs = self.attr_builder.build_base_attributes(\n            server_address=server_address,\n            server_port=server_port,\n        )\n        attrs.update(\n            self.attr_builder.build_operation_attributes(\n                network_peer_address=network_peer_address,\n                network_peer_port=network_peer_port,\n                retry_attempts=retry_attempts,\n            )\n        )\n\n        attrs.update(\n            self.attr_builder.build_error_attributes(\n                error_type=error_type,\n                is_internal=is_internal,\n            )\n        )\n\n        self.client_errors.add(1, attributes=attrs)\n\n    def record_maint_notification_count(\n        self,\n        server_address: str,\n        server_port: int,\n        network_peer_address: str,\n        network_peer_port: int,\n        maint_notification: str,\n    ):\n        \"\"\"\n        Record maintenance notification count\n\n        Args:\n            server_address: Server address\n            server_port: Server port\n            network_peer_address: Network peer address\n            network_peer_port: Network peer port\n            maint_notification: Maintenance notification\n        \"\"\"\n        if not hasattr(self, \"maintenance_notifications\"):\n            return\n\n        attrs = self.attr_builder.build_base_attributes(\n            server_address=server_address,\n            server_port=server_port,\n        )\n\n        attrs.update(\n            self.attr_builder.build_operation_attributes(\n                network_peer_address=network_peer_address,\n                network_peer_port=network_peer_port,\n            )\n        )\n\n        attrs[REDIS_CLIENT_CONNECTION_NOTIFICATION] = maint_notification\n        self.maintenance_notifications.add(1, attributes=attrs)\n\n    def record_geo_failover(\n        self,\n        fail_from: Union[\"SyncDatabase\", \"AsyncDatabase\"],\n        fail_to: Union[\"SyncDatabase\", \"AsyncDatabase\"],\n        reason: GeoFailoverReason,\n    ):\n        \"\"\"\n        Record geo failover\n\n        Args:\n            fail_from: Database failed from\n            fail_to: Database failed to\n            reason: Reason for the failover\n        \"\"\"\n\n        if not hasattr(self, \"geo_failovers\"):\n            return\n\n        attrs = self.attr_builder.build_geo_failover_attributes(\n            fail_from=fail_from,\n            fail_to=fail_to,\n            reason=reason,\n        )\n\n        return self.geo_failovers.add(1, attributes=attrs)\n\n    def record_connection_count(\n        self,\n        pool_name: str,\n        connection_state: ConnectionState,\n        counter: int = 1,\n    ) -> None:\n        \"\"\"\n        Record a connection count change for a single state.\n\n        Args:\n            pool_name: Connection pool name\n            connection_state: State to update (IDLE or USED)\n            counter: Number to add (positive) or subtract (negative)\n        \"\"\"\n        if not hasattr(self, \"connection_count_updown\"):\n            return\n\n        attrs = self.attr_builder.build_connection_attributes(\n            pool_name=pool_name,\n            connection_state=connection_state,\n        )\n        self.connection_count_updown.add(counter, attributes=attrs)\n\n    @deprecated_function(\n        reason=\"Connection count is now tracked via record_connection_count(). \"\n        \"This functionality will be removed in the next major version\",\n        version=\"7.4.0\",\n    )\n    def init_connection_count(\n        self,\n        callback: Callable,\n    ) -> None:\n        \"\"\"\n        Initialize observable gauge for connection count metric.\n\n        Args:\n            callback: Callback function to retrieve connection counts\n        \"\"\"\n        if MetricGroup.CONNECTION_BASIC not in self.config.metric_groups:\n            return\n\n        # DEPRECATED: Create observable gauge for backward compatibility\n        # This gauge uses a different metric name to avoid conflicts with\n        # the new push-based connection_count_updown counter\n        self.connection_count = self.meter.create_observable_gauge(\n            name=\"db.client.connection.count.deprecated\",\n            unit=\"{connection}\",\n            description=\"The number of connections that are currently in state \"\n            \"described by the state attribute (deprecated - use db.client.connection.count instead)\",\n            callbacks=[callback],\n        )\n\n    def init_csc_items(\n        self,\n        callback: Callable,\n    ) -> None:\n        \"\"\"\n        Initialize observable gauge for CSC items metric.\n\n        Args:\n            callback: Callback function to retrieve CSC items count\n        \"\"\"\n        if MetricGroup.CSC not in self.config.metric_groups and not self.csc_items:\n            return\n\n        self.csc_items = self.meter.create_observable_gauge(\n            name=\"redis.client.csc.items\",\n            unit=\"{item}\",\n            description=\"The total number of cached responses currently stored\",\n            callbacks=[callback],\n        )\n\n    def record_connection_timeout(self, pool_name: str) -> None:\n        \"\"\"\n        Record a connection timeout event.\n\n        Args:\n            pool_name: Connection pool name\n        \"\"\"\n        if not hasattr(self, \"connection_timeouts\"):\n            return\n\n        attrs = self.attr_builder.build_connection_attributes(pool_name=pool_name)\n        self.connection_timeouts.add(1, attributes=attrs)\n\n    def record_connection_create_time(\n        self,\n        connection_pool: Union[\"ConnectionPoolInterface\", \"ConnectionPool\"],\n        duration_seconds: float,\n    ) -> None:\n        \"\"\"\n        Record time taken to create a new connection.\n\n        Args:\n            connection_pool: Connection pool implementation\n            duration_seconds: Creation time in seconds\n        \"\"\"\n        if not hasattr(self, \"connection_create_time\"):\n            return\n\n        attrs = self.attr_builder.build_connection_attributes(\n            pool_name=get_pool_name(connection_pool)\n        )\n        self.connection_create_time.record(duration_seconds, attributes=attrs)\n\n    def record_connection_wait_time(\n        self,\n        pool_name: str,\n        duration_seconds: float,\n    ) -> None:\n        \"\"\"\n        Record time taken to obtain a connection from the pool.\n\n        Args:\n            pool_name: Connection pool name\n            duration_seconds: Wait time in seconds\n        \"\"\"\n        if not hasattr(self, \"connection_wait_time\"):\n            return\n\n        attrs = self.attr_builder.build_connection_attributes(pool_name=pool_name)\n        self.connection_wait_time.record(duration_seconds, attributes=attrs)\n\n    # Command execution metric recording methods\n\n    @deprecated_args(\n        args_to_warn=[\"batch_size\"],\n        reason=\"The batch_size argument is no longer used and will be removed in the next major version.\",\n        version=\"7.2.1\",\n    )\n    def record_operation_duration(\n        self,\n        command_name: str,\n        duration_seconds: float,\n        server_address: Optional[str] = None,\n        server_port: Optional[int] = None,\n        db_namespace: Optional[int] = None,\n        batch_size: Optional[int] = None,  # noqa\n        error_type: Optional[Exception] = None,\n        network_peer_address: Optional[str] = None,\n        network_peer_port: Optional[int] = None,\n        retry_attempts: Optional[int] = None,\n        is_blocking: Optional[bool] = None,\n    ) -> None:\n        \"\"\"\n        Record command execution duration.\n\n        Args:\n            command_name: Redis command name (e.g., 'GET', 'SET', 'MULTI')\n            duration_seconds: Execution time in seconds\n            server_address: Redis server address\n            server_port: Redis server port\n            db_namespace: Redis database index\n            batch_size: Number of commands in batch (for pipelines/transactions)\n            error_type: Error type if operation failed\n            network_peer_address: Resolved peer address\n            network_peer_port: Peer port number\n            retry_attempts: Number of retry attempts made\n            is_blocking: Whether the operation is a blocking command\n        \"\"\"\n        if not hasattr(self, \"operation_duration\"):\n            return\n\n        # Check if this command should be tracked\n        if not self.config.should_track_command(command_name):\n            return\n\n        # Build attributes\n        attrs = self.attr_builder.build_base_attributes(\n            server_address=server_address,\n            server_port=server_port,\n            db_namespace=db_namespace,\n        )\n\n        attrs.update(\n            self.attr_builder.build_operation_attributes(\n                command_name=command_name,\n                network_peer_address=network_peer_address,\n                network_peer_port=network_peer_port,\n                retry_attempts=retry_attempts,\n                is_blocking=is_blocking,\n            )\n        )\n\n        attrs.update(\n            self.attr_builder.build_error_attributes(\n                error_type=error_type,\n            )\n        )\n        self.operation_duration.record(duration_seconds, attributes=attrs)\n\n    def record_connection_closed(\n        self,\n        close_reason: Optional[CloseReason] = None,\n        error_type: Optional[Exception] = None,\n    ) -> None:\n        \"\"\"\n        Record a connection closed event.\n\n        Args:\n            close_reason: Reason for closing (e.g. 'error', 'application_close')\n            error_type: Error type if closed due to error\n        \"\"\"\n        if not hasattr(self, \"connection_closed\"):\n            return\n\n        attrs = self.attr_builder.build_connection_attributes()\n        if close_reason:\n            attrs[REDIS_CLIENT_CONNECTION_CLOSE_REASON] = close_reason.value\n\n        attrs.update(\n            self.attr_builder.build_error_attributes(\n                error_type=error_type,\n            )\n        )\n\n        self.connection_closed.add(1, attributes=attrs)\n\n    def record_connection_relaxed_timeout(\n        self,\n        connection_name: str,\n        maint_notification: str,\n        relaxed: bool,\n    ) -> None:\n        \"\"\"\n        Record a connection timeout relaxation event.\n\n        Args:\n            connection_name: Connection name\n            maint_notification: Maintenance notification type\n            relaxed: True to count up (relaxed), False to count down (unrelaxed)\n        \"\"\"\n        if not hasattr(self, \"connection_relaxed_timeout\"):\n            return\n\n        attrs = self.attr_builder.build_connection_attributes(pool_name=connection_name)\n        attrs[REDIS_CLIENT_CONNECTION_NOTIFICATION] = maint_notification\n        self.connection_relaxed_timeout.add(1 if relaxed else -1, attributes=attrs)\n\n    def record_connection_handoff(\n        self,\n        pool_name: str,\n    ) -> None:\n        \"\"\"\n        Record a connection handoff event (e.g., after MOVING notification).\n\n        Args:\n            pool_name: Connection pool name\n        \"\"\"\n        if not hasattr(self, \"connection_handoff\"):\n            return\n\n        attrs = self.attr_builder.build_connection_attributes(pool_name=pool_name)\n        self.connection_handoff.add(1, attributes=attrs)\n\n    # PubSub metric recording methods\n\n    def record_pubsub_message(\n        self,\n        direction: PubSubDirection,\n        channel: Optional[str] = None,\n        sharded: Optional[bool] = None,\n    ) -> None:\n        \"\"\"\n        Record a PubSub message (published or received).\n\n        Args:\n            direction: Message direction ('publish' or 'receive')\n            channel: Pub/Sub channel name\n            sharded: True if sharded Pub/Sub channel\n        \"\"\"\n        if not hasattr(self, \"pubsub_messages\"):\n            return\n\n        attrs = self.attr_builder.build_pubsub_message_attributes(\n            direction=direction,\n            channel=channel,\n            sharded=sharded,\n        )\n        self.pubsub_messages.add(1, attributes=attrs)\n\n    # Streaming metric recording methods\n\n    @deprecated_args(\n        args_to_warn=[\"consumer_name\"],\n        reason=\"The consumer_name argument is no longer used and will be removed in the next major version.\",\n        version=\"7.2.1\",\n    )\n    def record_streaming_lag(\n        self,\n        lag_seconds: float,\n        stream_name: Optional[str] = None,\n        consumer_group: Optional[str] = None,\n        consumer_name: Optional[str] = None,  # noqa\n    ) -> None:\n        \"\"\"\n        Record the lag of a streaming message.\n\n        Args:\n            lag_seconds: Lag in seconds\n            stream_name: Stream name\n            consumer_group: Consumer group name\n            consumer_name: Consumer name\n        \"\"\"\n        if not hasattr(self, \"stream_lag\"):\n            return\n\n        attrs = self.attr_builder.build_streaming_attributes(\n            stream_name=stream_name,\n            consumer_group=consumer_group,\n        )\n        self.stream_lag.record(lag_seconds, attributes=attrs)\n\n    # CSC metric recording methods\n\n    def record_csc_request(\n        self,\n        result: Optional[CSCResult] = None,\n    ) -> None:\n        \"\"\"\n        Record a Client Side Caching (CSC) request.\n\n        Args:\n            result: CSC result ('hit' or 'miss')\n        \"\"\"\n        if not hasattr(self, \"csc_requests\"):\n            return\n\n        attrs = self.attr_builder.build_csc_attributes(result=result)\n        self.csc_requests.add(1, attributes=attrs)\n\n    def record_csc_eviction(\n        self,\n        count: int,\n        reason: Optional[CSCReason] = None,\n    ) -> None:\n        \"\"\"\n        Record a Client Side Caching (CSC) eviction.\n\n        Args:\n            count: Number of evictions\n            reason: Reason for eviction\n        \"\"\"\n        if not hasattr(self, \"csc_evictions\"):\n            return\n\n        attrs = self.attr_builder.build_csc_attributes(reason=reason)\n        self.csc_evictions.add(count, attributes=attrs)\n\n    def record_csc_network_saved(\n        self,\n        bytes_saved: int,\n    ) -> None:\n        \"\"\"\n        Record the number of bytes saved by using Client Side Caching (CSC).\n\n        Args:\n            bytes_saved: Number of bytes saved\n        \"\"\"\n        if not hasattr(self, \"csc_network_saved\"):\n            return\n\n        attrs = self.attr_builder.build_csc_attributes()\n        self.csc_network_saved.add(bytes_saved, attributes=attrs)\n\n    # Utility methods\n\n    @staticmethod\n    def monotonic_time() -> float:\n        \"\"\"\n        Get monotonic time for duration measurements.\n\n        Returns:\n            Current monotonic time in seconds\n        \"\"\"\n        return time.monotonic()\n\n    def __repr__(self) -> str:\n        return f\"RedisMetricsCollector(meter={self.meter}, config={self.config})\"\n"
  },
  {
    "path": "redis/observability/providers.py",
    "content": "\"\"\"\nOpenTelemetry provider management for redis-py.\n\nThis module handles initialization and lifecycle management of OTel SDK components\nincluding MeterProvider, TracerProvider (future), and LoggerProvider (future).\n\nUses a singleton pattern - initialize once globally, all Redis clients use it automatically.\n\nRedis-py uses the global MeterProvider set by your application. Set it up before\ninitializing observability:\n\n    from opentelemetry import metrics\n    from opentelemetry.sdk.metrics import MeterProvider\n\n    provider = MeterProvider(...)\n    metrics.set_meter_provider(provider)\n\n    # Then initialize redis-py observability\n    otel = get_observability_instance()\n    otel.init(OTelConfig(enable_metrics=True))\n\"\"\"\n\nimport logging\nfrom typing import Optional\n\nfrom redis.observability.config import OTelConfig\n\nlogger = logging.getLogger(__name__)\n\n# Optional imports - OTel SDK may not be installed\ntry:\n    from opentelemetry.sdk.metrics import MeterProvider\n\n    OTEL_AVAILABLE = True\nexcept ImportError:\n    OTEL_AVAILABLE = False\n    MeterProvider = None\n\n# Global singleton instance\n_global_provider_manager: Optional[\"OTelProviderManager\"] = None\n\n\nclass OTelProviderManager:\n    \"\"\"\n    Manages OpenTelemetry SDK providers and their lifecycle.\n\n    This class handles:\n    - Getting the global MeterProvider set by the application\n    - Configuring histogram bucket boundaries via Views\n    - Graceful shutdown\n\n    Args:\n        config: OTel configuration object\n    \"\"\"\n\n    def __init__(self, config: OTelConfig):\n        self.config = config\n        self._meter_provider: Optional[MeterProvider] = None\n\n    def get_meter_provider(self) -> Optional[MeterProvider]:\n        \"\"\"\n        Get the global MeterProvider set by the application.\n\n        Returns:\n            MeterProvider instance or None if metrics are disabled\n\n        Raises:\n            ImportError: If OpenTelemetry is not installed\n            RuntimeError: If metrics are enabled but no global MeterProvider is set\n        \"\"\"\n        if not self.config.is_enabled():\n            return None\n\n        # Lazy import - only import OTel when metrics are enabled\n        try:\n            from opentelemetry import metrics\n            from opentelemetry.metrics import NoOpMeterProvider\n        except ImportError:\n            raise ImportError(\n                \"OpenTelemetry is not installed. Install it with:\\n\"\n                \"  pip install opentelemetry-api opentelemetry-sdk opentelemetry-exporter-otlp-proto-http\"\n            )\n\n        # Get the global MeterProvider\n        if self._meter_provider is None:\n            self._meter_provider = metrics.get_meter_provider()\n\n            # Check if it's a real provider (not NoOp)\n            if isinstance(self._meter_provider, NoOpMeterProvider):\n                raise RuntimeError(\n                    \"Metrics are enabled but no global MeterProvider is configured.\\n\"\n                    \"\\n\"\n                    \"Set up OpenTelemetry before initializing redis-py observability:\\n\"\n                    \"\\n\"\n                    \"  from opentelemetry import metrics\\n\"\n                    \"  from opentelemetry.sdk.metrics import MeterProvider\\n\"\n                    \"  from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader\\n\"\n                    \"  from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter\\n\"\n                    \"\\n\"\n                    \"  # Create exporter\\n\"\n                    \"  exporter = OTLPMetricExporter(\\n\"\n                    \"      endpoint='http://localhost:4318/v1/metrics'\\n\"\n                    \"  )\\n\"\n                    \"\\n\"\n                    \"  # Create reader\\n\"\n                    \"  reader = PeriodicExportingMetricReader(\\n\"\n                    \"      exporter=exporter,\\n\"\n                    \"      export_interval_millis=10000\\n\"\n                    \"  )\\n\"\n                    \"\\n\"\n                    \"  # Create and set global provider\\n\"\n                    \"  provider = MeterProvider(metric_readers=[reader])\\n\"\n                    \"  metrics.set_meter_provider(provider)\\n\"\n                    \"\\n\"\n                    \"  # Now initialize redis-py observability\\n\"\n                    \"  from redis.observability import get_observability_instance, OTelConfig\\n\"\n                    \"  otel = get_observability_instance()\\n\"\n                    \"  otel.init(OTelConfig(enable_metrics=True))\\n\"\n                )\n\n            logger.info(\"Using global MeterProvider from application\")\n\n        return self._meter_provider\n\n    def shutdown(self, timeout_millis: int = 30000) -> bool:\n        \"\"\"\n        Shutdown observability and flush any pending metrics.\n\n        Note: We don't shutdown the global MeterProvider since it's owned by the application.\n        We only force flush pending metrics.\n\n        Args:\n            timeout_millis: Maximum time to wait for flush\n\n        Returns:\n            True if flush was successful, False otherwise\n        \"\"\"\n        logger.debug(\n            \"Flushing metrics before shutdown (not shutting down global MeterProvider)\"\n        )\n        return self.force_flush(timeout_millis=timeout_millis)\n\n    def force_flush(self, timeout_millis: int = 30000) -> bool:\n        \"\"\"\n        Force flush any pending metrics from the global MeterProvider.\n\n        Args:\n            timeout_millis: Maximum time to wait for flush\n\n        Returns:\n            True if flush was successful, False otherwise\n        \"\"\"\n        if self._meter_provider is None:\n            return True\n\n        # NoOpMeterProvider doesn't have force_flush method\n        if not hasattr(self._meter_provider, \"force_flush\"):\n            logger.debug(\"MeterProvider does not support force_flush, skipping\")\n            return True\n\n        try:\n            logger.debug(\"Force flushing metrics from global MeterProvider\")\n            self._meter_provider.force_flush(timeout_millis=timeout_millis)\n            return True\n        except Exception as e:\n            logger.error(f\"Error flushing metrics: {e}\")\n            return False\n\n    def __enter__(self):\n        \"\"\"Context manager entry.\"\"\"\n        return self\n\n    def __exit__(self, _exc_type, _exc_val, _exc_tb):\n        \"\"\"Context manager exit - shutdown provider.\"\"\"\n        self.shutdown()\n\n    def __repr__(self) -> str:\n        return f\"OTelProviderManager(config={self.config})\"\n\n\n# Singleton instance class\n\n\nclass ObservabilityInstance:\n    \"\"\"\n    Singleton instance for managing OpenTelemetry observability.\n\n    This class follows the singleton pattern similar to Glide's GetOtelInstance().\n    Use GetObservabilityInstance() to get the singleton instance, then call init()\n    to initialize observability.\n\n    Example:\n        >>> from redis.observability.config import OTelConfig\n        >>>\n        >>> # Get singleton instance\n        >>> otel = get_observability_instance()\n        >>>\n        >>> # Initialize once at app startup\n        >>> otel.init(OTelConfig())\n        >>>\n        >>> # All Redis clients now automatically collect metrics\n        >>> import redis\n        >>> r = redis.Redis(host='localhost', port=6379)\n        >>> r.set('key', 'value')  # Metrics collected automatically\n    \"\"\"\n\n    def __init__(self):\n        self._provider_manager: Optional[OTelProviderManager] = None\n\n    def init(self, config: OTelConfig) -> \"ObservabilityInstance\":\n        \"\"\"\n        Initialize OpenTelemetry observability globally for all Redis clients.\n\n        This should be called once at application startup. After initialization,\n        all Redis clients will automatically collect and export metrics without\n        needing any additional configuration.\n\n        Safe to call multiple times - will shutdown previous instance before\n        initializing a new one.\n\n        Args:\n            config: OTel configuration object\n\n        Returns:\n            Self for method chaining\n\n        Example:\n            >>> otel = get_observability_instance()\n            >>> otel.init(OTelConfig())\n        \"\"\"\n        if self._provider_manager is not None:\n            logger.warning(\n                \"Observability already initialized. Shutting down previous instance.\"\n            )\n            self._provider_manager.shutdown()\n\n        self._provider_manager = OTelProviderManager(config)\n\n        logger.info(\"Observability initialized\")\n\n        return self\n\n    def is_enabled(self) -> bool:\n        \"\"\"\n        Check if observability is enabled.\n\n        Returns:\n            True if observability is initialized and metrics are enabled\n\n        Example:\n            >>> otel = get_observability_instance()\n            >>> if otel.is_enabled():\n            ...     print(\"Metrics are being collected\")\n        \"\"\"\n        return (\n            self._provider_manager is not None\n            and self._provider_manager.config.is_enabled()\n        )\n\n    def get_provider_manager(self) -> Optional[OTelProviderManager]:\n        \"\"\"\n        Get the provider manager instance.\n\n        Returns:\n            The provider manager, or None if not initialized\n\n        Example:\n            >>> otel = get_observability_instance()\n            >>> manager = otel.get_provider_manager()\n            >>> if manager is not None:\n            ...     print(f\"Observability enabled: {manager.config.is_enabled()}\")\n        \"\"\"\n        return self._provider_manager\n\n    def shutdown(self, timeout_millis: int = 30000) -> bool:\n        \"\"\"\n        Shutdown observability and flush any pending metrics.\n\n        This should be called at application shutdown to ensure all metrics\n        are exported before the application exits.\n\n        Args:\n            timeout_millis: Maximum time to wait for shutdown\n\n        Returns:\n            True if shutdown was successful\n\n        Example:\n            >>> otel = get_observability_instance()\n            >>> # At application shutdown\n            >>> otel.shutdown()\n        \"\"\"\n        if self._provider_manager is None:\n            logger.debug(\"Observability not initialized, nothing to shutdown\")\n            return True\n\n        success = self._provider_manager.shutdown(timeout_millis)\n        self._provider_manager = None\n        logger.info(\"Observability shutdown\")\n\n        return success\n\n    def force_flush(self, timeout_millis: int = 30000) -> bool:\n        \"\"\"\n        Force flush all pending metrics immediately.\n\n        Useful for testing or when you want to ensure metrics are exported\n        before a specific point in your application.\n\n        Args:\n            timeout_millis: Maximum time to wait for flush\n\n        Returns:\n            True if flush was successful\n\n        Example:\n            >>> otel = get_observability_instance()\n            >>> # Execute some Redis commands\n            >>> r.set('key', 'value')\n            >>> # Force flush metrics immediately\n            >>> otel.force_flush()\n        \"\"\"\n        if self._provider_manager is None:\n            logger.debug(\"Observability not initialized, nothing to flush\")\n            return True\n\n        return self._provider_manager.force_flush(timeout_millis)\n\n\n# Global singleton instance\n_observability_instance: Optional[ObservabilityInstance] = None\n\n\ndef get_observability_instance() -> ObservabilityInstance:\n    \"\"\"\n    Get the global observability singleton instance.\n\n    This is the Pythonic way to get the singleton instance.\n\n    Returns:\n        The global ObservabilityInstance singleton\n\n    Example:\n        >>>\n        >>> otel = get_observability_instance()\n        >>> otel.init(OTelConfig())\n    \"\"\"\n    global _observability_instance\n\n    if _observability_instance is None:\n        _observability_instance = ObservabilityInstance()\n\n    return _observability_instance\n\n\ndef reset_observability_instance() -> None:\n    \"\"\"\n    Reset the global observability singleton instance.\n\n    This is primarily used for testing and benchmarking to ensure\n    a clean state between test runs.\n\n    Warning:\n        This will shutdown any active provider manager and reset\n        the global state. Use with caution in production code.\n    \"\"\"\n    global _observability_instance\n\n    if _observability_instance is not None:\n        _observability_instance.shutdown()\n        _observability_instance = None\n"
  },
  {
    "path": "redis/observability/recorder.py",
    "content": "\"\"\"\nSimple, clean API for recording observability metrics.\n\nThis module provides a straightforward interface for Redis core code to record\nmetrics without needing to know about OpenTelemetry internals.\n\nUsage in Redis core code:\n    from redis.observability.recorder import record_operation_duration\n\n    start_time = time.monotonic()\n    # ... execute Redis command ...\n    record_operation_duration(\n        command_name='SET',\n        duration_seconds=time.monotonic() - start_time,\n        server_address='localhost',\n        server_port=6379,\n        db_namespace='0',\n        error=None\n    )\n\"\"\"\n\nfrom datetime import datetime\nfrom typing import TYPE_CHECKING, Callable, List, Optional\n\nfrom redis.observability.attributes import (\n    AttributeBuilder,\n    ConnectionState,\n    CSCReason,\n    CSCResult,\n    GeoFailoverReason,\n    PubSubDirection,\n)\nfrom redis.observability.metrics import CloseReason, RedisMetricsCollector\nfrom redis.observability.providers import get_observability_instance\nfrom redis.observability.registry import get_observables_registry_instance\nfrom redis.utils import deprecated_args, deprecated_function, str_if_bytes\n\nif TYPE_CHECKING:\n    from redis.connection import ConnectionPoolInterface\n    from redis.multidb.database import SyncDatabase\n    from redis.observability.config import OTelConfig\n\n# Global metrics collector instance (lazy-initialized)\n_metrics_collector: Optional[RedisMetricsCollector] = None\n\nCSC_ITEMS_REGISTRY_KEY = \"csc_items\"\nCONNECTION_COUNT_REGISTRY_KEY = \"connection_count\"\n\n\n@deprecated_args(\n    args_to_warn=[\"batch_size\"],\n    reason=\"The batch_size argument is no longer used and will be removed in the next major version.\",\n    version=\"7.2.1\",\n)\ndef record_operation_duration(\n    command_name: str,\n    duration_seconds: float,\n    server_address: Optional[str] = None,\n    server_port: Optional[int] = None,\n    db_namespace: Optional[str] = None,\n    error: Optional[Exception] = None,\n    is_blocking: Optional[bool] = None,\n    batch_size: Optional[int] = None,  # noqa\n    retry_attempts: Optional[int] = None,\n) -> None:\n    \"\"\"\n    Record a Redis command execution duration.\n\n    This is a simple, clean API that Redis core code can call directly.\n    If observability is not enabled, this returns immediately with zero overhead.\n\n    Args:\n        command_name: Redis command name (e.g., 'GET', 'SET')\n        duration_seconds: Command execution time in seconds\n        server_address: Redis server address\n        server_port: Redis server port\n        db_namespace: Redis database index\n        error: Exception if command failed, None if successful\n        is_blocking: Whether the operation is a blocking command\n        batch_size: Number of commands in batch (for pipelines/transactions)\n        retry_attempts: Number of retry attempts made\n\n    Example:\n        >>> start = time.monotonic()\n        >>> # ... execute command ...\n        >>> record_operation_duration('SET', time.monotonic() - start, 'localhost', 6379, '0')\n    \"\"\"\n    global _metrics_collector\n\n    # Fast path: if collector not initialized, observability is disabled\n    if _metrics_collector is None:\n        # Try to initialize (only once)\n        _metrics_collector = _get_or_create_collector()\n        if _metrics_collector is None:\n            return  # Observability not enabled\n\n    # Record the metric\n    try:\n        _metrics_collector.record_operation_duration(\n            command_name=command_name,\n            duration_seconds=duration_seconds,\n            server_address=server_address,\n            server_port=server_port,\n            db_namespace=db_namespace,\n            error_type=error,\n            network_peer_address=server_address,\n            network_peer_port=server_port,\n            is_blocking=is_blocking,\n            retry_attempts=retry_attempts,\n        )\n    except Exception:\n        # Don't let metric recording errors break Redis operations\n        pass\n\n\ndef record_connection_create_time(\n    connection_pool: \"ConnectionPoolInterface\",\n    duration_seconds: float,\n) -> None:\n    \"\"\"\n    Record connection creation time.\n\n    Args:\n        connection_pool: Connection pool implementation\n        duration_seconds: Time taken to create connection in seconds\n\n    Example:\n        >>> start = time.monotonic()\n        >>> # ... create connection ...\n        >>> record_connection_create_time('ConnectionPool<localhost:6379>', time.monotonic() - start)\n    \"\"\"\n    global _metrics_collector\n\n    # Fast path: if collector not initialized, observability is disabled\n    if _metrics_collector is None:\n        _metrics_collector = _get_or_create_collector()\n        if _metrics_collector is None:\n            return\n\n    try:\n        _metrics_collector.record_connection_create_time(\n            connection_pool=connection_pool,\n            duration_seconds=duration_seconds,\n        )\n    except Exception:\n        pass\n\n\ndef record_connection_count(\n    pool_name: str,\n    connection_state: ConnectionState,\n    counter: int = 1,\n) -> None:\n    \"\"\"\n    Record a connection count change for a single state.\n\n    Args:\n        pool_name: Connection pool identifier\n        connection_state: State to update (IDLE or USED)\n        counter: Number to add (positive) or subtract (negative)\n\n    Example:\n        # New connection created (goes to IDLE first)\n        >>> record_connection_count('pool_abc123', ConnectionState.IDLE, 1)\n\n        # Acquire from pool (transition)\n        >>> record_connection_count('pool_abc123', ConnectionState.IDLE, -1)\n        >>> record_connection_count('pool_abc123', ConnectionState.USED, 1)\n\n        # Release to pool (transition)\n        >>> record_connection_count('pool_abc123', ConnectionState.USED, -1)\n        >>> record_connection_count('pool_abc123', ConnectionState.IDLE, 1)\n\n        # Pool disconnect 5 idle connections\n        >>> record_connection_count('pool_abc123', ConnectionState.IDLE, -5)\n    \"\"\"\n    global _metrics_collector\n\n    if _metrics_collector is None:\n        _metrics_collector = _get_or_create_collector()\n        if _metrics_collector is None:\n            return\n\n    try:\n        _metrics_collector.record_connection_count(\n            pool_name=pool_name,\n            connection_state=connection_state,\n            counter=counter,\n        )\n    except Exception:\n        pass\n\n\n@deprecated_function(\n    reason=\"Connection count is now tracked via record_connection_count(). \"\n    \"This functionality will be removed in the next major version\",\n    version=\"7.4.0\",\n)\ndef init_connection_count() -> None:\n    \"\"\"\n    Initialize observable gauge for connection count metric.\n    \"\"\"\n    collector = _get_or_create_collector()\n    if collector is None:\n        return\n\n    def observable_callback(__):\n        observables_registry = get_observables_registry_instance()\n        callbacks = observables_registry.get(CONNECTION_COUNT_REGISTRY_KEY)\n        observations = []\n\n        for callback in callbacks:\n            observations.extend(callback())\n\n        return observations\n\n    try:\n        collector.init_connection_count(\n            callback=observable_callback,\n        )\n    except Exception:\n        pass\n\n\n@deprecated_function(\n    reason=\"Connection count is now tracked via record_connection_count(). \"\n    \"This functionality will be removed in the next major version\",\n    version=\"7.4.0\",\n)\ndef register_pools_connection_count(\n    connection_pools: List[\"ConnectionPoolInterface\"],\n) -> None:\n    \"\"\"\n    Add connection pools to connection count observable registry.\n    \"\"\"\n    collector = _get_or_create_collector()\n    if collector is None:\n        return\n\n    try:\n        # Lazy import\n        from opentelemetry.metrics import Observation\n\n        def connection_count_callback():\n            observations = []\n            for connection_pool in connection_pools:\n                for count, attributes in connection_pool.get_connection_count():\n                    observations.append(Observation(count, attributes=attributes))\n            return observations\n\n        observables_registry = get_observables_registry_instance()\n        observables_registry.register(\n            CONNECTION_COUNT_REGISTRY_KEY, connection_count_callback\n        )\n    except Exception:\n        pass\n\n\ndef record_connection_timeout(\n    pool_name: str,\n) -> None:\n    \"\"\"\n    Record a connection timeout event.\n\n    Args:\n        pool_name: Connection pool identifier\n\n    Example:\n        >>> record_connection_timeout('ConnectionPool<localhost:6379>')\n    \"\"\"\n    global _metrics_collector\n\n    if _metrics_collector is None:\n        _metrics_collector = _get_or_create_collector()\n        if _metrics_collector is None:\n            return\n\n    try:\n        _metrics_collector.record_connection_timeout(\n            pool_name=pool_name,\n        )\n    except Exception:\n        pass\n\n\ndef record_connection_wait_time(\n    pool_name: str,\n    duration_seconds: float,\n) -> None:\n    \"\"\"\n    Record time taken to obtain a connection from the pool.\n\n    Args:\n        pool_name: Connection pool identifier\n        duration_seconds: Wait time in seconds\n\n    Example:\n        >>> start = time.monotonic()\n        >>> # ... wait for connection from pool ...\n        >>> record_connection_wait_time('ConnectionPool<localhost:6379>', time.monotonic() - start)\n    \"\"\"\n    global _metrics_collector\n\n    if _metrics_collector is None:\n        _metrics_collector = _get_or_create_collector()\n        if _metrics_collector is None:\n            return\n\n    try:\n        _metrics_collector.record_connection_wait_time(\n            pool_name=pool_name,\n            duration_seconds=duration_seconds,\n        )\n    except Exception:\n        pass\n\n\ndef record_connection_closed(\n    close_reason: Optional[CloseReason] = None,\n    error_type: Optional[Exception] = None,\n) -> None:\n    \"\"\"\n    Record a connection closed event.\n\n    Args:\n        close_reason: Reason for closing (e.g. 'error', 'application_close')\n        error_type: Error type if closed due to error\n\n    Example:\n        >>> record_connection_closed('ConnectionPool<localhost:6379>', 'idle_timeout')\n    \"\"\"\n    global _metrics_collector\n\n    if _metrics_collector is None:\n        _metrics_collector = _get_or_create_collector()\n        if _metrics_collector is None:\n            return\n\n    try:\n        _metrics_collector.record_connection_closed(\n            close_reason=close_reason,\n            error_type=error_type,\n        )\n    except Exception:\n        pass\n\n\ndef record_connection_relaxed_timeout(\n    connection_name: str,\n    maint_notification: str,\n    relaxed: bool,\n) -> None:\n    \"\"\"\n    Record a connection timeout relaxation event.\n\n    Args:\n        connection_name: Connection identifier\n        maint_notification: Maintenance notification type\n        relaxed: True to count up (relaxed), False to count down (unrelaxed)\n\n    Example:\n        >>> record_connection_relaxed_timeout('localhost:6379_a1b2c3d4', 'MOVING', True)\n    \"\"\"\n    global _metrics_collector\n\n    if _metrics_collector is None:\n        _metrics_collector = _get_or_create_collector()\n        if _metrics_collector is None:\n            return\n\n    try:\n        _metrics_collector.record_connection_relaxed_timeout(\n            connection_name=connection_name,\n            maint_notification=maint_notification,\n            relaxed=relaxed,\n        )\n    except Exception:\n        pass\n\n\ndef record_connection_handoff(\n    pool_name: str,\n) -> None:\n    \"\"\"\n    Record a connection handoff event (e.g., after MOVING notification).\n\n    Args:\n        pool_name: Connection pool identifier\n\n    Example:\n        >>> record_connection_handoff('ConnectionPool<localhost:6379>')\n    \"\"\"\n    global _metrics_collector\n\n    if _metrics_collector is None:\n        _metrics_collector = _get_or_create_collector()\n        if _metrics_collector is None:\n            return\n\n    try:\n        _metrics_collector.record_connection_handoff(\n            pool_name=pool_name,\n        )\n    except Exception:\n        pass\n\n\ndef record_error_count(\n    server_address: Optional[str] = None,\n    server_port: Optional[int] = None,\n    network_peer_address: Optional[str] = None,\n    network_peer_port: Optional[int] = None,\n    error_type: Optional[Exception] = None,\n    retry_attempts: Optional[int] = None,\n    is_internal: bool = True,\n) -> None:\n    \"\"\"\n    Record error count.\n\n    Args:\n        server_address: Server address\n        server_port: Server port\n        network_peer_address: Network peer address\n        network_peer_port: Network peer port\n        error_type: Error type (Exception)\n        retry_attempts: Retry attempts\n        is_internal: Whether the error is internal (e.g., timeout, network error)\n\n    Example:\n        >>> record_error_count('localhost', 6379, 'localhost', 6379, ConnectionError(), 3)\n    \"\"\"\n    global _metrics_collector\n\n    if _metrics_collector is None:\n        _metrics_collector = _get_or_create_collector()\n        if _metrics_collector is None:\n            return\n\n    try:\n        _metrics_collector.record_error_count(\n            server_address=server_address,\n            server_port=server_port,\n            network_peer_address=network_peer_address,\n            network_peer_port=network_peer_port,\n            error_type=error_type,\n            retry_attempts=retry_attempts,\n            is_internal=is_internal,\n        )\n    except Exception:\n        pass\n\n\ndef record_pubsub_message(\n    direction: PubSubDirection,\n    channel: Optional[str] = None,\n    sharded: Optional[bool] = None,\n) -> None:\n    \"\"\"\n    Record a PubSub message (published or received).\n\n    Args:\n        direction: Message direction ('publish' or 'receive')\n        channel: Pub/Sub channel name\n        sharded: True if sharded Pub/Sub channel\n\n    Example:\n        >>> record_pubsub_message(PubSubDirection.PUBLISH, 'channel', False)\n    \"\"\"\n    global _metrics_collector\n\n    if _metrics_collector is None:\n        _metrics_collector = _get_or_create_collector()\n        if _metrics_collector is None:\n            return\n\n    # Check if channel names should be hidden\n    effective_channel = channel\n    if channel is not None:\n        config = _get_config()\n        if config is not None and config.hide_pubsub_channel_names:\n            effective_channel = None\n\n    try:\n        _metrics_collector.record_pubsub_message(\n            direction=direction,\n            channel=effective_channel,\n            sharded=sharded,\n        )\n    except Exception:\n        pass\n\n\n@deprecated_args(\n    args_to_warn=[\"consumer_name\"],\n    reason=\"The consumer_name argument is no longer used and will be removed in the next major version.\",\n    version=\"7.2.1\",\n)\ndef record_streaming_lag(\n    lag_seconds: float,\n    stream_name: Optional[str] = None,\n    consumer_group: Optional[str] = None,\n    consumer_name: Optional[str] = None,  # noqa\n) -> None:\n    \"\"\"\n    Record the lag of a streaming message.\n\n    Args:\n        lag_seconds: Lag in seconds\n        stream_name: Stream name\n        consumer_group: Consumer group name\n        consumer_name: Consumer name\n    \"\"\"\n    global _metrics_collector\n\n    if _metrics_collector is None:\n        _metrics_collector = _get_or_create_collector()\n        if _metrics_collector is None:\n            return\n\n    # Check if stream names should be hidden\n    effective_stream_name = stream_name\n    if stream_name is not None:\n        config = _get_config()\n        if config is not None and config.hide_stream_names:\n            effective_stream_name = None\n\n    try:\n        _metrics_collector.record_streaming_lag(\n            lag_seconds=lag_seconds,\n            stream_name=effective_stream_name,\n            consumer_group=consumer_group,\n        )\n    except Exception:\n        pass\n\n\n@deprecated_args(\n    args_to_warn=[\"consumer_name\"],\n    reason=\"The consumer_name argument is no longer used and will be removed in the next major version.\",\n    version=\"7.2.1\",\n)\ndef record_streaming_lag_from_response(\n    response,\n    consumer_group: Optional[str] = None,\n    consumer_name: Optional[str] = None,  # noqa\n) -> None:\n    \"\"\"\n    Record streaming lag from XREAD/XREADGROUP response.\n\n    Parses the response and calculates lag for each message based on message ID timestamp.\n\n    Args:\n        response: Response from XREAD/XREADGROUP command\n        consumer_group: Consumer group name (for XREADGROUP)\n        consumer_name: Consumer name (for XREADGROUP)\n    \"\"\"\n\n    global _metrics_collector\n\n    if _metrics_collector is None:\n        _metrics_collector = _get_or_create_collector()\n        if _metrics_collector is None:\n            return\n\n    if not response:\n        return\n\n    try:\n        now = datetime.now().timestamp()\n\n        # Check if stream names should be hidden\n        config = _get_config()\n        hide_stream_names = config is not None and config.hide_stream_names\n\n        # RESP3 format: dict\n        if isinstance(response, dict):\n            for stream_name, stream_messages in response.items():\n                effective_stream_name = (\n                    None if hide_stream_names else str_if_bytes(stream_name)\n                )\n                for messages in stream_messages:\n                    for message in messages:\n                        message_id, _ = message\n                        message_id = str_if_bytes(message_id)\n                        timestamp, _ = message_id.split(\"-\")\n                        # Ensure lag is non-negative (clock skew can cause negative values)\n                        lag_seconds = max(0.0, now - int(timestamp) / 1000)\n\n                        _metrics_collector.record_streaming_lag(\n                            lag_seconds=lag_seconds,\n                            stream_name=effective_stream_name,\n                            consumer_group=consumer_group,\n                        )\n        else:\n            # RESP2 format: list\n            for stream_entry in response:\n                stream_name = str_if_bytes(stream_entry[0])\n                effective_stream_name = None if hide_stream_names else stream_name\n\n                for message in stream_entry[1]:\n                    message_id, _ = message\n                    message_id = str_if_bytes(message_id)\n                    timestamp, _ = message_id.split(\"-\")\n                    # Ensure lag is non-negative (clock skew can cause negative values)\n                    lag_seconds = max(0.0, now - int(timestamp) / 1000)\n\n                    _metrics_collector.record_streaming_lag(\n                        lag_seconds=lag_seconds,\n                        stream_name=effective_stream_name,\n                        consumer_group=consumer_group,\n                    )\n    except Exception:\n        pass\n\n\ndef record_maint_notification_count(\n    server_address: str,\n    server_port: int,\n    network_peer_address: str,\n    network_peer_port: int,\n    maint_notification: str,\n) -> None:\n    \"\"\"\n    Record a maintenance notification count.\n\n    Args:\n        server_address: Server address\n        server_port: Server port\n        network_peer_address: Network peer address\n        network_peer_port: Network peer port\n        maint_notification: Maintenance notification type (e.g., 'MOVING', 'MIGRATING')\n\n    Example:\n        >>> record_maint_notification_count('localhost', 6379, 'localhost', 6379, 'MOVING')\n    \"\"\"\n    global _metrics_collector\n\n    if _metrics_collector is None:\n        _metrics_collector = _get_or_create_collector()\n        if _metrics_collector is None:\n            return\n\n    try:\n        _metrics_collector.record_maint_notification_count(\n            server_address=server_address,\n            server_port=server_port,\n            network_peer_address=network_peer_address,\n            network_peer_port=network_peer_port,\n            maint_notification=maint_notification,\n        )\n    except Exception:\n        pass\n\n\ndef record_csc_request(\n    result: Optional[CSCResult] = None,\n):\n    \"\"\"\n    Record a Client Side Caching (CSC) request.\n\n    Args:\n        result: CSC result ('hit' or 'miss')\n    \"\"\"\n    global _metrics_collector\n\n    if _metrics_collector is None:\n        _metrics_collector = _get_or_create_collector()\n        if _metrics_collector is None:\n            return\n\n    try:\n        _metrics_collector.record_csc_request(\n            result=result,\n        )\n    except Exception:\n        pass\n\n\ndef init_csc_items() -> None:\n    \"\"\"\n    Initialize observable gauge for CSC items metric.\n    \"\"\"\n    global _metrics_collector\n\n    if _metrics_collector is None:\n        _metrics_collector = _get_or_create_collector()\n        if _metrics_collector is None:\n            return\n\n    def observable_callback(__):\n        observables_registry = get_observables_registry_instance()\n        callbacks = observables_registry.get(CSC_ITEMS_REGISTRY_KEY)\n        observations = []\n\n        for callback in callbacks:\n            observations.extend(callback())\n\n        return observations\n\n    try:\n        _metrics_collector.init_csc_items(\n            callback=observable_callback,\n        )\n    except Exception:\n        pass\n\n\ndef register_csc_items_callback(\n    callback: Callable,\n    pool_name: Optional[str] = None,\n) -> None:\n    \"\"\"\n    Adds given callback to CSC items observable registry.\n\n    Args:\n        callback: Callback function that returns the cache size\n        pool_name: Connection pool name for observability\n    \"\"\"\n    global _metrics_collector\n\n    if _metrics_collector is None:\n        _metrics_collector = _get_or_create_collector()\n        if _metrics_collector is None:\n            return\n\n    # Lazy import\n    from opentelemetry.metrics import Observation\n\n    def csc_items_callback():\n        return [\n            Observation(\n                callback(),\n                attributes=AttributeBuilder.build_csc_attributes(pool_name=pool_name),\n            )\n        ]\n\n    try:\n        observables_registry = get_observables_registry_instance()\n        observables_registry.register(CSC_ITEMS_REGISTRY_KEY, csc_items_callback)\n    except Exception:\n        pass\n\n\ndef record_csc_eviction(\n    count: int,\n    reason: Optional[CSCReason] = None,\n) -> None:\n    \"\"\"\n    Record a Client Side Caching (CSC) eviction.\n\n    Args:\n        count: Number of evictions\n        reason: Reason for eviction\n    \"\"\"\n    global _metrics_collector\n\n    if _metrics_collector is None:\n        _metrics_collector = _get_or_create_collector()\n        if _metrics_collector is None:\n            return\n\n    try:\n        _metrics_collector.record_csc_eviction(\n            count=count,\n            reason=reason,\n        )\n    except Exception:\n        pass\n\n\ndef record_csc_network_saved(\n    bytes_saved: int,\n) -> None:\n    \"\"\"\n    Record the number of bytes saved by using Client Side Caching (CSC).\n\n    Args:\n        bytes_saved: Number of bytes saved\n    \"\"\"\n    global _metrics_collector\n\n    if _metrics_collector is None:\n        _metrics_collector = _get_or_create_collector()\n        if _metrics_collector is None:\n            return\n\n    try:\n        _metrics_collector.record_csc_network_saved(\n            bytes_saved=bytes_saved,\n        )\n    except Exception:\n        pass\n\n\ndef record_geo_failover(\n    fail_from: \"SyncDatabase\",\n    fail_to: \"SyncDatabase\",\n    reason: GeoFailoverReason,\n) -> None:\n    \"\"\"\n    Record a geo failover.\n\n    Args:\n        fail_from: Database failed from\n        fail_to: Database failed to\n        reason: Reason for the failover\n    \"\"\"\n    global _metrics_collector\n\n    if _metrics_collector is None:\n        _metrics_collector = _get_or_create_collector()\n        if _metrics_collector is None:\n            return\n\n    try:\n        _metrics_collector.record_geo_failover(\n            fail_from=fail_from,\n            fail_to=fail_to,\n            reason=reason,\n        )\n    except Exception:\n        pass\n\n\ndef _get_or_create_collector() -> Optional[RedisMetricsCollector]:\n    \"\"\"\n    Get or create the global metrics collector.\n\n    Returns:\n        RedisMetricsCollector instance if observability is enabled, None otherwise\n    \"\"\"\n    try:\n        manager = get_observability_instance().get_provider_manager()\n        if manager is None or not manager.config.enabled_telemetry:\n            return None\n\n        # Get meter from the global MeterProvider\n        meter = manager.get_meter_provider().get_meter(\n            RedisMetricsCollector.METER_NAME, RedisMetricsCollector.METER_VERSION\n        )\n\n        return RedisMetricsCollector(meter, manager.config)\n\n    except ImportError:\n        # Observability module not available\n        return None\n    except Exception:\n        # Any other error - don't break Redis operations\n        return None\n\n\ndef _get_config() -> Optional[\"OTelConfig\"]:\n    \"\"\"\n    Get the OTel configuration from the observability manager.\n\n    Returns:\n        OTelConfig instance if observability is enabled, None otherwise\n    \"\"\"\n    try:\n        manager = get_observability_instance().get_provider_manager()\n        if manager is None:\n            return None\n        return manager.config\n    except Exception:\n        return None\n\n\ndef reset_collector() -> None:\n    \"\"\"\n    Reset the global collector (used for testing or re-initialization).\n    \"\"\"\n    global _metrics_collector\n    _metrics_collector = None\n\n\ndef is_enabled() -> bool:\n    \"\"\"\n    Check if observability is enabled.\n\n    Returns:\n        True if metrics are being collected, False otherwise\n    \"\"\"\n    global _metrics_collector\n\n    if _metrics_collector is None:\n        _metrics_collector = _get_or_create_collector()\n\n    return _metrics_collector is not None\n"
  },
  {
    "path": "redis/observability/registry.py",
    "content": "import threading\nfrom typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional\n\n# Optional import - OTel SDK may not be installed\n# Use Any as fallback type when OTel is not available\nif TYPE_CHECKING:\n    try:\n        from opentelemetry.metrics import Observation\n    except ImportError:\n        Observation = Any  # type: ignore[misc]\nelse:\n    Observation = Any\n\n\nclass ObservablesRegistry:\n    \"\"\"\n    Global registry for storing callbacks for observable metrics.\n    \"\"\"\n\n    def __init__(self, registry: Dict[str, List[Callable[[], List[Any]]]] = None):\n        self._registry = registry or {}\n        self._lock = threading.Lock()\n\n    def register(self, name: str, callback: Callable[[], List[Any]]) -> None:\n        \"\"\"\n        Register a callback for an observable metric.\n        \"\"\"\n        with self._lock:\n            self._registry.setdefault(name, []).append(callback)\n\n    def get(self, name: str) -> List[Callable[[], List[Any]]]:\n        \"\"\"\n        Get all callbacks for an observable metric.\n        \"\"\"\n        with self._lock:\n            return self._registry.get(name, [])\n\n    def clear(self) -> None:\n        \"\"\"\n        Clear the registry.\n        \"\"\"\n        with self._lock:\n            self._registry.clear()\n\n    def __len__(self) -> int:\n        \"\"\"\n        Get the number of registered callbacks.\n        \"\"\"\n        return len(self._registry)\n\n\n# Global singleton instance\n_observables_registry_instance: Optional[ObservablesRegistry] = None\n\n\ndef get_observables_registry_instance() -> ObservablesRegistry:\n    \"\"\"\n    Get the global observables registry singleton instance.\n\n    This is the Pythonic way to get the singleton instance.\n\n    Returns:\n        The global ObservablesRegistry singleton\n\n    Example:\n        >>>\n        >>> registry = get_observables_registry_instance()\n        >>> registry.register('my_metric', my_callback)\n    \"\"\"\n    global _observables_registry_instance\n\n    if _observables_registry_instance is None:\n        _observables_registry_instance = ObservablesRegistry()\n\n    return _observables_registry_instance\n"
  },
  {
    "path": "redis/ocsp.py",
    "content": "import base64\nimport datetime\nimport ssl\nfrom urllib.parse import urljoin, urlparse\n\nimport cryptography.hazmat.primitives.hashes\nimport requests\nfrom cryptography import hazmat, x509\nfrom cryptography.exceptions import InvalidSignature\nfrom cryptography.hazmat import backends\nfrom cryptography.hazmat.primitives.asymmetric.dsa import DSAPublicKey\nfrom cryptography.hazmat.primitives.asymmetric.ec import ECDSA, EllipticCurvePublicKey\nfrom cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15\nfrom cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey\nfrom cryptography.hazmat.primitives.hashes import SHA1, Hash\nfrom cryptography.hazmat.primitives.serialization import Encoding, PublicFormat\nfrom cryptography.x509 import ocsp\n\nfrom redis.exceptions import AuthorizationError, ConnectionError\n\n\ndef _verify_response(issuer_cert, ocsp_response):\n    pubkey = issuer_cert.public_key()\n    try:\n        if isinstance(pubkey, RSAPublicKey):\n            pubkey.verify(\n                ocsp_response.signature,\n                ocsp_response.tbs_response_bytes,\n                PKCS1v15(),\n                ocsp_response.signature_hash_algorithm,\n            )\n        elif isinstance(pubkey, DSAPublicKey):\n            pubkey.verify(\n                ocsp_response.signature,\n                ocsp_response.tbs_response_bytes,\n                ocsp_response.signature_hash_algorithm,\n            )\n        elif isinstance(pubkey, EllipticCurvePublicKey):\n            pubkey.verify(\n                ocsp_response.signature,\n                ocsp_response.tbs_response_bytes,\n                ECDSA(ocsp_response.signature_hash_algorithm),\n            )\n        else:\n            pubkey.verify(ocsp_response.signature, ocsp_response.tbs_response_bytes)\n    except InvalidSignature:\n        raise ConnectionError(\"failed to valid ocsp response\")\n\n\ndef _check_certificate(issuer_cert, ocsp_bytes, validate=True):\n    \"\"\"A wrapper the return the validity of a known ocsp certificate\"\"\"\n\n    ocsp_response = ocsp.load_der_ocsp_response(ocsp_bytes)\n\n    if ocsp_response.response_status == ocsp.OCSPResponseStatus.UNAUTHORIZED:\n        raise AuthorizationError(\"you are not authorized to view this ocsp certificate\")\n    if ocsp_response.response_status == ocsp.OCSPResponseStatus.SUCCESSFUL:\n        if ocsp_response.certificate_status != ocsp.OCSPCertStatus.GOOD:\n            raise ConnectionError(\n                f\"Received an {str(ocsp_response.certificate_status).split('.')[1]} \"\n                \"ocsp certificate status\"\n            )\n    else:\n        raise ConnectionError(\n            \"failed to retrieve a successful response from the ocsp responder\"\n        )\n\n    if ocsp_response.this_update >= datetime.datetime.now():\n        raise ConnectionError(\"ocsp certificate was issued in the future\")\n\n    if (\n        ocsp_response.next_update\n        and ocsp_response.next_update < datetime.datetime.now()\n    ):\n        raise ConnectionError(\"ocsp certificate has invalid update - in the past\")\n\n    responder_name = ocsp_response.responder_name\n    issuer_hash = ocsp_response.issuer_key_hash\n    responder_hash = ocsp_response.responder_key_hash\n\n    cert_to_validate = issuer_cert\n    if (\n        responder_name is not None\n        and responder_name == issuer_cert.subject\n        or responder_hash == issuer_hash\n    ):\n        cert_to_validate = issuer_cert\n    else:\n        certs = ocsp_response.certificates\n        responder_certs = _get_certificates(\n            certs, issuer_cert, responder_name, responder_hash\n        )\n\n        try:\n            responder_cert = responder_certs[0]\n        except IndexError:\n            raise ConnectionError(\"no certificates found for the responder\")\n\n        ext = responder_cert.extensions.get_extension_for_class(x509.ExtendedKeyUsage)\n        if ext is None or x509.oid.ExtendedKeyUsageOID.OCSP_SIGNING not in ext.value:\n            raise ConnectionError(\"delegate not autorized for ocsp signing\")\n        cert_to_validate = responder_cert\n\n    if validate:\n        _verify_response(cert_to_validate, ocsp_response)\n    return True\n\n\ndef _get_certificates(certs, issuer_cert, responder_name, responder_hash):\n    if responder_name is None:\n        certificates = [\n            c\n            for c in certs\n            if _get_pubkey_hash(c) == responder_hash and c.issuer == issuer_cert.subject\n        ]\n    else:\n        certificates = [\n            c\n            for c in certs\n            if c.subject == responder_name and c.issuer == issuer_cert.subject\n        ]\n\n    return certificates\n\n\ndef _get_pubkey_hash(certificate):\n    pubkey = certificate.public_key()\n\n    # https://stackoverflow.com/a/46309453/600498\n    if isinstance(pubkey, RSAPublicKey):\n        h = pubkey.public_bytes(Encoding.DER, PublicFormat.PKCS1)\n    elif isinstance(pubkey, EllipticCurvePublicKey):\n        h = pubkey.public_bytes(Encoding.X962, PublicFormat.UncompressedPoint)\n    else:\n        h = pubkey.public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo)\n\n    sha1 = Hash(SHA1(), backend=backends.default_backend())\n    sha1.update(h)\n    return sha1.finalize()\n\n\ndef ocsp_staple_verifier(con, ocsp_bytes, expected=None):\n    \"\"\"An implementation of a function for set_ocsp_client_callback in PyOpenSSL.\n\n    This function validates that the provide ocsp_bytes response is valid,\n    and matches the expected, stapled responses.\n    \"\"\"\n    if ocsp_bytes in [b\"\", None]:\n        raise ConnectionError(\"no ocsp response present\")\n\n    issuer_cert = None\n    peer_cert = con.get_peer_certificate().to_cryptography()\n    for c in con.get_peer_cert_chain():\n        cert = c.to_cryptography()\n        if cert.subject == peer_cert.issuer:\n            issuer_cert = cert\n            break\n\n    if issuer_cert is None:\n        raise ConnectionError(\"no matching issuer cert found in certificate chain\")\n\n    if expected is not None:\n        e = x509.load_pem_x509_certificate(expected)\n        if peer_cert != e:\n            raise ConnectionError(\"received and expected certificates do not match\")\n\n    return _check_certificate(issuer_cert, ocsp_bytes)\n\n\nclass OCSPVerifier:\n    \"\"\"A class to verify ssl sockets for RFC6960/RFC6961. This can be used\n    when using direct validation of OCSP responses and certificate revocations.\n\n    @see https://datatracker.ietf.org/doc/html/rfc6960\n    @see https://datatracker.ietf.org/doc/html/rfc6961\n    \"\"\"\n\n    def __init__(self, sock, host, port, ca_certs=None):\n        self.SOCK = sock\n        self.HOST = host\n        self.PORT = port\n        self.CA_CERTS = ca_certs\n\n    def _bin2ascii(self, der):\n        \"\"\"Convert SSL certificates in a binary (DER) format to ASCII PEM.\"\"\"\n\n        pem = ssl.DER_cert_to_PEM_cert(der)\n        cert = x509.load_pem_x509_certificate(pem.encode(), backends.default_backend())\n        return cert\n\n    def components_from_socket(self):\n        \"\"\"This function returns the certificate, primary issuer, and primary ocsp\n        server in the chain for a socket already wrapped with ssl.\n        \"\"\"\n\n        # convert the binary certifcate to text\n        der = self.SOCK.getpeercert(True)\n        if der is False:\n            raise ConnectionError(\"no certificate found for ssl peer\")\n        cert = self._bin2ascii(der)\n        return self._certificate_components(cert)\n\n    def _certificate_components(self, cert):\n        \"\"\"Given an SSL certificate, retract the useful components for\n        validating the certificate status with an OCSP server.\n\n        Args:\n            cert ([bytes]): A PEM encoded ssl certificate\n        \"\"\"\n\n        try:\n            aia = cert.extensions.get_extension_for_oid(\n                x509.oid.ExtensionOID.AUTHORITY_INFORMATION_ACCESS\n            ).value\n        except cryptography.x509.extensions.ExtensionNotFound:\n            raise ConnectionError(\"No AIA information present in ssl certificate\")\n\n        # fetch certificate issuers\n        issuers = [\n            i\n            for i in aia\n            if i.access_method == x509.oid.AuthorityInformationAccessOID.CA_ISSUERS\n        ]\n        try:\n            issuer = issuers[0].access_location.value\n        except IndexError:\n            issuer = None\n\n        # now, the series of ocsp server entries\n        ocsps = [\n            i\n            for i in aia\n            if i.access_method == x509.oid.AuthorityInformationAccessOID.OCSP\n        ]\n\n        try:\n            ocsp = ocsps[0].access_location.value\n        except IndexError:\n            raise ConnectionError(\"no ocsp servers in certificate\")\n\n        return cert, issuer, ocsp\n\n    def components_from_direct_connection(self):\n        \"\"\"Return the certificate, primary issuer, and primary ocsp server\n        from the host defined by the socket. This is useful in cases where\n        different certificates are occasionally presented.\n        \"\"\"\n\n        pem = ssl.get_server_certificate((self.HOST, self.PORT), ca_certs=self.CA_CERTS)\n        cert = x509.load_pem_x509_certificate(pem.encode(), backends.default_backend())\n        return self._certificate_components(cert)\n\n    def build_certificate_url(self, server, cert, issuer_cert):\n        \"\"\"Return the complete url to the ocsp\"\"\"\n        orb = ocsp.OCSPRequestBuilder()\n\n        # add_certificate returns an initialized OCSPRequestBuilder\n        orb = orb.add_certificate(\n            cert, issuer_cert, cryptography.hazmat.primitives.hashes.SHA256()\n        )\n        request = orb.build()\n\n        path = base64.b64encode(\n            request.public_bytes(hazmat.primitives.serialization.Encoding.DER)\n        )\n        url = urljoin(server, path.decode(\"ascii\"))\n        return url\n\n    def check_certificate(self, server, cert, issuer_url):\n        \"\"\"Checks the validity of an ocsp server for an issuer\"\"\"\n\n        r = requests.get(issuer_url)\n        if not r.ok:\n            raise ConnectionError(\"failed to fetch issuer certificate\")\n        der = r.content\n        issuer_cert = self._bin2ascii(der)\n\n        ocsp_url = self.build_certificate_url(server, cert, issuer_cert)\n\n        # HTTP 1.1 mandates the addition of the Host header in ocsp responses\n        header = {\n            \"Host\": urlparse(ocsp_url).netloc,\n            \"Content-Type\": \"application/ocsp-request\",\n        }\n        r = requests.get(ocsp_url, headers=header)\n        if not r.ok:\n            raise ConnectionError(\"failed to fetch ocsp certificate\")\n        return _check_certificate(issuer_cert, r.content, True)\n\n    def is_valid(self):\n        \"\"\"Returns the validity of the certificate wrapping our socket.\n        This first retrieves for validate the certificate, issuer_url,\n        and ocsp_server for certificate validate. Then retrieves the\n        issuer certificate from the issuer_url, and finally checks\n        the validity of OCSP revocation status.\n        \"\"\"\n\n        # validate the certificate\n        try:\n            cert, issuer_url, ocsp_server = self.components_from_socket()\n            if issuer_url is None:\n                raise ConnectionError(\"no issuers found in certificate chain\")\n            return self.check_certificate(ocsp_server, cert, issuer_url)\n        except AuthorizationError:\n            cert, issuer_url, ocsp_server = self.components_from_direct_connection()\n            if issuer_url is None:\n                raise ConnectionError(\"no issuers found in certificate chain\")\n            return self.check_certificate(ocsp_server, cert, issuer_url)\n"
  },
  {
    "path": "redis/py.typed",
    "content": ""
  },
  {
    "path": "redis/retry.py",
    "content": "import abc\nimport socket\nfrom time import sleep\nfrom typing import (\n    TYPE_CHECKING,\n    Any,\n    Callable,\n    Generic,\n    Iterable,\n    Optional,\n    Tuple,\n    Type,\n    TypeVar,\n    Union,\n)\n\nfrom redis.exceptions import ConnectionError, TimeoutError\n\nT = TypeVar(\"T\")\nE = TypeVar(\"E\", bound=Exception, covariant=True)\n\nif TYPE_CHECKING:\n    from redis.backoff import AbstractBackoff\n\n\nclass AbstractRetry(Generic[E], abc.ABC):\n    \"\"\"Retry a specific number of times after a failure\"\"\"\n\n    _supported_errors: Tuple[Type[E], ...]\n\n    def __init__(\n        self,\n        backoff: \"AbstractBackoff\",\n        retries: int,\n        supported_errors: Tuple[Type[E], ...],\n    ):\n        \"\"\"\n        Initialize a `Retry` object with a `Backoff` object\n        that retries a maximum of `retries` times.\n        `retries` can be negative to retry forever.\n        You can specify the types of supported errors which trigger\n        a retry with the `supported_errors` parameter.\n        \"\"\"\n        self._backoff = backoff\n        self._retries = retries\n        self._supported_errors = supported_errors\n\n    @abc.abstractmethod\n    def __eq__(self, other: Any) -> bool:\n        return NotImplemented\n\n    def __hash__(self) -> int:\n        return hash((self._backoff, self._retries, frozenset(self._supported_errors)))\n\n    def update_supported_errors(self, specified_errors: Iterable[Type[E]]) -> None:\n        \"\"\"\n        Updates the supported errors with the specified error types\n        \"\"\"\n        self._supported_errors = tuple(\n            set(self._supported_errors + tuple(specified_errors))\n        )\n\n    def get_retries(self) -> int:\n        \"\"\"\n        Get the number of retries.\n        \"\"\"\n        return self._retries\n\n    def update_retries(self, value: int) -> None:\n        \"\"\"\n        Set the number of retries.\n        \"\"\"\n        self._retries = value\n\n\nclass Retry(AbstractRetry[Exception]):\n    __hash__ = AbstractRetry.__hash__\n\n    def __init__(\n        self,\n        backoff: \"AbstractBackoff\",\n        retries: int,\n        supported_errors: Tuple[Type[Exception], ...] = (\n            ConnectionError,\n            TimeoutError,\n            socket.timeout,\n        ),\n    ):\n        super().__init__(backoff, retries, supported_errors)\n\n    def __eq__(self, other: Any) -> bool:\n        if not isinstance(other, Retry):\n            return NotImplemented\n\n        return (\n            self._backoff == other._backoff\n            and self._retries == other._retries\n            and set(self._supported_errors) == set(other._supported_errors)\n        )\n\n    def call_with_retry(\n        self,\n        do: Callable[[], T],\n        fail: Union[Callable[[Exception], Any], Callable[[Exception, int], Any]],\n        is_retryable: Optional[Callable[[Exception], bool]] = None,\n        with_failure_count: bool = False,\n    ) -> T:\n        \"\"\"\n        Execute an operation that might fail and returns its result, or\n        raise the exception that was thrown depending on the `Backoff` object.\n        `do`: the operation to call. Expects no argument.\n        `fail`: the failure handler, expects the last error that was thrown\n        ``is_retryable``: optional function to determine if an error is retryable\n        ``with_failure_count``: if True, the failure count is passed to the failure handler\n        \"\"\"\n        self._backoff.reset()\n        failures = 0\n        while True:\n            try:\n                return do()\n            except self._supported_errors as error:\n                if is_retryable and not is_retryable(error):\n                    raise\n                failures += 1\n\n                if with_failure_count:\n                    fail(error, failures)\n                else:\n                    fail(error)\n\n                if self._retries >= 0 and failures > self._retries:\n                    raise error\n                backoff = self._backoff.compute(failures)\n                if backoff > 0:\n                    sleep(backoff)\n"
  },
  {
    "path": "redis/sentinel.py",
    "content": "import random\nimport weakref\nfrom typing import Optional, Union\n\nfrom redis._parsers.socket import SENTINEL\nfrom redis.client import Redis\nfrom redis.commands import SentinelCommands\nfrom redis.connection import Connection, ConnectionPool, SSLConnection\nfrom redis.exceptions import (\n    ConnectionError,\n    ReadOnlyError,\n    ResponseError,\n    TimeoutError,\n)\n\n\nclass MasterNotFoundError(ConnectionError):\n    pass\n\n\nclass SlaveNotFoundError(ConnectionError):\n    pass\n\n\nclass SentinelManagedConnection(Connection):\n    def __init__(self, **kwargs):\n        self.connection_pool = kwargs.pop(\"connection_pool\")\n        super().__init__(**kwargs)\n\n    def __repr__(self):\n        pool = self.connection_pool\n        s = (\n            f\"<{type(self).__module__}.{type(self).__name__}\"\n            f\"(service={pool.service_name}%s)>\"\n        )\n        if self.host:\n            host_info = f\",host={self.host},port={self.port}\"\n            s = s % host_info\n        return s\n\n    def connect_to(self, address):\n        self.host, self.port = address\n\n        self.connect_check_health(\n            check_health=self.connection_pool.check_connection,\n            retry_socket_connect=False,\n        )\n\n    def _connect_retry(self):\n        if self._sock:\n            return  # already connected\n        if self.connection_pool.is_master:\n            self.connect_to(self.connection_pool.get_master_address())\n        else:\n            for slave in self.connection_pool.rotate_slaves():\n                try:\n                    return self.connect_to(slave)\n                except ConnectionError:\n                    continue\n            raise SlaveNotFoundError  # Never be here\n\n    def connect(self):\n        return self.retry.call_with_retry(self._connect_retry, lambda error: None)\n\n    def read_response(\n        self,\n        disable_decoding=False,\n        *,\n        timeout: Union[float, object] = SENTINEL,\n        disconnect_on_error: Optional[bool] = False,\n        push_request: Optional[bool] = False,\n    ):\n        try:\n            return super().read_response(\n                disable_decoding=disable_decoding,\n                timeout=timeout,\n                disconnect_on_error=disconnect_on_error,\n                push_request=push_request,\n            )\n        except ReadOnlyError:\n            if self.connection_pool.is_master:\n                # When talking to a master, a ReadOnlyError when likely\n                # indicates that the previous master that we're still connected\n                # to has been demoted to a slave and there's a new master.\n                # calling disconnect will force the connection to re-query\n                # sentinel during the next connect() attempt.\n                self.disconnect()\n                raise ConnectionError(\"The previous master is now a slave\")\n            raise\n\n\nclass SentinelManagedSSLConnection(SentinelManagedConnection, SSLConnection):\n    pass\n\n\nclass SentinelConnectionPoolProxy:\n    def __init__(\n        self,\n        connection_pool,\n        is_master,\n        check_connection,\n        service_name,\n        sentinel_manager,\n    ):\n        self.connection_pool_ref = weakref.ref(connection_pool)\n        self.is_master = is_master\n        self.check_connection = check_connection\n        self.service_name = service_name\n        self.sentinel_manager = sentinel_manager\n        self.reset()\n\n    def reset(self):\n        self.master_address = None\n        self.slave_rr_counter = None\n\n    def get_master_address(self):\n        master_address = self.sentinel_manager.discover_master(self.service_name)\n        if self.is_master and self.master_address != master_address:\n            self.master_address = master_address\n            # disconnect any idle connections so that they reconnect\n            # to the new master the next time that they are used.\n            connection_pool = self.connection_pool_ref()\n            if connection_pool is not None:\n                connection_pool.disconnect(inuse_connections=False)\n        return master_address\n\n    def rotate_slaves(self):\n        slaves = self.sentinel_manager.discover_slaves(self.service_name)\n        if slaves:\n            if self.slave_rr_counter is None:\n                self.slave_rr_counter = random.randint(0, len(slaves) - 1)\n            for _ in range(len(slaves)):\n                self.slave_rr_counter = (self.slave_rr_counter + 1) % len(slaves)\n                slave = slaves[self.slave_rr_counter]\n                yield slave\n        # Fallback to the master connection\n        try:\n            yield self.get_master_address()\n        except MasterNotFoundError:\n            pass\n        raise SlaveNotFoundError(f\"No slave found for {self.service_name!r}\")\n\n\nclass SentinelConnectionPool(ConnectionPool):\n    \"\"\"\n    Sentinel backed connection pool.\n\n    If ``check_connection`` flag is set to True, SentinelManagedConnection\n    sends a PING command right after establishing the connection.\n    \"\"\"\n\n    def __init__(self, service_name, sentinel_manager, **kwargs):\n        kwargs[\"connection_class\"] = kwargs.get(\n            \"connection_class\",\n            (\n                SentinelManagedSSLConnection\n                if kwargs.pop(\"ssl\", False)\n                else SentinelManagedConnection\n            ),\n        )\n        self.is_master = kwargs.pop(\"is_master\", True)\n        self.check_connection = kwargs.pop(\"check_connection\", False)\n        self.proxy = SentinelConnectionPoolProxy(\n            connection_pool=self,\n            is_master=self.is_master,\n            check_connection=self.check_connection,\n            service_name=service_name,\n            sentinel_manager=sentinel_manager,\n        )\n        super().__init__(**kwargs)\n        self.connection_kwargs[\"connection_pool\"] = self.proxy\n        self.service_name = service_name\n        self.sentinel_manager = sentinel_manager\n\n    def __repr__(self):\n        role = \"master\" if self.is_master else \"slave\"\n        return (\n            f\"<{type(self).__module__}.{type(self).__name__}\"\n            f\"(service={self.service_name}({role}))>\"\n        )\n\n    def reset(self):\n        super().reset()\n        self.proxy.reset()\n\n    @property\n    def master_address(self):\n        return self.proxy.master_address\n\n    def owns_connection(self, connection):\n        check = not self.is_master or (\n            self.is_master and self.master_address == (connection.host, connection.port)\n        )\n        parent = super()\n        return check and parent.owns_connection(connection)\n\n    def get_master_address(self):\n        return self.proxy.get_master_address()\n\n    def rotate_slaves(self):\n        \"Round-robin slave balancer\"\n        return self.proxy.rotate_slaves()\n\n\nclass Sentinel(SentinelCommands):\n    \"\"\"\n    Redis Sentinel cluster client\n\n    >>> from redis.sentinel import Sentinel\n    >>> sentinel = Sentinel([('localhost', 26379)], socket_timeout=0.1)\n    >>> master = sentinel.master_for('mymaster', socket_timeout=0.1)\n    >>> master.set('foo', 'bar')\n    >>> slave = sentinel.slave_for('mymaster', socket_timeout=0.1)\n    >>> slave.get('foo')\n    b'bar'\n\n    ``sentinels`` is a list of sentinel nodes. Each node is represented by\n    a pair (hostname, port).\n\n    ``min_other_sentinels`` defined a minimum number of peers for a sentinel.\n    When querying a sentinel, if it doesn't meet this threshold, responses\n    from that sentinel won't be considered valid.\n\n    ``sentinel_kwargs`` is a dictionary of connection arguments used when\n    connecting to sentinel instances. Any argument that can be passed to\n    a normal Redis connection can be specified here. If ``sentinel_kwargs`` is\n    not specified, any socket_timeout and socket_keepalive options specified\n    in ``connection_kwargs`` will be used.\n\n    ``connection_kwargs`` are keyword arguments that will be used when\n    establishing a connection to a Redis server.\n    \"\"\"\n\n    def __init__(\n        self,\n        sentinels,\n        min_other_sentinels=0,\n        sentinel_kwargs=None,\n        force_master_ip=None,\n        **connection_kwargs,\n    ):\n        # if sentinel_kwargs isn't defined, use the socket_* options from\n        # connection_kwargs\n        if sentinel_kwargs is None:\n            sentinel_kwargs = {\n                k: v for k, v in connection_kwargs.items() if k.startswith(\"socket_\")\n            }\n        self.sentinel_kwargs = sentinel_kwargs\n\n        self.sentinels = [\n            Redis(hostname, port, **self.sentinel_kwargs)\n            for hostname, port in sentinels\n        ]\n        self.min_other_sentinels = min_other_sentinels\n        self.connection_kwargs = connection_kwargs\n        self._force_master_ip = force_master_ip\n\n    def execute_command(self, *args, **kwargs):\n        \"\"\"\n        Execute Sentinel command in sentinel nodes.\n        once - If set to True, then execute the resulting command on a single\n        node at random, rather than across the entire sentinel cluster.\n        \"\"\"\n        once = bool(kwargs.pop(\"once\", False))\n\n        # Check if command is supposed to return the original\n        # responses instead of boolean value.\n        return_responses = bool(kwargs.pop(\"return_responses\", False))\n\n        if once:\n            response = random.choice(self.sentinels).execute_command(*args, **kwargs)\n            if return_responses:\n                return [response]\n            else:\n                return True if response else False\n\n        responses = []\n        for sentinel in self.sentinels:\n            responses.append(sentinel.execute_command(*args, **kwargs))\n\n        if return_responses:\n            return responses\n\n        return all(responses)\n\n    def __repr__(self):\n        sentinel_addresses = []\n        for sentinel in self.sentinels:\n            sentinel_addresses.append(\n                \"{host}:{port}\".format_map(sentinel.connection_pool.connection_kwargs)\n            )\n        return (\n            f\"<{type(self).__module__}.{type(self).__name__}\"\n            f\"(sentinels=[{','.join(sentinel_addresses)}])>\"\n        )\n\n    def check_master_state(self, state, service_name):\n        if not state[\"is_master\"] or state[\"is_sdown\"] or state[\"is_odown\"]:\n            return False\n        # Check if our sentinel doesn't see other nodes\n        if state[\"num-other-sentinels\"] < self.min_other_sentinels:\n            return False\n        return True\n\n    def discover_master(self, service_name):\n        \"\"\"\n        Asks sentinel servers for the Redis master's address corresponding\n        to the service labeled ``service_name``.\n\n        Returns a pair (address, port) or raises MasterNotFoundError if no\n        master is found.\n        \"\"\"\n        collected_errors = list()\n        for sentinel_no, sentinel in enumerate(self.sentinels):\n            try:\n                masters = sentinel.sentinel_masters()\n            except (ConnectionError, TimeoutError) as e:\n                collected_errors.append(f\"{sentinel} - {e!r}\")\n                continue\n            state = masters.get(service_name)\n            if state and self.check_master_state(state, service_name):\n                # Put this sentinel at the top of the list\n                self.sentinels[0], self.sentinels[sentinel_no] = (\n                    sentinel,\n                    self.sentinels[0],\n                )\n\n                ip = (\n                    self._force_master_ip\n                    if self._force_master_ip is not None\n                    else state[\"ip\"]\n                )\n                return ip, state[\"port\"]\n\n        error_info = \"\"\n        if len(collected_errors) > 0:\n            error_info = f\" : {', '.join(collected_errors)}\"\n        raise MasterNotFoundError(f\"No master found for {service_name!r}{error_info}\")\n\n    def filter_slaves(self, slaves):\n        \"Remove slaves that are in an ODOWN or SDOWN state\"\n        slaves_alive = []\n        for slave in slaves:\n            if slave[\"is_odown\"] or slave[\"is_sdown\"]:\n                continue\n            slaves_alive.append((slave[\"ip\"], slave[\"port\"]))\n        return slaves_alive\n\n    def discover_slaves(self, service_name):\n        \"Returns a list of alive slaves for service ``service_name``\"\n        for sentinel in self.sentinels:\n            try:\n                slaves = sentinel.sentinel_slaves(service_name)\n            except (ConnectionError, ResponseError, TimeoutError):\n                continue\n            slaves = self.filter_slaves(slaves)\n            if slaves:\n                return slaves\n        return []\n\n    def master_for(\n        self,\n        service_name,\n        redis_class=Redis,\n        connection_pool_class=SentinelConnectionPool,\n        **kwargs,\n    ):\n        \"\"\"\n        Returns a redis client instance for the ``service_name`` master.\n        Sentinel client will detect failover and reconnect Redis clients\n        automatically.\n\n        A :py:class:`~redis.sentinel.SentinelConnectionPool` class is\n        used to retrieve the master's address before establishing a new\n        connection.\n\n        NOTE: If the master's address has changed, any cached connections to\n        the old master are closed.\n\n        By default clients will be a :py:class:`~redis.Redis` instance.\n        Specify a different class to the ``redis_class`` argument if you\n        desire something different.\n\n        The ``connection_pool_class`` specifies the connection pool to\n        use.  The :py:class:`~redis.sentinel.SentinelConnectionPool`\n        will be used by default.\n\n        All other keyword arguments are merged with any connection_kwargs\n        passed to this class and passed to the connection pool as keyword\n        arguments to be used to initialize Redis connections.\n        \"\"\"\n        kwargs[\"is_master\"] = True\n        connection_kwargs = dict(self.connection_kwargs)\n        connection_kwargs.update(kwargs)\n        return redis_class.from_pool(\n            connection_pool_class(service_name, self, **connection_kwargs)\n        )\n\n    def slave_for(\n        self,\n        service_name,\n        redis_class=Redis,\n        connection_pool_class=SentinelConnectionPool,\n        **kwargs,\n    ):\n        \"\"\"\n        Returns redis client instance for the ``service_name`` slave(s).\n\n        A SentinelConnectionPool class is used to retrieve the slave's\n        address before establishing a new connection.\n\n        By default clients will be a :py:class:`~redis.Redis` instance.\n        Specify a different class to the ``redis_class`` argument if you\n        desire something different.\n\n        The ``connection_pool_class`` specifies the connection pool to use.\n        The SentinelConnectionPool will be used by default.\n\n        All other keyword arguments are merged with any connection_kwargs\n        passed to this class and passed to the connection pool as keyword\n        arguments to be used to initialize Redis connections.\n        \"\"\"\n        kwargs[\"is_master\"] = False\n        connection_kwargs = dict(self.connection_kwargs)\n        connection_kwargs.update(kwargs)\n        return redis_class.from_pool(\n            connection_pool_class(service_name, self, **connection_kwargs)\n        )\n"
  },
  {
    "path": "redis/typing.py",
    "content": "# from __future__ import annotations\n\nfrom datetime import datetime, timedelta\nfrom typing import (\n    TYPE_CHECKING,\n    Any,\n    Awaitable,\n    Iterable,\n    Literal,\n    Mapping,\n    Protocol,\n    Type,\n    TypeVar,\n    Union,\n)\n\nif TYPE_CHECKING:\n    from redis._parsers import Encoder\n    from redis.event import EventDispatcherInterface\n\n\nNumber = Union[int, float]\n\n\nclass AsyncClientProtocol(Protocol):\n    \"\"\"Protocol for asynchronous Redis clients (redis.asyncio.client.Redis).\n\n    This protocol uses a Literal marker to identify async clients.\n    Used in @overload to provide correct return types for async clients.\n    \"\"\"\n\n    _is_async_client: Literal[True]\n\n\nclass SyncClientProtocol(Protocol):\n    \"\"\"Protocol for synchronous Redis clients (redis.client.Redis).\n\n    This protocol uses a Literal marker to identify sync clients.\n    Used in @overload to provide correct return types for sync clients.\n    \"\"\"\n\n    _is_async_client: Literal[False]\n\n\nEncodedT = Union[bytes, bytearray, memoryview]\nDecodedT = Union[str, int, float]\nEncodableT = Union[EncodedT, DecodedT]\nAbsExpiryT = Union[int, datetime]\nExpiryT = Union[int, timedelta]\nZScoreBoundT = Union[float, str]  # str allows for the [ or ( prefix\nBitfieldOffsetT = Union[int, str]  # str allows for #x syntax\n_StringLikeT = Union[bytes, str, memoryview]\nKeyT = _StringLikeT  # Main redis key space\nPatternT = _StringLikeT  # Patterns matched against keys, fields etc\nFieldT = EncodableT  # Fields within hash tables, streams and geo commands\nKeysT = Union[KeyT, Iterable[KeyT]]\nResponseT = Union[Awaitable[Any], Any]\nChannelT = _StringLikeT\nGroupT = _StringLikeT  # Consumer group\nConsumerT = _StringLikeT  # Consumer name\nStreamIdT = Union[int, _StringLikeT]\nScriptTextT = _StringLikeT\nTimeoutSecT = Union[int, float, _StringLikeT]\nACLGetUserData = (\n    dict[str, bool | list[str] | list[list[str]] | list[dict[str, str]]] | None\n)\nACLLogEntry = dict[str, str | float | dict[str, str | int]]\nACLLogData = list[ACLLogEntry]\nCommandGetKeysAndFlagsEntry = list[bytes | str | list[bytes | str]]\nCommandGetKeysAndFlagsResponse = list[CommandGetKeysAndFlagsEntry]\nBlockingListPopResponse = tuple[bytes | str, bytes | str] | list[bytes | str] | None\nHScanPayload = dict[bytes | str, bytes | str] | list[bytes | str]\nHScanResponse = tuple[int, HScanPayload]\nListMultiPopResponse = list[bytes | str | list[bytes | str]] | None\nScanResponse = tuple[int, list[bytes | str]]\nSortResponse = list[bytes | str] | list[tuple[bytes | str, ...]] | int\nStreamEntry = tuple[bytes | str | None, dict[bytes | str, bytes | str] | None]\nStreamRangeResponse = list[StreamEntry]\nXClaimResponse = StreamRangeResponse | list[bytes | str]\nXPendingRangeEntry = dict[str, bytes | str | int]\nXPendingRangeResponse = list[XPendingRangeEntry]\nXReadResponse = list[list[Any]] | dict[bytes | str, list[StreamRangeResponse]]\nBlockingZSetPopResponse = (\n    tuple[bytes | str, bytes | str, float] | list[bytes | str | float] | None\n)\nZMPopResponse = list[bytes | str | list[list[Any]]] | None\nZRandMemberResponse = (\n    bytes | str | None | list[bytes | str] | list[bytes | str | float] | list[list[Any]]\n)\nZSetScoredMembers = list[tuple[bytes | str, Any]] | list[list[Any]]\nZSetRangeResponse = list[bytes | str] | ZSetScoredMembers\nZScanResponse = tuple[int, list[tuple[bytes | str, float]]]\nLCSMatch = list[int | tuple[int, int]]\nLCSResult = dict[str, int | list[LCSMatch]]\nStralgoResponse = str | int | LCSResult\n\n# Mapping is not covariant in the key type, which prevents\n# Mapping[_StringLikeT, X] from accepting arguments of type Dict[str, X]. Using\n# a TypeVar instead of a Union allows mappings with any of the permitted types\n# to be passed. Care is needed if there is more than one such mapping in a\n# type signature because they will all be required to be the same key type.\nAnyKeyT = TypeVar(\"AnyKeyT\", bytes, str, memoryview)\nAnyFieldT = TypeVar(\"AnyFieldT\", bytes, str, memoryview)\nAnyChannelT = TypeVar(\"AnyChannelT\", bytes, str, memoryview)\n\nExceptionMappingT = Mapping[str, Union[Type[Exception], Mapping[str, Type[Exception]]]]\n\n\nclass CommandsProtocol(Protocol):\n    _event_dispatcher: \"EventDispatcherInterface\"\n\n    def execute_command(self, *args, **options) -> ResponseT: ...\n\n\nclass ClusterCommandsProtocol(CommandsProtocol):\n    encoder: \"Encoder\"\n"
  },
  {
    "path": "redis/utils.py",
    "content": "import datetime\nimport inspect\nimport logging\nimport textwrap\nimport warnings\nfrom collections.abc import Callable\nfrom contextlib import contextmanager\nfrom functools import wraps\nfrom typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, TypeVar, Union\n\nfrom redis.exceptions import DataError\nfrom redis.typing import AbsExpiryT, EncodableT, ExpiryT\n\nif TYPE_CHECKING:\n    from redis.client import Redis\n\ntry:\n    import hiredis  # noqa\n\n    # Only support Hiredis >= 3.0:\n    hiredis_version = hiredis.__version__.split(\".\")\n    HIREDIS_AVAILABLE = int(hiredis_version[0]) > 3 or (\n        int(hiredis_version[0]) == 3 and int(hiredis_version[1]) >= 2\n    )\n    if not HIREDIS_AVAILABLE:\n        raise ImportError(\"hiredis package should be >= 3.2.0\")\nexcept ImportError:\n    HIREDIS_AVAILABLE = False\n\ntry:\n    import ssl  # noqa\n\n    SSL_AVAILABLE = True\nexcept ImportError:\n    SSL_AVAILABLE = False\n\ntry:\n    import cryptography  # noqa\n\n    CRYPTOGRAPHY_AVAILABLE = True\nexcept ImportError:\n    CRYPTOGRAPHY_AVAILABLE = False\n\nfrom importlib import metadata\n\n\ndef from_url(url: str, **kwargs: Any) -> \"Redis\":\n    \"\"\"\n    Returns an active Redis client generated from the given database URL.\n\n    Will attempt to extract the database id from the path url fragment, if\n    none is provided.\n    \"\"\"\n    from redis.client import Redis\n\n    return Redis.from_url(url, **kwargs)\n\n\n@contextmanager\ndef pipeline(redis_obj):\n    p = redis_obj.pipeline()\n    yield p\n    p.execute()\n\n\ndef str_if_bytes(value: Union[str, bytes]) -> str:\n    return (\n        value.decode(\"utf-8\", errors=\"replace\") if isinstance(value, bytes) else value\n    )\n\n\ndef safe_str(value):\n    return str(str_if_bytes(value))\n\n\ndef dict_merge(*dicts: Mapping[str, Any]) -> Dict[str, Any]:\n    \"\"\"\n    Merge all provided dicts into 1 dict.\n    *dicts : `dict`\n        dictionaries to merge\n    \"\"\"\n    merged = {}\n\n    for d in dicts:\n        merged.update(d)\n\n    return merged\n\n\ndef list_keys_to_dict(key_list, callback):\n    return dict.fromkeys(key_list, callback)\n\n\ndef merge_result(command, res):\n    \"\"\"\n    Merge all items in `res` into a list.\n\n    This command is used when sending a command to multiple nodes\n    and the result from each node should be merged into a single list.\n\n    res : 'dict'\n    \"\"\"\n    result = set()\n\n    for v in res.values():\n        for value in v:\n            result.add(value)\n\n    return list(result)\n\n\ndef warn_deprecated(name, reason=\"\", version=\"\", stacklevel=2):\n    import warnings\n\n    msg = f\"Call to deprecated {name}.\"\n    if reason:\n        msg += f\" ({reason})\"\n    if version:\n        msg += f\" -- Deprecated since version {version}.\"\n    warnings.warn(msg, category=DeprecationWarning, stacklevel=stacklevel)\n\n\ndef deprecated_function(reason=\"\", version=\"\", name=None):\n    \"\"\"\n    Decorator to mark a function as deprecated.\n    \"\"\"\n\n    def decorator(func):\n        if inspect.iscoroutinefunction(func):\n            # Create async wrapper for async functions\n            @wraps(func)\n            async def async_wrapper(*args, **kwargs):\n                warn_deprecated(name or func.__name__, reason, version, stacklevel=3)\n                return await func(*args, **kwargs)\n\n            return async_wrapper\n        else:\n            # Create regular wrapper for sync functions\n            @wraps(func)\n            def wrapper(*args, **kwargs):\n                warn_deprecated(name or func.__name__, reason, version, stacklevel=3)\n                return func(*args, **kwargs)\n\n            return wrapper\n\n    return decorator\n\n\ndef warn_deprecated_arg_usage(\n    arg_name: Union[list, str],\n    function_name: str,\n    reason: str = \"\",\n    version: str = \"\",\n    stacklevel: int = 2,\n):\n    import warnings\n\n    msg = (\n        f\"Call to '{function_name}' function with deprecated\"\n        f\" usage of input argument/s '{arg_name}'.\"\n    )\n    if reason:\n        msg += f\" ({reason})\"\n    if version:\n        msg += f\" -- Deprecated since version {version}.\"\n    warnings.warn(msg, category=DeprecationWarning, stacklevel=stacklevel)\n\n\nC = TypeVar(\"C\", bound=Callable)\n\n\ndef _get_filterable_args(\n    func: Callable, args: tuple, kwargs: dict, allowed_args: Optional[List[str]] = None\n) -> dict:\n    \"\"\"\n    Extract arguments from function call that should be checked for deprecation/experimental warnings.\n    Excludes 'self' and any explicitly allowed args.\n    \"\"\"\n    arg_names = func.__code__.co_varnames[: func.__code__.co_argcount]\n    filterable_args = dict(zip(arg_names, args))\n    filterable_args.update(kwargs)\n    filterable_args.pop(\"self\", None)\n    if allowed_args:\n        for allowed_arg in allowed_args:\n            filterable_args.pop(allowed_arg, None)\n    return filterable_args\n\n\ndef deprecated_args(\n    args_to_warn: Optional[List[str]] = None,\n    allowed_args: Optional[List[str]] = None,\n    reason: str = \"\",\n    version: str = \"\",\n) -> Callable[[C], C]:\n    \"\"\"\n    Decorator to mark specified args of a function as deprecated.\n    If '*' is in args_to_warn, all arguments will be marked as deprecated.\n    \"\"\"\n    if args_to_warn is None:\n        args_to_warn = [\"*\"]\n    if allowed_args is None:\n        allowed_args = []\n\n    def _check_deprecated_args(func, filterable_args):\n        \"\"\"Check and warn about deprecated arguments.\"\"\"\n        for arg in args_to_warn:\n            if arg == \"*\" and len(filterable_args) > 0:\n                warn_deprecated_arg_usage(\n                    list(filterable_args.keys()),\n                    func.__name__,\n                    reason,\n                    version,\n                    stacklevel=5,\n                )\n            elif arg in filterable_args:\n                warn_deprecated_arg_usage(\n                    arg, func.__name__, reason, version, stacklevel=5\n                )\n\n    def decorator(func: C) -> C:\n        if inspect.iscoroutinefunction(func):\n\n            @wraps(func)\n            async def async_wrapper(*args, **kwargs):\n                filterable_args = _get_filterable_args(func, args, kwargs, allowed_args)\n                _check_deprecated_args(func, filterable_args)\n                return await func(*args, **kwargs)\n\n            return async_wrapper\n        else:\n\n            @wraps(func)\n            def wrapper(*args, **kwargs):\n                filterable_args = _get_filterable_args(func, args, kwargs, allowed_args)\n                _check_deprecated_args(func, filterable_args)\n                return func(*args, **kwargs)\n\n            return wrapper\n\n    return decorator\n\n\ndef _set_info_logger():\n    \"\"\"\n    Set up a logger that log info logs to stdout.\n    (This is used by the default push response handler)\n    \"\"\"\n    if \"push_response\" not in logging.root.manager.loggerDict.keys():\n        logger = logging.getLogger(\"push_response\")\n        logger.setLevel(logging.INFO)\n        handler = logging.StreamHandler()\n        handler.setLevel(logging.INFO)\n        logger.addHandler(handler)\n\n\ndef check_protocol_version(\n    protocol: Optional[Union[str, int]], expected_version: int = 3\n) -> bool:\n    if protocol is None:\n        return False\n    if isinstance(protocol, str):\n        try:\n            protocol = int(protocol)\n        except ValueError:\n            return False\n    return protocol == expected_version\n\n\ndef get_lib_version():\n    try:\n        libver = metadata.version(\"redis\")\n    except metadata.PackageNotFoundError:\n        libver = \"99.99.99\"\n    return libver\n\n\ndef format_error_message(host_error: str, exception: BaseException) -> str:\n    if not exception.args:\n        return f\"Error connecting to {host_error}.\"\n    elif len(exception.args) == 1:\n        return f\"Error {exception.args[0]} connecting to {host_error}.\"\n    else:\n        return (\n            f\"Error {exception.args[0]} connecting to {host_error}. \"\n            f\"{exception.args[1]}.\"\n        )\n\n\ndef compare_versions(version1: str, version2: str) -> int:\n    \"\"\"\n    Compare two versions.\n\n    :return: -1 if version1 > version2\n             0 if both versions are equal\n             1 if version1 < version2\n    \"\"\"\n\n    num_versions1 = list(map(int, version1.split(\".\")))\n    num_versions2 = list(map(int, version2.split(\".\")))\n\n    if len(num_versions1) > len(num_versions2):\n        diff = len(num_versions1) - len(num_versions2)\n        for _ in range(diff):\n            num_versions2.append(0)\n    elif len(num_versions1) < len(num_versions2):\n        diff = len(num_versions2) - len(num_versions1)\n        for _ in range(diff):\n            num_versions1.append(0)\n\n    for i, ver in enumerate(num_versions1):\n        if num_versions1[i] > num_versions2[i]:\n            return -1\n        elif num_versions1[i] < num_versions2[i]:\n            return 1\n\n    return 0\n\n\ndef ensure_string(key):\n    if isinstance(key, bytes):\n        return key.decode(\"utf-8\")\n    elif isinstance(key, str):\n        return key\n    else:\n        raise TypeError(\"Key must be either a string or bytes\")\n\n\ndef extract_expire_flags(\n    ex: Optional[ExpiryT] = None,\n    px: Optional[ExpiryT] = None,\n    exat: Optional[AbsExpiryT] = None,\n    pxat: Optional[AbsExpiryT] = None,\n) -> List[EncodableT]:\n    exp_options: list[EncodableT] = []\n    if ex is not None:\n        exp_options.append(\"EX\")\n        if isinstance(ex, datetime.timedelta):\n            exp_options.append(int(ex.total_seconds()))\n        elif isinstance(ex, int):\n            exp_options.append(ex)\n        elif isinstance(ex, str) and ex.isdigit():\n            exp_options.append(int(ex))\n        else:\n            raise DataError(\"ex must be datetime.timedelta or int\")\n    elif px is not None:\n        exp_options.append(\"PX\")\n        if isinstance(px, datetime.timedelta):\n            exp_options.append(int(px.total_seconds() * 1000))\n        elif isinstance(px, int):\n            exp_options.append(px)\n        else:\n            raise DataError(\"px must be datetime.timedelta or int\")\n    elif exat is not None:\n        if isinstance(exat, datetime.datetime):\n            exat = int(exat.timestamp())\n        exp_options.extend([\"EXAT\", exat])\n    elif pxat is not None:\n        if isinstance(pxat, datetime.datetime):\n            pxat = int(pxat.timestamp() * 1000)\n        exp_options.extend([\"PXAT\", pxat])\n\n    return exp_options\n\n\ndef truncate_text(txt, max_length=100):\n    return textwrap.shorten(\n        text=txt, width=max_length, placeholder=\"...\", break_long_words=True\n    )\n\n\ndef dummy_fail():\n    \"\"\"\n    Fake function for a Retry object if you don't need to handle each failure.\n    \"\"\"\n    pass\n\n\nasync def dummy_fail_async():\n    \"\"\"\n    Async fake function for a Retry object if you don't need to handle each failure.\n    \"\"\"\n    pass\n\n\ndef experimental(cls):\n    \"\"\"\n    Decorator to mark a class as experimental.\n    \"\"\"\n    original_init = cls.__init__\n\n    @wraps(original_init)\n    def new_init(self, *args, **kwargs):\n        warnings.warn(\n            f\"{cls.__name__} is an experimental and may change or be removed in future versions.\",\n            category=UserWarning,\n            stacklevel=2,\n        )\n        original_init(self, *args, **kwargs)\n\n    cls.__init__ = new_init\n    return cls\n\n\ndef warn_experimental(name, stacklevel=2):\n    import warnings\n\n    msg = (\n        f\"Call to experimental method {name}. \"\n        \"Be aware that the function arguments can \"\n        \"change or be removed in future versions.\"\n    )\n    warnings.warn(msg, category=UserWarning, stacklevel=stacklevel)\n\n\ndef experimental_method() -> Callable[[C], C]:\n    \"\"\"\n    Decorator to mark a function as experimental.\n    \"\"\"\n\n    def decorator(func: C) -> C:\n        if inspect.iscoroutinefunction(func):\n            # Create async wrapper for async functions\n            @wraps(func)\n            async def async_wrapper(*args, **kwargs):\n                warn_experimental(func.__name__, stacklevel=2)\n                return await func(*args, **kwargs)\n\n            return async_wrapper\n        else:\n            # Create regular wrapper for sync functions\n            @wraps(func)\n            def wrapper(*args, **kwargs):\n                warn_experimental(func.__name__, stacklevel=2)\n                return func(*args, **kwargs)\n\n            return wrapper\n\n    return decorator\n\n\ndef warn_experimental_arg_usage(\n    arg_name: Union[list, str],\n    function_name: str,\n    stacklevel: int = 2,\n):\n    import warnings\n\n    msg = (\n        f\"Call to '{function_name}' method with experimental\"\n        f\" usage of input argument/s '{arg_name}'.\"\n    )\n    warnings.warn(msg, category=UserWarning, stacklevel=stacklevel)\n\n\ndef experimental_args(\n    args_to_warn: Optional[List[str]] = None,\n) -> Callable[[C], C]:\n    \"\"\"\n    Decorator to mark specified args of a function as experimental.\n    If '*' is in args_to_warn, all arguments will be marked as experimental.\n    \"\"\"\n    if args_to_warn is None:\n        args_to_warn = [\"*\"]\n\n    def _check_experimental_args(func, filterable_args):\n        \"\"\"Check and warn about experimental arguments.\"\"\"\n        for arg in args_to_warn:\n            if arg == \"*\" and len(filterable_args) > 0:\n                warn_experimental_arg_usage(\n                    list(filterable_args.keys()), func.__name__, stacklevel=4\n                )\n            elif arg in filterable_args:\n                warn_experimental_arg_usage(arg, func.__name__, stacklevel=4)\n\n    def decorator(func: C) -> C:\n        if inspect.iscoroutinefunction(func):\n\n            @wraps(func)\n            async def async_wrapper(*args, **kwargs):\n                filterable_args = _get_filterable_args(func, args, kwargs)\n                if len(filterable_args) > 0:\n                    _check_experimental_args(func, filterable_args)\n                return await func(*args, **kwargs)\n\n            return async_wrapper\n        else:\n\n            @wraps(func)\n            def wrapper(*args, **kwargs):\n                filterable_args = _get_filterable_args(func, args, kwargs)\n                if len(filterable_args) > 0:\n                    _check_experimental_args(func, filterable_args)\n                return func(*args, **kwargs)\n\n            return wrapper\n\n    return decorator\n"
  },
  {
    "path": "specs/commands_overload_inventory.md",
    "content": "# Redis-py Commands Overload Inventory\n\nThis document catalogs all command methods that need `@overload` signatures for type-safe sync/async support.\n\n## Legend\n\n- **Defined Return**: The actual return type annotation in the current code\n- **Assumed Sync/Async Types**: Inferred types based on Redis documentation - **⚠️ REQUIRES MANUAL VERIFICATION**\n- **Status**:\n  - ✅ **Standard** - Can use overload pattern directly\n  - ⚠️ **Separate Async** - Has separate async implementation (needs review)\n  - 🔄 **Iterator** - Returns iterator/async iterator (cannot use simple overload)\n  - ❌ **Dunder** - Dunder method that cannot be async (raises TypeError in async)\n  - 📋 **Explicit** - Return type already explicitly defined (not ResponseT)\n\n---\n\n## Summary Statistics\n\n| Category | Count |\n|----------|-------|\n| **Total Methods** | **530** |\n| ✅ Standard (can use overload) | ~495 |\n| ⚠️ Separate Async Implementation | ~25 |\n| 🔄 Iterator Methods | 5 |\n| ❌ Dunder Methods (async N/A) | 4 |\n\n---\n\n## Core Commands (`redis/commands/core.py`)\n\n### ACLCommands\n\n| # | Method | Defined Return | Assumed Sync | Assumed Async | Status | Implementation |\n|---|--------|----------------|--------------|---------------|--------|----------------|\n| 1 | `acl_cat` | `ResponseT` | `list[str]` (RESP2) / `list[bytes \\| str]` (RESP3) | `Awaitable[...]` | ✅ RESP2: str_if_bytes / RESP3: no callback (raw) | ✅ Done |\n| 2 | `acl_dryrun` | *(none)* | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ No callback - depends on decode_responses | ✅ Done |\n| 3 | `acl_deluser` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 4 | `acl_genpass` | `ResponseT` | `str` (RESP2) / `bytes \\| str` (RESP3) | `Awaitable[...]` | ✅ RESP2: str_if_bytes / RESP3: no callback (raw) | ✅ Done |\n| 5 | `acl_getuser` | `ResponseT` | `dict[str, str \\| list[str] \\| list[list[str]] \\| list[dict[str, str]]] \\| None` | `Awaitable[...]` | ✅ Base: parse_acl_getuser (str keys/values) | ✅ Done |\n| 6 | `acl_help` | `ResponseT` | `list[str]` (RESP2) / `list[bytes \\| str]` (RESP3) | `Awaitable[...]` | ✅ RESP2: str_if_bytes / RESP3: no callback (raw) | ✅ Done |\n| 7 | `acl_list` | `ResponseT` | `list[str]` (RESP2) / `list[bytes \\| str]` (RESP3) | `Awaitable[...]` | ✅ RESP2: str_if_bytes / RESP3: no callback (raw) | ✅ Done |\n| 8 | `acl_log` | `ResponseT` | `list[dict[str, str \\| float \\| dict[str, str \\| int]]]` | `Awaitable[...]` | ✅ Base: parse_acl_log / RESP3: lambda (all str keys/values) | ✅ Done |\n| 9 | `acl_log_reset` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Via acl_log with RESET → bool_ok | ✅ Done |\n| 10 | `acl_load` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | ✅ Done |\n| 11 | `acl_save` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | ✅ Done |\n| 12 | `acl_setuser` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | ✅ Done |\n| 13 | `acl_users` | `ResponseT` | `list[str]` (RESP2) / `list[bytes \\| str]` (RESP3) | `Awaitable[...]` | ✅ RESP2: str_if_bytes / RESP3: no callback (raw) | ✅ Done |\n| 14 | `acl_whoami` | `ResponseT` | `str` (RESP2) / `bytes \\| str` (RESP3) | `Awaitable[...]` | ✅ RESP2: str_if_bytes / RESP3: no callback (raw) | ✅ Done |\n\n### ManagementCommands\n\n| # | Method | Defined Return | Assumed Sync | Assumed Async | Status | Implementation |\n|---|--------|----------------|--------------|---------------|--------|----------------|\n| 15 | `auth` | *(none)* | `bool` | `Awaitable[bool]` | ✅ Base: bool | ✅ Done |\n| 16 | `bgrewriteaof` | *(none)* | `bool` (RESP2) / `bytes \\| str` (RESP3) | `Awaitable[...]` | ✅ RESP2: lambda r: True / RESP3: no callback | ✅ Done |\n| 17 | `bgsave` | `ResponseT` | `bool` (RESP2) / `bytes \\| str` (RESP3) | `Awaitable[...]` | ✅ RESP2: lambda r: True / RESP3: no callback | ✅ Done |\n| 18 | `role` | `ResponseT` | `list[Any]` | `Awaitable[list[Any]]` | ✅ No callback - mixed types | ✅ Done |\n| 19 | `client_kill` | `ResponseT` | `bool \\| int` | `Awaitable[bool \\| int]` | ✅ Base: parse_client_kill | ✅ Done |\n| 20 | `client_kill_filter` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 21 | `client_info` | `ResponseT` | `dict[str, str \\| int]` | `Awaitable[dict[str, str \\| int]]` | ✅ Base: parse_client_info - mixed str/int values | ✅ Done |\n| 22 | `client_list` | `ResponseT` | `list[dict[str, str]]` | `Awaitable[list[dict[str, str]]]` | ✅ Base: parse_client_list - str keys/values | ✅ Done |\n| 23 | `client_getname` | `ResponseT` | `str \\| None` (RESP2) / `bytes \\| str \\| None` (RESP3) | `Awaitable[...]` | ✅ RESP2: str_if_bytes / RESP3: no callback | ✅ Done |\n| 24 | `client_getredir` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 25 | `client_reply` | `ResponseT` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ No callback - depends on decode_responses | ✅ Done |\n| 26 | `client_id` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 27 | `client_tracking_on` | `ResponseT` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ No callback - returns raw OK | ✅ Done |\n| 28 | `client_tracking_off` | `ResponseT` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ No callback - returns raw OK | ✅ Done |\n| 29 | `client_tracking` | `ResponseT` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ No callback - returns raw OK | ✅ Done |\n| 30 | `client_trackinginfo` | `ResponseT` | `list[str]` (RESP2) / `list[bytes \\| str]` (RESP3) | `Awaitable[...]` | ✅ RESP2: str_if_bytes / RESP3: no callback | ✅ Done |\n| 31 | `client_setname` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | ✅ Done |\n| 32 | `client_setinfo` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | ✅ Done |\n| 33 | `client_unblock` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool (converts int to bool) | ✅ Done |\n| 34 | `client_pause` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | ✅ Done |\n| 35 | `client_unpause` | `ResponseT` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ No callback - returns raw OK | ✅ Done |\n| 36 | `client_no_evict` | `Union[Awaitable[str], str]` | `bytes \\| str` | `Awaitable[bytes \\| str]` | 📋 No callback - depends on decode_responses | ✅ Done |\n| 37 | `client_no_touch` | `Union[Awaitable[str], str]` | `bytes \\| str` | `Awaitable[bytes \\| str]` | 📋 No callback - depends on decode_responses | ✅ Done |\n| 38 | `command` | *(none)* | `dict[str, dict[str, Any]]` | `Awaitable[dict[str, dict[str, Any]]]` | ✅ Base: parse_command / RESP3: parse_command_resp3 | ✅ Done |\n| 39 | `command_info` | `None` | `None` | `None` | ⚠️ Separate Async | ⏭️ N/A |\n| 40 | `command_count` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 41 | `command_list` | `ResponseT` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ No callback - depends on decode_responses | ✅ Done |\n| 42 | `command_getkeysandflags` | `List[Union[str, List[str]]]` | `list[list[bytes \\| str \\| list[bytes \\| str]]]` | `Awaitable[list[list[bytes \\| str \\| list[bytes \\| str]]]]` | ✅ No callback - mixed [key, flags] shape | ✅ Done |\n| 43 | `command_docs` | *(none)* | `dict` | `Awaitable[dict]` | ✅ Intentionally unsupported in `core.py` | ⏭️ N/A (NotImplementedError) |\n| 44 | `config_get` | `ResponseT` | `dict[str \\| None, str \\| None]` | `Awaitable[dict[str \\| None, str \\| None]]` | ✅ RESP2: parse_config_get / RESP3: lambda | ✅ Done |\n| 45 | `config_set` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | ✅ Done |\n| 46 | `config_resetstat` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | ✅ Done |\n| 47 | `config_rewrite` | `ResponseT` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ No callback - returns raw OK | ✅ Done |\n| 48 | `dbsize` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 49 | `debug_object` | `ResponseT` | `dict[str, str \\| int]` (RESP2) / `bytes \\| str` (RESP3) | `Awaitable[...]` | ✅ RESP2: parse_debug_object / RESP3: no callback | ✅ Done |\n| 50 | `debug_segfault` | `None` | `None` | `None` | ⚠️ Separate Async | ⏭️ N/A |\n| 51 | `echo` | `ResponseT` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ No callback - depends on decode_responses | ✅ Done |\n| 52 | `flushall` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | ✅ Done |\n| 53 | `flushdb` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | ✅ Done |\n| 54 | `sync` | `ResponseT` | `bytes` | `Awaitable[bytes]` | ✅ No callback - always bytes (RDB dump) | ✅ Done |\n| 55 | `psync` | *(none)* | `bytes` | `Awaitable[bytes]` | ✅ No callback - always bytes (RDB dump) | ✅ Done |\n| 56 | `swapdb` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | ✅ Done |\n| 57 | `select` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | ✅ Done |\n| 58 | `info` | `ResponseT` | `dict[str, Any]` | `Awaitable[dict[str, Any]]` | ✅ Base: parse_info - str keys | ✅ Done |\n| 59 | `lastsave` | `ResponseT` | `datetime` | `Awaitable[datetime]` | ✅ Base: timestamp_to_datetime | ✅ Done |\n| 60 | `latency_doctor` | *(none)* | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ Intentionally unsupported in `core.py` | ⏭️ N/A (NotImplementedError) |\n| 61 | `latency_graph` | *(none)* | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ Intentionally unsupported in `core.py` | ⏭️ N/A (NotImplementedError) |\n| 62 | `lolwut` | `ResponseT` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ No callback - depends on decode_responses | ✅ Done |\n| 63 | `reset` | `ResponseT` | `str` (RESP2) / `bytes \\| str` (RESP3) | `Awaitable[...]` | ✅ RESP2: str_if_bytes / RESP3: no callback | ✅ Done |\n| 64 | `migrate` | `ResponseT` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ No callback - returns raw `OK` / `NOKEY` | ✅ Done |\n| 65 | `object` | `ResponseT` | `Any` | `Awaitable[Any]` | ✅ Varies by subcommand | ✅ Done |\n| 66 | `memory_doctor` | `None` | `None` | `None` | ⚠️ Separate Async | ⏭️ N/A |\n| 67 | `memory_help` | `None` | `None` | `None` | ⚠️ Separate Async | ⏭️ N/A |\n| 68 | `memory_stats` | `ResponseT` | `dict[str, Any]` | `Awaitable[dict[str, Any]]` | ✅ RESP2: parse_memory_stats / RESP3: lambda (str keys) | ✅ Done |\n| 69 | `memory_malloc_stats` | `ResponseT` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ No callback - depends on decode_responses | ✅ Done |\n| 70 | `memory_usage` | `ResponseT` | `int \\| None` | `Awaitable[int \\| None]` | ✅ Integer reply or None - no callback | ✅ Done |\n| 71 | `memory_purge` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | ✅ Done |\n| 72 | `latency_histogram` | *(none)* | `dict` | `Awaitable[dict]` | ✅ Intentionally unsupported in `core.py` | ⏭️ N/A (NotImplementedError) |\n| 73 | `latency_history` | `ResponseT` | `list[list[int]]` | `Awaitable[list[list[int]]]` | ✅ Raw array reply - no callback | ✅ Done |\n| 74 | `latency_latest` | `ResponseT` | `list[list[bytes \\| str \\| int]]` | `Awaitable[list[list[bytes \\| str \\| int]]]` | ✅ Raw array reply - no callback | ✅ Done |\n| 75 | `latency_reset` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 76 | `ping` | `Union[Awaitable[bool], bool]` | `bool` | `Awaitable[bool]` | 📋 Base: lambda (str_if_bytes == \"PONG\") | ✅ Done |\n| 77 | `quit` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | ✅ Done |\n| 78 | `replicaof` | `ResponseT` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ No callback - returns raw OK | ✅ Done |\n| 79 | `save` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | ✅ Done |\n| 80 | `shutdown` | `None` | `None` | `None` | ⚠️ Separate Async | ⏭️ N/A |\n| 81 | `slaveof` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | ✅ Done |\n| 82 | `slowlog_get` | `ResponseT` | `list[dict[str, Any]]` | `Awaitable[list[dict[str, Any]]]` | ✅ Base: parse_slowlog_get | ✅ Done |\n| 83 | `slowlog_len` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 84 | `slowlog_reset` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | ✅ Done |\n| 85 | `time` | `ResponseT` | `tuple[int, int]` | `Awaitable[tuple[int, int]]` | ✅ Base: lambda (int(x[0]), int(x[1])) | ✅ Done |\n| 86 | `wait` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 87 | `waitaof` | `ResponseT` | `list[int]` | `Awaitable[list[int]]` | ✅ Array of integers - no callback | ✅ Done |\n| 88 | `hello` | *(none)* | `dict` | `Awaitable[dict]` | ✅ Intentionally unsupported in `core.py` | ⏭️ N/A (NotImplementedError) |\n| 89 | `failover` | *(none)* | `bool` | `Awaitable[bool]` | ✅ Intentionally unsupported in `core.py` | ⏭️ N/A (NotImplementedError) |\n| 90 | `hotkeys_start` | `Union[Awaitable[Union[str, bytes]], Union[str, bytes]]` | `bytes \\| str` | `Awaitable[bytes \\| str]` | 📋 No callback - depends on decode_responses | ✅ Done |\n| 91 | `hotkeys_stop` | `Union[Awaitable[Union[str, bytes]], Union[str, bytes]]` | `bytes \\| str` | `Awaitable[bytes \\| str]` | 📋 No callback - depends on decode_responses | ✅ Done |\n| 92 | `hotkeys_reset` | `Union[Awaitable[Union[str, bytes]], Union[str, bytes]]` | `bytes \\| str` | `Awaitable[bytes \\| str]` | 📋 No callback - depends on decode_responses | ✅ Done |\n| 93 | `hotkeys_get` | `Union[Awaitable[list[dict[...]]], list[dict[...]]]` | `list[dict[str \\| bytes, Any]]` | `Awaitable[list[dict[str \\| bytes, Any]]]` | 📋 Base callback for `HOTKEYS GET` | ✅ Done |\n\n### BasicKeyCommands\n\n| # | Method | Defined Return | Assumed Sync | Assumed Async | Status | Implementation |\n|---|--------|----------------|--------------|---------------|--------|----------------|\n| 94 | `append` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 95 | `bitcount` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 96 | `bitfield` | `BitFieldOperation` | `BitFieldOperation` | `BitFieldOperation` | 📋 Explicit - builder pattern | ✅ Done |\n| 97 | `bitfield_ro` | `ResponseT` | `list[int]` | `Awaitable[list[int]]` | ✅ Array of integers - no callback | ✅ Done |\n| 98 | `bitop` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 99 | `bitpos` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 100 | `copy` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool | ✅ Done |\n| 101 | `decrby` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 102 | `delete` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 103 | `__delitem__` | `None` | `None` | N/A | ❌ Dunder - raises TypeError in async | ⏭️ N/A |\n| 104 | `delex` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 105 | `dump` | `ResponseT` | `bytes \\| None` | `Awaitable[bytes \\| None]` | ✅ Always bytes (serialized format) - no callback | ✅ Done |\n| 106 | `exists` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 107 | `expire` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool | ✅ Done |\n| 108 | `expireat` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool | ✅ Done |\n| 109 | `expiretime` | `int` | `int` | `Awaitable[int]` | 📋 Integer reply - no callback | ✅ Done |\n| 110 | `digest_local` | `bytes \\| str` | `bytes \\| str` | `bytes \\| str` | 📋 Explicit - local computation | ✅ Done |\n| 111 | `digest` | `ResponseT` | `bytes \\| str \\| None` | `Awaitable[bytes \\| str \\| None]` | ✅ No callback - depends on decode_responses | ✅ Done |\n| 112 | `get` | `ResponseT` | `bytes \\| str \\| None` | `Awaitable[bytes \\| str \\| None]` | ✅ No callback - depends on decode_responses | ✅ Done |\n| 113 | `getdel` | `ResponseT` | `bytes \\| str \\| None` | `Awaitable[bytes \\| str \\| None]` | ✅ No callback - depends on decode_responses | ✅ Done |\n| 114 | `getex` | `ResponseT` | `bytes \\| str \\| None` | `Awaitable[bytes \\| str \\| None]` | ✅ No callback - depends on decode_responses | ✅ Done |\n| 115 | `__getitem__` | `bytes \\| str` | `bytes \\| str` | N/A | ❌ Dunder - depends on decode_responses | ⏭️ N/A |\n| 116 | `getbit` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply (0 or 1) - no callback | ✅ Done |\n| 117 | `getrange` | `ResponseT` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ No callback - depends on decode_responses | ✅ Done |\n| 118 | `getset` | `ResponseT` | `bytes \\| str \\| None` | `Awaitable[bytes \\| str \\| None]` | ✅ No callback - depends on decode_responses | ✅ Done |\n| 119 | `incrby` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 120 | `incrbyfloat` | `ResponseT` | `float` | `Awaitable[float]` | ✅ Base: float | ✅ Done |\n| 121 | `keys` | `ResponseT` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ No callback - depends on decode_responses | ✅ Done |\n| 122 | `lmove` | `ResponseT` | `bytes \\| str \\| None` | `Awaitable[bytes \\| str \\| None]` | ✅ No callback - depends on decode_responses | ✅ Done |\n| 123 | `blmove` | `ResponseT` | `bytes \\| str \\| None` | `Awaitable[bytes \\| str \\| None]` | ✅ No callback - depends on decode_responses | ✅ Done |\n| 124 | `mget` | `ResponseT` | `list[bytes \\| str \\| None]` | `Awaitable[list[bytes \\| str \\| None]]` | ✅ No callback - depends on decode_responses | ✅ Done |\n| 125 | `mset` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | ✅ Done |\n| 126 | `msetex` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 127 | `msetnx` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool | ✅ Done |\n| 128 | `move` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool | ✅ Done |\n| 129 | `persist` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool | ✅ Done |\n| 130 | `pexpire` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool | ✅ Done |\n| 131 | `pexpireat` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool | ✅ Done |\n| 132 | `pexpiretime` | `int` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 133 | `psetex` | *(none)* | `bool` | `Awaitable[bool]` | ✅ Base: bool | ✅ Done |\n| 134 | `pttl` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 135 | `hrandfield` | `ResponseT` | `bytes \\| str \\| list[bytes \\| str] \\| None` | `Awaitable[bytes \\| str \\| list[bytes \\| str] \\| None]` | ✅ No callback - depends on decode_responses | ✅ Done |\n| 136 | `randomkey` | `ResponseT` | `bytes \\| str \\| None` | `Awaitable[bytes \\| str \\| None]` | ✅ No callback - depends on decode_responses | ✅ Done |\n| 137 | `rename` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | ✅ Done |\n| 138 | `renamenx` | *(none)* | `bool` | `Awaitable[bool]` | ✅ Base: bool | ✅ Done |\n| 139 | `restore` | `ResponseT` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ No callback - returns raw OK | ✅ Done |\n| 140 | `set` | `ResponseT` | `bool \\| bytes \\| str \\| None` | `Awaitable[bool \\| bytes \\| str \\| None]` | ✅ Base: parse_set_result - bool or value with GET | ✅ Done |\n| 141 | `__setitem__` | `None` | `None` | N/A | ❌ Dunder - raises TypeError in async | ⏭️ N/A |\n| 142 | `setbit` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply (0 or 1) - no callback | ✅ Done |\n| 143 | `setex` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool | ✅ Done |\n| 144 | `setnx` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool | ✅ Done |\n| 145 | `setrange` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 146 | `stralgo` | `ResponseT` | `str \\| int \\| dict[str, int \\| list[list[int \\| tuple[int, int]]]]` | `Awaitable[...]` | ✅ RESP2: parse_stralgo / RESP3: lambda | ✅ Done |\n| 147 | `strlen` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 148 | `substr` | `ResponseT` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ No callback - depends on decode_responses | ✅ Done |\n| 149 | `touch` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 150 | `ttl` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 151 | `type` | `ResponseT` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ No callback - depends on decode_responses | ✅ Done |\n| 152 | `watch` | `bool` | `bool` | `Awaitable[bool]` | ⚠️ Base: bool_ok - Separate Async | ⏭️ N/A |\n| 153 | `unwatch` | `bool` | `bool` | `Awaitable[bool]` | ⚠️ Base: bool_ok - Separate Async | ⏭️ N/A |\n| 154 | `unlink` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 155 | `lcs` | `ResponseT` | `bytes \\| str \\| int \\| list[Any] \\| dict[Any, Any]` | `Awaitable[...]` | ✅ Raw reply varies by options and protocol | ✅ Done |\n| 156 | `__contains__` | `bool` | `bool` | N/A | ❌ Dunder - raises TypeError in async | ⏭️ N/A |\n\n### ListCommands\n\n| # | Method | Defined Return | Assumed Sync | Assumed Async | Status | Implementation |\n|---|--------|----------------|--------------|---------------|--------|----------------|\n| 157 | `blpop` | `ResponseT` | `tuple[bytes \\| str, bytes \\| str] \\| list[bytes \\| str] \\| None` | `Awaitable[...]` | ✅ RESP2: tuple / RESP3: raw list | ✅ Done |\n| 158 | `brpop` | `ResponseT` | `tuple[bytes \\| str, bytes \\| str] \\| list[bytes \\| str] \\| None` | `Awaitable[...]` | ✅ RESP2: tuple / RESP3: raw list | ✅ Done |\n| 159 | `brpoplpush` | `ResponseT` | `bytes \\| str \\| None` | `Awaitable[bytes \\| str \\| None]` | ✅ No callback - depends on decode_responses | ✅ Done |\n| 160 | `blmpop` | `ResponseT` | `list[bytes \\| str \\| list[bytes \\| str]] \\| None` | `Awaitable[...]` | ✅ No callback - nested [key, values] shape | ✅ Done |\n| 161 | `lmpop` | `ResponseT` | `list[bytes \\| str \\| list[bytes \\| str]] \\| None` | `Awaitable[...]` | ✅ No callback - nested [key, values] shape | ✅ Done |\n| 162 | `lindex` | `ResponseT` | `bytes \\| str \\| None` | `Awaitable[bytes \\| str \\| None]` | ✅ No callback - depends on decode_responses | ✅ Done |\n| 163 | `linsert` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 164 | `llen` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 165 | `lpop` | `ResponseT` | `bytes \\| str \\| list[bytes \\| str] \\| None` | `Awaitable[...]` | ✅ No callback - depends on decode_responses | ✅ Done |\n| 166 | `lpush` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 167 | `lpushx` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 168 | `lrange` | `ResponseT` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ No callback - depends on decode_responses | ✅ Done |\n| 169 | `lrem` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 170 | `lset` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | ✅ Done |\n| 171 | `ltrim` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | ✅ Done |\n| 172 | `rpop` | `ResponseT` | `bytes \\| str \\| list[bytes \\| str] \\| None` | `Awaitable[...]` | ✅ No callback - depends on decode_responses | ✅ Done |\n| 173 | `rpoplpush` | `ResponseT` | `bytes \\| str \\| None` | `Awaitable[bytes \\| str \\| None]` | ✅ No callback - depends on decode_responses | ✅ Done |\n| 174 | `rpush` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 175 | `rpushx` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 176 | `lpos` | `ResponseT` | `int \\| list[int] \\| None` | `Awaitable[int \\| list[int] \\| None]` | ✅ Integer(s) or None - no callback | ✅ Done |\n| 177 | `sort` | `ResponseT` | `list[bytes \\| str] \\| list[tuple[bytes \\| str, ...]] \\| int` | `Awaitable[...]` | ✅ Base: sort_return_tuples incl. grouped tuples | ✅ Done |\n| 178 | `sort_ro` | `ResponseT` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ No callback - depends on decode_responses | ✅ Done |\n\n### ScanCommands\n\n| # | Method | Defined Return | Assumed Sync | Assumed Async | Status | Implementation |\n|---|--------|----------------|--------------|---------------|--------|----------------|\n| 179 | `scan` | `ResponseT` | `tuple[int, list[bytes \\| str]]` | `Awaitable[tuple[int, list[bytes \\| str]]]` | ✅ Base: parse_scan - depends on decode_responses | ✅ Done |\n| 180 | `scan_iter` | `Iterator` | `Iterator[bytes \\| str]` | `AsyncIterator[bytes \\| str]` | 🔄 Iterator - separate impl | ⏭️ N/A |\n| 181 | `sscan` | `ResponseT` | `tuple[int, list[bytes \\| str]]` | `Awaitable[tuple[int, list[bytes \\| str]]]` | ✅ Base: parse_scan - depends on decode_responses | ✅ Done |\n| 182 | `sscan_iter` | `Iterator` | `Iterator[bytes \\| str]` | `AsyncIterator[bytes \\| str]` | 🔄 Iterator - separate impl | ⏭️ N/A |\n| 183 | `hscan` | `ResponseT` | `tuple[int, dict[bytes \\| str, bytes \\| str] \\| list[bytes \\| str]]` | `Awaitable[tuple[int, dict[bytes \\| str, bytes \\| str] \\| list[bytes \\| str]]]` | ✅ Base: parse_hscan - `NOVALUES` returns key list | ✅ Done |\n| 184 | `hscan_iter` | `Iterator` | `Iterator[tuple[bytes \\| str, bytes \\| str]]` | `AsyncIterator[tuple[bytes \\| str, bytes \\| str]]` | 🔄 Iterator - separate impl | ⏭️ N/A |\n| 185 | `zscan` | `ResponseT` | `tuple[int, list[tuple[bytes \\| str, float]]]` | `Awaitable[tuple[int, list[tuple[bytes \\| str, float]]]]` | ✅ Base: parse_zscan - depends on decode_responses | ✅ Done |\n| 186 | `zscan_iter` | `Iterator` | `Iterator[tuple[bytes \\| str, float]]` | `AsyncIterator[tuple[bytes \\| str, float]]` | 🔄 Iterator - separate impl | ⏭️ N/A |\n\n### SetCommands\n\n| # | Method | Defined Return | Assumed Sync | Assumed Async | Status | Implementation |\n|---|--------|----------------|--------------|---------------|--------|----------------|\n| 187 | `sadd` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 188 | `scard` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 189 | `sdiff` | `ResponseT` | `set[bytes \\| str]` | `Awaitable[set[bytes \\| str]]` | ✅ RESP2 & RESP3: lambda (set or empty set) | ✅ Done |\n| 190 | `sdiffstore` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 191 | `sinter` | `ResponseT` | `set[bytes \\| str]` | `Awaitable[set[bytes \\| str]]` | ✅ RESP2 & RESP3: lambda (set or empty set) | ✅ Done |\n| 192 | `sintercard` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 193 | `sinterstore` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 194 | `sismember` | `ResponseT` | `Literal[0] \\| Literal[1]` | `Awaitable[Literal[0] \\| Literal[1]]` | ✅ Integer reply (0 or 1) - no callback | ✅ Done |\n| 195 | `smembers` | `ResponseT` | `set[bytes \\| str]` | `Awaitable[set[bytes \\| str]]` | ✅ RESP2 & RESP3: lambda (set or empty set) | ✅ Done |\n| 196 | `smismember` | `ResponseT` | `list[Literal[0] \\| Literal[1]]` | `Awaitable[list[Literal[0] \\| Literal[1]]]` | ✅ Array of integers (0 or 1) - no callback | ✅ Done |\n| 197 | `smove` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool | ✅ Done |\n| 198 | `spop` | `ResponseT` | `bytes \\| str \\| set[bytes \\| str] \\| None` | `Awaitable[...]` | ✅ No callback - single item or set when count is used | ✅ Done |\n| 199 | `srandmember` | `ResponseT` | `bytes \\| str \\| list[bytes \\| str] \\| None` | `Awaitable[...]` | ✅ No callback - depends on `number` | ✅ Done |\n| 200 | `srem` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 201 | `sunion` | `ResponseT` | `set[bytes \\| str]` | `Awaitable[set[bytes \\| str]]` | ✅ RESP2 & RESP3: lambda (set or empty set) | ✅ Done |\n| 202 | `sunionstore` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n\n### StreamCommands\n\n| # | Method | Defined Return | Assumed Sync | Assumed Async | Status | Implementation |\n|---|--------|----------------|--------------|---------------|--------|----------------|\n| 203 | `xack` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 204 | `xackdel` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 205 | `xadd` | `ResponseT` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ No callback - stream ID depends on decode_responses | ✅ Done |\n| 206 | `xcfgset` | `ResponseT` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ No callback - returns raw OK | ✅ Done |\n| 207 | `xautoclaim` | `ResponseT` | `list[Any]` | `Awaitable[list[Any]]` | ✅ Base: parse_xautoclaim - mixed list shape / JUSTID special case | ✅ Done |\n| 208 | `xclaim` | `ResponseT` | `list[tuple[bytes \\| str \\| None, dict[bytes \\| str, bytes \\| str] \\| None]] \\| list[bytes \\| str]` | `Awaitable[...]` | ✅ Base: parse_xclaim / JUSTID returns ID list | ✅ Done |\n| 209 | `xdel` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 210 | `xdelex` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 211 | `xgroup_create` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | ✅ Done |\n| 212 | `xgroup_delconsumer` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 213 | `xgroup_destroy` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool | ✅ Done |\n| 214 | `xgroup_createconsumer` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 215 | `xgroup_setid` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | ✅ Done |\n| 216 | `xinfo_consumers` | `ResponseT` | `list[dict[str, Any]]` | `Awaitable[list[dict[str, Any]]]` | ✅ RESP2: parse_list_of_dicts / RESP3: lambda | ✅ Done |\n| 217 | `xinfo_groups` | `ResponseT` | `list[dict[str, Any]]` | `Awaitable[list[dict[str, Any]]]` | ✅ RESP2: parse_list_of_dicts / RESP3: lambda | ✅ Done |\n| 218 | `xinfo_stream` | `ResponseT` | `dict[str, Any]` | `Awaitable[dict[str, Any]]` | ✅ Base: parse_xinfo_stream | ✅ Done |\n| 219 | `xlen` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n| 220 | `xpending` | `ResponseT` | `dict[str, Any]` | `Awaitable[dict[str, Any]]` | ✅ Base: parse_xpending | ✅ Done |\n| 221 | `xpending_range` | `ResponseT` | `list[dict[str, bytes \\| str \\| int]]` | `Awaitable[list[dict[str, bytes \\| str \\| int]]]` | ✅ parse_xpending_range detail rows | ✅ Done |\n| 222 | `xrange` | `ResponseT` | `list[tuple[bytes \\| str \\| None, dict[bytes \\| str, bytes \\| str] \\| None]] \\| None` | `Awaitable[...]` | ✅ Base: parse_stream_list | ✅ Done |\n| 223 | `xread` | `ResponseT` | `list[list[Any]] \\| dict[bytes \\| str, list[list[tuple[bytes \\| str \\| None, dict[bytes \\| str, bytes \\| str] \\| None]]]]` | `Awaitable[...]` | ✅ RESP2: parse_xread / RESP3: parse_xread_resp3 | ✅ Done |\n| 224 | `xreadgroup` | `ResponseT` | `list[list[Any]] \\| dict[bytes \\| str, list[list[tuple[bytes \\| str \\| None, dict[bytes \\| str, bytes \\| str] \\| None]]]]` | `Awaitable[...]` | ✅ RESP2: parse_xread / RESP3: parse_xread_resp3 | ✅ Done |\n| 225 | `xrevrange` | `ResponseT` | `list[tuple[bytes \\| str \\| None, dict[bytes \\| str, bytes \\| str] \\| None]] \\| None` | `Awaitable[...]` | ✅ Base: parse_stream_list | ✅ Done |\n| 226 | `xtrim` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ Done |\n\n### SortedSetCommands\n\n| # | Method | Defined Return | Assumed Sync | Assumed Async | Status | Implementation |\n|---|--------|----------------|--------------|---------------|--------|----------------|\n| 227 | `zadd` | `ResponseT` | `int \\| float \\| None` | `Awaitable[int \\| float \\| None]` | ✅ RESP2: parse_zadd / RESP3: no callback - int or float with INCR | 🔲 TODO |\n| 228 | `zcard` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | 🔲 TODO |\n| 229 | `zcount` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ DONE |\n| 230 | `zdiff` | `ResponseT` | `ZSetRangeResponse` | `Awaitable[ZSetRangeResponse]` | ✅ `WITHSCORES`: RESP2 tuple pairs / RESP3 raw nested lists | ✅ DONE |\n| 231 | `zdiffstore` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ DONE |\n| 232 | `zincrby` | `ResponseT` | `float \\| None` | `Awaitable[float \\| None]` | ✅ RESP2: `float_or_none` / RESP3: raw float | ✅ DONE |\n| 233 | `zinter` | `ResponseT` | `ZSetRangeResponse` | `Awaitable[ZSetRangeResponse]` | ✅ `score_cast_func` means scored branch uses `Any` | ✅ DONE |\n| 234 | `zinterstore` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ DONE |\n| 235 | `zintercard` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ DONE |\n| 236 | `zlexcount` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ DONE |\n| 237 | `zpopmax` | `ResponseT` | `ZSetRangeResponse` | `Awaitable[ZSetRangeResponse]` | ✅ RESP2 tuple pairs / RESP3 nested lists | ✅ DONE |\n| 238 | `zpopmin` | `ResponseT` | `ZSetRangeResponse` | `Awaitable[ZSetRangeResponse]` | ✅ RESP2 tuple pairs / RESP3 nested lists | ✅ DONE |\n| 239 | `zrandmember` | `ResponseT` | `ZRandMemberResponse` | `Awaitable[ZRandMemberResponse]` | ✅ COUNT / WITHSCORES shape varies by protocol | ✅ DONE |\n| 240 | `bzpopmax` | `ResponseT` | `BlockingZSetPopResponse` | `Awaitable[BlockingZSetPopResponse]` | ✅ RESP2 tuple / RESP3 raw list / `None` | ✅ DONE |\n| 241 | `bzpopmin` | `ResponseT` | `BlockingZSetPopResponse` | `Awaitable[BlockingZSetPopResponse]` | ✅ RESP2 tuple / RESP3 raw list / `None` | ✅ DONE |\n| 242 | `zmpop` | `ResponseT` | `ZMPopResponse` | `Awaitable[ZMPopResponse]` | ✅ Raw `[key, [[member, score], ...]]` or `None` | ✅ DONE |\n| 243 | `bzmpop` | `ResponseT` | `ZMPopResponse` | `Awaitable[ZMPopResponse]` | ✅ Raw `[key, [[member, score], ...]]` or `None` | ✅ DONE |\n| 244 | `zrange` | `ResponseT` | `ZSetRangeResponse` | `Awaitable[ZSetRangeResponse]` | ✅ `score_cast_func` means scored branch uses `Any` | ✅ DONE |\n| 245 | `zrevrange` | `ResponseT` | `ZSetRangeResponse` | `Awaitable[ZSetRangeResponse]` | ✅ `score_cast_func` means scored branch uses `Any` | ✅ DONE |\n| 246 | `zrangestore` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ DONE |\n| 247 | `zrangebylex` | `ResponseT` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ No callback - raw array | ✅ DONE |\n| 248 | `zrevrangebylex` | `ResponseT` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ No callback - raw array | ✅ DONE |\n| 249 | `zrangebyscore` | `ResponseT` | `ZSetRangeResponse` | `Awaitable[ZSetRangeResponse]` | ✅ `score_cast_func` means scored branch uses `Any` | ✅ DONE |\n| 250 | `zrevrangebyscore` | `ResponseT` | `ZSetRangeResponse` | `Awaitable[ZSetRangeResponse]` | ✅ `score_cast_func` means scored branch uses `Any` | ✅ DONE |\n| 251 | `zrank` | `ResponseT` | `ZRankResponse` | `Awaitable[ZRankResponse]` | ✅ `WITHSCORE` returns `[rank, score]`, not tuple | ✅ DONE |\n| 252 | `zrem` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ DONE |\n| 253 | `zremrangebylex` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ DONE |\n| 254 | `zremrangebyrank` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ DONE |\n| 255 | `zremrangebyscore` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ DONE |\n| 256 | `zrevrank` | `ResponseT` | `ZRankResponse` | `Awaitable[ZRankResponse]` | ✅ `WITHSCORE` returns `[rank, score]`, not tuple | ✅ DONE |\n| 257 | `zscore` | `ResponseT` | `float \\| None` | `Awaitable[float \\| None]` | ✅ RESP2: `float_or_none` / RESP3: raw float | ✅ DONE |\n| 258 | `zunion` | `ResponseT` | `ZSetRangeResponse` | `Awaitable[ZSetRangeResponse]` | ✅ `score_cast_func` means scored branch uses `Any` | ✅ DONE |\n| 259 | `zunionstore` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ DONE |\n| 260 | `zmscore` | `ResponseT` | `list[float \\| None]` | `Awaitable[list[float \\| None]]` | ✅ RESP2: `parse_zmscore` / RESP3: raw float list | ✅ DONE |\n\n### HyperlogCommands\n\n| # | Method | Defined Return | Assumed Sync | Assumed Async | Status | Implementation |\n|---|--------|----------------|--------------|---------------|--------|----------------|\n| 261 | `pfadd` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply (0 or 1) - no callback | ✅ DONE |\n| 262 | `pfcount` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ DONE |\n| 263 | `pfmerge` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | ✅ DONE |\n\n### HashCommands\n\n| # | Method | Defined Return | Assumed Sync | Assumed Async | Status | Implementation |\n|---|--------|----------------|--------------|---------------|--------|----------------|\n| 264 | `hdel` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ DONE |\n| 265 | `hexists` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool | ✅ DONE |\n| 266 | `hget` | `ResponseT` | `bytes \\| str \\| None` | `Awaitable[bytes \\| str \\| None]` | ✅ No callback - depends on decode_responses | ✅ DONE |\n| 267 | `hgetall` | `ResponseT` | `dict[bytes \\| str, bytes \\| str]` | `Awaitable[dict[bytes \\| str, bytes \\| str]]` | ✅ RESP2: pairs_to_dict / RESP3: identity | ✅ DONE |\n| 268 | `hgetdel` | `ResponseT` | `list[bytes \\| str \\| None]` | `Awaitable[list[bytes \\| str \\| None]]` | ✅ No callback - one result per requested field | ✅ DONE |\n| 269 | `hgetex` | `ResponseT` | `list[bytes \\| str \\| None]` | `Awaitable[list[bytes \\| str \\| None]]` | ✅ No callback - one result per requested field | ✅ DONE |\n| 270 | `hincrby` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ DONE |\n| 271 | `hincrbyfloat` | `ResponseT` | `float` | `Awaitable[float]` | ✅ Base: float | ✅ DONE |\n| 272 | `hkeys` | `ResponseT` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ No callback - raw array | ✅ DONE |\n| 273 | `hlen` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ DONE |\n| 274 | `hset` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ DONE |\n| 275 | `hsetex` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ DONE |\n| 276 | `hsetnx` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply (0 or 1) - no callback | ✅ DONE |\n| 277 | `hmset` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool | ✅ DONE |\n| 278 | `hmget` | `ResponseT` | `list[bytes \\| str \\| None]` | `Awaitable[list[bytes \\| str \\| None]]` | ✅ No callback - raw array | ✅ DONE |\n| 279 | `hvals` | `ResponseT` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ No callback - raw array | ✅ DONE |\n| 280 | `hstrlen` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ DONE |\n| 281 | `hexpire` | `ResponseT` | `list[int]` | `Awaitable[list[int]]` | ✅ Array of integers - no callback | ✅ DONE |\n| 282 | `hpexpire` | `ResponseT` | `list[int]` | `Awaitable[list[int]]` | ✅ Array of integers - no callback | ✅ DONE |\n| 283 | `hexpireat` | `ResponseT` | `list[int]` | `Awaitable[list[int]]` | ✅ Array of integers - no callback | ✅ DONE |\n| 284 | `hpexpireat` | `ResponseT` | `list[int]` | `Awaitable[list[int]]` | ✅ Array of integers - no callback | ✅ DONE |\n| 285 | `hpersist` | `ResponseT` | `list[int]` | `Awaitable[list[int]]` | ✅ Array of integers - no callback | ✅ DONE |\n| 286 | `hexpiretime` | `ResponseT` | `list[int]` | `Awaitable[list[int]]` | ✅ Array of integers - no callback | ✅ DONE |\n| 287 | `hpexpiretime` | `ResponseT` | `list[int]` | `Awaitable[list[int]]` | ✅ Array of integers - no callback | ✅ DONE |\n| 288 | `httl` | `ResponseT` | `list[int]` | `Awaitable[list[int]]` | ✅ Array of integers - no callback | ✅ DONE |\n| 289 | `hpttl` | `ResponseT` | `list[int]` | `Awaitable[list[int]]` | ✅ Array of integers - no callback | ✅ DONE |\n\n### PubSubCommands\n\n| # | Method | Defined Return | Assumed Sync | Assumed Async | Status | Implementation |\n|---|--------|----------------|--------------|---------------|--------|----------------|\n| 290 | `publish` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ DONE |\n| 291 | `spublish` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ DONE |\n| 292 | `pubsub_channels` | `ResponseT` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ No callback - raw array | ✅ DONE |\n| 293 | `pubsub_shardchannels` | `ResponseT` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ No callback - raw array | ✅ DONE |\n| 294 | `pubsub_numpat` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ DONE |\n| 295 | `pubsub_numsub` | `ResponseT` | `list[tuple[bytes \\| str, int]]` | `Awaitable[list[tuple[bytes \\| str, int]]]` | ✅ Base: `parse_pubsub_numsub` | ✅ DONE |\n| 296 | `pubsub_shardnumsub` | `ResponseT` | `list[tuple[bytes \\| str, int]]` | `Awaitable[list[tuple[bytes \\| str, int]]]` | ✅ Base: `parse_pubsub_numsub` | ✅ DONE |\n\n### ScriptCommands\n\n| # | Method | Defined Return | Assumed Sync | Assumed Async | Status | Implementation |\n|---|--------|----------------|--------------|---------------|--------|----------------|\n| 297 | `eval` | `ResponseT` | `Any` | `Awaitable[Any]` | ✅ No callback - return depends on script | ✅ DONE |\n| 298 | `eval_ro` | `ResponseT` | `Any` | `Awaitable[Any]` | ✅ No callback - return depends on script | ✅ DONE |\n| 299 | `evalsha` | `ResponseT` | `Any` | `Awaitable[Any]` | ✅ No callback - return depends on script | ✅ DONE |\n| 300 | `evalsha_ro` | `ResponseT` | `Any` | `Awaitable[Any]` | ✅ No callback - return depends on script | ✅ DONE |\n| 301 | `script_exists` | `ResponseT` | `list[bool]` | `Awaitable[list[bool]]` | ✅ Base: lambda (list(map(bool, r))) | ✅ DONE |\n| 302 | `script_debug` | `None` | `None` | `None` | ⚠️ Separate Async | ⏭️ N/A |\n| 303 | `script_flush` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | ✅ DONE |\n| 304 | `script_kill` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | ✅ DONE |\n| 305 | `script_load` | `ResponseT` | `str` | `Awaitable[str]` | ✅ Base: str_if_bytes - always str | ✅ DONE |\n| 306 | `register_script` | `Script` | `Script` | `AsyncScript` | ⚠️ Returns different class | ⏭️ N/A |\n\n### GeoCommands\n\n| # | Method | Defined Return | Assumed Sync | Assumed Async | Status | Implementation |\n|---|--------|----------------|--------------|---------------|--------|----------------|\n| 307 | `geoadd` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ DONE |\n| 308 | `geodist` | `ResponseT` | `float \\| None` | `Awaitable[float \\| None]` | ✅ Base: float_or_none | ✅ DONE |\n| 309 | `geohash` | `ResponseT` | `list[bytes \\| str \\| None]` | `Awaitable[list[bytes \\| str \\| None]]` | ✅ RESP2: `str_if_bytes` / RESP3: raw | ✅ DONE |\n| 310 | `geopos` | `ResponseT` | `list[tuple[float, float] \\| None]` | `Awaitable[list[tuple[float, float] \\| None]]` | ✅ RESP2: float tuples / RESP3: raw | ✅ DONE |\n| 311 | `georadius` | `ResponseT` | `list[Any] \\| int` | `Awaitable[list[Any] \\| int]` | ✅ `store` / `store_dist` return int, otherwise parsed list | ✅ DONE |\n| 312 | `georadiusbymember` | `ResponseT` | `list[Any] \\| int` | `Awaitable[list[Any] \\| int]` | ✅ `store` / `store_dist` return int, otherwise parsed list | ✅ DONE |\n| 313 | `geosearch` | `ResponseT` | `list[Any]` | `Awaitable[list[Any]]` | ✅ Base: parse_geosearch_generic | ✅ DONE |\n| 314 | `geosearchstore` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ DONE |\n\n### ModuleCommands\n\n| # | Method | Defined Return | Assumed Sync | Assumed Async | Status | Implementation |\n|---|--------|----------------|--------------|---------------|--------|----------------|\n| 315 | `module_load` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool (MODULE LOAD) | ✅ DONE |\n| 316 | `module_loadex` | `ResponseT` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ No callback - raw OK reply | ✅ DONE |\n| 317 | `module_unload` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool (MODULE UNLOAD) | ✅ DONE |\n| 318 | `module_list` | `ResponseT` | `list[dict[Any, Any]]` | `Awaitable[list[dict[Any, Any]]]` | ✅ RESP2: lambda (pairs_to_dict) / RESP3: raw dict list | ✅ DONE |\n| 319 | `command_info` | `None` | `None` | `None` | ⚠️ Separate Async | ⏭️ N/A |\n| 320 | `command_count` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | ✅ DONE |\n| 321 | `command_getkeys` | `ResponseT` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ RESP2: str_if_bytes / RESP3: raw | ✅ DONE |\n| 322 | `command` | *(none)* | `dict[str, dict[str, Any]]` | `Awaitable[dict[str, dict[str, Any]]]` | ✅ Base: parse_command / RESP3: parse_command_resp3 | ✅ DONE |\n\n### ClusterCommands\n\n| # | Method | Defined Return | Assumed Sync | Assumed Async | Status | Implementation |\n|---|--------|----------------|--------------|---------------|--------|----------------|\n| 323 | `cluster` | `ResponseT` | `Any` | `Awaitable[Any]` | ✅ Generic cluster command - varies | ✅ DONE |\n| 324 | `readwrite` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | ✅ DONE |\n| 325 | `readonly` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | ✅ DONE |\n\n### FunctionCommands\n\n| # | Method | Defined Return | Assumed Sync | Assumed Async | Status | Implementation |\n|---|--------|----------------|--------------|---------------|--------|----------------|\n| 326 | `function_load` | `ResponseT` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ No callback - raw reply | ✅ DONE |\n| 327 | `function_delete` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | ✅ DONE |\n| 328 | `function_flush` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | ✅ DONE |\n| 329 | `function_list` | `ResponseT` | `list[Any]` | `Awaitable[list[Any]]` | ✅ No callback - raw protocol-dependent list shape | ✅ DONE |\n| 330 | `fcall` | `ResponseT` | `Any` | `Awaitable[Any]` | ✅ No callback - return depends on function | ✅ DONE |\n| 331 | `fcall_ro` | `ResponseT` | `Any` | `Awaitable[Any]` | ✅ No callback - return depends on function | ✅ DONE |\n| 332 | `function_dump` | `ResponseT` | `bytes` | `Awaitable[bytes]` | ✅ No callback - binary dump with NEVER_DECODE | ✅ DONE |\n| 333 | `function_restore` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | ✅ DONE |\n| 334 | `function_kill` | `ResponseT` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ No callback - raw OK reply | ✅ DONE |\n| 335 | `function_stats` | `ResponseT` | `Any` | `Awaitable[Any]` | ✅ No callback - raw protocol-dependent structure | ✅ DONE |\n\n---\n\n## Cluster Commands (`redis/commands/cluster.py`)\n\n### ClusterMultiKeyCommands\n\n| # | Method | Defined Return | Assumed Sync | Assumed Async | Status | Implementation |\n|---|--------|----------------|--------------|---------------|--------|----------------|\n| 336 | `mget_nonatomic` | `list` | `list` | `Awaitable[list]` | ⚠️ Separate Async impl | ⏭️ N/A |\n| 337 | `mset_nonatomic` | `list[bool]` | `list[bool]` | `Awaitable[list[bool]]` | ⚠️ Separate Async impl | ⏭️ N/A |\n| 338 | `exists` | `int` | `int` | `Awaitable[int]` | 📋 Explicit | 🔲 TODO |\n| 339 | `delete` | `int` | `int` | `Awaitable[int]` | 📋 Explicit | 🔲 TODO |\n| 340 | `touch` | `int` | `int` | `Awaitable[int]` | 📋 Explicit | 🔲 TODO |\n| 341 | `unlink` | `int` | `int` | `Awaitable[int]` | 📋 Explicit | 🔲 TODO |\n\n### ClusterManagementCommands\n\n| # | Method | Defined Return | Assumed Sync | Assumed Async | Status | Implementation |\n|---|--------|----------------|--------------|---------------|--------|----------------|\n| 342 | `slaveof` | `NoReturn` | `NoReturn` | `NoReturn` | 📋 Explicit (raises) | ⏭️ N/A |\n| 343 | `replicaof` | `NoReturn` | `NoReturn` | `NoReturn` | 📋 Explicit (raises) | ⏭️ N/A |\n| 344 | `swapdb` | `NoReturn` | `NoReturn` | `NoReturn` | 📋 Explicit (raises) | ⏭️ N/A |\n| 345 | `cluster_myid` | `ResponseT` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ No callback - depends on decode_responses | 🔲 TODO |\n| 346 | `cluster_addslots` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | 🔲 TODO |\n| 347 | `cluster_addslotsrange` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | 🔲 TODO |\n| 348 | `cluster_countkeysinslot` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | 🔲 TODO |\n| 349 | `cluster_count_failure_report` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | 🔲 TODO |\n| 350 | `cluster_delslots` | `list[bool]` | `list[bool]` | `Awaitable[list[bool]]` | ⚠️ Separate Async impl | ⏭️ N/A |\n| 351 | `cluster_delslotsrange` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | 🔲 TODO |\n| 352 | `cluster_failover` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | 🔲 TODO |\n| 353 | `cluster_info` | `ResponseT` | `dict[str, str]` | `Awaitable[dict[str, str]]` | ✅ Base: parse_cluster_info - str keys | 🔲 TODO |\n| 354 | `cluster_keyslot` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply - no callback | 🔲 TODO |\n| 355 | `cluster_meet` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | 🔲 TODO |\n| 356 | `cluster_nodes` | `ResponseT` | `str` | `Awaitable[str]` | ✅ Base: parse_cluster_nodes - returns str | 🔲 TODO |\n| 357 | `cluster_replicate` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | 🔲 TODO |\n| 358 | `cluster_reset` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | 🔲 TODO |\n| 359 | `cluster_save_config` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | 🔲 TODO |\n| 360 | `cluster_get_keys_in_slot` | `ResponseT` | `list[str]` (RESP2) / `list[bytes \\| str]` (RESP3) | `Awaitable[...]` | ✅ RESP2: lambda (str_if_bytes) / RESP3: no callback | 🔲 TODO |\n| 361 | `cluster_set_config_epoch` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | 🔲 TODO |\n| 362 | `cluster_setslot` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | 🔲 TODO |\n| 363 | `cluster_setslot_stable` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | 🔲 TODO |\n| 364 | `cluster_replicas` | `ResponseT` | `str` | `Awaitable[str]` | ✅ Base: parse_cluster_nodes - returns str | 🔲 TODO |\n| 365 | `cluster_slots` | `ResponseT` | `list` | `Awaitable[list]` | ✅ No callback - complex structure | 🔲 TODO |\n| 366 | `cluster_shards` | `ResponseT` | `list` | `Awaitable[list]` | ✅ No callback - complex structure | 🔲 TODO |\n| 367 | `cluster_myshardid` | `ResponseT` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ No callback - depends on decode_responses | 🔲 TODO |\n| 368 | `cluster_links` | `ResponseT` | `list` | `Awaitable[list]` | ✅ No callback - complex structure | 🔲 TODO |\n| 369 | `cluster_flushslots` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Returns OK - no specific callback | 🔲 TODO |\n| 370 | `cluster_bumpepoch` | `ResponseT` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ No callback - depends on decode_responses | 🔲 TODO |\n| 371 | `client_tracking_on` | `Union[Awaitable[bool], bool]` | `bool` | `Awaitable[bool]` | ⚠️ Separate Async impl | ⏭️ N/A |\n| 372 | `client_tracking_off` | `Union[Awaitable[bool], bool]` | `bool` | `Awaitable[bool]` | ⚠️ Separate Async impl | ⏭️ N/A |\n| 373 | `hotkeys_start` | `Union[Awaitable[...], ...]` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ⚠️ Separate Async impl | ⏭️ N/A |\n| 374 | `hotkeys_stop` | `Union[Awaitable[...], ...]` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ⚠️ Separate Async impl | ⏭️ N/A |\n| 375 | `hotkeys_reset` | `Union[Awaitable[...], ...]` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ⚠️ Separate Async impl | ⏭️ N/A |\n| 376 | `hotkeys_get` | `Union[Awaitable[...], ...]` | `list[dict]` | `Awaitable[list[dict]]` | ⚠️ Separate Async impl | ⏭️ N/A |\n\n### ClusterDataAccessCommands\n\n| # | Method | Defined Return | Assumed Sync | Assumed Async | Status | Implementation |\n|---|--------|----------------|--------------|---------------|--------|----------------|\n| 377 | `stralgo` | `ResponseT` | `str \\| int \\| dict[str, str]` | `Awaitable[...]` | ✅ RESP2: parse_stralgo / RESP3: lambda (str keys/values) | 🔲 TODO |\n| 378 | `scan_iter` | `Iterator` | `Iterator[bytes \\| str]` | `AsyncIterator[bytes \\| str]` | 🔄 Iterator - separate impl | ⏭️ N/A |\n\n---\n\n## Sentinel Commands (`redis/commands/sentinel.py`)\n\n### SentinelCommands\n\n| # | Method | Defined Return | Assumed Sync | Assumed Async | Status | Implementation |\n|---|--------|----------------|--------------|---------------|--------|----------------|\n| 379 | `sentinel` | `ResponseT` | `Any` | `Awaitable[Any]` | ⚠️ Separate Async impl | ⏭️ N/A |\n| 380 | `sentinel_get_master_addr_by_name` | `ResponseT` | `tuple[str, int] \\| None` | `Awaitable[tuple[str, int] \\| None]` | ✅ Base: parse_sentinel_get_master - str + int | 🔲 TODO |\n| 381 | `sentinel_master` | `ResponseT` | `dict[str, Any]` | `Awaitable[dict[str, Any]]` | ✅ RESP2: parse_sentinel_master / RESP3: parse_sentinel_state_resp3 | 🔲 TODO |\n| 382 | `sentinel_masters` | `ResponseT` | `dict[str, dict] \\| list[dict]` | `Awaitable[...]` | ✅ RESP2: parse_sentinel_masters / RESP3: parse_sentinel_masters_resp3 | 🔲 TODO |\n| 383 | `sentinel_monitor` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | 🔲 TODO |\n| 384 | `sentinel_remove` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | 🔲 TODO |\n| 385 | `sentinel_sentinels` | `ResponseT` | `list[dict[str, Any]]` | `Awaitable[list[dict[str, Any]]]` | ✅ RESP2: parse_sentinel_slaves_and_sentinels / RESP3: parse_sentinel_slaves_and_sentinels_resp3 | 🔲 TODO |\n| 386 | `sentinel_set` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | 🔲 TODO |\n| 387 | `sentinel_slaves` | `ResponseT` | `list[dict[str, Any]]` | `Awaitable[list[dict[str, Any]]]` | ✅ RESP2: parse_sentinel_slaves_and_sentinels / RESP3: parse_sentinel_slaves_and_sentinels_resp3 | 🔲 TODO |\n| 388 | `sentinel_reset` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | 🔲 TODO |\n| 389 | `sentinel_failover` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | 🔲 TODO |\n| 390 | `sentinel_ckquorum` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | 🔲 TODO |\n| 391 | `sentinel_flushconfig` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Base: bool_ok | 🔲 TODO |\n\n---\n\n## Search Commands (`redis/commands/search/commands.py`)\n\n### SearchCommands\n\n| # | Method | Defined Return | Assumed Sync | Assumed Async | Status | Implementation |\n|---|--------|----------------|--------------|---------------|--------|----------------|\n| 392 | `batch_indexer` | `BatchIndexer` | `BatchIndexer` | `BatchIndexer` | 📋 Explicit | 🔲 TODO |\n| 393 | `create_index` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Returns OK - module command | 🔲 TODO |\n| 394 | `alter_schema_add` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Returns OK - module command | 🔲 TODO |\n| 395 | `dropindex` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Returns OK - module command | 🔲 TODO |\n| 396 | `add_document` | *(none)* | `bool` | `Awaitable[bool]` | ✅ Returns OK - module command | 🔲 TODO |\n| 397 | `add_document_hash` | *(none)* | `bool` | `Awaitable[bool]` | ✅ Returns OK - module command | 🔲 TODO |\n| 398 | `delete_document` | *(none)* | `int` | `Awaitable[int]` | ✅ Integer reply | 🔲 TODO |\n| 399 | `load_document` | `Document` | `Document` | `Awaitable[Document]` | ⚠️ Separate Async impl | ⏭️ N/A |\n| 400 | `get` | *(none)* | `Document \\| None` | `Awaitable[Document \\| None]` | ✅ Module-specific Document type | 🔲 TODO |\n| 401 | `info` | *(none)* | `dict` | `Awaitable[dict]` | ⚠️ Separate Async impl | ⏭️ N/A |\n| 402 | `get_params_args` | `list` | `list` | `list` | 📋 Explicit (helper) | ⏭️ N/A |\n| 403 | `search` | `Result` | `Result` | `Awaitable[Result]` | ⚠️ Separate Async impl | ⏭️ N/A |\n| 404 | `hybrid_search` | `HybridResult` | `HybridResult` | `Awaitable[HybridResult]` | ⚠️ Separate Async impl | ⏭️ N/A |\n| 405 | `explain` | `ResponseT` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ No callback - depends on decode_responses | 🔲 TODO |\n| 406 | `explain_cli` | `ResponseT` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ No callback - depends on decode_responses | 🔲 TODO |\n| 407 | `aggregate` | `AggregateResult` | `AggregateResult` | `Awaitable[AggregateResult]` | ⚠️ Separate Async impl | ⏭️ N/A |\n| 408 | `profile` | *(none)* | `tuple` | `Awaitable[tuple]` | ✅ Module-specific structure | 🔲 TODO |\n| 409 | `spellcheck` | *(none)* | `dict` | `Awaitable[dict]` | ⚠️ Separate Async impl | ⏭️ N/A |\n| 410 | `dict_add` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply | 🔲 TODO |\n| 411 | `dict_del` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply | 🔲 TODO |\n| 412 | `dict_dump` | `ResponseT` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ No callback - depends on decode_responses | 🔲 TODO |\n| 413 | `config_set` | *(none)* | `bool` | `Awaitable[bool]` | ⚠️ Separate Async impl | ⏭️ N/A |\n| 414 | `config_get` | *(none)* | `dict` | `Awaitable[dict]` | ⚠️ Separate Async impl | ⏭️ N/A |\n| 415 | `tagvals` | `ResponseT` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ No callback - depends on decode_responses | 🔲 TODO |\n| 416 | `aliasadd` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Returns OK - module command | 🔲 TODO |\n| 417 | `aliasupdate` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Returns OK - module command | 🔲 TODO |\n| 418 | `aliasdel` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Returns OK - module command | 🔲 TODO |\n| 419 | `sugadd` | `int` | `int` | `Awaitable[int]` | ⚠️ Separate Async impl | ⏭️ N/A |\n| 420 | `suglen` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply | 🔲 TODO |\n| 421 | `sugdel` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Integer reply | 🔲 TODO |\n| 422 | `sugget` | *(none)* | `list` | `Awaitable[list]` | ⚠️ Separate Async impl | ⏭️ N/A |\n| 423 | `synupdate` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Returns OK - module command | 🔲 TODO |\n| 424 | `syndump` | `ResponseT` | `dict[bytes \\| str, list]` | `Awaitable[dict[bytes \\| str, list]]` | ✅ No callback - depends on decode_responses | 🔲 TODO |\n\n---\n\n## JSON Commands (`redis/commands/json/commands.py`)\n\n### JSONCommands\n\n| # | Method | Defined Return | Assumed Sync | Assumed Async | Status | Implementation |\n|---|--------|----------------|--------------|---------------|--------|----------------|\n| 425 | `arrappend` | `ResponseT` | `list[int \\| None]` | `Awaitable[list[int \\| None]]` | ✅ Module command - int array | 🔲 TODO |\n| 426 | `arrindex` | `ResponseT` | `list[int \\| None]` | `Awaitable[list[int \\| None]]` | ✅ Module command - int array | 🔲 TODO |\n| 427 | `arrinsert` | `ResponseT` | `list[int \\| None]` | `Awaitable[list[int \\| None]]` | ✅ Module command - int array | 🔲 TODO |\n| 428 | `arrlen` | `ResponseT` | `list[int \\| None]` | `Awaitable[list[int \\| None]]` | ✅ Module command - int array | 🔲 TODO |\n| 429 | `arrpop` | `ResponseT` | `list[bytes \\| str \\| None]` | `Awaitable[list[bytes \\| str \\| None]]` | ✅ Module command - depends on decode_responses | 🔲 TODO |\n| 430 | `arrtrim` | `ResponseT` | `list[int \\| None]` | `Awaitable[list[int \\| None]]` | ✅ Module command - int array | 🔲 TODO |\n| 431 | `type` | `ResponseT` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ Module command - depends on decode_responses | 🔲 TODO |\n| 432 | `resp` | `ResponseT` | `list` | `Awaitable[list]` | ✅ Module command - JSON structure | 🔲 TODO |\n| 433 | `objkeys` | `ResponseT` | `list[list[bytes \\| str] \\| None]` | `Awaitable[list[list[bytes \\| str] \\| None]]` | ✅ Module command - depends on decode_responses | 🔲 TODO |\n| 434 | `objlen` | `ResponseT` | `list[int \\| None]` | `Awaitable[list[int \\| None]]` | ✅ Module command - int array | 🔲 TODO |\n| 435 | `numincrby` | `ResponseT` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ Module command - depends on decode_responses | 🔲 TODO |\n| 436 | `nummultby` | `ResponseT` | `bytes \\| str` | `Awaitable[bytes \\| str]` | ✅ Module command - depends on decode_responses | 🔲 TODO |\n| 437 | `clear` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Module command - int reply | 🔲 TODO |\n| 438 | `delete` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Module command - int reply | 🔲 TODO |\n| 439 | `get` | `ResponseT` | `Any` | `Awaitable[Any]` | ✅ Module command - JSON parsed | 🔲 TODO |\n| 440 | `mget` | `ResponseT` | `list[Any]` | `Awaitable[list[Any]]` | ✅ Module command - JSON parsed | 🔲 TODO |\n| 441 | `set` | `ResponseT` | `bool \\| None` | `Awaitable[bool \\| None]` | ✅ Module command - OK or None | 🔲 TODO |\n| 442 | `mset` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Module command - OK | 🔲 TODO |\n| 443 | `merge` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Module command - OK | 🔲 TODO |\n| 444 | `set_file` | `Optional[str]` | `bool \\| None` | `Awaitable[bool \\| None]` | 📋 Explicit | 🔲 TODO |\n| 445 | `set_path` | `dict[str, bool]` | `dict[str, bool]` | `Awaitable[dict[str, bool]]` | 📋 Explicit | 🔲 TODO |\n| 446 | `strlen` | `ResponseT` | `list[int \\| None]` | `Awaitable[list[int \\| None]]` | ✅ Module command - int array | 🔲 TODO |\n| 447 | `toggle` | `ResponseT` | `bool \\| list[bool]` | `Awaitable[bool \\| list[bool]]` | ✅ Module command - bool | 🔲 TODO |\n| 448 | `strappend` | `ResponseT` | `int \\| list[int \\| None]` | `Awaitable[int \\| list[int \\| None]]` | ✅ Module command - int | 🔲 TODO |\n| 449 | `debug` | `ResponseT` | `int \\| list[bytes \\| str]` | `Awaitable[int \\| list[bytes \\| str]]` | ✅ Module command - mixed | 🔲 TODO |\n| 450 | `jsonget` | *(none)* | `Any` | `Awaitable[Any]` | ⚠️ Deprecated alias for get | 🔲 TODO |\n| 451 | `jsonmget` | *(none)* | `Any` | `Awaitable[Any]` | ⚠️ Deprecated alias for mget | 🔲 TODO |\n| 452 | `jsonset` | *(none)* | `Any` | `Awaitable[Any]` | ⚠️ Deprecated alias for set | 🔲 TODO |\n\n---\n\n## TimeSeries Commands (`redis/commands/timeseries/commands.py`)\n\n### TimeSeriesCommands\n\n| # | Method | Defined Return | Assumed Sync | Assumed Async | Status | Implementation |\n|---|--------|----------------|--------------|---------------|--------|----------------|\n| 453 | `create` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Module command - OK | 🔲 TODO |\n| 454 | `alter` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Module command - OK | 🔲 TODO |\n| 455 | `add` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Module command - timestamp | 🔲 TODO |\n| 456 | `madd` | `ResponseT` | `list[int]` | `Awaitable[list[int]]` | ✅ Module command - timestamps | 🔲 TODO |\n| 457 | `incrby` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Module command - timestamp | 🔲 TODO |\n| 458 | `decrby` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Module command - timestamp | 🔲 TODO |\n| 459 | `delete` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Module command - count | 🔲 TODO |\n| 460 | `createrule` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Module command - OK | 🔲 TODO |\n| 461 | `deleterule` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Module command - OK | 🔲 TODO |\n| 462 | `range` | `ResponseT` | `list[tuple[int, float]]` | `Awaitable[list[tuple[int, float]]]` | ✅ Module command - parsed samples | 🔲 TODO |\n| 463 | `revrange` | `ResponseT` | `list[tuple[int, float]]` | `Awaitable[list[tuple[int, float]]]` | ✅ Module command - parsed samples | 🔲 TODO |\n| 464 | `mrange` | `ResponseT` | `list` | `Awaitable[list]` | ✅ Module command - complex structure | 🔲 TODO |\n| 465 | `mrevrange` | `ResponseT` | `list` | `Awaitable[list]` | ✅ Module command - complex structure | 🔲 TODO |\n| 466 | `get` | `ResponseT` | `tuple[int, float] \\| list` | `Awaitable[tuple[int, float] \\| list]` | ✅ Module command - parsed sample | 🔲 TODO |\n| 467 | `mget` | `ResponseT` | `list` | `Awaitable[list]` | ✅ Module command - complex structure | 🔲 TODO |\n| 468 | `info` | `TSInfo` | `TSInfo` | `Awaitable[TSInfo]` | 📋 Explicit | 🔲 TODO |\n| 469 | `queryindex` | `ResponseT` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ No callback - depends on decode_responses | 🔲 TODO |\n\n---\n\n## Bloom Filter Commands (`redis/commands/bf/commands.py`)\n\n### BFCommands (Bloom Filter)\n\n| # | Method | Defined Return | Assumed Sync | Assumed Async | Status | Implementation |\n|---|--------|----------------|--------------|---------------|--------|----------------|\n| 470 | `create` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Module command - OK | 🔲 TODO |\n| 471 | `add` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Module command - 0 or 1 | 🔲 TODO |\n| 472 | `madd` | `ResponseT` | `list[int]` | `Awaitable[list[int]]` | ✅ Module command - 0s and 1s | 🔲 TODO |\n| 473 | `insert` | `ResponseT` | `list[int]` | `Awaitable[list[int]]` | ✅ Module command - 0s and 1s | 🔲 TODO |\n| 474 | `exists` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Module command - 0 or 1 | 🔲 TODO |\n| 475 | `mexists` | `ResponseT` | `list[int]` | `Awaitable[list[int]]` | ✅ Module command - 0s and 1s | 🔲 TODO |\n| 476 | `scandump` | `ResponseT` | `tuple[int, bytes \\| None]` | `Awaitable[tuple[int, bytes \\| None]]` | ✅ Module command - cursor + data | 🔲 TODO |\n| 477 | `loadchunk` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Module command - OK | 🔲 TODO |\n| 478 | `info` | `ResponseT` | `dict` | `Awaitable[dict]` | ✅ Module command - parsed info | 🔲 TODO |\n| 479 | `card` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Module command - int | 🔲 TODO |\n\n### CFCommands (Cuckoo Filter)\n\n| # | Method | Defined Return | Assumed Sync | Assumed Async | Status | Implementation |\n|---|--------|----------------|--------------|---------------|--------|----------------|\n| 480 | `create` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Module command - OK | 🔲 TODO |\n| 481 | `add` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Module command - 0 or 1 | 🔲 TODO |\n| 482 | `addnx` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Module command - 0 or 1 | 🔲 TODO |\n| 483 | `insert` | `ResponseT` | `list[int]` | `Awaitable[list[int]]` | ✅ Module command - 0s and 1s | 🔲 TODO |\n| 484 | `insertnx` | `ResponseT` | `list[int]` | `Awaitable[list[int]]` | ✅ Module command - 0s and 1s | 🔲 TODO |\n| 485 | `exists` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Module command - 0 or 1 | 🔲 TODO |\n| 486 | `mexists` | `ResponseT` | `list[int]` | `Awaitable[list[int]]` | ✅ Module command - 0s and 1s | 🔲 TODO |\n| 487 | `delete` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Module command - 0 or 1 | 🔲 TODO |\n| 488 | `count` | `ResponseT` | `int` | `Awaitable[int]` | ✅ Module command - int | 🔲 TODO |\n| 489 | `scandump` | `ResponseT` | `tuple[int, bytes \\| None]` | `Awaitable[tuple[int, bytes \\| None]]` | ✅ Module command - cursor + data | 🔲 TODO |\n| 490 | `loadchunk` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Module command - OK | 🔲 TODO |\n| 491 | `info` | `ResponseT` | `dict` | `Awaitable[dict]` | ✅ Module command - parsed info | 🔲 TODO |\n\n### TOPKCommands\n\n| # | Method | Defined Return | Assumed Sync | Assumed Async | Status | Implementation |\n|---|--------|----------------|--------------|---------------|--------|----------------|\n| 492 | `reserve` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Module command - OK | 🔲 TODO |\n| 493 | `add` | `ResponseT` | `list[bytes \\| str \\| None]` | `Awaitable[list[bytes \\| str \\| None]]` | ✅ Module command - depends on decode_responses | 🔲 TODO |\n| 494 | `incrby` | `ResponseT` | `list[bytes \\| str \\| None]` | `Awaitable[list[bytes \\| str \\| None]]` | ✅ Module command - depends on decode_responses | 🔲 TODO |\n| 495 | `query` | `ResponseT` | `list[int]` | `Awaitable[list[int]]` | ✅ Module command - 0s and 1s | 🔲 TODO |\n| 496 | `count` | `ResponseT` | `list[int]` | `Awaitable[list[int]]` | ✅ Module command - ints | 🔲 TODO |\n| 497 | `list` | `ResponseT` | `list[bytes \\| str]` | `Awaitable[list[bytes \\| str]]` | ✅ Module command - depends on decode_responses | 🔲 TODO |\n| 498 | `info` | `ResponseT` | `dict` | `Awaitable[dict]` | ✅ Module command - parsed info | 🔲 TODO |\n\n### TDigestCommands\n\n| # | Method | Defined Return | Assumed Sync | Assumed Async | Status | Implementation |\n|---|--------|----------------|--------------|---------------|--------|----------------|\n| 499 | `create` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Module command - OK | 🔲 TODO |\n| 500 | `reset` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Module command - OK | 🔲 TODO |\n| 501 | `add` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Module command - OK | 🔲 TODO |\n| 502 | `merge` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Module command - OK | 🔲 TODO |\n| 503 | `min` | `ResponseT` | `float` | `Awaitable[float]` | ✅ Module command - float | 🔲 TODO |\n| 504 | `max` | `ResponseT` | `float` | `Awaitable[float]` | ✅ Module command - float | 🔲 TODO |\n| 505 | `quantile` | `ResponseT` | `list[float]` | `Awaitable[list[float]]` | ✅ Module command - floats | 🔲 TODO |\n| 506 | `cdf` | `ResponseT` | `list[float]` | `Awaitable[list[float]]` | ✅ Module command - floats | 🔲 TODO |\n| 507 | `info` | `ResponseT` | `dict` | `Awaitable[dict]` | ✅ Module command - parsed info | 🔲 TODO |\n| 508 | `trimmed_mean` | `ResponseT` | `float` | `Awaitable[float]` | ✅ Module command - float | 🔲 TODO |\n| 509 | `rank` | `ResponseT` | `list[int]` | `Awaitable[list[int]]` | ✅ Module command - ints | 🔲 TODO |\n| 510 | `revrank` | `ResponseT` | `list[int]` | `Awaitable[list[int]]` | ✅ Module command - ints | 🔲 TODO |\n| 511 | `byrank` | `ResponseT` | `list[float]` | `Awaitable[list[float]]` | ✅ Module command - floats | 🔲 TODO |\n| 512 | `byrevrank` | `ResponseT` | `list[float]` | `Awaitable[list[float]]` | ✅ Module command - floats | 🔲 TODO |\n\n### CMSCommands (Count-Min Sketch)\n\n| # | Method | Defined Return | Assumed Sync | Assumed Async | Status | Implementation |\n|---|--------|----------------|--------------|---------------|--------|----------------|\n| 513 | `initbydim` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Module command - OK | 🔲 TODO |\n| 514 | `initbyprob` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Module command - OK | 🔲 TODO |\n| 515 | `incrby` | `ResponseT` | `list[int]` | `Awaitable[list[int]]` | ✅ Module command - ints | 🔲 TODO |\n| 516 | `query` | `ResponseT` | `list[int]` | `Awaitable[list[int]]` | ✅ Module command - ints | 🔲 TODO |\n| 517 | `merge` | `ResponseT` | `bool` | `Awaitable[bool]` | ✅ Module command - OK | 🔲 TODO |\n| 518 | `info` | `ResponseT` | `dict` | `Awaitable[dict]` | ✅ Module command - parsed info | 🔲 TODO |\n\n---\n\n## VectorSet Commands (`redis/commands/vectorset/commands.py`)\n\n### VectorSetCommands\n\n| # | Method | Defined Return | Assumed Sync | Assumed Async | Status | Implementation |\n|---|--------|----------------|--------------|---------------|--------|----------------|\n| 519 | `vadd` | `int` | `int` | `Awaitable[int]` | ✅ Standard | ✅ Done |\n| 520 | `vsim` | `VSimResult` | `VSimResult` | `Awaitable[VSimResult]` | 📋 Explicit | ✅ Done |\n| 521 | `vdim` | `int` | `int` | `Awaitable[int]` | ✅ Standard | ✅ Done |\n| 522 | `vcard` | `int` | `int` | `Awaitable[int]` | ✅ Standard | ✅ Done |\n| 523 | `vrem` | `int` | `int` | `Awaitable[int]` | ✅ Standard | ✅ Done |\n| 524 | `vemb` | `VEmbResult` | `VEmbResult` | `Awaitable[VEmbResult]` | 📋 Explicit | ✅ Done |\n| 525 | `vlinks` | `VLinksResult` | `VLinksResult` | `Awaitable[VLinksResult]` | 📋 Explicit | ✅ Done |\n| 526 | `vinfo` | `dict` | `dict` | `Awaitable[dict]` | ✅ Standard | ✅ Done |\n| 527 | `vsetattr` | `int` | `int` | `Awaitable[int]` | ✅ Standard | ✅ Done |\n| 528 | `vgetattr` | `VGetAttrResult` | `VGetAttrResult` | `Awaitable[VGetAttrResult]` | 📋 Explicit | ✅ Done |\n| 529 | `vrandmember` | `VRandMemberResult` | `VRandMemberResult` | `Awaitable[VRandMemberResult]` | 📋 Explicit | ✅ Done |\n| 530 | `vrange` | `list[str]` | `list[str]` | `Awaitable[list[str]]` | ✅ Standard | ✅ Done |\n\n---\n\n## Summary by Category\n\n### Methods That Can Use Standard Overload Pattern: ~495\n\nThese methods can directly use the `@overload` pattern with self-type discrimination.\n\n**Note:** The \"Assumed Sync\" and \"Assumed Async\" columns contain types inferred from Redis documentation and need **manual verification** before implementation.\n\n### Methods Requiring Separate Async Implementation: ~25\n\n| File | Method | Reason |\n|------|--------|--------|\n| `core.py` | `command_info`, `debug_segfault`, `memory_doctor`, `memory_help`, `shutdown` | Returns `None`, async just awaits |\n| `core.py` | `watch`, `unwatch` | Transaction-related, needs async await |\n| `core.py` | `script_debug` | Returns `None` |\n| `core.py` | `register_script` | Returns different class (`Script` vs `AsyncScript`) |\n| `cluster.py` | `mget_nonatomic`, `mset_nonatomic` | Complex multi-node operations |\n| `cluster.py` | `cluster_delslots`, `client_tracking_*`, `hotkeys_*` | Cluster-specific async handling |\n| `search/commands.py` | `info`, `search`, `hybrid_search`, `aggregate`, `spellcheck`, `config_*`, `sugadd`, `sugget`, `load_document` | Result parsing after await |\n| `sentinel.py` | `sentinel` | Async implementation differs |\n\n### Iterator Methods (Cannot Use Simple Overload): 5\n\n| File | Method | Notes |\n|------|--------|-------|\n| `core.py` | `scan_iter` | Returns `Iterator` / `AsyncIterator` |\n| `core.py` | `sscan_iter` | Returns `Iterator` / `AsyncIterator` |\n| `core.py` | `hscan_iter` | Returns `Iterator` / `AsyncIterator` |\n| `core.py` | `zscan_iter` | Returns `Iterator` / `AsyncIterator` |\n| `cluster.py` | `scan_iter` | Returns `Iterator` / `AsyncIterator` |\n\n### Dunder Methods (Async Raises TypeError): 4\n\n| File | Method | Notes |\n|------|--------|-------|\n| `core.py` | `__delitem__` | Cannot be async, raises `TypeError` |\n| `core.py` | `__getitem__` | Cannot be async, raises `TypeError` |\n| `core.py` | `__setitem__` | Cannot be async, raises `TypeError` |\n| `core.py` | `__contains__` | Cannot be async, raises `TypeError` |\n\n---\n\n## Verification Checklist\n\nBefore implementing overloads for any method marked \"✅ Needs verification\", verify the return type by checking:\n\n1. **Redis Documentation**: Check [redis.io/commands](https://redis.io/commands) for the official return type specification\n2. **Existing Tests**: Look at test assertions to see what types are expected\n3. **Response Callbacks**: Check if there are any response parsing callbacks that transform the raw response\n\n### Return Type Mapping Reference (Redis → Python)\n\n| Redis Type | Python Type |\n|------------|-------------|\n| Integer reply | `int` |\n| Simple string reply (\"OK\") | `bool` (usually after callback) |\n| Bulk string reply | `bytes` or `str` (depends on decode_responses) |\n| Array reply | `list` |\n| Null bulk string | `None` |\n| Map reply (RESP3) | `dict` |\n\n---\n\n## Implementation Priority\n\n### Phase 1: High-Impact Core Commands (~50 methods)\nMost commonly used commands that would benefit users immediately:\n- `get`, `set`, `delete`, `exists`, `expire`, `ttl`\n- `hget`, `hset`, `hgetall`, `hdel`\n- `lpush`, `rpush`, `lpop`, `rpop`, `lrange`\n- `sadd`, `srem`, `smembers`, `sismember`\n- `zadd`, `zrem`, `zrange`, `zscore`\n- `publish`, `subscribe`\n\n### Phase 2: Extended Core Commands (~150 methods)\nRemaining core commands in `core.py`\n\n### Phase 3: Module Commands (~100 methods)\n- Search commands\n- JSON commands\n- TimeSeries commands\n- Bloom filter commands\n- VectorSet commands\n\n### Phase 4: Cluster & Special Commands (~50 methods)\n- Cluster management\n- Sentinel commands\n- Script commands\n"
  },
  {
    "path": "specs/sync_async_deduplication_analysis.md",
    "content": "# Sync/Async Command Deduplication Analysis for redis-py\n\n## Problem Summary\n\nBased on GitHub issues [#2933](https://github.com/redis/redis-py/issues/2933), [#3169](https://github.com/redis/redis-py/issues/3169), [#2399](https://github.com/redis/redis-py/issues/2399), [#3195](https://github.com/redis/redis-py/issues/3195), and [#2897](https://github.com/redis/redis-py/issues/2897):\n\n### The Core Problem: Type Safety\n\nThe current `ResponseT = Union[Awaitable[Any], Any]` return type **breaks static analysis**:\n\n```python\n# Current approach - type checker cannot help!\ndef get(self, key: str) -> ResponseT:  # ResponseT = Union[Awaitable[Any], Any]\n    return self.execute_command(\"GET\", key)\n\n# When using async client:\nresult = await client.get(\"key\")  # IDE/mypy can't verify this needs await\nresult = client.get(\"key\")         # No warning! Will fail at runtime\n```\n\n**What we need:**\n- Single source of truth for command logic (no duplication like `search` module)\n- Async client methods typed as `-> Awaitable[X]`\n- Sync client methods typed as `-> X`\n- Static analysis tools correctly understand the types\n\n### What NOT to do (Search Module Anti-Pattern)\n\nThe search module duplicates every method:\n```python\nclass SearchCommands:\n    def search(self, query) -> Result:\n        res = self.execute_command(SEARCH_CMD, *args)\n        return self._parse_results(res)\n\nclass AsyncSearchCommands(SearchCommands):\n    async def search(self, query) -> Result:  # Complete duplication!\n        res = await self.execute_command(SEARCH_CMD, *args)\n        return self._parse_results(res)\n```\n\n---\n\n## Solution Comparison\n\n| Solution | Initial Effort | Maintenance | Type Safety | Breaking Changes | Runtime Performance |\n|----------|---------------|-------------|-------------|------------------|---------------------|\n| **1. @overload + Self-Type** | Medium | Low | ✅ Full | None | ✅ Zero overhead |\n| **2. Type Stub Files (.pyi)** | Medium | Medium | ✅ Full | None | ✅ Zero overhead |\n| **3. Generic Protocol Pattern** | Medium-High | Low | ✅ Full | Low | ✅ Zero overhead |\n| **4. Metaclass/Decorator Gen** | High | Medium | ⚠️ Partial | None | ⚠️ Import-time cost |\n\n---\n\n## Detailed Solutions\n\n### 1. @overload with Self-Type Discrimination ⭐ RECOMMENDED\n**Effort: Medium | Maintenance: Low | Type Safety: Full**\n\nUse `typing.overload` with self-type to give different return types based on which client class is using the mixin.\n\n```python\nfrom typing import TYPE_CHECKING, overload, Protocol\n\nclass SyncClient(Protocol):\n    def execute_command(self, *args, **kwargs) -> Any: ...\n\nclass AsyncClient(Protocol):\n    def execute_command(self, *args, **kwargs) -> Awaitable[Any]: ...\n\nclass HashCommands:\n    @overload\n    def hget(self: SyncClient, name: str, key: str) -> bytes | None: ...\n    @overload\n    def hget(self: AsyncClient, name: str, key: str) -> Awaitable[bytes | None]: ...\n\n    def hget(self, name: str, key: str):\n        return self.execute_command(\"HGET\", name, key)\n```\n\n**How it works:**\n- Type checker sees which protocol `self` matches\n- Returns precise type based on sync vs async client\n- Single implementation, no duplication\n- Zero runtime overhead (overloads are for type checker only)\n\n**Pros:**\n- ✅ Full type safety for both sync and async\n- ✅ Single method body - no duplication\n- ✅ No build tools or code generation\n- ✅ IDE autocompletion works correctly\n- ✅ Supported by mypy, pyright, and other type checkers\n\n**Cons:**\n- Requires adding overload signatures for each method\n- Initial effort to add overloads to ~200+ methods\n- Can be automated with a script\n\n**Performance Impact:** ✅ **Zero runtime overhead**\n- `@overload` decorators are completely ignored at runtime (they're no-ops)\n- The actual implementation method is called directly, exactly as it would be without overloads\n- No additional function calls, no wrapper functions, no type checking at runtime\n- Memory footprint is unchanged - overload signatures are not stored at runtime\n\n---\n\n### 2. Type Stub Files (.pyi)\n**Effort: Medium | Maintenance: Medium | Type Safety: Full**\n\nKeep implementation simple, use separate `.pyi` stub files for precise typing.\n\n**Implementation file (`commands/core.py`):**\n```python\nclass HashCommands:\n    def hget(self, name, key):\n        return self.execute_command(\"HGET\", name, key)\n```\n\n**Stub file for sync (`commands/core.pyi` or via overloads):**\n```python\nclass HashCommands:\n    def hget(self, name: str, key: str) -> bytes | None: ...\n```\n\n**Stub file for async (separate or conditional):**\n```python\nclass AsyncHashCommands:\n    def hget(self, name: str, key: str) -> Awaitable[bytes | None]: ...\n```\n\n**Pros:**\n- ✅ Full type safety\n- ✅ Implementation stays clean and simple\n- ✅ Type stubs are the standard Python approach\n\n**Cons:**\n- Must maintain stubs separately from implementation\n- Risk of stubs getting out of sync\n- More files to manage\n\n**Performance Impact:** ✅ **Zero runtime overhead**\n- `.pyi` stub files are **never loaded at runtime** - they exist only for type checkers\n- Python interpreter completely ignores stub files during execution\n- No impact on import time, memory usage, or method call performance\n- The implementation files remain unchanged and execute exactly as before\n\n---\n\n### 3. Generic Protocol with TypeVar Pattern\n**Effort: Medium-High | Maintenance: Low | Type Safety: Full**\n\nUse generics to parameterize the return type wrapper.\n\n```python\nfrom typing import TypeVar, Generic, Callable, Awaitable\n\nT = TypeVar('T')\nR = TypeVar('R')  # Return wrapper type\n\nclass CommandsMixin(Generic[R]):\n    execute_command: Callable[..., R]\n\n    def hget(self, name: str, key: str) -> R:\n        return self.execute_command(\"HGET\", name, key)\n\nclass Redis(CommandsMixin[bytes | None]):  # Sync: returns direct value\n    ...\n\nclass AsyncRedis(CommandsMixin[Awaitable[bytes | None]]):  # Async: returns Awaitable\n    ...\n```\n\n**Limitation:** This simple form doesn't capture the actual return type `T` inside the `Awaitable[T]`. A more sophisticated approach uses higher-kinded types or callback protocols.\n\n**Alternative with Callable:**\n```python\nfrom typing import Protocol, TypeVar, Generic\n\nT = TypeVar('T', covariant=True)\n\nclass SyncExecutor(Protocol):\n    def __call__(self, *args: Any) -> Any: ...\n\nclass AsyncExecutor(Protocol):\n    def __call__(self, *args: Any) -> Awaitable[Any]: ...\n```\n\n**Pros:**\n- Conceptually clean\n- Type-safe\n\n**Cons:**\n- Python's type system doesn't fully support higher-kinded types\n- May require complex workarounds\n- Less IDE support than overloads\n\n**Performance Impact:** ✅ **Zero runtime overhead**\n- Python's generic types use **type erasure** at runtime - `Generic[T]` adds no runtime cost\n- `TypeVar` and `Protocol` are purely static constructs, not evaluated during execution\n- Method calls go directly to the implementation with no indirection\n- No additional memory for type parameters (they don't exist at runtime)\n\n---\n\n### 4. Metaclass/Decorator-Based Generation\n**Effort: High | Maintenance: Medium | Type Safety: Partial**\n\nGenerate sync/async variants at class creation time.\n\n```python\ndef command(cmd_name: str, return_type: type):\n    def decorator(func):\n        func._cmd = cmd_name\n        func._return_type = return_type\n        return func\n    return decorator\n\nclass CommandsMeta(type):\n    def __new__(mcs, name, bases, namespace, is_async=False):\n        for attr_name, attr in namespace.items():\n            if hasattr(attr, '_cmd'):\n                if is_async:\n                    namespace[attr_name] = mcs._make_async(attr)\n                else:\n                    namespace[attr_name] = mcs._make_sync(attr)\n        return super().__new__(mcs, name, bases, namespace)\n```\n\n**Pros:**\n- Single source of truth\n- No external tools\n\n**Cons:**\n- Type checkers struggle with metaclass magic\n- Harder to debug\n- IDE support is poor\n\n**Performance Impact:** ⚠️ **Import-time overhead, potential runtime cost**\n- **Import time:** Metaclass `__new__` runs when module is imported, processing all methods\n  - For ~200 commands, this could add 10-50ms to import time\n  - Decorators are evaluated at class definition time\n- **Memory overhead:** Dynamically generated methods may create additional function objects\n- **Runtime overhead:** Depends on implementation:\n  - If decorators wrap methods: extra function call per command (~50-100ns overhead)\n  - If metaclass replaces methods directly: minimal overhead after import\n- **Debugging impact:** Stack traces may show generated code, making debugging harder\n\n---\n\n## Recommendation\n\n**Solution #1: @overload with Self-Type Discrimination**\n\n### Why this is the best approach:\n\n1. **Full type safety** - mypy/pyright understand exactly what type each client returns\n2. **No duplication** - single method body, overloads are type-only\n3. **No magic** - standard Python typing, well-documented pattern\n4. **No build tools** - works with existing Python tooling\n5. **Incremental adoption** - can migrate method by method\n\n### Implementation Strategy:\n\n1. **Define protocols** for sync and async clients:\n```python\nclass SyncCommandsProtocol(Protocol):\n    def execute_command(self, *args, **options) -> Any: ...\n\nclass AsyncCommandsProtocol(Protocol):\n    def execute_command(self, *args, **options) -> Awaitable[Any]: ...\n```\n\n2. **Add overloads** to command methods:\n```python\nclass ACLCommands:\n    @overload\n    def acl_cat(self: SyncCommandsProtocol, category: str | None = None) -> list[str]: ...\n    @overload\n    def acl_cat(self: AsyncCommandsProtocol, category: str | None = None) -> Awaitable[list[str]]: ...\n\n    def acl_cat(self, category: str | None = None):\n        pieces = [category] if category else []\n        return self.execute_command(\"ACL CAT\", *pieces)\n```\n\n3. **Automate** - write a script to generate overload signatures from existing method signatures\n\n### Migration Path:\n1. Add the protocol definitions\n2. Start with high-usage commands (get, set, hget, etc.)\n3. Gradually add overloads to remaining ~200 methods\n4. Can be done incrementally without breaking changes\n\n"
  },
  {
    "path": "tasks.py",
    "content": "# https://github.com/pyinvoke/invoke/issues/833\nimport inspect\nimport os\nimport shutil\n\nfrom invoke import run, task\n\nif not hasattr(inspect, \"getargspec\"):\n    inspect.getargspec = inspect.getfullargspec\n\n\n@task\ndef devenv(c, endpoints=\"all\"):\n    \"\"\"Brings up the test environment, by wrapping docker compose.\"\"\"\n    clean(c)\n    cmd = f\"docker compose --profile {endpoints} up -d --build\"\n    run(cmd)\n\n\n@task\ndef build_docs(c):\n    \"\"\"Generates the sphinx documentation.\"\"\"\n    run(\"pip install -r docs/requirements.txt\")\n    run(\"make -C docs html\")\n\n\n@task\ndef linters(c):\n    \"\"\"Run code linters\"\"\"\n    run(\"ruff check tests redis\")\n    run(\"ruff format --check --diff tests redis\")\n    run(\"vulture redis whitelist.py --min-confidence 80\")\n\n@task\ndef linters_fix(c):\n    \"\"\"Run code linters and fix issues\"\"\"\n    run(\"ruff check --fix tests redis\")\n    run(\"ruff format tests redis\")\n\n@task\ndef all_tests(c):\n    \"\"\"Run all linters, and tests in redis-py.\"\"\"\n    linters(c)\n    tests(c)\n\n\n@task\ndef tests(c, uvloop=False, protocol=2, profile=False):\n    \"\"\"Run the redis-py test suite against the current python.\"\"\"\n    print(\"Starting Redis tests\")\n    standalone_tests(c, uvloop=uvloop, protocol=protocol, profile=profile)\n    cluster_tests(c, uvloop=uvloop, protocol=protocol, profile=profile)\n\n\n@task\ndef standalone_tests(\n    c, uvloop=False, protocol=2, profile=False, redis_mod_url=None, extra_markers=\"\"\n):\n    \"\"\"Run tests against a standalone redis instance\"\"\"\n    profile_arg = \"--profile\" if profile else \"\"\n    redis_mod_url = f\"--redis-mod-url={redis_mod_url}\" if redis_mod_url else \"\"\n    extra_markers = f\" and {extra_markers}\" if extra_markers else \"\"\n\n    if uvloop:\n        run(\n            f\"pytest {profile_arg} --protocol={protocol} {redis_mod_url}  --ignore=tests/test_scenario --ignore=tests/test_asyncio/test_scenario --cov=./ --cov-report=xml:coverage_resp{protocol}_uvloop.xml -m 'not onlycluster{extra_markers}' --uvloop --junit-xml=standalone-resp{protocol}-uvloop-results.xml\"\n        )\n    else:\n        run(\n            f\"pytest {profile_arg} --protocol={protocol} {redis_mod_url}  --ignore=tests/test_scenario --ignore=tests/test_asyncio/test_scenario --cov=./ --cov-report=xml:coverage_resp{protocol}.xml -m 'not onlycluster{extra_markers}' --junit-xml=standalone-resp{protocol}-results.xml\"\n        )\n\n\n@task\ndef cluster_tests(c, uvloop=False, protocol=2, profile=False):\n    \"\"\"Run tests against a redis cluster\"\"\"\n    profile_arg = \"--profile\" if profile else \"\"\n    cluster_url = \"redis://localhost:16379/0\"\n    cluster_tls_url = \"rediss://localhost:27379/0\"\n    if uvloop:\n        run(\n            f\"pytest {profile_arg} --protocol={protocol}  --ignore=tests/test_scenario --ignore=tests/test_asyncio/test_scenario  --cov=./ --cov-report=xml:coverage_cluster_resp{protocol}_uvloop.xml -m 'not onlynoncluster and not redismod' --redis-url={cluster_url} --redis-ssl-url={cluster_tls_url} --junit-xml=cluster-resp{protocol}-uvloop-results.xml --uvloop\"\n        )\n    else:\n        run(\n            f\"pytest  {profile_arg} --protocol={protocol}  --ignore=tests/test_scenario --ignore=tests/test_asyncio/test_scenario  --cov=./ --cov-report=xml:coverage_cluster_resp{protocol}.xml -m 'not onlynoncluster and not redismod' --redis-url={cluster_url} --redis-ssl-url={cluster_tls_url} --junit-xml=cluster-resp{protocol}-results.xml\"\n        )\n\n\n@task\ndef clean(c):\n    \"\"\"Stop all dockers, and clean up the built binaries, if generated.\"\"\"\n    if os.path.isdir(\"build\"):\n        shutil.rmtree(\"build\")\n    if os.path.isdir(\"dist\"):\n        shutil.rmtree(\"dist\")\n    run(\"docker compose --profile all rm -s -f\")\n\n\n@task\ndef package(c):\n    \"\"\"Create the python packages\"\"\"\n    run(\"python -m build .\")\n"
  },
  {
    "path": "tests/__init__.py",
    "content": ""
  },
  {
    "path": "tests/conftest.py",
    "content": "import argparse\nimport json\nimport os\nimport random\nimport time\nfrom datetime import datetime, timezone\nfrom typing import Callable, TypeVar\nfrom unittest import mock\nfrom unittest.mock import Mock\nfrom urllib.parse import urlparse\n\nimport pytest\nimport redis\nfrom packaging.version import Version\nfrom redis import Sentinel\nfrom redis.auth.idp import IdentityProviderInterface\nfrom redis.auth.token import JWToken\nfrom redis.backoff import NoBackoff\nfrom redis.cache import (\n    CacheConfig,\n    CacheFactoryInterface,\n    CacheInterface,\n    CacheKey,\n    EvictionPolicy,\n)\nfrom redis.connection import Connection, ConnectionInterface, SSLConnection, parse_url\nfrom redis.credentials import CredentialProvider\nfrom redis.event import EventDispatcherInterface\nfrom redis.exceptions import RedisClusterException\nfrom redis.retry import Retry\nfrom tests.ssl_utils import get_tls_certificates\n\nREDIS_INFO = {}\ndefault_redis_url = \"redis://localhost:6379/0\"\ndefault_protocol = \"2\"\ndefault_redismod_url = \"redis://localhost:6479\"\n\n# default ssl client ignores verification for the purpose of testing\ndefault_redis_ssl_url = \"rediss://localhost:6666\"\ndefault_cluster_nodes = 6\n\n_DecoratedTest = TypeVar(\"_DecoratedTest\", bound=\"Callable\")\n_TestDecorator = Callable[[_DecoratedTest], _DecoratedTest]\n\n\n# Taken from python3.9\nclass BooleanOptionalAction(argparse.Action):\n    def __init__(\n        self,\n        option_strings,\n        dest,\n        default=None,\n        type=None,\n        choices=None,\n        required=False,\n        help=None,\n        metavar=None,\n    ):\n        _option_strings = []\n        for option_string in option_strings:\n            _option_strings.append(option_string)\n\n            if option_string.startswith(\"--\"):\n                option_string = \"--no-\" + option_string[2:]\n                _option_strings.append(option_string)\n\n        if help is not None and default is not None:\n            help += f\" (default: {default})\"\n\n        super().__init__(\n            option_strings=_option_strings,\n            dest=dest,\n            nargs=0,\n            default=default,\n            type=type,\n            choices=choices,\n            required=required,\n            help=help,\n            metavar=metavar,\n        )\n\n    def __call__(self, parser, namespace, values, option_string=None):\n        if option_string in self.option_strings:\n            setattr(namespace, self.dest, not option_string.startswith(\"--no-\"))\n\n    def format_usage(self):\n        return \" | \".join(self.option_strings)\n\n\n@pytest.fixture(scope=\"session\", autouse=True)\ndef enable_tracemalloc():\n    \"\"\"\n    Enable tracemalloc while tests are being executed.\n    \"\"\"\n    try:\n        import tracemalloc\n\n        tracemalloc.start()\n        yield\n        tracemalloc.stop()\n    except ImportError:\n        yield\n\n\ndef pytest_addoption(parser):\n    parser.addoption(\n        \"--redis-url\",\n        default=default_redis_url,\n        action=\"store\",\n        help=\"Redis connection string, defaults to `%(default)s`\",\n    )\n\n    parser.addoption(\n        \"--redis-mod-url\",\n        default=default_redismod_url,\n        action=\"store\",\n        help=\"Redis with modules connection string, defaults to `%(default)s`\",\n    )\n\n    parser.addoption(\n        \"--protocol\",\n        default=default_protocol,\n        action=\"store\",\n        help=\"Protocol version, defaults to `%(default)s`\",\n    )\n    parser.addoption(\n        \"--redis-ssl-url\",\n        default=default_redis_ssl_url,\n        action=\"store\",\n        help=\"Redis SSL connection string, defaults to `%(default)s`\",\n    )\n\n    parser.addoption(\n        \"--redis-cluster-nodes\",\n        default=default_cluster_nodes,\n        action=\"store\",\n        help=\"The number of cluster nodes that need to be \"\n        \"available before the test can start,\"\n        \" defaults to `%(default)s`\",\n    )\n\n    parser.addoption(\n        \"--uvloop\", action=BooleanOptionalAction, help=\"Run tests with uvloop\"\n    )\n\n    parser.addoption(\n        \"--sentinels\",\n        action=\"store\",\n        default=\"localhost:26379,localhost:26380,localhost:26381\",\n        help=\"Comma-separated list of sentinel IPs and ports\",\n    )\n    parser.addoption(\n        \"--master-service\",\n        action=\"store\",\n        default=\"redis-py-test\",\n        help=\"Name of the Redis master service that the sentinels are monitoring\",\n    )\n\n    parser.addoption(\n        \"--endpoint-name\",\n        action=\"store\",\n        default=None,\n        help=\"Name of the Redis endpoint the tests should be executed on\",\n    )\n\n    parser.addoption(\n        \"--cluster-endpoint-name\",\n        action=\"store\",\n        default=None,\n        help=\"Name of the Redis endpoint with OSS API the tests should be executed on\",\n    )\n\n\ndef _get_info(redis_url):\n    client = redis.Redis.from_url(redis_url)\n    info = client.info()\n    try:\n        client.execute_command(\"DPING\")\n        info[\"enterprise\"] = True\n    except redis.ResponseError:\n        info[\"enterprise\"] = False\n    client.connection_pool.disconnect()\n    return info\n\n\ndef pytest_sessionstart(session):\n    # during test discovery, e.g. with VS Code, we may not\n    # have a server running.\n    protocol = session.config.getoption(\"--protocol\")\n    REDIS_INFO[\"resp_version\"] = int(protocol) if protocol else None\n    redis_url = session.config.getoption(\"--redis-url\")\n    try:\n        info = _get_info(redis_url)\n        version = info[\"redis_version\"]\n        arch_bits = info[\"arch_bits\"]\n        cluster_enabled = info[\"cluster_enabled\"]\n        enterprise = info[\"enterprise\"]\n    except redis.ConnectionError:\n        # provide optimistic defaults\n        info = {}\n        version = \"10.0.0\"\n        arch_bits = 64\n        cluster_enabled = False\n        enterprise = False\n    REDIS_INFO[\"version\"] = version\n    REDIS_INFO[\"arch_bits\"] = arch_bits\n    REDIS_INFO[\"cluster_enabled\"] = cluster_enabled\n    REDIS_INFO[\"tls_cert_subdir\"] = \"cluster\" if cluster_enabled else \"standalone\"\n    REDIS_INFO[\"enterprise\"] = enterprise\n    # store REDIS_INFO in config so that it is available from \"condition strings\"\n    session.config.REDIS_INFO = REDIS_INFO\n\n    # module info\n    stack_url = session.config.getoption(\"--redis-mod-url\")\n\n    try:\n        stack_info = _get_info(stack_url)\n        REDIS_INFO[\"modules\"] = stack_info[\"modules\"]\n    except (KeyError, redis.exceptions.ConnectionError):\n        pass\n\n    if cluster_enabled:\n        cluster_nodes = session.config.getoption(\"--redis-cluster-nodes\")\n        wait_for_cluster_creation(redis_url, cluster_nodes)\n\n    use_uvloop = session.config.getoption(\"--uvloop\")\n\n    if use_uvloop:\n        try:\n            import uvloop\n\n            uvloop.install()\n        except ImportError as e:\n            raise RuntimeError(\n                \"Can not import uvloop, make sure it is installed\"\n            ) from e\n\n\ndef wait_for_cluster_creation(redis_url, cluster_nodes, timeout=60):\n    \"\"\"\n    Waits for the cluster creation to complete.\n    As soon as all :cluster_nodes: nodes become available, the cluster will be\n    considered ready.\n    :param redis_url: the cluster's url, e.g. redis://localhost:16379/0\n    :param cluster_nodes: The number of nodes in the cluster\n    :param timeout: the amount of time to wait (in seconds)\n    \"\"\"\n    now = time.monotonic()\n    end_time = now + timeout\n    client = None\n    print(f\"Waiting for {cluster_nodes} cluster nodes to become available\")\n    while now < end_time:\n        try:\n            client = redis.RedisCluster.from_url(redis_url)\n            if len(client.get_nodes()) == int(cluster_nodes):\n                print(\"All nodes are available!\")\n                break\n        except RedisClusterException:\n            pass\n        time.sleep(1)\n        now = time.monotonic()\n    if now >= end_time:\n        available_nodes = 0 if client is None else len(client.get_nodes())\n        raise RedisClusterException(\n            f\"The cluster did not become available after {timeout} seconds. \"\n            f\"Only {available_nodes} nodes out of {cluster_nodes} are available\"\n        )\n\n\ndef skip_if_server_version_lt(min_version: str) -> _TestDecorator:\n    redis_version = REDIS_INFO.get(\"version\", \"0\")\n    check = Version(redis_version) < Version(min_version)\n    return pytest.mark.skipif(check, reason=f\"Redis version required >= {min_version}\")\n\n\ndef skip_if_server_version_gte(min_version: str) -> _TestDecorator:\n    redis_version = REDIS_INFO.get(\"version\", \"0\")\n    check = Version(redis_version) >= Version(min_version)\n    return pytest.mark.skipif(check, reason=f\"Redis version required < {min_version}\")\n\n\ndef skip_unless_arch_bits(arch_bits: int) -> _TestDecorator:\n    return pytest.mark.skipif(\n        REDIS_INFO.get(\"arch_bits\", \"\") != arch_bits,\n        reason=f\"server is not {arch_bits}-bit\",\n    )\n\n\ndef skip_ifmodversion_lt(min_version: str, module_name: str):\n    try:\n        modules = REDIS_INFO[\"modules\"]\n    except KeyError:\n        return pytest.mark.skipif(True, reason=\"Redis server does not have modules\")\n    if modules == []:\n        return pytest.mark.skipif(True, reason=\"No redis modules found\")\n\n    for j in modules:\n        if module_name == j.get(\"name\"):\n            version = j.get(\"ver\")\n            mv = int(\n                \"\".join([\"%02d\" % int(segment) for segment in min_version.split(\".\")])\n            )\n            check = version < mv\n            return pytest.mark.skipif(check, reason=\"Redis module version\")\n\n    raise AttributeError(f\"No redis module named {module_name}\")\n\n\ndef skip_if_redis_enterprise() -> _TestDecorator:\n    check = REDIS_INFO.get(\"enterprise\", False) is True\n    return pytest.mark.skipif(check, reason=\"Redis enterprise\")\n\n\ndef skip_ifnot_redis_enterprise() -> _TestDecorator:\n    check = REDIS_INFO.get(\"enterprise\", False) is False\n    return pytest.mark.skipif(check, reason=\"Not running in redis enterprise\")\n\n\ndef skip_if_nocryptography() -> _TestDecorator:\n    # try:\n    #     import cryptography  # noqa\n    #\n    #     return pytest.mark.skipif(False, reason=\"Cryptography dependency found\")\n    # except ImportError:\n    # TODO: Because JWT library depends on cryptography,\n    #  now it's always true and tests should be fixed\n    return pytest.mark.skipif(True, reason=\"No cryptography dependency\")\n\n\ndef skip_if_cryptography() -> _TestDecorator:\n    try:\n        import cryptography  # noqa\n\n        return pytest.mark.skipif(True, reason=\"Cryptography dependency found\")\n    except ImportError:\n        return pytest.mark.skipif(False, reason=\"No cryptography dependency\")\n\n\ndef skip_if_resp_version(resp_version) -> _TestDecorator:\n    check = REDIS_INFO.get(\"resp_version\", None) == resp_version\n    return pytest.mark.skipif(check, reason=f\"RESP version required != {resp_version}\")\n\n\ndef skip_if_hiredis_parser() -> _TestDecorator:\n    try:\n        import hiredis  # noqa\n\n        return pytest.mark.skipif(True, reason=\"hiredis dependency found\")\n    except ImportError:\n        return pytest.mark.skipif(False, reason=\"No hiredis dependency\")\n\n\ndef _get_client(\n    cls, request, single_connection_client=True, flushdb=True, from_url=None, **kwargs\n):\n    \"\"\"\n    Helper for fixtures or tests that need a Redis client\n\n    Uses the \"--redis-url\" command line argument for connection info. Unlike\n    ConnectionPool.from_url, keyword arguments to this function override\n    values specified in the URL.\n    \"\"\"\n    if from_url is None:\n        redis_url = request.config.getoption(\"--redis-url\")\n    else:\n        redis_url = from_url\n\n    redis_tls_url = request.config.getoption(\"--redis-ssl-url\")\n\n    if \"protocol\" not in redis_url and kwargs.get(\"protocol\") is None:\n        kwargs[\"protocol\"] = request.config.getoption(\"--protocol\")\n\n    cluster_mode = REDIS_INFO[\"cluster_enabled\"]\n    ssl = kwargs.pop(\"ssl\", False)\n    if not cluster_mode:\n        url_options = parse_url(redis_url)\n        connection_class = Connection\n        if ssl:\n            connection_class = SSLConnection\n            kwargs[\"ssl_certfile\"], kwargs[\"ssl_keyfile\"], kwargs[\"ssl_ca_certs\"] = (\n                get_tls_certificates()\n            )\n            kwargs[\"ssl_cert_reqs\"] = \"required\"\n            kwargs[\"port\"] = urlparse(redis_tls_url).port\n        kwargs[\"connection_class\"] = connection_class\n        url_options.update(kwargs)\n        pool = redis.ConnectionPool(**url_options)\n        client = cls(connection_pool=pool)\n    else:\n        client = redis.RedisCluster.from_url(redis_url, **kwargs)\n        single_connection_client = False\n    if single_connection_client:\n        client = client.client()\n    if request:\n\n        def teardown():\n            if not cluster_mode:\n                if flushdb:\n                    try:\n                        client.flushdb()\n                    except redis.ConnectionError:\n                        # handle cases where a test disconnected a client\n                        # just manually retry the flushdb\n                        client.flushdb()\n                client.close()\n                client.connection_pool.disconnect()\n            else:\n                cluster_teardown(client, flushdb)\n\n        request.addfinalizer(teardown)\n    return client\n\n\ndef cluster_teardown(client, flushdb):\n    if flushdb:\n        try:\n            client.flushdb(target_nodes=\"primaries\")\n        except redis.ConnectionError:\n            # handle cases where a test disconnected a client\n            # just manually retry the flushdb\n            client.flushdb(target_nodes=\"primaries\")\n    client.close()\n    client.disconnect_connection_pools()\n\n\n@pytest.fixture()\ndef r(request):\n    with _get_client(redis.Redis, request) as client:\n        yield client\n\n\n@pytest.fixture()\ndef stack_url(request):\n    return request.config.getoption(\"--redis-mod-url\", default=default_redismod_url)\n\n\n@pytest.fixture()\ndef stack_r(request, stack_url):\n    with _get_client(redis.Redis, request, from_url=stack_url) as client:\n        yield client\n\n\n@pytest.fixture()\ndef decoded_r(request):\n    with _get_client(redis.Redis, request, decode_responses=True) as client:\n        yield client\n\n\n@pytest.fixture()\ndef r_timeout(request):\n    with _get_client(redis.Redis, request, socket_timeout=1) as client:\n        yield client\n\n\n@pytest.fixture()\ndef r2(request):\n    \"A second client for tests that need multiple\"\n    with _get_client(redis.Redis, request) as client:\n        yield client\n\n\n@pytest.fixture()\ndef sslclient(request):\n    with _get_client(redis.Redis, request, ssl=True) as client:\n        yield client\n\n\n@pytest.fixture()\ndef sentinel_setup(request):\n    sentinel_ips = request.config.getoption(\"--sentinels\")\n    sentinel_endpoints = [\n        (ip.strip(), int(port.strip()))\n        for ip, port in (endpoint.split(\":\") for endpoint in sentinel_ips.split(\",\"))\n    ]\n    kwargs = request.param.get(\"kwargs\", {}) if hasattr(request, \"param\") else {}\n    cache = request.param.get(\"cache\", None)\n    cache_config = request.param.get(\"cache_config\", None)\n    force_master_ip = request.param.get(\"force_master_ip\", None)\n    decode_responses = request.param.get(\"decode_responses\", False)\n    sentinel = Sentinel(\n        sentinel_endpoints,\n        force_master_ip=force_master_ip,\n        socket_timeout=0.1,\n        cache=cache,\n        cache_config=cache_config,\n        protocol=3,\n        decode_responses=decode_responses,\n        **kwargs,\n    )\n    yield sentinel\n    for s in sentinel.sentinels:\n        s.close()\n\n\n@pytest.fixture()\ndef master(request, sentinel_setup):\n    master_service = request.config.getoption(\"--master-service\")\n    master = sentinel_setup.master_for(master_service)\n    yield master\n    master.close()\n\n\ndef _gen_cluster_mock_resp(r, response):\n    connection = Mock(spec=Connection)\n    connection.retry = Retry(NoBackoff(), 0)\n    connection.read_response.return_value = response\n    connection.host = \"localhost\"\n    connection.port = 6379\n    connection.db = 0\n    with mock.patch.object(r, \"connection\", connection):\n        yield r\n\n\n@pytest.fixture()\ndef mock_cluster_resp_ok(request, **kwargs):\n    r = _get_client(redis.Redis, request, **kwargs)\n    yield from _gen_cluster_mock_resp(r, \"OK\")\n\n\n@pytest.fixture()\ndef mock_cluster_resp_int(request, **kwargs):\n    r = _get_client(redis.Redis, request, **kwargs)\n    yield from _gen_cluster_mock_resp(r, 2)\n\n\n@pytest.fixture()\ndef mock_cluster_resp_info(request, **kwargs):\n    r = _get_client(redis.Redis, request, **kwargs)\n    response = (\n        \"cluster_state:ok\\r\\ncluster_slots_assigned:16384\\r\\n\"\n        \"cluster_slots_ok:16384\\r\\ncluster_slots_pfail:0\\r\\n\"\n        \"cluster_slots_fail:0\\r\\ncluster_known_nodes:7\\r\\n\"\n        \"cluster_size:3\\r\\ncluster_current_epoch:7\\r\\n\"\n        \"cluster_my_epoch:2\\r\\ncluster_stats_messages_sent:170262\\r\\n\"\n        \"cluster_stats_messages_received:105653\\r\\n\"\n    )\n    yield from _gen_cluster_mock_resp(r, response)\n\n\n@pytest.fixture()\ndef mock_cluster_resp_nodes(request, **kwargs):\n    r = _get_client(redis.Redis, request, **kwargs)\n    response = (\n        \"c8253bae761cb1ecb2b61857d85dfe455a0fec8b 172.17.0.7:7006 \"\n        \"slave aa90da731f673a99617dfe930306549a09f83a6b 0 \"\n        \"1447836263059 5 connected\\n\"\n        \"9bd595fe4821a0e8d6b99d70faa660638a7612b3 172.17.0.7:7008 \"\n        \"master - 0 1447836264065 0 connected\\n\"\n        \"aa90da731f673a99617dfe930306549a09f83a6b 172.17.0.7:7003 \"\n        \"myself,master - 0 0 2 connected 5461-10922\\n\"\n        \"1df047e5a594f945d82fc140be97a1452bcbf93e 172.17.0.7:7007 \"\n        \"slave 19efe5a631f3296fdf21a5441680f893e8cc96ec 0 \"\n        \"1447836262556 3 connected\\n\"\n        \"4ad9a12e63e8f0207025eeba2354bcf4c85e5b22 172.17.0.7:7005 \"\n        \"master - 0 1447836262555 7 connected 0-5460\\n\"\n        \"19efe5a631f3296fdf21a5441680f893e8cc96ec 172.17.0.7:7004 \"\n        \"master - 0 1447836263562 3 connected 10923-16383\\n\"\n        \"fbb23ed8cfa23f17eaf27ff7d0c410492a1093d6 172.17.0.7:7002 \"\n        \"master,fail - 1447829446956 1447829444948 1 disconnected\\n\"\n    )\n    yield from _gen_cluster_mock_resp(r, response)\n\n\n@pytest.fixture()\ndef mock_cluster_resp_slaves(request, **kwargs):\n    r = _get_client(redis.Redis, request, **kwargs)\n    response = (\n        \"['1df047e5a594f945d82fc140be97a1452bcbf93e 172.17.0.7:7007 \"\n        \"slave 19efe5a631f3296fdf21a5441680f893e8cc96ec 0 \"\n        \"1447836789290 3 connected']\"\n    )\n    yield from _gen_cluster_mock_resp(r, response)\n\n\n@pytest.fixture(scope=\"session\")\ndef master_host(request):\n    url = request.config.getoption(\"--redis-url\")\n    parts = urlparse(url)\n    return parts.hostname, (parts.port or 6379)\n\n\n@pytest.fixture()\ndef cache_conf() -> CacheConfig:\n    return CacheConfig(max_size=100, eviction_policy=EvictionPolicy.LRU)\n\n\n@pytest.fixture()\ndef mock_cache_factory() -> CacheFactoryInterface:\n    mock_factory = Mock(spec=CacheFactoryInterface)\n    return mock_factory\n\n\n@pytest.fixture()\ndef mock_cache() -> CacheInterface:\n    mock_cache = Mock(spec=CacheInterface)\n    return mock_cache\n\n\n@pytest.fixture()\ndef mock_connection() -> ConnectionInterface:\n    mock_connection = Mock(spec=ConnectionInterface)\n    # Add host and port attributes needed by find_connection_owner\n    mock_connection.host = \"127.0.0.1\"\n    mock_connection.port = 6379\n    return mock_connection\n\n\n@pytest.fixture()\ndef mock_ed() -> EventDispatcherInterface:\n    mock_ed = Mock(spec=EventDispatcherInterface)\n    return mock_ed\n\n\n@pytest.fixture()\ndef cache_key(request) -> CacheKey:\n    command = request.param.get(\"command\")\n    keys = request.param.get(\"redis_keys\")\n\n    return CacheKey(command, keys)\n\n\ndef mock_identity_provider() -> IdentityProviderInterface:\n    jwt = pytest.importorskip(\"jwt\")\n    mock_provider = Mock(spec=IdentityProviderInterface)\n    token = {\"exp\": datetime.now(timezone.utc).timestamp() + 3600, \"oid\": \"username\"}\n    encoded = jwt.encode(token, \"secret\", algorithm=\"HS256\")\n    jwt_token = JWToken(encoded)\n    mock_provider.request_token.return_value = jwt_token\n    return mock_provider\n\n\ndef get_credential_provider(request) -> CredentialProvider:\n    cred_provider_class = request.param.get(\"cred_provider_class\")\n    cred_provider_kwargs = request.param.get(\"cred_provider_kwargs\", {})\n\n    # Since we can't import EntraIdCredentialsProvider in this module,\n    # we'll just check the class name.\n    if cred_provider_class.__name__ != \"EntraIdCredentialsProvider\":\n        return cred_provider_class(**cred_provider_kwargs)\n\n    from tests.entraid_utils import get_entra_id_credentials_provider\n\n    return get_entra_id_credentials_provider(request, cred_provider_kwargs)\n\n\n@pytest.fixture()\ndef credential_provider(request) -> CredentialProvider:\n    return get_credential_provider(request)\n\n\ndef get_endpoint(endpoint_name: str):\n    endpoints_config = os.getenv(\"REDIS_ENDPOINTS_CONFIG_PATH\", None)\n\n    if not (endpoints_config and os.path.exists(endpoints_config)):\n        raise FileNotFoundError(f\"Endpoints config file not found: {endpoints_config}\")\n\n    try:\n        with open(endpoints_config, \"r\") as f:\n            data = json.load(f)\n            db = data[endpoint_name]\n            return db[\"endpoints\"][0]\n    except Exception as e:\n        raise ValueError(\n            f\"Failed to load endpoints config file: {endpoints_config}\"\n        ) from e\n\n\ndef wait_for_command(client, monitor, command, key=None):\n    # issue a command with a key name that's local to this process.\n    # if we find a command with our key before the command we're waiting\n    # for, something went wrong\n    if key is None:\n        # generate key\n        redis_version = REDIS_INFO[\"version\"]\n        if Version(redis_version) >= Version(\"5.0.0\"):\n            id_str = str(client.client_id())\n        else:\n            id_str = f\"{random.randrange(2**32):08x}\"\n        key = f\"__REDIS-PY-{id_str}__\"\n    client.get(key)\n    while True:\n        monitor_response = monitor.next_command()\n        if command in monitor_response[\"command\"]:\n            return monitor_response\n        if key in monitor_response[\"command\"]:\n            return None\n\n\ndef is_resp2_connection(r):\n    if isinstance(r, redis.Redis) or isinstance(r, redis.asyncio.Redis):\n        protocol = r.connection_pool.connection_kwargs.get(\"protocol\")\n    elif isinstance(r, redis.cluster.AbstractRedisCluster):\n        protocol = r.nodes_manager.connection_kwargs.get(\"protocol\")\n    return protocol in [\"2\", 2, None]\n\n\ndef get_protocol_version(r):\n    if isinstance(r, redis.Redis) or isinstance(r, redis.asyncio.Redis):\n        return r.connection_pool.connection_kwargs.get(\"protocol\")\n    elif isinstance(r, redis.cluster.AbstractRedisCluster):\n        return r.nodes_manager.connection_kwargs.get(\"protocol\")\n\n\ndef assert_resp_response(r, response, resp2_expected, resp3_expected):\n    protocol = get_protocol_version(r)\n    if protocol in [2, \"2\", None]:\n        assert response == resp2_expected\n    else:\n        assert response == resp3_expected\n\n\ndef assert_resp_response_in(r, response, resp2_expected, resp3_expected):\n    protocol = get_protocol_version(r)\n    if protocol in [2, \"2\", None]:\n        assert response in resp2_expected\n    else:\n        assert response in resp3_expected\n"
  },
  {
    "path": "tests/entraid_utils.py",
    "content": "import os\nfrom enum import Enum\nfrom typing import Union\n\nfrom redis.auth.idp import IdentityProviderInterface\nfrom redis.auth.token_manager import RetryPolicy, TokenManagerConfig\nfrom redis_entraid.cred_provider import (\n    DEFAULT_DELAY_IN_MS,\n    DEFAULT_EXPIRATION_REFRESH_RATIO,\n    DEFAULT_LOWER_REFRESH_BOUND_MILLIS,\n    DEFAULT_MAX_ATTEMPTS,\n    DEFAULT_TOKEN_REQUEST_EXECUTION_TIMEOUT_IN_MS,\n    EntraIdCredentialsProvider,\n)\nfrom redis_entraid.identity_provider import (\n    ManagedIdentityIdType,\n    ManagedIdentityProviderConfig,\n    ManagedIdentityType,\n    ServicePrincipalIdentityProviderConfig,\n    _create_provider_from_managed_identity,\n    _create_provider_from_service_principal,\n    DefaultAzureCredentialIdentityProviderConfig,\n    _create_provider_from_default_azure_credential,\n)\nfrom tests.conftest import mock_identity_provider\n\n\nclass AuthType(Enum):\n    MANAGED_IDENTITY = \"managed_identity\"\n    SERVICE_PRINCIPAL = \"service_principal\"\n    DEFAULT_AZURE_CREDENTIAL = \"default_azure_credential\"\n\n\ndef identity_provider(request) -> IdentityProviderInterface:\n    if hasattr(request, \"param\"):\n        kwargs = request.param.get(\"idp_kwargs\", {})\n    else:\n        kwargs = {}\n\n    if request.param.get(\"mock_idp\", None) is not None:\n        return mock_identity_provider()\n\n    auth_type = kwargs.get(\"auth_type\", AuthType.SERVICE_PRINCIPAL)\n    config = get_identity_provider_config(request=request)\n\n    if auth_type == AuthType.MANAGED_IDENTITY:\n        return _create_provider_from_managed_identity(config)\n\n    if auth_type == AuthType.DEFAULT_AZURE_CREDENTIAL:\n        return _create_provider_from_default_azure_credential(config)\n\n    return _create_provider_from_service_principal(config)\n\n\ndef get_identity_provider_config(\n    request,\n) -> Union[\n    ManagedIdentityProviderConfig,\n    ServicePrincipalIdentityProviderConfig,\n    DefaultAzureCredentialIdentityProviderConfig,\n]:\n    if hasattr(request, \"param\"):\n        kwargs = request.param.get(\"idp_kwargs\", {})\n    else:\n        kwargs = {}\n\n    auth_type = kwargs.pop(\"auth_type\", AuthType.SERVICE_PRINCIPAL)\n\n    if auth_type == AuthType.MANAGED_IDENTITY:\n        return _get_managed_identity_provider_config(request)\n\n    if auth_type == AuthType.DEFAULT_AZURE_CREDENTIAL:\n        return _get_default_azure_credential_provider_config(request)\n\n    return _get_service_principal_provider_config(request)\n\n\ndef _get_managed_identity_provider_config(request) -> ManagedIdentityProviderConfig:\n    resource = os.getenv(\"AZURE_RESOURCE\")\n    id_value = os.getenv(\"AZURE_USER_ASSIGNED_MANAGED_ID\", None)\n\n    if hasattr(request, \"param\"):\n        kwargs = request.param.get(\"idp_kwargs\", {})\n    else:\n        kwargs = {}\n\n    identity_type = kwargs.pop(\"identity_type\", ManagedIdentityType.SYSTEM_ASSIGNED)\n    id_type = kwargs.pop(\"id_type\", ManagedIdentityIdType.OBJECT_ID)\n\n    return ManagedIdentityProviderConfig(\n        identity_type=identity_type,\n        resource=resource,\n        id_type=id_type,\n        id_value=id_value,\n        kwargs=kwargs,\n    )\n\n\ndef _get_service_principal_provider_config(\n    request,\n) -> ServicePrincipalIdentityProviderConfig:\n    client_id = os.getenv(\"AZURE_CLIENT_ID\")\n    client_credential = os.getenv(\"AZURE_CLIENT_SECRET\")\n    tenant_id = os.getenv(\"AZURE_TENANT_ID\")\n    scopes = os.getenv(\"AZURE_REDIS_SCOPES\", None)\n\n    if hasattr(request, \"param\"):\n        kwargs = request.param.get(\"idp_kwargs\", {})\n        token_kwargs = request.param.get(\"token_kwargs\", {})\n        timeout = request.param.get(\"timeout\", None)\n    else:\n        kwargs = {}\n        token_kwargs = {}\n        timeout = None\n\n    if isinstance(scopes, str):\n        scopes = scopes.split(\",\")\n\n    return ServicePrincipalIdentityProviderConfig(\n        client_id=client_id,\n        client_credential=client_credential,\n        scopes=scopes,\n        timeout=timeout,\n        token_kwargs=token_kwargs,\n        tenant_id=tenant_id,\n        app_kwargs=kwargs,\n    )\n\n\ndef _get_default_azure_credential_provider_config(\n    request,\n) -> DefaultAzureCredentialIdentityProviderConfig:\n    scopes = os.getenv(\"AZURE_REDIS_SCOPES\", ())\n\n    if hasattr(request, \"param\"):\n        kwargs = request.param.get(\"idp_kwargs\", {})\n        token_kwargs = request.param.get(\"token_kwargs\", {})\n    else:\n        kwargs = {}\n        token_kwargs = {}\n\n    if isinstance(scopes, str):\n        scopes = scopes.split(\",\")\n\n    return DefaultAzureCredentialIdentityProviderConfig(\n        scopes=scopes, app_kwargs=kwargs, token_kwargs=token_kwargs\n    )\n\n\ndef get_entra_id_credentials_provider(request, cred_provider_kwargs):\n    idp = identity_provider(request)\n    expiration_refresh_ratio = cred_provider_kwargs.get(\n        \"expiration_refresh_ratio\", DEFAULT_EXPIRATION_REFRESH_RATIO\n    )\n    lower_refresh_bound_millis = cred_provider_kwargs.get(\n        \"lower_refresh_bound_millis\", DEFAULT_LOWER_REFRESH_BOUND_MILLIS\n    )\n    max_attempts = cred_provider_kwargs.get(\"max_attempts\", DEFAULT_MAX_ATTEMPTS)\n    delay_in_ms = cred_provider_kwargs.get(\"delay_in_ms\", DEFAULT_DELAY_IN_MS)\n    token_mgr_config = TokenManagerConfig(\n        expiration_refresh_ratio=expiration_refresh_ratio,\n        lower_refresh_bound_millis=lower_refresh_bound_millis,\n        token_request_execution_timeout_in_ms=DEFAULT_TOKEN_REQUEST_EXECUTION_TIMEOUT_IN_MS,  # noqa\n        retry_policy=RetryPolicy(\n            max_attempts=max_attempts,\n            delay_in_ms=delay_in_ms,\n        ),\n    )\n    return EntraIdCredentialsProvider(\n        identity_provider=idp,\n        token_manager_config=token_mgr_config,\n        initial_delay_in_ms=delay_in_ms,\n    )\n"
  },
  {
    "path": "tests/helpers.py",
    "content": "import logging\nfrom time import sleep\nfrom typing import Callable\n\nfrom redis._parsers.commands import RequestPolicy, ResponsePolicy\n\n\ndef wait_for_condition(\n    predicate: Callable[[], bool],\n    timeout: float = 0.2,\n    check_interval: float = 0.01,\n    error_message: str = \"Timeout waiting for condition\",\n) -> None:\n    \"\"\"\n    Poll a condition until it becomes True or timeout is reached.\n\n    Args:\n        predicate: A callable that returns True when the condition is met\n        timeout: Maximum time to wait in seconds (default: 0.2s = 20 * 0.01s)\n        check_interval: Time to sleep between checks in seconds (default: 0.01s)\n        error_message: Error message to raise if timeout occurs\n\n    Raises:\n        AssertionError: If the condition is not met within the timeout period\n\n    Example:\n        # Wait for circuit breaker to open\n        wait_for_condition(\n            lambda: cb2.state == CBState.OPEN,\n            timeout=0.2,\n            error_message=\"Timeout waiting for cb2 to open\"\n        )\n\n        # Wait for failover strategy to select a specific database\n        wait_for_condition(\n            lambda: client.command_executor.active_database is mock_db,\n            timeout=0.2,\n            error_message=\"Timeout waiting for active database to change\"\n        )\n    \"\"\"\n    max_retries = int(timeout / check_interval)\n\n    for attempt in range(max_retries):\n        if predicate():\n            logging.debug(f\"Condition met after {attempt} attempts\")\n            return\n        sleep(check_interval)\n\n    raise AssertionError(error_message)\n\n\ndef get_expected_command_policies(changes_in_defaults={}):\n    default_cmd_policies = {\n        \"core\": {\n            \"keys\": [\n                \"keys\",\n                RequestPolicy.ALL_SHARDS,\n                ResponsePolicy.DEFAULT_KEYLESS,\n            ],\n            \"acl setuser\": [\n                \"acl setuser\",\n                RequestPolicy.ALL_NODES,\n                ResponsePolicy.ALL_SUCCEEDED,\n            ],\n            \"exists\": [\"exists\", RequestPolicy.MULTI_SHARD, ResponsePolicy.AGG_SUM],\n            \"config resetstat\": [\n                \"config resetstat\",\n                RequestPolicy.ALL_NODES,\n                ResponsePolicy.ALL_SUCCEEDED,\n            ],\n            \"slowlog len\": [\n                \"slowlog len\",\n                RequestPolicy.ALL_NODES,\n                ResponsePolicy.AGG_SUM,\n            ],\n            \"scan\": [\"scan\", RequestPolicy.SPECIAL, ResponsePolicy.SPECIAL],\n            \"latency history\": [\n                \"latency history\",\n                RequestPolicy.ALL_NODES,\n                ResponsePolicy.SPECIAL,\n            ],\n            \"memory doctor\": [\n                \"memory doctor\",\n                RequestPolicy.ALL_SHARDS,\n                ResponsePolicy.SPECIAL,\n            ],\n            \"randomkey\": [\n                \"randomkey\",\n                RequestPolicy.ALL_SHARDS,\n                ResponsePolicy.SPECIAL,\n            ],\n            \"mget\": [\n                \"mget\",\n                RequestPolicy.MULTI_SHARD,\n                ResponsePolicy.DEFAULT_KEYED,\n            ],\n            \"function restore\": [\n                \"function restore\",\n                RequestPolicy.ALL_SHARDS,\n                ResponsePolicy.ALL_SUCCEEDED,\n            ],\n        },\n        \"json\": {\n            \"debug\": [\n                \"debug\",\n                RequestPolicy.DEFAULT_KEYED,\n                ResponsePolicy.DEFAULT_KEYED,\n            ],\n            \"get\": [\n                \"get\",\n                RequestPolicy.DEFAULT_KEYED,\n                ResponsePolicy.DEFAULT_KEYED,\n            ],\n        },\n        \"ft\": {\n            \"search\": [\n                \"search\",\n                RequestPolicy.DEFAULT_KEYLESS,\n                ResponsePolicy.DEFAULT_KEYLESS,\n            ],\n            \"create\": [\n                \"create\",\n                RequestPolicy.DEFAULT_KEYLESS,\n                ResponsePolicy.DEFAULT_KEYLESS,\n            ],\n        },\n        \"bf\": {\n            \"add\": [\n                \"add\",\n                RequestPolicy.DEFAULT_KEYED,\n                ResponsePolicy.DEFAULT_KEYED,\n            ],\n            \"madd\": [\n                \"madd\",\n                RequestPolicy.DEFAULT_KEYED,\n                ResponsePolicy.DEFAULT_KEYED,\n            ],\n        },\n        \"cf\": {\n            \"add\": [\n                \"add\",\n                RequestPolicy.DEFAULT_KEYED,\n                ResponsePolicy.DEFAULT_KEYED,\n            ],\n            \"mexists\": [\n                \"mexists\",\n                RequestPolicy.DEFAULT_KEYED,\n                ResponsePolicy.DEFAULT_KEYED,\n            ],\n        },\n        \"tdigest\": {\n            \"add\": [\n                \"add\",\n                RequestPolicy.DEFAULT_KEYED,\n                ResponsePolicy.DEFAULT_KEYED,\n            ],\n            \"min\": [\n                \"min\",\n                RequestPolicy.DEFAULT_KEYED,\n                ResponsePolicy.DEFAULT_KEYED,\n            ],\n        },\n        \"ts\": {\n            \"create\": [\n                \"create\",\n                RequestPolicy.DEFAULT_KEYED,\n                ResponsePolicy.DEFAULT_KEYED,\n            ],\n            \"info\": [\n                \"info\",\n                RequestPolicy.DEFAULT_KEYED,\n                ResponsePolicy.DEFAULT_KEYED,\n            ],\n        },\n        \"topk\": {\n            \"list\": [\n                \"list\",\n                RequestPolicy.DEFAULT_KEYED,\n                ResponsePolicy.DEFAULT_KEYED,\n            ],\n            \"query\": [\n                \"query\",\n                RequestPolicy.DEFAULT_KEYED,\n                ResponsePolicy.DEFAULT_KEYED,\n            ],\n        },\n    }\n    default_cmd_policies.update(changes_in_defaults)\n    return default_cmd_policies\n"
  },
  {
    "path": "tests/maint_notifications/proxy_server_helpers.py",
    "content": "import base64\nfrom dataclasses import dataclass\n\nfrom redis.http.http_client import HttpClient, HttpError\n\n\nclass RespTranslator:\n    \"\"\"Helper class to translate between RESP and other encodings.\"\"\"\n\n    @staticmethod\n    def re_cluster_maint_notification_to_resp(txt: str) -> str:\n        \"\"\"Convert query to RESP format.\"\"\"\n        parts = txt.split()\n\n        match parts:\n            case [\"MOVING\", seq_id, time, new_host]:\n                return f\">4\\r\\n+MOVING\\r\\n:{seq_id}\\r\\n:{time}\\r\\n+{new_host}\\r\\n\"\n            case [\"MIGRATING\", seq_id, time, shards]:\n                return f\">4\\r\\n+MIGRATING\\r\\n:{seq_id}\\r\\n:{time}\\r\\n+{shards}\\r\\n\"\n            case [\"MIGRATED\", seq_id, shards]:\n                return f\">3\\r\\n+MIGRATED\\r\\n:{seq_id}\\r\\n+{shards}\\r\\n\"\n            case [\"FAILING_OVER\", seq_id, time, shards]:\n                return f\">4\\r\\n+FAILING_OVER\\r\\n:{seq_id}\\r\\n:{time}\\r\\n+{shards}\\r\\n\"\n            case [\"FAILED_OVER\", seq_id, shards]:\n                return f\">3\\r\\n+FAILED_OVER\\r\\n:{seq_id}\\r\\n+{shards}\\r\\n\"\n            case _:\n                raise NotImplementedError(f\"Unknown notification: {txt}\")\n\n    @staticmethod\n    def oss_maint_notification_to_resp(txt: str) -> str:\n        \"\"\"Convert query to RESP format.\"\"\"\n        if txt.startswith(\"SMIGRATED\"):\n            # Format: SMIGRATED SeqID host:port slot1,range1-range2 host1:port1 slot2,range3-range4\n            # SMIGRATED 93923 abc.com:6789 123,789-1000 abc.com:4545 1000-2000 abc.com:4323 900,910,920\n            # SMIGRATED - simple string\n            # SeqID - integer\n            # host and slots info are provided as array of arrays\n            # host:port - simple string\n            # slots - simple string\n\n            parts = txt.split()\n            notification = parts[0]\n            seq_id = parts[1]\n            hosts_and_slots = parts[2:]\n            resp = (\n                \">3\\r\\n\"  # Push message with 3 elements\n                f\"+{notification}\\r\\n\"  # Element 1: Command\n                f\":{seq_id}\\r\\n\"  # Element 2: SeqID\n                f\"*{len(hosts_and_slots) // 3}\\r\\n\"  # Element 3: Array of src_host:src_port, dest_host:dest_port, slots pairs\n            )\n            for i in range(0, len(hosts_and_slots), 3):\n                resp += \"*3\\r\\n\"\n                resp += f\"+{hosts_and_slots[i]}\\r\\n\"\n                resp += f\"+{hosts_and_slots[i + 1]}\\r\\n\"\n                resp += f\"+{hosts_and_slots[i + 2]}\\r\\n\"\n        else:\n            # SMIGRATING\n            # Format: SMIGRATING SeqID slot,range1-range2\n            # SMIGRATING 93923 123,789-1000\n            # SMIGRATING - simple string\n            # SeqID - integer\n            # slots - simple string\n\n            parts = txt.split()\n            notification = parts[0]\n            seq_id = parts[1]\n            slots = parts[2]\n\n            resp = (\n                \">3\\r\\n\"  # Push message with 3 elements\n                f\"+{notification}\\r\\n\"  # Element 1: Command\n                f\":{seq_id}\\r\\n\"  # Element 2: SeqID\n                f\"+{slots}\\r\\n\"  # Element 3: Array of [host:port, slots] pairs\n            )\n        return resp\n\n\n@dataclass\nclass SlotsRange:\n    host: str\n    port: int\n    start_slot: int\n    end_slot: int\n\n\nclass ProxyInterceptorHelper:\n    \"\"\"Helper class for intercepting socket calls and managing interceptor server.\"\"\"\n\n    def __init__(self, server_url: str = \"http://localhost:4000\"):\n        self.server_url = server_url\n        self._resp_translator = RespTranslator()\n        self.http_client = HttpClient()\n        self._interceptors = list()\n\n    def cleanup_interceptors(self, *names: str):\n        \"\"\"\n        Resets all the interceptors by providing empty pattern and returned response.\n\n        Args:\n            names: Names of the interceptors to reset\n        \"\"\"\n        if not names:\n            names = self._interceptors\n        for name in tuple(names):\n            self._reset_interceptor(name)\n\n    def set_cluster_slots(\n        self,\n        name: str,\n        slots_ranges: list[SlotsRange],\n    ) -> str:\n        \"\"\"\n        Set cluster slots and nodes by intercepting CLUSTER SLOTS command.\n\n        This method creates an interceptor that intercepts CLUSTER SLOTS commands\n        and returns a modified topology with the provided data.\n\n        Args:\n            name: Name of the interceptor\n            slots_ranges: List of SlotsRange objects representing the cluster\n                nodes and slots coverage\n\n        Returns:\n            The interceptor name that was created\n\n        Example:\n            interceptor = ProxyInterceptorHelper(None, \"http://localhost:4000\")\n            interceptor.set_cluster_slots(\n                \"test_topology\",\n                [\n                    SlotsRange(\"127.0.0.1\", 6379, 0, 5000),\n                    SlotsRange(\"127.0.0.1\", 6380, 5001, 10000),\n                    SlotsRange(\"127.0.0.1\", 6381, 10001, 16383),\n                ]\n            )\n        \"\"\"\n        # Build RESP response for CLUSTER SLOTS\n        # Format: *<num_slots_ranges> for each range: *3 :start :end *3 $<host_len> <host> :<port> $<id_len> <id>\n        resp_parts = [f\"*{len(slots_ranges)}\"]\n\n        for slots_range in slots_ranges:\n            # Node info: *3 for (host, port, id)\n            resp_parts.append(\"*3\")\n            # 1st elem --> start slot\n            resp_parts.append(f\":{slots_range.start_slot}\")\n            # 2nd elem --> end slot\n            resp_parts.append(f\":{slots_range.end_slot}\")\n\n            # 3rd elem --> list with node details: *4 for (host, port, id, empty hash)\n            resp_parts.append(\"*4\")\n            # 1st elem --> host\n            resp_parts.append(f\"${len(slots_range.host)}\")\n            resp_parts.append(f\"{slots_range.host}\")\n            # 2nd elem --> port\n            resp_parts.append(f\":{slots_range.port}\")\n            # 3rd elem --> node id\n            node_id = f\"proxy-id-{slots_range.port}\"\n            resp_parts.append(f\"${len(node_id)}\")\n            resp_parts.append(node_id)\n            # 4th elem --> empty hash\n            resp_parts.append(\"$0\")\n            resp_parts.append(\"\")\n\n        response = \"\\r\\n\".join(resp_parts) + \"\\r\\n\"\n\n        # Add the interceptor\n        self._add_interceptor(\n            name=name,\n            match=\"*2\\r\\n$7\\r\\ncluster\\r\\n$5\\r\\nslots\\r\\n\",\n            response=response,\n            encoding=\"raw\",\n        )\n\n        return name\n\n    def get_stats(self) -> dict:\n        \"\"\"\n        Get statistics from the interceptor server.\n\n        Returns:\n            Statistics dictionary containing connection information\n        \"\"\"\n        url = f\"{self.server_url}/stats\"\n\n        try:\n            response = self.http_client.get(url)\n            if isinstance(response, dict):\n                return response\n            return response.json()\n\n        except HttpError as e:\n            raise RuntimeError(f\"Failed to get stats from interceptor server: {e}\")\n\n    def get_connections(self) -> dict:\n        \"\"\"\n        Get all active connections from the server.\n\n        Returns:\n            Response from the server as a dictionary\n        \"\"\"\n        url = f\"{self.server_url}/connections\"\n\n        try:\n            response = self.http_client.get(url)\n            if isinstance(response, dict):\n                return response\n            return response.json()\n        except HttpError as e:\n            raise RuntimeError(f\"Failed to get connections: {e}\")\n\n    def send_notification(\n        self,\n        notification: str,\n    ) -> dict:\n        \"\"\"\n        Send a notification to all connections.\n\n        Args:\n            notification: The notification message to send (in RESP format)\n\n        Returns:\n            Response from the server as a dictionary\n\n        Example:\n            interceptor = ProxyInterceptorHelper(None, \"http://localhost:4000\")\n            result = interceptor.send_notification(\n                \"KjENCiQ0DQpQSU5HDQo=\"  # PING command in base64\n            )\n        \"\"\"\n        # Send notification to all connections\n        results = {}\n        url = f\"{self.server_url}/send-to-all-clients?encoding=base64\"\n        # Encode notification to base64\n        data = base64.b64encode(notification.encode(\"utf-8\"))\n\n        try:\n            response = self.http_client.post(url, data=data)\n            if isinstance(response, dict):\n                return response\n            results = response.json()\n        except HttpError as e:\n            results = {\"error\": str(e)}\n\n        return {\n            \"results\": results,\n        }\n\n    def _add_interceptor(\n        self,\n        name: str,\n        match: str,\n        response: str,\n        encoding: str = \"raw\",\n    ) -> dict:\n        \"\"\"\n        Add an interceptor to the server.\n\n        Args:\n            name: Name of the interceptor\n            match: Pattern to match (RESP format)\n            response: Response to return when matched (RESP format)\n            encoding: Encoding type - \"base64\" or \"raw\"\n\n        Returns:\n            Response from the server as a dictionary\n        \"\"\"\n        url = f\"{self.server_url}/interceptors\"\n        payload = {\n            \"name\": name,\n            \"match\": match,\n            \"response\": response,\n            \"encoding\": encoding,\n        }\n        headers = {\"Content-Type\": \"application/json\"}\n\n        try:\n            proxy_response = self.http_client.post(\n                url, json_body=payload, headers=headers\n            )\n            self._interceptors.append(name)\n            if isinstance(proxy_response, dict):\n                return proxy_response\n            return proxy_response.json() if proxy_response else {}\n        except HttpError as e:\n            raise RuntimeError(f\"Failed to add interceptor: {e}\")\n\n    def _reset_interceptor(self, name: str):\n        \"\"\"\n        Reset an interceptor by providing empty pattern and returned response.\n\n        Args:\n            name: Name of the interceptor to reset\n        \"\"\"\n        self._add_interceptor(name, \"no_match\", \"\")\n"
  },
  {
    "path": "tests/maint_notifications/test_cluster_maint_notifications_handling.py",
    "content": "from dataclasses import dataclass\nimport logging\nfrom typing import List, Optional, cast\n\nfrom redis import ConnectionPool, RedisCluster\nfrom redis.cluster import ClusterNode\nfrom redis.connection import (\n    BlockingConnectionPool,\n)\nfrom redis.maint_notifications import MaintNotificationsConfig, MaintenanceState\nfrom redis.cache import CacheConfig\nfrom tests.conftest import skip_if_server_version_lt\nfrom tests.maint_notifications.proxy_server_helpers import (\n    ProxyInterceptorHelper,\n    RespTranslator,\n    SlotsRange,\n)\n\nNODE_PORT_1 = 15379\nNODE_PORT_2 = 15380\nNODE_PORT_3 = 15381\n\nNODE_PORT_NEW = 15382\n\n# IP addresses used in tests\nNODE_IP_LOCALHOST = \"127.0.0.1\"\nNODE_IP_PROXY = \"0.0.0.0\"\n\n# Initial cluster node configuration for proxy-based tests\nPROXY_CLUSTER_NODES = [\n    ClusterNode(\"127.0.0.1\", NODE_PORT_1),\n    ClusterNode(\"127.0.0.1\", NODE_PORT_2),\n    ClusterNode(\"127.0.0.1\", NODE_PORT_3),\n]\n\nCLUSTER_SLOTS_INTERCEPTOR_NAME = \"test_topology\"\n\n\nclass TestRespTranslatorHelper:\n    def test_oss_maint_notification_to_resp(self):\n        resp = RespTranslator.oss_maint_notification_to_resp(\n            \"SMIGRATING 12 123,456,5000-7000\"\n        )\n        assert resp == \">3\\r\\n+SMIGRATING\\r\\n:12\\r\\n+123,456,5000-7000\\r\\n\"\n\n        resp = RespTranslator.oss_maint_notification_to_resp(\n            f\"SMIGRATED 12 {NODE_IP_LOCALHOST}:{NODE_PORT_1} {NODE_IP_LOCALHOST}:{NODE_PORT_2} 123,456,5000-7000\"\n        )\n        assert (\n            resp\n            == f\">3\\r\\n+SMIGRATED\\r\\n:12\\r\\n*1\\r\\n*3\\r\\n+{NODE_IP_LOCALHOST}:{NODE_PORT_1}\\r\\n+{NODE_IP_LOCALHOST}:{NODE_PORT_2}\\r\\n+123,456,5000-7000\\r\\n\"\n        )\n        resp = RespTranslator.oss_maint_notification_to_resp(\n            f\"SMIGRATED 12 {NODE_IP_LOCALHOST}:{NODE_PORT_1} {NODE_IP_LOCALHOST}:{NODE_PORT_2} 123,456,5000-7000 {NODE_IP_LOCALHOST}:{NODE_PORT_1} {NODE_IP_LOCALHOST}:{NODE_PORT_3} 7000-8000 {NODE_IP_LOCALHOST}:{NODE_PORT_1} {NODE_IP_LOCALHOST}:{NODE_PORT_NEW} 8000-9000\"\n        )\n\n        assert (\n            resp\n            == f\">3\\r\\n+SMIGRATED\\r\\n:12\\r\\n*3\\r\\n*3\\r\\n+{NODE_IP_LOCALHOST}:{NODE_PORT_1}\\r\\n+{NODE_IP_LOCALHOST}:{NODE_PORT_2}\\r\\n+123,456,5000-7000\\r\\n*3\\r\\n+{NODE_IP_LOCALHOST}:{NODE_PORT_1}\\r\\n+{NODE_IP_LOCALHOST}:{NODE_PORT_3}\\r\\n+7000-8000\\r\\n*3\\r\\n+{NODE_IP_LOCALHOST}:{NODE_PORT_1}\\r\\n+{NODE_IP_LOCALHOST}:{NODE_PORT_NEW}\\r\\n+8000-9000\\r\\n\"\n        )\n\n\nclass TestClusterMaintNotificationsBase:\n    \"\"\"Base class for cluster maintenance notifications handling tests.\"\"\"\n\n    def _create_cluster_client(\n        self,\n        pool_class=ConnectionPool,\n        enable_cache=False,\n        max_connections=10,\n        maint_config=None,\n        protocol=3,\n    ) -> RedisCluster:\n        \"\"\"Create a RedisCluster instance with mocked sockets.\"\"\"\n        if maint_config is None and hasattr(self, \"config\") and self.config is not None:\n            maint_config = self.config\n\n        kwargs = {}\n        if enable_cache:\n            kwargs = {\"cache_config\": CacheConfig()}\n\n        test_redis_client = RedisCluster(\n            protocol=protocol,\n            startup_nodes=PROXY_CLUSTER_NODES,\n            maint_notifications_config=maint_config,\n            connection_pool_class=pool_class,\n            max_connections=max_connections,\n            **kwargs,\n        )\n\n        return test_redis_client\n\n\nclass TestClusterMaintNotificationsConfig(TestClusterMaintNotificationsBase):\n    \"\"\"Test the maint_notifications_config parameter of RedisCluster.\"\"\"\n\n    def _validate_maint_config_on_nodes_manager(\n        self,\n        cluster: RedisCluster,\n        expected_enabled: bool,\n        expected_proactive_reconnect: bool,\n        expected_relaxed_timeout: int,\n    ) -> None:\n        \"\"\"Validate maint_notifications_config on NodesManager.\"\"\"\n        assert cluster.nodes_manager.maint_notifications_config is not None\n        assert (\n            cluster.nodes_manager.maint_notifications_config.enabled == expected_enabled\n        )\n        assert (\n            cluster.nodes_manager.maint_notifications_config.proactive_reconnect\n            == expected_proactive_reconnect\n        )\n        assert (\n            cluster.nodes_manager.maint_notifications_config.relaxed_timeout\n            == expected_relaxed_timeout\n        )\n\n    def _validate_maint_config_on_nodes(\n        self,\n        cluster: RedisCluster,\n        expected_enabled: bool,\n        expected_proactive_reconnect: bool,\n        expected_relaxed_timeout: int,\n        should_have_handler: bool = True,\n    ) -> None:\n        \"\"\"Validate maint_notifications_config on individual nodes.\"\"\"\n        nodes = list(cluster.nodes_manager.nodes_cache.values())\n        assert len(nodes) > 0, \"Cluster should have at least one node\"\n\n        for node in nodes:\n            cluster_node = cast(ClusterNode, node)\n            assert cluster_node.redis_connection is not None\n            connection_pool = cluster_node.redis_connection.connection_pool\n            assert connection_pool is not None\n\n            if should_have_handler:\n                if hasattr(connection_pool, \"_maint_notifications_pool_handler\"):\n                    handler = connection_pool._maint_notifications_pool_handler\n                    if handler is not None:\n                        assert handler.config.enabled == expected_enabled\n                        assert (\n                            handler.config.proactive_reconnect\n                            == expected_proactive_reconnect\n                        )\n                        assert (\n                            handler.config.relaxed_timeout == expected_relaxed_timeout\n                        )\n\n    def test_maint_notifications_config(self):\n        \"\"\"\n        Test that maint_notifications_config is passed to NodesManager and nodes.\n\n        Creates a RedisCluster instance with 3 real startup nodes and validates\n        that the maint_notifications_config is properly set on both the NodesManager\n        and the individual nodes.\n        \"\"\"\n        maint_config = MaintNotificationsConfig(\n            enabled=False, proactive_reconnect=True, relaxed_timeout=30\n        )\n\n        cluster = self._create_cluster_client(maint_config=maint_config)\n\n        try:\n            self._validate_maint_config_on_nodes_manager(cluster, False, True, 30)\n            self._validate_maint_config_on_nodes(cluster, False, True, 30)\n\n            # Verify we can execute commands without errors\n            cluster.set(\"test\", \"VAL\")\n            res = cluster.get(\"test\")\n            assert res == b\"VAL\"\n        finally:\n            cluster.close()\n\n    def test_config_propagation_to_new_nodes(self):\n        \"\"\"\n        Test that when a new node is discovered/added to the cluster,\n        it receives the same maint_notifications_config.\n        \"\"\"\n        maint_config = MaintNotificationsConfig(\n            enabled=False, proactive_reconnect=True, relaxed_timeout=25\n        )\n\n        cluster = self._create_cluster_client(maint_config=maint_config)\n\n        try:\n            # Verify initial nodes have the config\n            initial_node_count = len(cluster.nodes_manager.nodes_cache)\n            self._validate_maint_config_on_nodes(cluster, False, True, 25)\n\n            # Reinitialize to ensure all nodes are discovered\n            cluster.nodes_manager.initialize()\n\n            # Verify all nodes have the config\n            new_node_count = len(cluster.nodes_manager.nodes_cache)\n            assert new_node_count >= initial_node_count\n            self._validate_maint_config_on_nodes(cluster, False, True, 25)\n        finally:\n            cluster.close()\n\n    def test_config_with_blocking_connection_pool(self):\n        \"\"\"\n        Test that maint_notifications_config works with BlockingConnectionPool.\n        \"\"\"\n        maint_config = MaintNotificationsConfig(\n            enabled=False, proactive_reconnect=True, relaxed_timeout=20\n        )\n\n        cluster = self._create_cluster_client(\n            maint_config=maint_config,\n            pool_class=BlockingConnectionPool,\n        )\n\n        try:\n            # Verify config is set on NodesManager\n            self._validate_maint_config_on_nodes_manager(cluster, False, True, 20)\n\n            # Verify config is set on nodes\n            self._validate_maint_config_on_nodes(cluster, False, True, 20)\n\n            # Verify we can execute commands without errors\n            cluster.set(\"test\", \"VAL\")\n            res = cluster.get(\"test\")\n            assert res == b\"VAL\"\n        finally:\n            cluster.close()\n\n    @skip_if_server_version_lt(\"7.4.0\")\n    def test_config_with_cache_enabled(self):\n        \"\"\"\n        Test that maint_notifications_config works with caching enabled.\n        \"\"\"\n        maint_config = MaintNotificationsConfig(\n            enabled=False, proactive_reconnect=True, relaxed_timeout=15\n        )\n\n        cluster = self._create_cluster_client(\n            maint_config=maint_config,\n            enable_cache=True,\n        )\n\n        try:\n            self._validate_maint_config_on_nodes_manager(cluster, False, True, 15)\n            self._validate_maint_config_on_nodes(cluster, False, True, 15)\n\n            # Verify we can execute commands without errors\n            cluster.set(\"test\", \"VAL\")\n            res = cluster.get(\"test\")\n            assert res == b\"VAL\"\n        finally:\n            cluster.close()\n\n    def test_none_config_default_behavior(self):\n        \"\"\"\n        Test that when maint_notifications_config=None, it will be initialized with default values.\n        \"\"\"\n        cluster = self._create_cluster_client(maint_config=None)\n\n        try:\n            # Verify cluster is created successfully\n            assert cluster.nodes_manager is not None\n            # for protocol 3, maint_notifications_config should be initialized with default values\n            assert cluster.nodes_manager.maint_notifications_config is not None\n            assert cluster.nodes_manager.maint_notifications_config.enabled == \"auto\"\n            assert len(cluster.nodes_manager.nodes_cache) > 0\n            # Verify we can execute commands without errors\n            cluster.set(\"test\", \"VAL\")\n            res = cluster.get(\"test\")\n            assert res == b\"VAL\"\n        finally:\n            cluster.close()\n\n    def test_none_config_default_behavior_for_protocol_2(self):\n        \"\"\"\n        Test that when maint_notifications_config=None and protocol=2,\n        it will not be initialized.\n        \"\"\"\n        cluster = self._create_cluster_client(protocol=2)\n\n        try:\n            # Verify cluster is created successfully\n            assert cluster.nodes_manager is not None\n            # for protocol 2, maint_notifications_config should not be created\n            assert cluster.nodes_manager.maint_notifications_config is None\n\n            assert len(cluster.nodes_manager.nodes_cache) > 0\n            # Verify we can execute commands without errors\n            cluster.set(\"test\", \"VAL\")\n            res = cluster.get(\"test\")\n            assert res == b\"VAL\"\n        finally:\n            cluster.close()\n\n    def test_config_with_enabled_false(self):\n        \"\"\"\n        Test that when enabled=False, maint notifications handlers are not created/initialized.\n        \"\"\"\n        maint_config = MaintNotificationsConfig(\n            enabled=False, proactive_reconnect=False, relaxed_timeout=-1\n        )\n\n        cluster = self._create_cluster_client(maint_config=maint_config)\n\n        try:\n            self._validate_maint_config_on_nodes_manager(cluster, False, False, -1)\n            # When enabled=False, handlers should not be created\n            self._validate_maint_config_on_nodes(\n                cluster, False, False, -1, should_have_handler=False\n            )\n\n            # Verify we can execute commands without errors\n            cluster.set(\"test\", \"VAL\")\n            res = cluster.get(\"test\")\n            assert res == b\"VAL\"\n        finally:\n            cluster.close()\n\n    def test_config_with_pipeline_operations(self):\n        \"\"\"\n        Test that maint_notifications_config works with pipelined commands.\n        \"\"\"\n        maint_config = MaintNotificationsConfig(\n            enabled=False, proactive_reconnect=True, relaxed_timeout=10\n        )\n\n        cluster = self._create_cluster_client(maint_config=maint_config)\n\n        try:\n            self._validate_maint_config_on_nodes_manager(cluster, False, True, 10)\n            self._validate_maint_config_on_nodes(cluster, False, True, 10)\n\n            # Verify pipeline operations work without errors\n            pipe = cluster.pipeline()\n            pipe.set(\"pipe_key1\", \"value1\")\n            pipe.set(\"pipe_key2\", \"value2\")\n            pipe.get(\"pipe_key1\")\n            pipe.get(\"pipe_key2\")\n            results = pipe.execute()\n\n            # Verify pipeline results\n            assert results[0] is True or results[0] == b\"OK\"  # SET returns True or OK\n            assert results[1] is True or results[1] == b\"OK\"  # SET returns True or OK\n            assert results[2] == b\"value1\"  # GET returns value\n            assert results[3] == b\"value2\"  # GET returns value\n        finally:\n            cluster.close()\n\n\nclass TestClusterMaintNotificationsHandler(TestClusterMaintNotificationsBase):\n    \"\"\"Test OSSMaintNotificationsHandler propagation with RedisCluster.\"\"\"\n\n    def _validate_connection_handlers(\n        self, conn, cluster_client, config, is_cache_conn=False\n    ):\n        \"\"\"Helper method to validate connection handlers are properly set.\"\"\"\n        # Test that the oss cluster handler function is correctly set\n        oss_cluster_parser_handler_set_for_con = (\n            conn._parser.oss_cluster_maint_push_handler_func\n        )\n        assert oss_cluster_parser_handler_set_for_con is not None\n        assert hasattr(oss_cluster_parser_handler_set_for_con, \"__self__\")\n        assert hasattr(oss_cluster_parser_handler_set_for_con, \"__func__\")\n        assert (\n            oss_cluster_parser_handler_set_for_con.__self__.cluster_client\n            is cluster_client\n        )\n        assert (\n            oss_cluster_parser_handler_set_for_con.__self__._lock\n            is cluster_client._oss_cluster_maint_notifications_handler._lock\n        )\n        assert (\n            oss_cluster_parser_handler_set_for_con.__self__._processed_notifications\n            is cluster_client._oss_cluster_maint_notifications_handler._processed_notifications\n        )\n        assert (\n            oss_cluster_parser_handler_set_for_con.__func__\n            is cluster_client._oss_cluster_maint_notifications_handler.handle_notification.__func__\n        )\n\n        # Test that the maintenance handler function is correctly set\n        parser_maint_handler_set_for_con = conn._parser.maintenance_push_handler_func\n        assert parser_maint_handler_set_for_con is not None\n        assert hasattr(parser_maint_handler_set_for_con, \"__self__\")\n        assert hasattr(parser_maint_handler_set_for_con, \"__func__\")\n        # The maintenance handler should be bound to the connection's\n        # maintenance notification connection handler\n        assert (\n            parser_maint_handler_set_for_con.__self__\n            is conn._maint_notifications_connection_handler\n        )\n        assert (\n            parser_maint_handler_set_for_con.__func__\n            is conn._maint_notifications_connection_handler.handle_notification.__func__\n        )\n\n        # Validate that the connection's maintenance handler has the same config object\n        assert conn._maint_notifications_connection_handler.config is config\n\n    def test_oss_maint_handler_propagation(self):\n        \"\"\"Test that OSSMaintNotificationsHandler is propagated to all connections.\"\"\"\n        cluster = self._create_cluster_client()\n        # Verify all nodes have the handler\n        for node in cluster.nodes_manager.nodes_cache.values():\n            assert node.redis_connection is not None\n            assert node.redis_connection.connection_pool is not None\n            for conn in (\n                *node.redis_connection.connection_pool._get_in_use_connections(),\n                *node.redis_connection.connection_pool._get_free_connections(),\n            ):\n                assert conn._oss_cluster_maint_notifications_handler is not None\n                self._validate_connection_handlers(\n                    conn, cluster, cluster.maint_notifications_config\n                )\n\n    @skip_if_server_version_lt(\"7.4.0\")\n    def test_oss_maint_handler_propagation_cache_enabled(self):\n        \"\"\"Test that OSSMaintNotificationsHandler is propagated to all connections.\"\"\"\n        cluster = self._create_cluster_client(enable_cache=True)\n        # Verify all nodes have the handler\n        for node in cluster.nodes_manager.nodes_cache.values():\n            assert node.redis_connection is not None\n            assert node.redis_connection.connection_pool is not None\n            for conn in (\n                *node.redis_connection.connection_pool._get_in_use_connections(),\n                *node.redis_connection.connection_pool._get_free_connections(),\n            ):\n                assert conn._conn._oss_cluster_maint_notifications_handler is not None\n                self._validate_connection_handlers(\n                    conn._conn, cluster, cluster.maint_notifications_config\n                )\n\n\nclass TestClusterMaintNotificationsHandlingBase(TestClusterMaintNotificationsBase):\n    \"\"\"Base class for maintenance notifications handling tests.\"\"\"\n\n    def setup_method(self):\n        \"\"\"Set up test fixtures with mocked sockets.\"\"\"\n        self.proxy_helper = ProxyInterceptorHelper()\n        self.proxy_helper.cleanup_interceptors(CLUSTER_SLOTS_INTERCEPTOR_NAME)\n\n        # Create maintenance notifications config\n        self.config = MaintNotificationsConfig(\n            enabled=\"auto\", proactive_reconnect=True, relaxed_timeout=30\n        )\n        self.cluster = self._create_cluster_client(maint_config=self.config)\n\n    def teardown_method(self):\n        \"\"\"Clean up test fixtures.\"\"\"\n        self.cluster.close()\n        # interceptors that are changed during the tests are collected in the proxy helper\n        self.proxy_helper.cleanup_interceptors()\n\n\n@dataclass\nclass ConnectionStateExpectation:\n    \"\"\"Data class to hold connection state details for validation.\"\"\"\n\n    node_port: int\n    changed_connections_count: int = 0\n    state: MaintenanceState = MaintenanceState.NONE\n    relaxed_timeout: Optional[int] = None\n\n\nclass TestClusterMaintNotificationsHandling(TestClusterMaintNotificationsHandlingBase):\n    \"\"\"Test maintenance notifications handling with RedisCluster.\"\"\"\n\n    def _warm_up_connection_pools(\n        self, cluster: RedisCluster, created_connections_count: int = 3\n    ):\n        \"\"\"Warm up connection pools by getting a connection from each pool.\"\"\"\n        for node in cluster.nodes_manager.nodes_cache.values():\n            node_connections = []\n            for _ in range(created_connections_count):\n                node_connections.append(\n                    node.redis_connection.connection_pool.get_connection()\n                )\n            for conn in node_connections:\n                node.redis_connection.connection_pool.release(conn)\n\n            node_connections.clear()\n\n    def _get_expected_node_state(\n        self, expectations_list: List[ConnectionStateExpectation], node_port: int\n    ) -> Optional[ConnectionStateExpectation]:\n        \"\"\"Get the expected state for a node.\"\"\"\n        for expectation in expectations_list:\n            if expectation.node_port == node_port:\n                return expectation\n        return None\n\n    def _validate_connections_states(\n        self,\n        cluster: RedisCluster,\n        expected_states: List[ConnectionStateExpectation],\n    ):\n        \"\"\"Validate connections states.\"\"\"\n        default_maint_state = MaintenanceState.NONE\n        default_timeout = None\n        nodes = list(cluster.nodes_manager.nodes_cache.values())\n        for node in nodes:\n            cluster_node = cast(ClusterNode, node)\n            assert cluster_node.redis_connection is not None\n            connection_pool = cluster_node.redis_connection.connection_pool\n            assert connection_pool is not None\n            expected_state = self._get_expected_node_state(\n                expected_states, cluster_node.port\n            )\n            if expected_state is None:\n                # No expectation for this node\n                continue\n            changed_connections_count = 0\n            for conn in (\n                *connection_pool._get_in_use_connections(),\n                *connection_pool._get_free_connections(),\n            ):\n                if (\n                    conn.maintenance_state != default_maint_state\n                    and conn.maintenance_state == expected_state.state\n                ) or (\n                    conn.socket_timeout != default_timeout\n                    and conn.socket_timeout == expected_state.relaxed_timeout\n                ):\n                    changed_connections_count += 1\n            assert changed_connections_count == expected_state.changed_connections_count\n\n    def _validate_removed_node_connections(self, node):\n        \"\"\"Validate connections in a removed node.\"\"\"\n        assert node.redis_connection is not None\n        connection_pool = node.redis_connection.connection_pool\n        assert connection_pool is not None\n\n        # validate all connections are disconnected or marked for reconnect\n        for conn in connection_pool._get_free_connections():\n            assert conn._sock is None\n        for conn in connection_pool._get_in_use_connections():\n            assert conn.should_reconnect()\n\n    def test_receive_smigrating_notification(self):\n        \"\"\"Test receiving an OSS maintenance notification.\"\"\"\n        # warm up connection pools\n        self._warm_up_connection_pools(self.cluster, created_connections_count=3)\n\n        # send a notification to node 1\n        notification = RespTranslator.oss_maint_notification_to_resp(\n            \"SMIGRATING 12 123,456,5000-7000\"\n        )\n        self.proxy_helper.send_notification(notification)\n\n        # validate no timeout is relaxed on any connection\n        self._validate_connections_states(\n            self.cluster,\n            [\n                ConnectionStateExpectation(\n                    node_port=NODE_PORT_1, changed_connections_count=0\n                ),\n                ConnectionStateExpectation(\n                    node_port=NODE_PORT_2, changed_connections_count=0\n                ),\n                ConnectionStateExpectation(\n                    node_port=NODE_PORT_3, changed_connections_count=0\n                ),\n            ],\n        )\n\n        # execute a command that will receive the notification\n        res = self.cluster.set(\"anyprefix:{3}:k\", \"VAL\")\n        assert res is True\n\n        # validate the timeout was relaxed on just one connection for the node\n        self._validate_connections_states(\n            self.cluster,\n            [\n                ConnectionStateExpectation(\n                    node_port=NODE_PORT_1,\n                    changed_connections_count=1,\n                    state=MaintenanceState.MAINTENANCE,\n                    relaxed_timeout=self.config.relaxed_timeout,\n                ),\n                ConnectionStateExpectation(\n                    node_port=NODE_PORT_2, changed_connections_count=0\n                ),\n                ConnectionStateExpectation(\n                    node_port=NODE_PORT_3, changed_connections_count=0\n                ),\n            ],\n        )\n\n    def test_receive_smigrating_with_disabled_relaxed_timeout(self):\n        \"\"\"Test receiving an OSS maintenance notification with disabled relaxed timeout.\"\"\"\n        # Create config with disabled relaxed timeout\n        disabled_config = MaintNotificationsConfig(\n            enabled=\"auto\",\n            relaxed_timeout=-1,  # This means the relaxed timeout is Disabled\n        )\n        cluster = self._create_cluster_client(maint_config=disabled_config)\n\n        # warm up connection pools\n        self._warm_up_connection_pools(cluster, created_connections_count=3)\n\n        # send a notification to node 1\n        notification = RespTranslator.oss_maint_notification_to_resp(\n            \"SMIGRATING 12 123,456,5000-7000\"\n        )\n        self.proxy_helper.send_notification(notification)\n\n        # validate no timeout is relaxed on any connection\n        self._validate_connections_states(\n            self.cluster,\n            [\n                ConnectionStateExpectation(\n                    node_port=NODE_PORT_1, changed_connections_count=0\n                ),\n                ConnectionStateExpectation(\n                    node_port=NODE_PORT_2, changed_connections_count=0\n                ),\n                ConnectionStateExpectation(\n                    node_port=NODE_PORT_3, changed_connections_count=0\n                ),\n            ],\n        )\n\n    def test_receive_smigrated_notification(self):\n        \"\"\"Test receiving an OSS maintenance completed notification.\"\"\"\n        # create three connections in each node's connection pool\n        self._warm_up_connection_pools(self.cluster, created_connections_count=3)\n\n        self.proxy_helper.set_cluster_slots(\n            CLUSTER_SLOTS_INTERCEPTOR_NAME,\n            [\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_NEW, 0, 5460),\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_2, 5461, 10922),\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_3, 10923, 16383),\n            ],\n        )\n        # send a notification to node 1\n        notification = RespTranslator.oss_maint_notification_to_resp(\n            f\"SMIGRATED 12 {NODE_IP_PROXY}:{NODE_PORT_1} {NODE_IP_PROXY}:{NODE_PORT_2} 123,456,5000-7000\"\n        )\n        self.proxy_helper.send_notification(notification)\n\n        # execute a command that will receive the notification\n        res = self.cluster.set(\"anyprefix:{3}:k\", \"VAL\")\n        assert res is True\n\n        # validate the cluster topology was updated\n        new_node = self.cluster.nodes_manager.get_node(\n            host=NODE_IP_PROXY, port=NODE_PORT_NEW\n        )\n        assert new_node is not None\n\n    def test_receive_smigrated_notification_with_two_nodes(self):\n        \"\"\"Test receiving an OSS maintenance completed notification.\"\"\"\n        # create three connections in each node's connection pool\n        self._warm_up_connection_pools(self.cluster, created_connections_count=3)\n\n        self.proxy_helper.set_cluster_slots(\n            CLUSTER_SLOTS_INTERCEPTOR_NAME,\n            [\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_NEW, 0, 5460),\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_2, 5461, 10922),\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_3, 10923, 16383),\n            ],\n        )\n        # send a notification to node 1\n        notification = RespTranslator.oss_maint_notification_to_resp(\n            f\"SMIGRATED 12 {NODE_IP_PROXY}:{NODE_PORT_1} {NODE_IP_PROXY}:{NODE_PORT_2} 123,456,5000-7000 {NODE_IP_PROXY}:{NODE_PORT_1} {NODE_IP_PROXY}:{NODE_PORT_NEW} 110-120\"\n        )\n        self.proxy_helper.send_notification(notification)\n\n        # execute a command that will receive the notification\n        res = self.cluster.set(\"anyprefix:{3}:k\", \"VAL\")\n        assert res is True\n\n        # validate the cluster topology was updated\n        new_node = self.cluster.nodes_manager.get_node(\n            host=NODE_IP_PROXY, port=NODE_PORT_NEW\n        )\n        assert new_node is not None\n\n    def test_smigrating_smigrated_on_two_nodes_without_node_replacement(self):\n        \"\"\"Test receiving an OSS maintenance notification on two nodes without node replacement.\"\"\"\n        # warm up connection pools - create several connections in each pool\n        self._warm_up_connection_pools(self.cluster, created_connections_count=3)\n\n        node_1 = self.cluster.nodes_manager.get_node(\n            host=NODE_IP_PROXY, port=NODE_PORT_1\n        )\n        node_2 = self.cluster.nodes_manager.get_node(\n            host=NODE_IP_PROXY, port=NODE_PORT_2\n        )\n\n        smigrating_node_1 = RespTranslator.oss_maint_notification_to_resp(\n            \"SMIGRATING 12 123,2000-3000\"\n        )\n        self.proxy_helper.send_notification(smigrating_node_1)\n        # execute command with node 1 connection\n        self.cluster.set(\"anyprefix:{3}:k\", \"VAL\")\n        self._validate_connections_states(\n            self.cluster,\n            [\n                ConnectionStateExpectation(\n                    node_port=NODE_PORT_1,\n                    changed_connections_count=1,\n                    state=MaintenanceState.MAINTENANCE,\n                    relaxed_timeout=self.config.relaxed_timeout,\n                ),\n                ConnectionStateExpectation(\n                    node_port=NODE_PORT_2,\n                    changed_connections_count=0,\n                ),\n            ],\n        )\n\n        smigrating_node_2 = RespTranslator.oss_maint_notification_to_resp(\n            \"SMIGRATING 13 8000-9000\"\n        )\n        self.proxy_helper.send_notification(smigrating_node_2)\n\n        # execute command with node 2 connection\n        self.cluster.set(\"anyprefix:{1}:k\", \"VAL\")\n\n        self._validate_connections_states(\n            self.cluster,\n            [\n                ConnectionStateExpectation(\n                    node_port=NODE_PORT_1,\n                    changed_connections_count=1,\n                    state=MaintenanceState.MAINTENANCE,\n                    relaxed_timeout=self.config.relaxed_timeout,\n                ),\n                ConnectionStateExpectation(\n                    node_port=NODE_PORT_2,\n                    changed_connections_count=1,\n                    state=MaintenanceState.MAINTENANCE,\n                    relaxed_timeout=self.config.relaxed_timeout,\n                ),\n            ],\n        )\n        smigrated_node_1 = RespTranslator.oss_maint_notification_to_resp(\n            f\"SMIGRATED 14 {NODE_IP_PROXY}:{NODE_PORT_1} {NODE_IP_PROXY}:{NODE_PORT_3} 123,2000-3000\"\n        )\n        self.proxy_helper.send_notification(smigrated_node_1)\n\n        self.proxy_helper.set_cluster_slots(\n            CLUSTER_SLOTS_INTERCEPTOR_NAME,\n            [\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_1, 0, 122),\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_3, 123, 123),\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_1, 124, 1999),\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_3, 2000, 3000),\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_1, 3001, 5460),\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_2, 5461, 10922),\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_3, 10923, 16383),\n            ],\n        )\n\n        # execute command with node 1 connection\n        self.cluster.set(\"anyprefix:{3}:k\", \"VAL\")\n\n        # validate the cluster topology was updated\n        # validate old nodes are there\n        assert node_1 in self.cluster.nodes_manager.nodes_cache.values()\n        assert node_2 in self.cluster.nodes_manager.nodes_cache.values()\n        # validate changed slot is assigned to node 3\n        assert self.cluster.nodes_manager.get_node_from_slot(\n            123\n        ) == self.cluster.nodes_manager.get_node(host=NODE_IP_PROXY, port=NODE_PORT_3)\n        # validate the connections are in the correct state\n        self._validate_connections_states(\n            self.cluster,\n            [\n                ConnectionStateExpectation(\n                    node_port=NODE_PORT_1,\n                    changed_connections_count=0,\n                ),\n                ConnectionStateExpectation(\n                    node_port=NODE_PORT_2,\n                    changed_connections_count=1,\n                    state=MaintenanceState.MAINTENANCE,\n                    relaxed_timeout=self.config.relaxed_timeout,\n                ),\n            ],\n        )\n\n        smigrated_node_2 = RespTranslator.oss_maint_notification_to_resp(\n            f\"SMIGRATED 15 {NODE_IP_PROXY}:{NODE_PORT_2} {NODE_IP_PROXY}:{NODE_PORT_3} 7000-7999\"\n        )\n        self.proxy_helper.send_notification(smigrated_node_2)\n\n        self.proxy_helper.set_cluster_slots(\n            CLUSTER_SLOTS_INTERCEPTOR_NAME,\n            [\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_1, 0, 122),\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_3, 123, 123),\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_1, 124, 2000),\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_3, 2001, 3000),\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_1, 3001, 5460),\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_2, 5461, 6999),\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_3, 7000, 7999),\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_2, 8000, 10922),\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_3, 10923, 16383),\n            ],\n        )\n        # execute command with node 2 connection\n        self.cluster.set(\"anyprefix:{1}:k\", \"VAL\")\n\n        # validate old nodes are there\n        assert node_1 in self.cluster.nodes_manager.nodes_cache.values()\n        assert node_2 in self.cluster.nodes_manager.nodes_cache.values()\n        # validate slot changes are reflected\n        assert self.cluster.nodes_manager.get_node_from_slot(\n            7000\n        ) == self.cluster.nodes_manager.get_node(host=NODE_IP_PROXY, port=NODE_PORT_3)\n\n        # validate the connections are in the correct state\n        self._validate_connections_states(\n            self.cluster,\n            [\n                ConnectionStateExpectation(\n                    node_port=NODE_PORT_1,\n                    changed_connections_count=0,\n                ),\n                ConnectionStateExpectation(\n                    node_port=NODE_PORT_2,\n                    changed_connections_count=0,\n                ),\n            ],\n        )\n\n    def test_smigrating_smigrated_on_two_nodes_with_node_replacements(self):\n        \"\"\"Test receiving an OSS maintenance notification on two nodes with node replacement.\"\"\"\n        # warm up connection pools - create several connections in each pool\n        self._warm_up_connection_pools(self.cluster, created_connections_count=3)\n\n        node_1 = self.cluster.nodes_manager.get_node(\n            host=NODE_IP_PROXY, port=NODE_PORT_1\n        )\n        node_2 = self.cluster.nodes_manager.get_node(\n            host=NODE_IP_PROXY, port=NODE_PORT_2\n        )\n        node_3 = self.cluster.nodes_manager.get_node(\n            host=NODE_IP_PROXY, port=NODE_PORT_3\n        )\n\n        smigrating_node_1 = RespTranslator.oss_maint_notification_to_resp(\n            \"SMIGRATING 12 0-5460\"\n        )\n        self.proxy_helper.send_notification(smigrating_node_1)\n        # execute command with node 1 connection\n        self.cluster.set(\"anyprefix:{3}:k\", \"VAL\")\n        self._validate_connections_states(\n            self.cluster,\n            [\n                ConnectionStateExpectation(\n                    node_port=NODE_PORT_1,\n                    changed_connections_count=1,\n                    state=MaintenanceState.MAINTENANCE,\n                    relaxed_timeout=self.config.relaxed_timeout,\n                ),\n                ConnectionStateExpectation(\n                    node_port=NODE_PORT_2, changed_connections_count=0\n                ),\n            ],\n        )\n\n        smigrating_node_2 = RespTranslator.oss_maint_notification_to_resp(\n            \"SMIGRATING 13 5461-10922\"\n        )\n        self.proxy_helper.send_notification(smigrating_node_2)\n\n        # execute command with node 2 connection\n        self.cluster.set(\"anyprefix:{1}:k\", \"VAL\")\n\n        self._validate_connections_states(\n            self.cluster,\n            [\n                ConnectionStateExpectation(\n                    node_port=NODE_PORT_1,\n                    changed_connections_count=1,\n                    state=MaintenanceState.MAINTENANCE,\n                    relaxed_timeout=self.config.relaxed_timeout,\n                ),\n                ConnectionStateExpectation(\n                    node_port=NODE_PORT_2,\n                    changed_connections_count=1,\n                    state=MaintenanceState.MAINTENANCE,\n                    relaxed_timeout=self.config.relaxed_timeout,\n                ),\n            ],\n        )\n\n        smigrated_node_1 = RespTranslator.oss_maint_notification_to_resp(\n            f\"SMIGRATED 14 {NODE_IP_PROXY}:{NODE_PORT_1} {NODE_IP_PROXY}:{NODE_PORT_NEW} 0-5460\"\n        )\n        self.proxy_helper.send_notification(smigrated_node_1)\n\n        self.proxy_helper.set_cluster_slots(\n            CLUSTER_SLOTS_INTERCEPTOR_NAME,\n            [\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_NEW, 0, 5460),\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_2, 5461, 10922),\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_3, 10923, 16383),\n            ],\n        )\n\n        # execute command with node 1 connection\n        self.cluster.set(\"anyprefix:{3}:k\", \"VAL\")\n\n        # validate node 1 is removed\n        assert node_1 not in self.cluster.nodes_manager.nodes_cache.values()\n        # validate node 2 is still there\n        assert node_2 in self.cluster.nodes_manager.nodes_cache.values()\n        # validate new node is added\n        new_node = self.cluster.nodes_manager.get_node(\n            host=NODE_IP_PROXY, port=NODE_PORT_NEW\n        )\n        assert new_node is not None\n        assert new_node.redis_connection is not None\n        # validate a slot from the changed range is assigned to the new node\n        assert self.cluster.nodes_manager.get_node_from_slot(\n            123\n        ) == self.cluster.nodes_manager.get_node(host=NODE_IP_PROXY, port=NODE_PORT_NEW)\n\n        # validate the connections are in the correct state\n        self._validate_removed_node_connections(node_1)\n\n        # validate the connections are in the correct state\n        self._validate_connections_states(\n            self.cluster,\n            [\n                ConnectionStateExpectation(\n                    node_port=NODE_PORT_2,\n                    changed_connections_count=1,\n                    state=MaintenanceState.MAINTENANCE,\n                    relaxed_timeout=self.config.relaxed_timeout,\n                ),\n            ],\n        )\n\n        smigrated_node_2 = RespTranslator.oss_maint_notification_to_resp(\n            f\"SMIGRATED 15 {NODE_IP_PROXY}:{NODE_PORT_2} {NODE_IP_PROXY}:15383 5461-10922\"\n        )\n        self.proxy_helper.send_notification(smigrated_node_2)\n\n        self.proxy_helper.set_cluster_slots(\n            CLUSTER_SLOTS_INTERCEPTOR_NAME,\n            [\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_NEW, 0, 5460),\n                SlotsRange(NODE_IP_PROXY, 15383, 5461, 10922),\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_3, 10923, 16383),\n            ],\n        )\n        # execute command with node 2 connection\n        self.cluster.set(\"anyprefix:{1}:k\", \"VAL\")\n\n        # validate node 2 is removed\n        assert node_2 not in self.cluster.nodes_manager.nodes_cache.values()\n        # validate node 3 is still there\n        assert node_3 in self.cluster.nodes_manager.nodes_cache.values()\n        # validate new node is added\n        new_node = self.cluster.nodes_manager.get_node(host=NODE_IP_PROXY, port=15383)\n        assert new_node is not None\n        assert new_node.redis_connection is not None\n        # validate a slot from the changed range is assigned to the new node\n        assert self.cluster.nodes_manager.get_node_from_slot(\n            8000\n        ) == self.cluster.nodes_manager.get_node(host=NODE_IP_PROXY, port=15383)\n\n        # validate the connections in removed node are in the correct state\n        self._validate_removed_node_connections(node_2)\n\n    def test_smigrating_smigrated_on_the_same_node_two_slot_ranges(\n        self,\n    ):\n        \"\"\"\n        Test receiving an OSS maintenance notification on the same node twice.\n        The focus here is to validate that the timeouts are not unrelaxed if a second\n        migration is in progress\n        \"\"\"\n        # warm up connection pools - create several connections in each pool\n        self._warm_up_connection_pools(self.cluster, created_connections_count=1)\n\n        smigrating_node_1 = RespTranslator.oss_maint_notification_to_resp(\n            \"SMIGRATING 12 1000-2000,2500-3000\"\n        )\n        self.proxy_helper.send_notification(smigrating_node_1)\n        # execute command with node 1 connection\n        self.cluster.set(\"anyprefix:{3}:k\", \"VAL\")\n        self._validate_connections_states(\n            self.cluster,\n            [\n                ConnectionStateExpectation(\n                    node_port=NODE_PORT_1,\n                    changed_connections_count=1,\n                    state=MaintenanceState.MAINTENANCE,\n                    relaxed_timeout=self.config.relaxed_timeout,\n                ),\n            ],\n        )\n\n        smigrated_node_1 = RespTranslator.oss_maint_notification_to_resp(\n            f\"SMIGRATED 14 {NODE_IP_PROXY}:{NODE_PORT_1} {NODE_IP_PROXY}:{NODE_PORT_2} 1000-2000 {NODE_IP_PROXY}:{NODE_PORT_1} {NODE_IP_PROXY}:{NODE_PORT_3} 2500-3000\"\n        )\n        self.proxy_helper.send_notification(smigrated_node_1)\n        # execute command with node 1 connection\n        self.cluster.set(\"anyprefix:{3}:k\", \"VAL\")\n\n        # validate the timeout is still relaxed\n        self._validate_connections_states(\n            self.cluster,\n            [\n                ConnectionStateExpectation(\n                    node_port=NODE_PORT_1,\n                ),\n            ],\n        )\n        smigrated_node_1_2 = RespTranslator.oss_maint_notification_to_resp(\n            f\"SMIGRATED 15 {NODE_IP_PROXY}:{NODE_PORT_1} {NODE_IP_PROXY}:{NODE_PORT_3} 3000-4000\"\n        )\n        self.proxy_helper.send_notification(smigrated_node_1_2)\n        # execute command with node 1 connection\n        self.cluster.set(\"anyprefix:{3}:k\", \"VAL\")\n        self._validate_connections_states(\n            self.cluster,\n            [\n                ConnectionStateExpectation(\n                    node_port=NODE_PORT_1,\n                    changed_connections_count=0,\n                ),\n            ],\n        )\n\n    def test_smigrating_smigrated_with_sharded_pubsub(\n        self,\n    ):\n        \"\"\"\n        Test handling of sharded pubsub connections when SMIGRATING and SMIGRATED\n        notifications are received.\n        \"\"\"\n        # warm up connection pools - create several connections in each pool\n        self._warm_up_connection_pools(self.cluster, created_connections_count=5)\n\n        node_1 = self.cluster.nodes_manager.get_node(\n            host=NODE_IP_PROXY, port=NODE_PORT_1\n        )\n\n        pubsub = self.cluster.pubsub()\n\n        # subscribe to a channel on node1\n        pubsub.ssubscribe(\"anyprefix:{7}:k\")\n\n        msg = pubsub.get_sharded_message(\n            ignore_subscribe_messages=False, timeout=10, target_node=node_1\n        )\n        # subscribe msg\n        assert msg is not None and msg[\"type\"] == \"ssubscribe\"\n\n        smigrating_node_1 = RespTranslator.oss_maint_notification_to_resp(\n            \"SMIGRATING 12 5200-5460\"\n        )\n        self.proxy_helper.send_notification(smigrating_node_1)\n\n        # get message with node 1 connection to consume the SMIGRATING notification\n        # timeout is 1 second\n        msg = pubsub.get_sharded_message(ignore_subscribe_messages=False, timeout=5000)\n        # smigrating handled\n        assert msg is None\n\n        assert pubsub.node_pubsub_mapping[node_1.name].connection._sock is not None\n        assert pubsub.node_pubsub_mapping[node_1.name].connection._socket_timeout == 30\n        assert (\n            pubsub.node_pubsub_mapping[node_1.name].connection._socket_connect_timeout\n            == 30\n        )\n\n        smigrated_node_1 = RespTranslator.oss_maint_notification_to_resp(\n            f\"SMIGRATED 14 {NODE_IP_PROXY}:{NODE_PORT_1} {NODE_IP_PROXY}:{NODE_PORT_2} 123\"\n        )\n        self.proxy_helper.send_notification(smigrated_node_1)\n\n        self.proxy_helper.set_cluster_slots(\n            CLUSTER_SLOTS_INTERCEPTOR_NAME,\n            [\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_1, 0, 122),\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_2, 123, 123),\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_1, 124, 5200),\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_2, 5201, 10922),\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_3, 10923, 16383),\n            ],\n        )\n\n        # execute command with node 1 connection\n        # this will first consume the SMIGRATING notification for the connection\n        # then should process the SMIGRATED notification and update the cluster\n        # topology and move the slot range to the new node\n        # and should set the pubsub connection for reconnect\n        res = self.cluster.set(\"anyprefix:{3}:k\", \"VAL\")\n        assert res is True\n\n        assert pubsub.node_pubsub_mapping[node_1.name].connection._should_reconnect\n        assert pubsub.node_pubsub_mapping[node_1.name].connection._sock is not None\n        # validate timeout is not relaxed - it will be relaxed\n        # when this concrete connections reads the notification\n        assert pubsub.node_pubsub_mapping[node_1.name].connection._socket_timeout == 30\n        assert (\n            pubsub.node_pubsub_mapping[node_1.name].connection._socket_connect_timeout\n            == 30\n        )\n\n        # during this read the connection will detect that it needs to reconnect\n        # and the waiting on the socket SMIGRATED won't be processed\n        # it will directly reconnect and receive again the SMIGRATED notification\n        logging.info(\n            \"Waiting for message with pubsub connection that will reconnect...\"\n        )\n        msg = None\n        while msg is None or msg[\"type\"] != \"ssubscribe\":\n            logging.info(\"Waiting for ssubscribe message...\")\n            msg = pubsub.get_sharded_message(\n                ignore_subscribe_messages=False, timeout=10\n            )\n        assert msg is not None and msg[\"type\"] == \"ssubscribe\"\n        logging.info(\"Reconnect ended.\")\n\n        logging.info(\"Consuming SMIGRATED notification with pubsub connection...\")\n        # simulating server's behavior that send the last notification to the new connection\n        self.proxy_helper.send_notification(smigrated_node_1)\n        msg = pubsub.get_sharded_message(ignore_subscribe_messages=True, timeout=10)\n        assert msg is None\n\n        assert not pubsub.node_pubsub_mapping[node_1.name].connection._should_reconnect\n        assert pubsub.node_pubsub_mapping[node_1.name].connection._sock is not None\n        assert (\n            pubsub.node_pubsub_mapping[node_1.name].connection._socket_timeout is None\n        )\n        assert (\n            pubsub.node_pubsub_mapping[node_1.name].connection._socket_connect_timeout\n            is None\n        )\n        assert (\n            pubsub.node_pubsub_mapping[node_1.name].connection.maintenance_state\n            == MaintenanceState.NONE\n        )\n        # validate resubscribed\n        assert pubsub.node_pubsub_mapping[node_1.name].subscribed\n\n    def test_smigrating_smigrated_with_sharded_pubsub_and_reconnect_after_smigrated_expires(\n        self,\n    ):\n        \"\"\"\n        Test handling of sharded pubsub connections when SMIGRATING and SMIGRATED\n        notifications are received.\n        \"\"\"\n        # warm up connection pools - create several connections in each pool\n        self._warm_up_connection_pools(self.cluster, created_connections_count=5)\n\n        node_1 = self.cluster.nodes_manager.get_node(\n            host=NODE_IP_PROXY, port=NODE_PORT_1\n        )\n\n        pubsub = self.cluster.pubsub()\n\n        # subscribe to a channel on node1\n        pubsub.ssubscribe(\"anyprefix:{7}:k\")\n\n        msg = pubsub.get_sharded_message(\n            ignore_subscribe_messages=False, timeout=10, target_node=node_1\n        )\n        # subscribe msg\n        assert msg is not None and msg[\"type\"] == \"ssubscribe\"\n\n        smigrating_node_1 = RespTranslator.oss_maint_notification_to_resp(\n            \"SMIGRATING 12 5200-5460\"\n        )\n        self.proxy_helper.send_notification(smigrating_node_1)\n\n        # get message with node 1 connection to consume the SMIGRATING notification\n        # timeout is 1 second\n        msg = pubsub.get_sharded_message(ignore_subscribe_messages=False, timeout=5000)\n        # smigrating handled\n        assert msg is None\n\n        assert pubsub.node_pubsub_mapping[node_1.name].connection._sock is not None\n        assert pubsub.node_pubsub_mapping[node_1.name].connection._socket_timeout == 30\n        assert (\n            pubsub.node_pubsub_mapping[node_1.name].connection._socket_connect_timeout\n            == 30\n        )\n\n        smigrated_node_1 = RespTranslator.oss_maint_notification_to_resp(\n            f\"SMIGRATED 14 {NODE_IP_PROXY}:{NODE_PORT_1} {NODE_IP_PROXY}:{NODE_PORT_2} 123\"\n        )\n        self.proxy_helper.send_notification(smigrated_node_1)\n\n        self.proxy_helper.set_cluster_slots(\n            CLUSTER_SLOTS_INTERCEPTOR_NAME,\n            [\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_1, 0, 122),\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_2, 123, 123),\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_1, 124, 5200),\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_2, 5201, 10922),\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_3, 10923, 16383),\n            ],\n        )\n\n        # execute command with node 1 connection\n        # this will first consume the SMIGRATING notification for the connection\n        # then should process the SMIGRATED notification and update the cluster\n        # topology and move the slot range to the new node\n        # and should set the pubsub connection for reconnect\n        res = self.cluster.set(\"anyprefix:{3}:k\", \"VAL\")\n        assert res is True\n\n        assert pubsub.node_pubsub_mapping[node_1.name].connection._should_reconnect\n        assert pubsub.node_pubsub_mapping[node_1.name].connection._sock is not None\n        # validate timeout is not relaxed - it will be relaxed\n        # when this concrete connections reads the notification\n        assert pubsub.node_pubsub_mapping[node_1.name].connection._socket_timeout == 30\n        assert (\n            pubsub.node_pubsub_mapping[node_1.name].connection._socket_connect_timeout\n            == 30\n        )\n\n        # during this read the connection will detect that it needs to reconnect\n        # and the waiting on the socket SMIGRATED won't be processed\n        # it will directly reconnect and receive again the SMIGRATED notification\n        logging.info(\n            \"Waiting for message with pubsub connection that will reconnect...\"\n        )\n        msg = None\n        while msg is None or msg[\"type\"] != \"ssubscribe\":\n            logging.info(\"Waiting for ssubscribe message...\")\n            msg = pubsub.get_sharded_message(\n                ignore_subscribe_messages=False, timeout=10\n            )\n        assert msg is not None and msg[\"type\"] == \"ssubscribe\"\n        logging.info(\"Reconnect ended.\")\n\n        assert not pubsub.node_pubsub_mapping[node_1.name].connection._should_reconnect\n        assert pubsub.node_pubsub_mapping[node_1.name].connection._sock is not None\n        assert (\n            pubsub.node_pubsub_mapping[node_1.name].connection._socket_timeout is None\n        )\n        assert (\n            pubsub.node_pubsub_mapping[node_1.name].connection._socket_connect_timeout\n            is None\n        )\n        assert (\n            pubsub.node_pubsub_mapping[node_1.name].connection.maintenance_state\n            == MaintenanceState.NONE\n        )\n        # validate resubscribed\n        assert pubsub.node_pubsub_mapping[node_1.name].subscribed\n\n    def test_smigrating_smigrated_with_std_pubsub(\n        self,\n    ):\n        \"\"\"\n        Test handling of standard pubsub connections when SMIGRATING and SMIGRATED\n        notifications are received.\n        \"\"\"\n        # warm up connection pools - create several connections in each pool\n        self._warm_up_connection_pools(self.cluster, created_connections_count=5)\n\n        pubsub = self.cluster.pubsub()\n\n        # subscribe to a channel on node1\n        pubsub.subscribe(\"anyprefix:{7}:k\")\n\n        msg = pubsub.get_message(ignore_subscribe_messages=False, timeout=10)\n        # subscribe msg\n        assert msg is not None and msg[\"type\"] == \"subscribe\"\n\n        smigrating_node_1 = RespTranslator.oss_maint_notification_to_resp(\n            \"SMIGRATING 12 5200-5460\"\n        )\n        self.proxy_helper.send_notification(smigrating_node_1)\n\n        # get message with to consume the SMIGRATING notification\n        # timeout is 1 second\n        msg = pubsub.get_message(ignore_subscribe_messages=False, timeout=5000)\n        # smigrating handled\n        assert msg is None\n\n        assert pubsub.connection._sock is not None\n        assert pubsub.connection._socket_timeout == 30\n        assert pubsub.connection._socket_connect_timeout == 30\n\n        self.proxy_helper.set_cluster_slots(\n            CLUSTER_SLOTS_INTERCEPTOR_NAME,\n            [\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_1, 0, 5199),\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_2, 5200, 10922),\n                SlotsRange(NODE_IP_PROXY, NODE_PORT_3, 10923, 16383),\n            ],\n        )\n\n        smigrated_node_1 = RespTranslator.oss_maint_notification_to_resp(\n            f\"SMIGRATED 13 {NODE_IP_PROXY}:{NODE_PORT_1} {NODE_IP_PROXY}:{NODE_PORT_2} 5200-5460\"\n        )\n        self.proxy_helper.send_notification(smigrated_node_1)\n        # execute command with node 1 connection\n        # this will first consume the SMIGRATING and SMIGRATED notifications for the connection\n        # this should update the cluster topology and move the slot range to the new node\n        # and should set the pubsub connection for reconnect\n        res = self.cluster.set(\"anyprefix:{3}:k\", \"VAL\")\n        assert res is True\n\n        assert pubsub.connection._should_reconnect\n        assert pubsub.connection._sock is not None\n        # validate timeout is still relaxed - it will be unrelaxed when this concrete connection\n        # will read the notification\n        assert pubsub.connection._socket_timeout == 30\n        assert pubsub.connection._socket_connect_timeout == 30\n\n        # next message will be SMIGRATED notification handling\n        # during this read connection will be reconnected and will resubscribe to channels\n        msg = pubsub.get_message(ignore_subscribe_messages=True, timeout=10)\n        assert msg is None\n\n        assert not pubsub.connection._should_reconnect\n        assert pubsub.connection._sock is not None\n        assert pubsub.connection._socket_timeout is None\n        assert pubsub.connection._socket_connect_timeout is None\n        assert pubsub.connection.maintenance_state == MaintenanceState.NONE\n        # validate resubscribed\n        assert pubsub.subscribed\n"
  },
  {
    "path": "tests/maint_notifications/test_maint_notifications.py",
    "content": "import threading\nfrom unittest.mock import Mock, call, patch, MagicMock\nimport pytest\n\nfrom redis.connection import ConnectionInterface, MaintNotificationsAbstractConnection\n\nfrom redis.maint_notifications import (\n    MaintenanceNotification,\n    NodeMovingNotification,\n    NodeMigratingNotification,\n    NodeMigratedNotification,\n    NodeFailingOverNotification,\n    NodeFailedOverNotification,\n    OSSNodeMigratingNotification,\n    OSSNodeMigratedNotification,\n    MaintNotificationsConfig,\n    MaintNotificationsPoolHandler,\n    MaintNotificationsConnectionHandler,\n    MaintenanceState,\n    EndpointType,\n)\n\n\nclass TestMaintenanceNotification:\n    \"\"\"Test the base MaintenanceNotification class functionality through concrete subclasses.\"\"\"\n\n    def test_abstract_class_cannot_be_instantiated(self):\n        \"\"\"Test that MaintenanceNotification cannot be instantiated directly.\"\"\"\n        with patch(\"time.monotonic\", return_value=1000):\n            with pytest.raises(TypeError):\n                MaintenanceNotification(id=1, ttl=10)  # type: ignore\n\n    def test_init_through_subclass(self):\n        \"\"\"Test MaintenanceNotification initialization through concrete subclass.\"\"\"\n        with patch(\"time.monotonic\", return_value=1000):\n            notification = NodeMovingNotification(\n                id=1, new_node_host=\"localhost\", new_node_port=6379, ttl=10\n            )\n            assert notification.id == 1\n            assert notification.ttl == 10\n            assert notification.creation_time == 1000\n            assert notification.expire_at == 1010\n\n    @pytest.mark.parametrize(\n        (\"current_time\", \"expected_expired_state\"),\n        [\n            (1005, False),\n            (1015, True),\n        ],\n    )\n    def test_is_expired(self, current_time, expected_expired_state):\n        \"\"\"Test is_expired returns False for non-expired notification.\"\"\"\n        with patch(\"time.monotonic\", return_value=1000):\n            notification = NodeMovingNotification(\n                id=1, new_node_host=\"localhost\", new_node_port=6379, ttl=10\n            )\n\n        with patch(\"time.monotonic\", return_value=current_time):\n            assert notification.is_expired() == expected_expired_state\n\n    def test_is_expired_exact_boundary(self):\n        \"\"\"Test is_expired at exact expiration boundary.\"\"\"\n        with patch(\"time.monotonic\", return_value=1000):\n            notification = NodeMovingNotification(\n                id=1, new_node_host=\"localhost\", new_node_port=6379, ttl=10\n            )\n\n        with patch(\"time.monotonic\", return_value=1010):  # Exactly at expiration\n            assert not notification.is_expired()\n\n        with patch(\"time.monotonic\", return_value=1011):  # 1 second past expiration\n            assert notification.is_expired()\n\n\nclass TestNodeMovingNotification:\n    \"\"\"Test the NodeMovingNotification class.\"\"\"\n\n    def test_init(self):\n        \"\"\"Test NodeMovingNotification initialization.\"\"\"\n        with patch(\"time.monotonic\", return_value=1000):\n            notification = NodeMovingNotification(\n                id=1, new_node_host=\"localhost\", new_node_port=6379, ttl=10\n            )\n            assert notification.id == 1\n            assert notification.new_node_host == \"localhost\"\n            assert notification.new_node_port == 6379\n            assert notification.ttl == 10\n            assert notification.creation_time == 1000\n\n    def test_repr(self):\n        \"\"\"Test NodeMovingNotification string representation.\"\"\"\n        with patch(\"time.monotonic\", return_value=1000):\n            notification = NodeMovingNotification(\n                id=1, new_node_host=\"localhost\", new_node_port=6379, ttl=10\n            )\n\n        with patch(\"time.monotonic\", return_value=1005):  # 5 seconds later\n            repr_str = repr(notification)\n            assert \"NodeMovingNotification\" in repr_str\n            assert \"id=1\" in repr_str\n            assert \"new_node_host='localhost'\" in repr_str\n            assert \"new_node_port=6379\" in repr_str\n            assert \"ttl=10\" in repr_str\n            assert \"remaining=5.0s\" in repr_str\n            assert \"expired=False\" in repr_str\n\n    def test_equality_none_id_none_port(self):\n        \"\"\"Test equality for notifications with same id and host and port - None.\"\"\"\n        notification1 = NodeMovingNotification(\n            id=1, new_node_host=None, new_node_port=None, ttl=10\n        )\n        notification2 = NodeMovingNotification(\n            id=1, new_node_host=None, new_node_port=None, ttl=20\n        )  # Different TTL\n        assert notification1 == notification2\n\n    def test_equality_same_id_host_port(self):\n        \"\"\"Test equality for notifications with same id, host, and port.\"\"\"\n        notification1 = NodeMovingNotification(\n            id=1, new_node_host=\"localhost\", new_node_port=6379, ttl=10\n        )\n        notification2 = NodeMovingNotification(\n            id=1, new_node_host=\"localhost\", new_node_port=6379, ttl=20\n        )  # Different TTL\n        assert notification1 == notification2\n\n    def test_equality_same_id_different_host(self):\n        \"\"\"Test inequality for notifications with same id but different host.\"\"\"\n        notification1 = NodeMovingNotification(\n            id=1, new_node_host=\"host1\", new_node_port=6379, ttl=10\n        )\n        notification2 = NodeMovingNotification(\n            id=1, new_node_host=\"host2\", new_node_port=6379, ttl=10\n        )\n        assert notification1 != notification2\n\n    def test_equality_same_id_different_port(self):\n        \"\"\"Test inequality for notifications with same id but different port.\"\"\"\n        notification1 = NodeMovingNotification(\n            id=1, new_node_host=\"localhost\", new_node_port=6379, ttl=10\n        )\n        notification2 = NodeMovingNotification(\n            id=1, new_node_host=\"localhost\", new_node_port=6380, ttl=10\n        )\n        assert notification1 != notification2\n\n    def test_equality_different_id(self):\n        \"\"\"Test inequality for notifications with different id.\"\"\"\n        notification1 = NodeMovingNotification(\n            id=1, new_node_host=\"localhost\", new_node_port=6379, ttl=10\n        )\n        notification2 = NodeMovingNotification(\n            id=2, new_node_host=\"localhost\", new_node_port=6379, ttl=10\n        )\n        assert notification1 != notification2\n\n    def test_equality_different_type(self):\n        \"\"\"Test inequality for notifications of different types.\"\"\"\n        notification1 = NodeMovingNotification(\n            id=1, new_node_host=\"localhost\", new_node_port=6379, ttl=10\n        )\n        notification2 = NodeMigratingNotification(id=1, ttl=10)\n        assert notification1 != notification2\n\n    def test_hash_same_id_host_port(self):\n        \"\"\"Test hash consistency for notifications with same id, host, and port.\"\"\"\n        notification1 = NodeMovingNotification(\n            id=1, new_node_host=\"localhost\", new_node_port=6379, ttl=10\n        )\n        notification2 = NodeMovingNotification(\n            id=1, new_node_host=\"localhost\", new_node_port=6379, ttl=20\n        )  # Different TTL\n        assert hash(notification1) == hash(notification2)\n\n    def test_hash_different_host(self):\n        \"\"\"Test hash difference for notifications with different host.\"\"\"\n        notification1 = NodeMovingNotification(\n            id=1, new_node_host=\"host1\", new_node_port=6379, ttl=10\n        )\n        notification2 = NodeMovingNotification(\n            id=1, new_node_host=\"host2\", new_node_port=6379, ttl=10\n        )\n        assert hash(notification1) != hash(notification2)\n\n    def test_hash_different_port(self):\n        \"\"\"Test hash difference for notifications with different port.\"\"\"\n        notification1 = NodeMovingNotification(\n            id=1, new_node_host=\"localhost\", new_node_port=6379, ttl=10\n        )\n        notification2 = NodeMovingNotification(\n            id=1, new_node_host=\"localhost\", new_node_port=6380, ttl=10\n        )\n        assert hash(notification1) != hash(notification2)\n\n    def test_hash_different_id(self):\n        \"\"\"Test hash difference for notifications with different id.\"\"\"\n        notification1 = NodeMovingNotification(\n            id=1, new_node_host=\"localhost\", new_node_port=6379, ttl=10\n        )\n        notification2 = NodeMovingNotification(\n            id=2, new_node_host=\"localhost\", new_node_port=6379, ttl=10\n        )\n        assert hash(notification1) != hash(notification2)\n\n    def test_set_functionality(self):\n        \"\"\"Test that notifications can be used in sets correctly.\"\"\"\n        notification1 = NodeMovingNotification(\n            id=1, new_node_host=\"localhost\", new_node_port=6379, ttl=10\n        )\n        notification2 = NodeMovingNotification(\n            id=1, new_node_host=\"localhost\", new_node_port=6379, ttl=20\n        )  # Same id, host, port - should be considered the same\n        notification3 = NodeMovingNotification(\n            id=1, new_node_host=\"host2\", new_node_port=6380, ttl=10\n        )  # Same id but different host/port - should be different\n        notification4 = NodeMovingNotification(\n            id=2, new_node_host=\"localhost\", new_node_port=6379, ttl=10\n        )  # Different id - should be different\n\n        notification_set = {notification1, notification2, notification3, notification4}\n        assert (\n            len(notification_set) == 3\n        )  # notification1 and notification2 should be considered the same\n\n\nclass TestNodeMigratingNotification:\n    \"\"\"Test the NodeMigratingNotification class.\"\"\"\n\n    def test_init(self):\n        \"\"\"Test NodeMigratingNotification initialization.\"\"\"\n        with patch(\"time.monotonic\", return_value=1000):\n            notification = NodeMigratingNotification(id=1, ttl=5)\n            assert notification.id == 1\n            assert notification.ttl == 5\n            assert notification.creation_time == 1000\n\n    def test_repr(self):\n        \"\"\"Test NodeMigratingNotification string representation.\"\"\"\n        with patch(\"time.monotonic\", return_value=1000):\n            notification = NodeMigratingNotification(id=1, ttl=5)\n\n        with patch(\"time.monotonic\", return_value=1002):  # 2 seconds later\n            repr_str = repr(notification)\n            assert \"NodeMigratingNotification\" in repr_str\n            assert \"id=1\" in repr_str\n            assert \"ttl=5\" in repr_str\n            assert \"remaining=3.0s\" in repr_str\n            assert \"expired=False\" in repr_str\n\n    def test_equality_and_hash(self):\n        \"\"\"Test equality and hash for NodeMigratingNotification.\"\"\"\n        notification1 = NodeMigratingNotification(id=1, ttl=5)\n        notification2 = NodeMigratingNotification(\n            id=1, ttl=10\n        )  # Same id, different ttl\n        notification3 = NodeMigratingNotification(id=2, ttl=5)  # Different id\n\n        assert notification1 == notification2\n        assert notification1 != notification3\n        assert hash(notification1) == hash(notification2)\n        assert hash(notification1) != hash(notification3)\n\n\nclass TestNodeMigratedNotification:\n    \"\"\"Test the NodeMigratedNotification class.\"\"\"\n\n    def test_init(self):\n        \"\"\"Test NodeMigratedNotification initialization.\"\"\"\n        with patch(\"time.monotonic\", return_value=1000):\n            notification = NodeMigratedNotification(id=1)\n            assert notification.id == 1\n            assert notification.ttl == NodeMigratedNotification.DEFAULT_TTL\n            assert notification.creation_time == 1000\n\n    def test_default_ttl(self):\n        \"\"\"Test that DEFAULT_TTL is used correctly.\"\"\"\n        assert NodeMigratedNotification.DEFAULT_TTL == 5\n        notification = NodeMigratedNotification(id=1)\n        assert notification.ttl == 5\n\n    def test_repr(self):\n        \"\"\"Test NodeMigratedNotification string representation.\"\"\"\n        with patch(\"time.monotonic\", return_value=1000):\n            notification = NodeMigratedNotification(id=1)\n\n        with patch(\"time.monotonic\", return_value=1001):  # 1 second later\n            repr_str = repr(notification)\n            assert \"NodeMigratedNotification\" in repr_str\n            assert \"id=1\" in repr_str\n            assert \"ttl=5\" in repr_str\n            assert \"remaining=4.0s\" in repr_str\n            assert \"expired=False\" in repr_str\n\n    def test_equality_and_hash(self):\n        \"\"\"Test equality and hash for NodeMigratedNotification.\"\"\"\n        notification1 = NodeMigratedNotification(id=1)\n        notification2 = NodeMigratedNotification(id=1)  # Same id\n        notification3 = NodeMigratedNotification(id=2)  # Different id\n\n        assert notification1 == notification2\n        assert notification1 != notification3\n        assert hash(notification1) == hash(notification2)\n        assert hash(notification1) != hash(notification3)\n\n\nclass TestNodeFailingOverNotification:\n    \"\"\"Test the NodeFailingOverNotification class.\"\"\"\n\n    def test_init(self):\n        \"\"\"Test NodeFailingOverNotification initialization.\"\"\"\n        with patch(\"time.monotonic\", return_value=1000):\n            notification = NodeFailingOverNotification(id=1, ttl=5)\n            assert notification.id == 1\n            assert notification.ttl == 5\n            assert notification.creation_time == 1000\n\n    def test_repr(self):\n        \"\"\"Test NodeFailingOverNotification string representation.\"\"\"\n        with patch(\"time.monotonic\", return_value=1000):\n            notification = NodeFailingOverNotification(id=1, ttl=5)\n\n        with patch(\"time.monotonic\", return_value=1002):  # 2 seconds later\n            repr_str = repr(notification)\n            assert \"NodeFailingOverNotification\" in repr_str\n            assert \"id=1\" in repr_str\n            assert \"ttl=5\" in repr_str\n            assert \"remaining=3.0s\" in repr_str\n            assert \"expired=False\" in repr_str\n\n    def test_equality_and_hash(self):\n        \"\"\"Test equality and hash for NodeFailingOverNotification.\"\"\"\n        notification1 = NodeFailingOverNotification(id=1, ttl=5)\n        notification2 = NodeFailingOverNotification(\n            id=1, ttl=10\n        )  # Same id, different ttl\n        notification3 = NodeFailingOverNotification(id=2, ttl=5)  # Different id\n\n        assert notification1 == notification2\n        assert notification1 != notification3\n        assert hash(notification1) == hash(notification2)\n        assert hash(notification1) != hash(notification3)\n\n\nclass TestNodeFailedOverNotification:\n    \"\"\"Test the NodeFailedOverNotification class.\"\"\"\n\n    def test_init(self):\n        \"\"\"Test NodeFailedOverNotification initialization.\"\"\"\n        with patch(\"time.monotonic\", return_value=1000):\n            notification = NodeFailedOverNotification(id=1)\n            assert notification.id == 1\n            assert notification.ttl == NodeFailedOverNotification.DEFAULT_TTL\n            assert notification.creation_time == 1000\n\n    def test_default_ttl(self):\n        \"\"\"Test that DEFAULT_TTL is used correctly.\"\"\"\n        assert NodeFailedOverNotification.DEFAULT_TTL == 5\n        notification = NodeFailedOverNotification(id=1)\n        assert notification.ttl == 5\n\n    def test_repr(self):\n        \"\"\"Test NodeFailedOverNotification string representation.\"\"\"\n        with patch(\"time.monotonic\", return_value=1000):\n            notification = NodeFailedOverNotification(id=1)\n\n        with patch(\"time.monotonic\", return_value=1001):  # 1 second later\n            repr_str = repr(notification)\n            assert \"NodeFailedOverNotification\" in repr_str\n            assert \"id=1\" in repr_str\n            assert \"ttl=5\" in repr_str\n            assert \"remaining=4.0s\" in repr_str\n            assert \"expired=False\" in repr_str\n\n    def test_equality_and_hash(self):\n        \"\"\"Test equality and hash for NodeFailedOverNotification.\"\"\"\n        notification1 = NodeFailedOverNotification(id=1)\n        notification2 = NodeFailedOverNotification(id=1)  # Same id\n        notification3 = NodeFailedOverNotification(id=2)  # Different id\n\n        assert notification1 == notification2\n        assert notification1 != notification3\n        assert hash(notification1) == hash(notification2)\n        assert hash(notification1) != hash(notification3)\n\n\nclass TestOSSNodeMigratingNotification:\n    \"\"\"Test the OSSNodeMigratingNotification class.\"\"\"\n\n    def test_init_with_defaults(self):\n        \"\"\"Test OSSNodeMigratingNotification initialization with default values.\"\"\"\n        with patch(\"time.monotonic\", return_value=1000):\n            notification = OSSNodeMigratingNotification(id=1)\n            assert notification.id == 1\n            assert notification.ttl == OSSNodeMigratingNotification.DEFAULT_TTL\n            assert notification.creation_time == 1000\n            assert notification.slots is None\n\n    def test_init_with_all_parameters(self):\n        \"\"\"Test OSSNodeMigratingNotification initialization with all parameters.\"\"\"\n        with patch(\"time.monotonic\", return_value=1000):\n            slots = \"1,2,3,4,5\"\n            notification = OSSNodeMigratingNotification(\n                id=1,\n                slots=slots,\n            )\n            assert notification.id == 1\n            assert notification.ttl == OSSNodeMigratingNotification.DEFAULT_TTL\n            assert notification.creation_time == 1000\n            assert notification.slots == slots\n\n    def test_default_ttl(self):\n        \"\"\"Test that DEFAULT_TTL is used correctly.\"\"\"\n        assert OSSNodeMigratingNotification.DEFAULT_TTL == 30\n        notification = OSSNodeMigratingNotification(id=1)\n        assert notification.ttl == 30\n\n    def test_repr(self):\n        \"\"\"Test OSSNodeMigratingNotification string representation.\"\"\"\n        with patch(\"time.monotonic\", return_value=1000):\n            notification = OSSNodeMigratingNotification(\n                id=1,\n                slots=\"1,2,3\",\n            )\n\n        with patch(\"time.monotonic\", return_value=1005):  # 5 seconds later\n            repr_str = repr(notification)\n            assert \"OSSNodeMigratingNotification\" in repr_str\n            assert \"id=1\" in repr_str\n            assert \"ttl=30\" in repr_str\n            assert \"remaining=25.0s\" in repr_str\n            assert \"expired=False\" in repr_str\n\n    def test_equality_same_id_and_type(self):\n        \"\"\"Test equality for notifications with same id and type.\"\"\"\n        notification1 = OSSNodeMigratingNotification(\n            id=1,\n            slots=\"1,2,3\",\n        )\n        notification2 = OSSNodeMigratingNotification(\n            id=1,\n            slots=\"4,5,6\",\n        )\n        # Should be equal because id and type are the same\n        assert notification1 == notification2\n\n    def test_equality_different_id(self):\n        \"\"\"Test inequality for notifications with different id.\"\"\"\n        notification1 = OSSNodeMigratingNotification(id=1)\n        notification2 = OSSNodeMigratingNotification(id=2)\n        assert notification1 != notification2\n\n    def test_equality_different_type(self):\n        \"\"\"Test inequality for notifications of different types.\"\"\"\n        notification1 = OSSNodeMigratingNotification(id=1)\n        notification2 = NodeMigratingNotification(id=1, ttl=30)\n        assert notification1 != notification2\n\n    def test_hash_same_id_and_type(self):\n        \"\"\"Test hash for notifications with same id and type.\"\"\"\n        notification1 = OSSNodeMigratingNotification(\n            id=1,\n            slots=\"1,2,3\",\n        )\n        notification2 = OSSNodeMigratingNotification(\n            id=1,\n            slots=\"4,5,6\",\n        )\n        # Should have same hash because id and type are the same\n        assert hash(notification1) == hash(notification2)\n\n    def test_hash_different_id(self):\n        \"\"\"Test hash for notifications with different id.\"\"\"\n        notification1 = OSSNodeMigratingNotification(id=1)\n        notification2 = OSSNodeMigratingNotification(id=2)\n        assert hash(notification1) != hash(notification2)\n\n    def test_in_set(self):\n        \"\"\"Test that notifications can be used in sets.\"\"\"\n        notification1 = OSSNodeMigratingNotification(id=1)\n        notification2 = OSSNodeMigratingNotification(id=1)\n        notification3 = OSSNodeMigratingNotification(id=2)\n        notification4 = OSSNodeMigratingNotification(id=2)\n\n        notification_set = {notification1, notification2, notification3, notification4}\n        assert (\n            len(notification_set) == 2\n        )  # notification1 and notification2 should be the same\n\n\nclass TestOSSNodeMigratedNotification:\n    \"\"\"Test the OSSNodeMigratedNotification class.\"\"\"\n\n    def test_init_with_defaults(self):\n        \"\"\"Test OSSNodeMigratedNotification initialization with default values.\"\"\"\n        with patch(\"time.monotonic\", return_value=1000):\n            nodes_to_slots_mapping = {\"127.0.0.1:6379\": [{\"127.0.0.1:6380\": \"1-100\"}]}\n            notification = OSSNodeMigratedNotification(\n                id=1, nodes_to_slots_mapping=nodes_to_slots_mapping\n            )\n            assert notification.id == 1\n            assert notification.ttl == OSSNodeMigratedNotification.DEFAULT_TTL\n            assert notification.creation_time == 1000\n            assert notification.nodes_to_slots_mapping == nodes_to_slots_mapping\n\n    def test_init_with_all_parameters(self):\n        \"\"\"Test OSSNodeMigratedNotification initialization with all parameters.\"\"\"\n        with patch(\"time.monotonic\", return_value=1000):\n            nodes_to_slots_mapping = {\n                \"127.0.0.1:6379\": [\n                    {\"127.0.0.1:6380\": \"1-100\"},\n                    {\"127.0.0.1:6381\": \"101-200\"},\n                ]\n            }\n            notification = OSSNodeMigratedNotification(\n                id=1,\n                nodes_to_slots_mapping=nodes_to_slots_mapping,\n            )\n            assert notification.id == 1\n            assert notification.ttl == OSSNodeMigratedNotification.DEFAULT_TTL\n            assert notification.creation_time == 1000\n            assert notification.nodes_to_slots_mapping == nodes_to_slots_mapping\n\n    def test_default_ttl(self):\n        \"\"\"Test that DEFAULT_TTL is used correctly.\"\"\"\n        assert OSSNodeMigratedNotification.DEFAULT_TTL == 120\n        notification = OSSNodeMigratedNotification(\n            id=1,\n            nodes_to_slots_mapping={\"127.0.0.1:6379\": [{\"127.0.0.1:6380\": \"1-100\"}]},\n        )\n        assert notification.ttl == 120\n\n    def test_repr(self):\n        \"\"\"Test OSSNodeMigratedNotification string representation.\"\"\"\n        with patch(\"time.monotonic\", return_value=1000):\n            nodes_to_slots_mapping = {\"127.0.0.1:6379\": [{\"127.0.0.1:6380\": \"1-100\"}]}\n            notification = OSSNodeMigratedNotification(\n                id=1,\n                nodes_to_slots_mapping=nodes_to_slots_mapping,\n            )\n\n        with patch(\"time.monotonic\", return_value=1010):  # 10 seconds later\n            repr_str = repr(notification)\n            assert \"OSSNodeMigratedNotification\" in repr_str\n            assert \"id=1\" in repr_str\n            assert \"ttl=120\" in repr_str\n            assert \"remaining=110.0s\" in repr_str\n            assert \"expired=False\" in repr_str\n\n    def test_equality_same_id_and_type(self):\n        \"\"\"Test equality for notifications with same id and type.\"\"\"\n        notification1 = OSSNodeMigratedNotification(\n            id=1,\n            nodes_to_slots_mapping={\"127.0.0.1:6379\": [{\"127.0.0.1:6380\": \"1-100\"}]},\n        )\n        notification2 = OSSNodeMigratedNotification(\n            id=1,\n            nodes_to_slots_mapping={\"127.0.0.1:6379\": [{\"127.0.0.1:6381\": \"101-200\"}]},\n        )\n        # Should be equal because id and type are the same\n        assert notification1 == notification2\n\n    def test_equality_different_id(self):\n        \"\"\"Test inequality for notifications with different id.\"\"\"\n        notification1 = OSSNodeMigratedNotification(\n            id=1,\n            nodes_to_slots_mapping={\"127.0.0.1:6379\": [{\"127.0.0.1:6380\": \"1-100\"}]},\n        )\n        notification2 = OSSNodeMigratedNotification(\n            id=2,\n            nodes_to_slots_mapping={\"127.0.0.1:6379\": [{\"127.0.0.1:6380\": \"1-100\"}]},\n        )\n        assert notification1 != notification2\n\n    def test_equality_different_type(self):\n        \"\"\"Test inequality for notifications of different types.\"\"\"\n        notification1 = OSSNodeMigratedNotification(\n            id=1,\n            nodes_to_slots_mapping={\"127.0.0.1:6379\": [{\"127.0.0.1:6380\": \"1-100\"}]},\n        )\n        notification2 = NodeMigratedNotification(id=1)\n        assert notification1 != notification2\n\n    def test_hash_same_id_and_type(self):\n        \"\"\"Test hash for notifications with same id and type.\"\"\"\n        notification1 = OSSNodeMigratedNotification(\n            id=1,\n            nodes_to_slots_mapping={\"127.0.0.1:6379\": [{\"127.0.0.1:6380\": \"1-100\"}]},\n        )\n        notification2 = OSSNodeMigratedNotification(\n            id=1,\n            nodes_to_slots_mapping={\"127.0.0.1:6379\": [{\"127.0.0.1:6381\": \"101-200\"}]},\n        )\n        # Should have same hash because id and type are the same\n        assert hash(notification1) == hash(notification2)\n\n    def test_hash_different_id(self):\n        \"\"\"Test hash for notifications with different id.\"\"\"\n        notification1 = OSSNodeMigratedNotification(\n            id=1,\n            nodes_to_slots_mapping={\"127.0.0.1:6379\": [{\"127.0.0.1:6380\": \"1-100\"}]},\n        )\n        notification2 = OSSNodeMigratedNotification(\n            id=2,\n            nodes_to_slots_mapping={\"127.0.0.1:6379\": [{\"127.0.0.1:6380\": \"1-100\"}]},\n        )\n        assert hash(notification1) != hash(notification2)\n\n    def test_in_set(self):\n        \"\"\"Test that notifications can be used in sets.\"\"\"\n        notification1 = OSSNodeMigratedNotification(\n            id=1,\n            nodes_to_slots_mapping={\"127.0.0.1:6379\": [{\"127.0.0.1:6380\": \"1-100\"}]},\n        )\n        notification2 = OSSNodeMigratedNotification(\n            id=1,\n            nodes_to_slots_mapping={\"127.0.0.1:6379\": [{\"127.0.0.1:6380\": \"1-100\"}]},\n        )\n        notification3 = OSSNodeMigratedNotification(\n            id=2,\n            nodes_to_slots_mapping={\"127.0.0.1:6379\": [{\"127.0.0.1:6381\": \"101-200\"}]},\n        )\n        notification4 = OSSNodeMigratedNotification(\n            id=2,\n            nodes_to_slots_mapping={\"127.0.0.1:6379\": [{\"127.0.0.1:6381\": \"101-200\"}]},\n        )\n\n        notification_set = {notification1, notification2, notification3, notification4}\n        assert (\n            len(notification_set) == 2\n        )  # notification1 and notification2 should be the same\n\n\nclass TestMaintNotificationsConfig:\n    \"\"\"Test the MaintNotificationsConfig class.\"\"\"\n\n    def test_init_defaults(self):\n        \"\"\"Test MaintNotificationsConfig initialization with defaults.\"\"\"\n        config = MaintNotificationsConfig()\n        assert config.enabled == \"auto\"\n        assert config.proactive_reconnect is True\n        assert config.relaxed_timeout == 10\n\n    def test_init_custom_values(self):\n        \"\"\"Test MaintNotificationsConfig initialization with custom values.\"\"\"\n        config = MaintNotificationsConfig(\n            enabled=True, proactive_reconnect=False, relaxed_timeout=30\n        )\n        assert config.enabled is True\n        assert config.proactive_reconnect is False\n        assert config.relaxed_timeout == 30\n\n    def test_repr(self):\n        \"\"\"Test MaintNotificationsConfig string representation.\"\"\"\n        config = MaintNotificationsConfig(\n            enabled=True, proactive_reconnect=False, relaxed_timeout=30\n        )\n        repr_str = repr(config)\n        assert \"MaintNotificationsConfig\" in repr_str\n        assert \"enabled=True\" in repr_str\n        assert \"proactive_reconnect=False\" in repr_str\n        assert \"relaxed_timeout=30\" in repr_str\n\n    def test_is_relaxed_timeouts_enabled_true(self):\n        \"\"\"Test is_relaxed_timeouts_enabled returns True for positive timeout.\"\"\"\n        config = MaintNotificationsConfig(relaxed_timeout=20)\n        assert config.is_relaxed_timeouts_enabled() is True\n\n    def test_is_relaxed_timeouts_enabled_false(self):\n        \"\"\"Test is_relaxed_timeouts_enabled returns False for -1 timeout.\"\"\"\n        config = MaintNotificationsConfig(relaxed_timeout=-1)\n        assert config.is_relaxed_timeouts_enabled() is False\n\n    def test_is_relaxed_timeouts_enabled_zero(self):\n        \"\"\"Test is_relaxed_timeouts_enabled returns True for zero timeout.\"\"\"\n        config = MaintNotificationsConfig(relaxed_timeout=0)\n        assert config.is_relaxed_timeouts_enabled() is True\n\n    def test_is_relaxed_timeouts_enabled_none(self):\n        \"\"\"Test is_relaxed_timeouts_enabled returns True for None timeout.\"\"\"\n        config = MaintNotificationsConfig(relaxed_timeout=None)\n        assert config.is_relaxed_timeouts_enabled() is True\n\n    def test_relaxed_timeout_none_is_saved_as_none(self):\n        \"\"\"Test that None value for relaxed_timeout is saved as None.\"\"\"\n        config = MaintNotificationsConfig(relaxed_timeout=None)\n        assert config.relaxed_timeout is None\n\n\nclass TestMaintNotificationsPoolHandler:\n    \"\"\"Test the MaintNotificationsPoolHandler class.\"\"\"\n\n    def setup_method(self):\n        \"\"\"Set up test fixtures.\"\"\"\n        self.mock_pool = Mock()\n        self.mock_pool._lock = MagicMock()\n        self.mock_pool._lock.__enter__.return_value = None\n        self.mock_pool._lock.__exit__.return_value = None\n        self.config = MaintNotificationsConfig(\n            enabled=True, proactive_reconnect=True, relaxed_timeout=20\n        )\n        self.handler = MaintNotificationsPoolHandler(self.mock_pool, self.config)\n\n    def test_init(self):\n        \"\"\"Test MaintNotificationsPoolHandler initialization.\"\"\"\n        assert self.handler.pool == self.mock_pool\n        assert self.handler.config == self.config\n        assert isinstance(self.handler._processed_notifications, set)\n        assert isinstance(self.handler._lock, type(threading.RLock()))\n\n    def test_remove_expired_notifications(self):\n        \"\"\"Test removal of expired notifications.\"\"\"\n        with patch(\"time.monotonic\", return_value=1000):\n            notification1 = NodeMovingNotification(\n                id=1, new_node_host=\"host1\", new_node_port=6379, ttl=10\n            )\n            notification2 = NodeMovingNotification(\n                id=2, new_node_host=\"host2\", new_node_port=6380, ttl=5\n            )\n            self.handler._processed_notifications.add(notification1)\n            self.handler._processed_notifications.add(notification2)\n\n        # Move time forward but not enough to expire notification2 (expires at 1005)\n        with patch(\"time.monotonic\", return_value=1003):\n            self.handler.remove_expired_notifications()\n            assert notification1 in self.handler._processed_notifications\n            assert (\n                notification2 in self.handler._processed_notifications\n            )  # Not expired yet\n\n        # Move time forward to expire notification2 but not notification1\n        with patch(\"time.monotonic\", return_value=1006):\n            self.handler.remove_expired_notifications()\n            assert notification1 in self.handler._processed_notifications\n            assert (\n                notification2 not in self.handler._processed_notifications\n            )  # Now expired\n\n    def test_handle_notification_node_moving(self):\n        \"\"\"Test handling of NodeMovingNotification.\"\"\"\n        notification = NodeMovingNotification(\n            id=1, new_node_host=\"localhost\", new_node_port=6379, ttl=10\n        )\n\n        with patch.object(\n            self.handler, \"handle_node_moving_notification\"\n        ) as mock_handle:\n            self.handler.handle_notification(notification)\n            mock_handle.assert_called_once_with(notification)\n\n    def test_handle_notification_unknown_type(self):\n        \"\"\"Test handling of unknown notification type.\"\"\"\n        notification = NodeMigratingNotification(\n            id=1, ttl=5\n        )  # Not handled by pool handler\n\n        result = self.handler.handle_notification(notification)\n        assert result is None\n\n    def test_handle_node_moving_notification_disabled_config(self):\n        \"\"\"Test node moving notification handling when both features are disabled.\"\"\"\n        config = MaintNotificationsConfig(proactive_reconnect=False, relaxed_timeout=-1)\n        handler = MaintNotificationsPoolHandler(self.mock_pool, config)\n        notification = NodeMovingNotification(\n            id=1, new_node_host=\"localhost\", new_node_port=6379, ttl=10\n        )\n\n        result = handler.handle_node_moving_notification(notification)\n        assert result is None\n        assert notification not in handler._processed_notifications\n\n    def test_handle_node_moving_notification_already_processed(self):\n        \"\"\"Test node moving notification handling when notification already processed.\"\"\"\n        notification = NodeMovingNotification(\n            id=1, new_node_host=\"localhost\", new_node_port=6379, ttl=10\n        )\n        self.handler._processed_notifications.add(notification)\n\n        result = self.handler.handle_node_moving_notification(notification)\n        assert result is None\n\n    def test_handle_node_moving_notification_success(self):\n        \"\"\"Test successful node moving notification handling.\"\"\"\n        notification = NodeMovingNotification(\n            id=1, new_node_host=\"localhost\", new_node_port=6379, ttl=10\n        )\n\n        with (\n            patch(\"threading.Timer\") as mock_timer,\n            patch(\"time.monotonic\", return_value=1000),\n        ):\n            self.handler.handle_node_moving_notification(notification)\n\n            # Verify timer was started\n            mock_timer.assert_called_once_with(\n                notification.ttl,\n                self.handler.handle_node_moved_notification,\n                args=(notification,),\n            )\n            mock_timer.return_value.start.assert_called_once()\n\n            # Verify notification was added to processed set\n            assert notification in self.handler._processed_notifications\n\n            # Verify pool methods were called\n            self.mock_pool.update_connections_settings.assert_called_once()\n\n    def test_handle_node_moving_notification_with_no_host_and_port(self):\n        \"\"\"Test successful node moving notification handling.\"\"\"\n        notification = NodeMovingNotification(\n            id=1, new_node_host=None, new_node_port=None, ttl=2\n        )\n\n        with (\n            patch(\"threading.Timer\") as mock_timer,\n            patch(\"time.monotonic\", return_value=1000),\n        ):\n            self.handler.handle_node_moving_notification(notification)\n\n            # Verify timer was started\n            mock_timer.assert_has_calls(\n                [\n                    call(\n                        notification.ttl / 2,\n                        self.handler.run_proactive_reconnect,\n                        args=(None,),\n                    ),\n                    call().start(),\n                    call(\n                        notification.ttl,\n                        self.handler.handle_node_moved_notification,\n                        args=(notification,),\n                    ),\n                    call().start(),\n                ]\n            )\n\n            # Verify notification was added to processed set\n            assert notification in self.handler._processed_notifications\n\n            # Verify pool methods were called\n            self.mock_pool.update_connections_settings.assert_called_once()\n\n    def test_handle_node_moved_notification(self):\n        \"\"\"Test handling of node moved notification (cleanup).\"\"\"\n        notification = NodeMovingNotification(\n            id=1, new_node_host=\"localhost\", new_node_port=6379, ttl=10\n        )\n        self.mock_pool.connection_kwargs = {\"host\": \"localhost\"}\n        self.handler.handle_node_moved_notification(notification)\n\n        # Verify cleanup methods were called\n        self.mock_pool.update_connections_settings.assert_called_once()\n\n\nclass TestMaintNotificationsConnectionHandler:\n    \"\"\"Test the MaintNotificationsConnectionHandler class.\"\"\"\n\n    def setup_method(self):\n        \"\"\"Set up test fixtures.\"\"\"\n        self.mock_connection = Mock()\n        # Configure _sock.getsockname() to return a proper tuple (host, port)\n        self.mock_connection._sock.getsockname.return_value = (\"127.0.0.1\", 12345)\n        self.config = MaintNotificationsConfig(enabled=True, relaxed_timeout=20)\n        self.handler = MaintNotificationsConnectionHandler(\n            self.mock_connection, self.config\n        )\n\n    def test_init(self):\n        \"\"\"Test MaintNotificationsConnectionHandler initialization.\"\"\"\n        assert self.handler.connection == self.mock_connection\n        assert self.handler.config == self.config\n\n    def test_handle_notification_migrating(self):\n        \"\"\"Test handling of NodeMigratingNotification.\"\"\"\n        notification = NodeMigratingNotification(id=1, ttl=5)\n\n        with patch.object(\n            self.handler, \"handle_maintenance_start_notification\"\n        ) as mock_handle:\n            self.handler.handle_notification(notification)\n            mock_handle.assert_called_once_with(\n                MaintenanceState.MAINTENANCE, notification\n            )\n\n    def test_handle_notification_migrated(self):\n        \"\"\"Test handling of NodeMigratedNotification.\"\"\"\n        notification = NodeMigratedNotification(id=1)\n\n        with patch.object(\n            self.handler, \"handle_maintenance_completed_notification\"\n        ) as mock_handle:\n            self.handler.handle_notification(notification)\n            mock_handle.assert_called_once_with(notification=notification)\n\n    def test_handle_notification_failing_over(self):\n        \"\"\"Test handling of NodeFailingOverNotification.\"\"\"\n        notification = NodeFailingOverNotification(id=1, ttl=5)\n\n        with patch.object(\n            self.handler, \"handle_maintenance_start_notification\"\n        ) as mock_handle:\n            self.handler.handle_notification(notification)\n            mock_handle.assert_called_once_with(\n                MaintenanceState.MAINTENANCE, notification\n            )\n\n    def test_handle_notification_failed_over(self):\n        \"\"\"Test handling of NodeFailedOverNotification.\"\"\"\n        notification = NodeFailedOverNotification(id=1)\n\n        with patch.object(\n            self.handler, \"handle_maintenance_completed_notification\"\n        ) as mock_handle:\n            self.handler.handle_notification(notification)\n            mock_handle.assert_called_once_with(notification=notification)\n\n    def test_handle_notification_unknown_type(self):\n        \"\"\"Test handling of unknown notification type.\"\"\"\n        notification = NodeMovingNotification(\n            id=1, new_node_host=\"localhost\", new_node_port=6379, ttl=10\n        )\n\n        result = self.handler.handle_notification(notification)\n        assert result is None\n\n    def test_handle_maintenance_start_notification_disabled(self):\n        \"\"\"Test maintenance start notification handling when relaxed timeouts are disabled.\"\"\"\n        config = MaintNotificationsConfig(relaxed_timeout=-1)\n        handler = MaintNotificationsConnectionHandler(self.mock_connection, config)\n\n        result = handler.handle_maintenance_start_notification(\n            MaintenanceState.MAINTENANCE, NodeMigratingNotification(id=1, ttl=5)\n        )\n\n        assert result is None\n        self.mock_connection.update_current_socket_timeout.assert_not_called()\n\n    def test_handle_maintenance_start_notification_moving_state(self):\n        \"\"\"Test maintenance start notification handling when connection is in MOVING state.\"\"\"\n        self.mock_connection.maintenance_state = MaintenanceState.MOVING\n\n        result = self.handler.handle_maintenance_start_notification(\n            MaintenanceState.MAINTENANCE, NodeMigratingNotification(id=1, ttl=5)\n        )\n        assert result is None\n        self.mock_connection.update_current_socket_timeout.assert_not_called()\n\n    def test_handle_maintenance_start_notification_success(self):\n        \"\"\"Test successful maintenance start notification handling for migrating.\"\"\"\n        self.mock_connection.maintenance_state = MaintenanceState.NONE\n\n        self.handler.handle_maintenance_start_notification(\n            MaintenanceState.MAINTENANCE, NodeMigratingNotification(id=1, ttl=5)\n        )\n\n        assert self.mock_connection.maintenance_state == MaintenanceState.MAINTENANCE\n        self.mock_connection.update_current_socket_timeout.assert_called_once_with(20)\n        self.mock_connection.set_tmp_settings.assert_called_once_with(\n            tmp_relaxed_timeout=20\n        )\n\n    def test_handle_maintenance_completed_notification_disabled(self):\n        \"\"\"Test maintenance completed notification handling when relaxed timeouts are disabled.\"\"\"\n        config = MaintNotificationsConfig(relaxed_timeout=-1)\n        handler = MaintNotificationsConnectionHandler(self.mock_connection, config)\n\n        result = handler.handle_maintenance_completed_notification()\n        assert result is None\n        self.mock_connection.update_current_socket_timeout.assert_not_called()\n\n    def test_handle_maintenance_completed_notification_moving_state(self):\n        \"\"\"Test maintenance completed notification handling when connection is in MOVING state.\"\"\"\n        self.mock_connection.maintenance_state = MaintenanceState.MOVING\n\n        result = self.handler.handle_maintenance_completed_notification()\n        assert result is None\n        self.mock_connection.update_current_socket_timeout.assert_not_called()\n\n    def test_handle_maintenance_completed_notification_success(self):\n        \"\"\"Test successful maintenance completed notification handling.\"\"\"\n        self.mock_connection.maintenance_state = MaintenanceState.MAINTENANCE\n\n        self.handler.handle_maintenance_completed_notification()\n\n        assert self.mock_connection.maintenance_state == MaintenanceState.NONE\n\n        self.mock_connection.update_current_socket_timeout.assert_called_once_with(-1)\n        self.mock_connection.reset_tmp_settings.assert_called_once_with(\n            reset_relaxed_timeout=True\n        )\n\n\nclass TestEndpointType:\n    \"\"\"Test the EndpointType class functionality.\"\"\"\n\n    def test_endpoint_type_constants(self):\n        \"\"\"Test that the EndpointType constants are correct.\"\"\"\n        assert EndpointType.INTERNAL_IP.value == \"internal-ip\"\n        assert EndpointType.INTERNAL_FQDN.value == \"internal-fqdn\"\n        assert EndpointType.EXTERNAL_IP.value == \"external-ip\"\n        assert EndpointType.EXTERNAL_FQDN.value == \"external-fqdn\"\n        assert EndpointType.NONE.value == \"none\"\n\n\nclass TestMaintNotificationsConfigEndpointType:\n    \"\"\"Test MaintNotificationsConfig endpoint type functionality.\"\"\"\n\n    def setup_method(self):\n        \"\"\"Set up common mock classes for all tests.\"\"\"\n\n        class MockSocket:\n            def __init__(self, resolved_ip):\n                self.resolved_ip = resolved_ip\n\n            def getpeername(self):\n                return (self.resolved_ip, 6379)\n\n        class MockConnection(MaintNotificationsAbstractConnection, ConnectionInterface):\n            def __init__(self, host, resolved_ip=None, is_ssl=False):\n                self.host = host\n                self.port = 6379\n                self._sock = MockSocket(resolved_ip) if resolved_ip else None\n                self.__class__.__name__ = \"SSLConnection\" if is_ssl else \"Connection\"\n\n            def _get_socket(self):\n                return self._sock\n\n            def get_resolved_ip(self):\n                # Call the actual method from AbstractConnection\n                from redis.connection import AbstractConnection\n\n                return AbstractConnection.get_resolved_ip(self)  # type: ignore\n\n        self.MockSocket = MockSocket\n        self.MockConnection = MockConnection\n\n    def test_config_validation_valid_endpoint_types(self):\n        \"\"\"Test that MaintNotificationsConfig accepts valid endpoint types.\"\"\"\n        for endpoint_type in EndpointType:\n            config = MaintNotificationsConfig(endpoint_type=endpoint_type)\n            assert config.endpoint_type == endpoint_type\n\n    def test_config_validation_none_endpoint_type(self):\n        \"\"\"Test that MaintNotificationsConfig accepts None as endpoint type.\"\"\"\n        config = MaintNotificationsConfig(endpoint_type=None)\n        assert config.endpoint_type is None\n\n    def test_endpoint_type_detection_ip_addresses(self):\n        \"\"\"Test endpoint type detection for IP addresses.\"\"\"\n        config = MaintNotificationsConfig()\n\n        # Test private IPv4 addresses\n        conn1 = self.MockConnection(\"192.168.1.1\", resolved_ip=\"192.168.1.1\")\n        assert (\n            config.get_endpoint_type(\"192.168.1.1\", conn1) == EndpointType.INTERNAL_IP\n        )\n\n        # Test public IPv4 addresses\n        conn2 = self.MockConnection(\"8.8.8.8\", resolved_ip=\"8.8.8.8\")\n        assert config.get_endpoint_type(\"8.8.8.8\", conn2) == EndpointType.EXTERNAL_IP\n\n        # Test IPv6 loopback\n        conn3 = self.MockConnection(\"::1\")\n        assert config.get_endpoint_type(\"::1\", conn3) == EndpointType.INTERNAL_IP\n\n        # Test IPv6 public address\n        conn4 = self.MockConnection(\"2001:4860:4860::8888\")\n        assert (\n            config.get_endpoint_type(\"2001:4860:4860::8888\", conn4)\n            == EndpointType.EXTERNAL_IP\n        )\n\n    def test_endpoint_type_detection_fqdn_with_resolved_ip(self):\n        \"\"\"Test endpoint type detection for FQDNs with resolved IP addresses.\"\"\"\n        config = MaintNotificationsConfig()\n\n        # Test FQDN resolving to private IP\n        conn1 = self.MockConnection(\n            \"redis.internal.company.com\", resolved_ip=\"192.168.1.1\"\n        )\n        assert (\n            config.get_endpoint_type(\"redis.internal.company.com\", conn1)\n            == EndpointType.INTERNAL_FQDN\n        )\n\n        # Test FQDN resolving to public IP\n        conn2 = self.MockConnection(\"db123.redis.com\", resolved_ip=\"8.8.8.8\")\n        assert (\n            config.get_endpoint_type(\"db123.redis.com\", conn2)\n            == EndpointType.EXTERNAL_FQDN\n        )\n\n        # Test internal FQDN resolving to public IP (should use resolved IP)\n        conn3 = self.MockConnection(\n            \"redis.internal.company.com\", resolved_ip=\"10.8.8.8\"\n        )\n        assert (\n            config.get_endpoint_type(\"redis.internal.company.com\", conn3)\n            == EndpointType.INTERNAL_FQDN\n        )\n\n        # Test FQDN with TLS\n        conn4 = self.MockConnection(\n            \"redis.internal.company.com\", resolved_ip=\"192.168.1.1\", is_ssl=True\n        )\n        assert (\n            config.get_endpoint_type(\"redis.internal.company.com\", conn4)\n            == EndpointType.INTERNAL_FQDN\n        )\n\n        conn5 = self.MockConnection(\n            \"db123.redis.com\", resolved_ip=\"8.8.8.8\", is_ssl=True\n        )\n        assert (\n            config.get_endpoint_type(\"db123.redis.com\", conn5)\n            == EndpointType.EXTERNAL_FQDN\n        )\n\n    def test_endpoint_type_detection_fqdn_heuristics(self):\n        \"\"\"Test endpoint type detection using FQDN heuristics when no resolved IP is available.\"\"\"\n        config = MaintNotificationsConfig()\n\n        # Test localhost (should be internal)\n        conn1 = self.MockConnection(\"localhost\")\n        assert (\n            config.get_endpoint_type(\"localhost\", conn1) == EndpointType.INTERNAL_FQDN\n        )\n\n        # Test .local domain (should be internal)\n        conn2 = self.MockConnection(\"server.local\")\n        assert (\n            config.get_endpoint_type(\"server.local\", conn2)\n            == EndpointType.INTERNAL_FQDN\n        )\n\n        # Test public domain (should be external)\n        conn3 = self.MockConnection(\"example.com\")\n        assert (\n            config.get_endpoint_type(\"example.com\", conn3) == EndpointType.EXTERNAL_FQDN\n        )\n\n    def test_endpoint_type_override(self):\n        \"\"\"Test that configured endpoint_type overrides detection.\"\"\"\n\n        # Test with endpoint_type set to NONE\n        config = MaintNotificationsConfig(endpoint_type=EndpointType.NONE)\n        conn = self.MockConnection(\"localhost\")\n\n        assert config.get_endpoint_type(\"localhost\", conn) == EndpointType.NONE\n\n        # Test with endpoint_type set to EXTERNAL_IP\n        config = MaintNotificationsConfig(endpoint_type=EndpointType.EXTERNAL_IP)\n        assert config.get_endpoint_type(\"localhost\", conn) == EndpointType.EXTERNAL_IP\n\n\nclass TestMaintNotificationsMetricsRecording:\n    \"\"\"\n    Tests for metrics recording from maintenance notification handlers.\n    These tests verify that the OTel recorder functions are called with correct arguments.\n    \"\"\"\n\n    @patch(\"redis.maint_notifications.record_maint_notification_count\")\n    def test_connection_handler_calls_record_maint_notification_count(\n        self, mock_record_maint_notification_count\n    ):\n        \"\"\"Test that handle_notification calls record_maint_notification_count.\"\"\"\n        mock_connection = Mock()\n        mock_connection.maintenance_state = MaintenanceState.NONE\n        mock_connection.host = \"localhost\"\n        mock_connection.port = 6379\n\n        config = MaintNotificationsConfig(enabled=True, relaxed_timeout=20)\n        handler = MaintNotificationsConnectionHandler(mock_connection, config)\n\n        notification = NodeMigratingNotification(id=1, ttl=5)\n        handler.handle_notification(notification)\n\n        mock_record_maint_notification_count.assert_called_once_with(\n            server_address=\"localhost\",\n            server_port=6379,\n            network_peer_address=\"localhost\",\n            network_peer_port=6379,\n            maint_notification=\"MIGRATING\",\n        )\n\n    @patch(\"redis.maint_notifications.record_connection_relaxed_timeout\")\n    @patch(\"redis.maint_notifications.get_pool_name\")\n    def test_connection_handler_calls_record_connection_relaxed_timeout_on_start(\n        self, mock_get_pool_name, mock_record_connection_relaxed_timeout\n    ):\n        \"\"\"Test that handle_notification calls record_connection_relaxed_timeout with relaxed=True.\"\"\"\n        mock_connection = Mock()\n        mock_connection.maintenance_state = MaintenanceState.NONE\n        mock_connection._maint_notifications_pool_handler = Mock()\n        mock_connection._maint_notifications_pool_handler.pool = Mock()\n        mock_get_pool_name.return_value = \"localhost:6379_abc123\"\n\n        config = MaintNotificationsConfig(enabled=True, relaxed_timeout=20)\n        handler = MaintNotificationsConnectionHandler(mock_connection, config)\n\n        notification = NodeMigratingNotification(id=1, ttl=5)\n        handler.handle_notification(notification)\n\n        mock_record_connection_relaxed_timeout.assert_called_once_with(\n            connection_name=\"localhost:6379_abc123\",\n            maint_notification=\"MIGRATING\",\n            relaxed=True,\n        )\n\n    @patch(\"redis.maint_notifications.record_connection_relaxed_timeout\")\n    @patch(\"redis.maint_notifications.get_pool_name\")\n    def test_connection_handler_calls_record_connection_relaxed_timeout_on_complete(\n        self, mock_get_pool_name, mock_record_connection_relaxed_timeout\n    ):\n        \"\"\"Test that handle_notification calls record_connection_relaxed_timeout with relaxed=False.\"\"\"\n        mock_connection = Mock()\n        mock_connection.maintenance_state = MaintenanceState.MAINTENANCE\n        mock_connection._maint_notifications_pool_handler = Mock()\n        mock_connection._maint_notifications_pool_handler.pool = Mock()\n        mock_get_pool_name.return_value = \"localhost:6379_abc123\"\n\n        config = MaintNotificationsConfig(relaxed_timeout=20)\n        handler = MaintNotificationsConnectionHandler(mock_connection, config)\n\n        notification = NodeMigratedNotification(id=1)\n        handler.handle_notification(notification)\n\n        mock_record_connection_relaxed_timeout.assert_called_once_with(\n            connection_name=\"localhost:6379_abc123\",\n            maint_notification=\"MIGRATED\",\n            relaxed=False,\n        )\n\n    @patch(\"redis.maint_notifications.record_connection_relaxed_timeout\")\n    def test_connection_handler_no_relaxed_timeout_call_when_disabled(\n        self, mock_record_connection_relaxed_timeout\n    ):\n        \"\"\"Test that record_connection_relaxed_timeout is not called when relaxed_timeout is disabled.\"\"\"\n        mock_connection = Mock()\n        mock_connection.maintenance_state = MaintenanceState.NONE\n        mock_connection.host = \"localhost\"\n        mock_connection.port = 6379\n\n        config = MaintNotificationsConfig(enabled=True, relaxed_timeout=-1)\n        handler = MaintNotificationsConnectionHandler(mock_connection, config)\n\n        notification = NodeMigratingNotification(id=1, ttl=5)\n        handler.handle_notification(notification)\n\n        mock_record_connection_relaxed_timeout.assert_not_called()\n\n    @patch(\"redis.maint_notifications.record_connection_handoff\")\n    def test_pool_handler_calls_record_connection_handoff(\n        self, mock_record_connection_handoff\n    ):\n        \"\"\"Test that handle_node_moving_notification calls record_connection_handoff.\"\"\"\n        mock_pool = Mock()\n        mock_pool._lock = MagicMock()\n        mock_pool._lock.__enter__ = Mock(return_value=None)\n        mock_pool._lock.__exit__ = Mock(return_value=None)\n        mock_pool.connection_kwargs = {\"host\": \"localhost\", \"port\": 6379, \"db\": 0}\n        mock_pool._pool_id = \"a1b2c3d4\"  # Mock the unique pool ID\n\n        config = MaintNotificationsConfig(\n            enabled=True, proactive_reconnect=True, relaxed_timeout=20\n        )\n        handler = MaintNotificationsPoolHandler(mock_pool, config)\n\n        notification = NodeMovingNotification(\n            id=1, new_node_host=\"localhost\", new_node_port=6379, ttl=10\n        )\n\n        with patch(\"threading.Timer\"):\n            handler.handle_node_moving_notification(notification)\n\n        mock_record_connection_handoff.assert_called_once_with(\n            pool_name=\"localhost:6379_a1b2c3d4\",\n        )\n\n    @patch(\"redis.maint_notifications.record_connection_handoff\")\n    def test_pool_handler_no_handoff_call_when_already_processed(\n        self, mock_record_connection_handoff\n    ):\n        \"\"\"Test that record_connection_handoff is not called for already processed notification.\"\"\"\n        mock_pool = Mock()\n        mock_pool._lock = MagicMock()\n        mock_pool._lock.__enter__ = Mock(return_value=None)\n        mock_pool._lock.__exit__ = Mock(return_value=None)\n\n        config = MaintNotificationsConfig(\n            enabled=True, proactive_reconnect=True, relaxed_timeout=20\n        )\n        handler = MaintNotificationsPoolHandler(mock_pool, config)\n\n        notification = NodeMovingNotification(\n            id=1, new_node_host=\"localhost\", new_node_port=6379, ttl=10\n        )\n        # Add notification to processed set\n        handler._processed_notifications.add(notification)\n\n        handler.handle_node_moving_notification(notification)\n\n        mock_record_connection_handoff.assert_not_called()\n\n    @patch(\"redis.maint_notifications.record_connection_handoff\")\n    def test_pool_handler_no_handoff_call_when_disabled(\n        self, mock_record_connection_handoff\n    ):\n        \"\"\"Test that record_connection_handoff is not called when both features are disabled.\"\"\"\n        mock_pool = Mock()\n        mock_pool._lock = MagicMock()\n        mock_pool._lock.__enter__ = Mock(return_value=None)\n        mock_pool._lock.__exit__ = Mock(return_value=None)\n\n        config = MaintNotificationsConfig(\n            enabled=True, proactive_reconnect=False, relaxed_timeout=-1\n        )\n        handler = MaintNotificationsPoolHandler(mock_pool, config)\n\n        notification = NodeMovingNotification(\n            id=1, new_node_host=\"localhost\", new_node_port=6379, ttl=10\n        )\n\n        handler.handle_node_moving_notification(notification)\n\n        mock_record_connection_handoff.assert_not_called()\n"
  },
  {
    "path": "tests/maint_notifications/test_maint_notifications_handling.py",
    "content": "import socket\nimport threading\nfrom typing import List, Union\nfrom unittest.mock import patch\n\nimport pytest\nfrom time import sleep\n\nfrom redis import Redis\nfrom redis.cache import CacheConfig\nfrom redis.connection import (\n    AbstractConnection,\n    Connection,\n    ConnectionPool,\n    BlockingConnectionPool,\n    MaintenanceState,\n)\nfrom redis.exceptions import ResponseError\nfrom redis.maint_notifications import (\n    EndpointType,\n    MaintNotificationsConfig,\n    NodeMigratingNotification,\n    NodeMigratedNotification,\n    NodeFailingOverNotification,\n    NodeFailedOverNotification,\n    MaintNotificationsPoolHandler,\n    NodeMovingNotification,\n)\n\n\nAFTER_MOVING_ADDRESS = \"1.2.3.4:6379\"\nDEFAULT_ADDRESS = \"12.45.34.56:6379\"\nMOVING_TIMEOUT = 1\n\nMOVING_NOTIFICATION = NodeMovingNotification(\n    id=1,\n    new_node_host=AFTER_MOVING_ADDRESS.split(\":\")[0],\n    new_node_port=int(AFTER_MOVING_ADDRESS.split(\":\")[1]),\n    ttl=MOVING_TIMEOUT,\n)\n\nMOVING_NONE_NOTIFICATION = NodeMovingNotification(\n    id=1,\n    new_node_host=None,\n    new_node_port=None,\n    ttl=MOVING_TIMEOUT,\n)\n\n\nclass Helpers:\n    \"\"\"Helper class containing static methods for validation in maintenance notifications tests.\"\"\"\n\n    @staticmethod\n    def validate_in_use_connections_state(\n        in_use_connections: List[AbstractConnection],\n        expected_state=MaintenanceState.NONE,\n        expected_should_reconnect: Union[bool, str] = True,\n        expected_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n        expected_socket_timeout=None,\n        expected_socket_connect_timeout=None,\n        expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n        expected_orig_socket_timeout=None,\n        expected_orig_socket_connect_timeout=None,\n        expected_current_socket_timeout=None,\n        expected_current_peername=DEFAULT_ADDRESS.split(\":\")[0],\n    ):\n        \"\"\"Helper method to validate state of in-use connections.\"\"\"\n\n        # validate in use connections are still working with set flag for reconnect\n        # and timeout is updated\n        for connection in in_use_connections:\n            if expected_should_reconnect != \"any\":\n                assert connection.should_reconnect() == expected_should_reconnect\n            assert connection.host == expected_host_address\n            assert connection.socket_timeout == expected_socket_timeout\n            assert connection.socket_connect_timeout == expected_socket_connect_timeout\n            assert connection.orig_host_address == expected_orig_host_address\n            assert connection.orig_socket_timeout == expected_orig_socket_timeout\n            assert (\n                connection.orig_socket_connect_timeout\n                == expected_orig_socket_connect_timeout\n            )\n            conn_socket = connection._get_socket()\n            if conn_socket is not None:\n                assert conn_socket.gettimeout() == expected_current_socket_timeout\n                assert conn_socket.connected is True\n                if expected_current_peername != \"any\":\n                    assert conn_socket.getpeername()[0] == expected_current_peername\n            assert connection.maintenance_state == expected_state\n\n    @staticmethod\n    def validate_free_connections_state(\n        pool,\n        should_be_connected_count=0,\n        connected_to_tmp_address=False,\n        tmp_address=AFTER_MOVING_ADDRESS.split(\":\")[0],\n        expected_state=MaintenanceState.MOVING,\n        expected_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n        expected_socket_timeout=None,\n        expected_socket_connect_timeout=None,\n        expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n        expected_orig_socket_timeout=None,\n        expected_orig_socket_connect_timeout=None,\n    ):\n        \"\"\"Helper method to validate state of free/available connections.\"\"\"\n\n        if isinstance(pool, BlockingConnectionPool):\n            free_connections = [conn for conn in pool.pool.queue if conn is not None]\n        elif isinstance(pool, ConnectionPool):\n            free_connections = pool._available_connections\n        else:\n            raise ValueError(f\"Unsupported pool type: {type(pool)}\")\n\n        connected_count = 0\n        for connection in free_connections:\n            assert connection.should_reconnect() is False\n            assert connection.host == expected_host_address\n            assert connection.socket_timeout == expected_socket_timeout\n            assert connection.socket_connect_timeout == expected_socket_connect_timeout\n            assert connection.orig_host_address == expected_orig_host_address\n            assert connection.orig_socket_timeout == expected_orig_socket_timeout\n            assert (\n                connection.orig_socket_connect_timeout\n                == expected_orig_socket_connect_timeout\n            )\n            assert connection.maintenance_state == expected_state\n            if expected_state == MaintenanceState.NONE:\n                assert connection.maintenance_notification_hash is None\n\n            conn_socket = connection._get_socket()\n            if conn_socket is not None:\n                assert conn_socket.connected is True\n                if connected_to_tmp_address and tmp_address != \"any\":\n                    assert conn_socket.getpeername()[0] == tmp_address\n                connected_count += 1\n        assert connected_count == should_be_connected_count\n\n    @staticmethod\n    def validate_conn_kwargs(\n        pool,\n        expected_maintenance_state,\n        expected_maintenance_notification_hash,\n        expected_host_address,\n        expected_port,\n        expected_socket_timeout,\n        expected_socket_connect_timeout,\n        expected_orig_host_address,\n        expected_orig_socket_timeout,\n        expected_orig_socket_connect_timeout,\n    ):\n        \"\"\"Helper method to validate connection kwargs.\"\"\"\n        assert pool.connection_kwargs[\"maintenance_state\"] == expected_maintenance_state\n        assert (\n            pool.connection_kwargs[\"maintenance_notification_hash\"]\n            == expected_maintenance_notification_hash\n        )\n        assert pool.connection_kwargs[\"host\"] == expected_host_address\n        assert pool.connection_kwargs[\"port\"] == expected_port\n        assert pool.connection_kwargs[\"socket_timeout\"] == expected_socket_timeout\n        assert (\n            pool.connection_kwargs[\"socket_connect_timeout\"]\n            == expected_socket_connect_timeout\n        )\n        assert (\n            pool.connection_kwargs.get(\"orig_host_address\", None)\n            == expected_orig_host_address\n        )\n        assert (\n            pool.connection_kwargs.get(\"orig_socket_timeout\", None)\n            == expected_orig_socket_timeout\n        )\n        assert (\n            pool.connection_kwargs.get(\"orig_socket_connect_timeout\", None)\n            == expected_orig_socket_connect_timeout\n        )\n\n\nclass MockSocket:\n    \"\"\"Mock socket that simulates Redis protocol responses.\"\"\"\n\n    def __init__(self):\n        self.connected = False\n        self.address = None\n        self.sent_data = []\n        self.closed = False\n        self.command_count = 0\n        self.pending_responses = []\n        # Track socket timeout changes for maintenance notifications validation\n        self.timeout = None\n        self.thread_timeouts = {}  # Track last applied timeout per thread\n        self.moving_sent = False\n\n    def connect(self, address):\n        \"\"\"Simulate socket connection.\"\"\"\n        self.connected = True\n        self.address = address\n\n    def send(self, data):\n        \"\"\"Simulate sending data to Redis.\"\"\"\n        if self.closed:\n            raise ConnectionError(\"Socket is closed\")\n        self.sent_data.append(data)\n\n        # Analyze the command and prepare appropriate response\n        if b\"HELLO\" in data:\n            response = b\"%7\\r\\n$6\\r\\nserver\\r\\n$5\\r\\nredis\\r\\n$7\\r\\nversion\\r\\n$5\\r\\n7.4.0\\r\\n$5\\r\\nproto\\r\\n:3\\r\\n$2\\r\\nid\\r\\n:1\\r\\n$4\\r\\nmode\\r\\n$10\\r\\nstandalone\\r\\n$4\\r\\nrole\\r\\n$6\\r\\nmaster\\r\\n$7\\r\\nmodules\\r\\n*0\\r\\n\"\n            self.pending_responses.append(response)\n        elif b\"MAINT_NOTIFICATIONS\" in data and b\"internal-ip\" in data:\n            # Simulate error response - activate it only for internal-ip tests\n            response = b\"+ERROR\\r\\n\"\n            self.pending_responses.append(response)\n        elif b\"SET\" in data:\n            response = b\"+OK\\r\\n\"\n\n            # Check if this is a key that should trigger a push message\n            if b\"key_receive_migrating_\" in data or b\"key_receive_migrating\" in data:\n                # MIGRATING push message before SET key_receive_migrating_X response\n                # Format: >3\\r\\n$9\\r\\nMIGRATING\\r\\n:1\\r\\n:10\\r\\n (3 elements: MIGRATING, id, ttl)\n                migrating_push = \">3\\r\\n$9\\r\\nMIGRATING\\r\\n:1\\r\\n:10\\r\\n\"\n                response = migrating_push.encode() + response\n            elif b\"key_receive_migrated_\" in data or b\"key_receive_migrated\" in data:\n                # MIGRATED push message before SET key_receive_migrated_X response\n                # Format: >2\\r\\n$8\\r\\nMIGRATED\\r\\n:1\\r\\n (2 elements: MIGRATED, id)\n                migrated_push = \">2\\r\\n$8\\r\\nMIGRATED\\r\\n:1\\r\\n\"\n                response = migrated_push.encode() + response\n            elif (\n                b\"key_receive_failing_over_\" in data\n                or b\"key_receive_failing_over\" in data\n            ):\n                # FAILING_OVER push message before SET key_receive_failing_over_X response\n                # Format: >3\\r\\n$12\\r\\nFAILING_OVER\\r\\n:1\\r\\n:10\\r\\n (3 elements: FAILING_OVER, id, ttl)\n                failing_over_push = \">3\\r\\n$12\\r\\nFAILING_OVER\\r\\n:1\\r\\n:10\\r\\n\"\n\n                response = failing_over_push.encode() + response\n            elif (\n                b\"key_receive_failed_over_\" in data\n                or b\"key_receive_failed_over\" in data\n            ):\n                # FAILED_OVER push message before SET key_receive_failed_over_X response\n                # Format: >2\\r\\n$11\\r\\nFAILED_OVER\\r\\n:1\\r\\n (2 elements: FAILED_OVER, id)\n                failed_over_push = \">2\\r\\n$11\\r\\nFAILED_OVER\\r\\n:1\\r\\n\"\n                response = failed_over_push.encode() + response\n            elif b\"key_receive_moving_none_\" in data:\n                # MOVING push message before SET key_receive_moving_none_X response\n                # Format: >4\\r\\n$6\\r\\nMOVING\\r\\n:1\\r\\n:1\\r\\n+null\\r\\n (4 elements: MOVING, id, ttl, null)\n                # Note: Using + instead of $ to send as simple string instead of bulk string\n                moving_push = f\">4\\r\\n$6\\r\\nMOVING\\r\\n:1\\r\\n:{MOVING_TIMEOUT}\\r\\n_\\r\\n\"\n                response = moving_push.encode() + response\n            elif b\"key_receive_moving_\" in data:\n                # MOVING push message before SET key_receive_moving_X response\n                # Format: >4\\r\\n$6\\r\\nMOVING\\r\\n:1\\r\\n:1\\r\\n+1.2.3.4:6379\\r\\n (4 elements: MOVING, id, ttl, host:port)\n                # Note: Using + instead of $ to send as simple string instead of bulk string\n                moving_push = f\">4\\r\\n$6\\r\\nMOVING\\r\\n:1\\r\\n:{MOVING_TIMEOUT}\\r\\n+{AFTER_MOVING_ADDRESS}\\r\\n\"\n                response = moving_push.encode() + response\n\n            self.pending_responses.append(response)\n        elif b\"GET\" in data:\n            # Extract key and provide appropriate response\n            if b\"hello\" in data:\n                response = b\"$5\\r\\nworld\\r\\n\"\n                self.pending_responses.append(response)\n            # Handle specific keys used in tests\n            elif b\"key_receive_moving_0\" in data:\n                self.pending_responses.append(b\"$8\\r\\nvalue3_0\\r\\n\")\n            elif b\"key_receive_migrated_0\" in data:\n                self.pending_responses.append(b\"$13\\r\\nmigrated_value\\r\\n\")\n            elif b\"key_receive_migrating\" in data:\n                self.pending_responses.append(b\"$6\\r\\nvalue2\\r\\n\")\n            elif b\"key_receive_migrated\" in data:\n                self.pending_responses.append(b\"$6\\r\\nvalue3\\r\\n\")\n            elif b\"key_receive_failing_over\" in data:\n                self.pending_responses.append(b\"$6\\r\\nvalue4\\r\\n\")\n            elif b\"key_receive_failed_over\" in data:\n                self.pending_responses.append(b\"$6\\r\\nvalue5\\r\\n\")\n            elif b\"key1\" in data:\n                self.pending_responses.append(b\"$6\\r\\nvalue1\\r\\n\")\n            else:\n                self.pending_responses.append(b\"$-1\\r\\n\")  # NULL response\n        else:\n            self.pending_responses.append(b\"+OK\\r\\n\")  # Default response\n\n        self.command_count += 1\n        return len(data)\n\n    def sendall(self, data):\n        \"\"\"Simulate sending all data to Redis.\"\"\"\n        return self.send(data)\n\n    def recv(self, bufsize):\n        \"\"\"Simulate receiving data from Redis.\"\"\"\n        if self.closed:\n            raise ConnectionError(\"Socket is closed\")\n\n        # Use pending responses that were prepared when commands were sent\n        if self.pending_responses:\n            response = self.pending_responses.pop(0)\n            if b\"MOVING\" in response:\n                self.moving_sent = True\n            return response[:bufsize]  # Respect buffer size\n        else:\n            # No data available - this should block or raise an exception\n            # For can_read checks, we should indicate no data is available\n            import errno\n\n            raise BlockingIOError(errno.EAGAIN, \"Resource temporarily unavailable\")\n\n    def recv_into(self, buffer, nbytes=0):\n        \"\"\"\n        Receive data from Redis and write it into the provided buffer.\n        Returns the number of bytes written.\n\n        This method is used by the hiredis parser for efficient data reading.\n        \"\"\"\n        if self.closed:\n            raise ConnectionError(\"Socket is closed\")\n\n        # Use pending responses that were prepared when commands were sent\n        if self.pending_responses:\n            response = self.pending_responses.pop(0)\n            if b\"MOVING\" in response:\n                self.moving_sent = True\n\n            # Determine how many bytes to write\n            if nbytes == 0:\n                nbytes = len(buffer)\n\n            # Write data into the buffer (up to nbytes or response length)\n            bytes_to_write = min(len(response), nbytes, len(buffer))\n            buffer[:bytes_to_write] = response[:bytes_to_write]\n\n            return bytes_to_write\n        else:\n            # No data available - this should block or raise an exception\n            # For can_read checks, we should indicate no data is available\n            import errno\n\n            raise BlockingIOError(errno.EAGAIN, \"Resource temporarily unavailable\")\n\n    def fileno(self):\n        \"\"\"Return a fake file descriptor for select/poll operations.\"\"\"\n        return 1  # Fake file descriptor\n\n    def close(self):\n        \"\"\"Simulate closing the socket.\"\"\"\n        self.closed = True\n        self.connected = False\n        self.address = None\n        self.timeout = None\n        self.thread_timeouts = {}\n\n    def settimeout(self, timeout):\n        \"\"\"Simulate setting socket timeout and track changes per thread.\"\"\"\n        self.timeout = timeout\n\n        # Track last applied timeout with thread_id information added\n        thread_id = threading.current_thread().ident\n        self.thread_timeouts[thread_id] = timeout\n\n    def gettimeout(self):\n        \"\"\"Simulate getting socket timeout.\"\"\"\n        return self.timeout\n\n    def setsockopt(self, level, optname, value):\n        \"\"\"Simulate setting socket options.\"\"\"\n        pass\n\n    def setblocking(self, blocking):\n        pass\n\n    def getpeername(self):\n        \"\"\"Simulate getting peer name.\"\"\"\n        return self.address\n\n    def getsockname(self):\n        \"\"\"Simulate getting socket name.\"\"\"\n        return (self.address.split(\":\")[0], 12345)\n\n    def shutdown(self, how):\n        \"\"\"Simulate socket shutdown.\"\"\"\n        pass\n\n\nclass TestMaintenanceNotificationsBase:\n    \"\"\"Base class for maintenance notifications handling tests.\"\"\"\n\n    def setup_method(self):\n        \"\"\"Set up test fixtures with mocked sockets.\"\"\"\n        self.mock_sockets = []\n        self.original_socket = socket.socket\n\n        # Mock socket creation to return our mock sockets\n        def mock_socket_factory(*args, **kwargs):\n            mock_sock = MockSocket()\n            self.mock_sockets.append(mock_sock)\n            return mock_sock\n\n        self.socket_patcher = patch(\"socket.socket\", side_effect=mock_socket_factory)\n        self.socket_patcher.start()\n\n        # Mock select.select to simulate data availability for reading\n        def mock_select(rlist, wlist, xlist, timeout=0):\n            # Check if any of the sockets in rlist have data available\n            ready_sockets = []\n            for sock in rlist:\n                if hasattr(sock, \"connected\") and sock.connected and not sock.closed:\n                    # Only return socket as ready if it actually has data to read\n                    if hasattr(sock, \"pending_responses\") and sock.pending_responses:\n                        ready_sockets.append(sock)\n                    # Don't return socket as ready just because it received commands\n                    # Only when there are actual responses available\n            return (ready_sockets, [], [])\n\n        self.select_patcher = patch(\"select.select\", side_effect=mock_select)\n        self.select_patcher.start()\n\n        # Create maintenance notifications config\n        self.config = MaintNotificationsConfig(\n            enabled=True, proactive_reconnect=True, relaxed_timeout=30\n        )\n\n    def teardown_method(self):\n        \"\"\"Clean up test fixtures.\"\"\"\n        self.socket_patcher.stop()\n        self.select_patcher.stop()\n\n    def _get_client(\n        self,\n        pool_class,\n        connection_class=Connection,\n        enable_cache=False,\n        max_connections=10,\n        maint_notifications_config=None,\n    ):\n        \"\"\"Helper method to create a pool and Redis client with maintenance notifications configuration.\n\n        Args:\n            pool_class: The connection pool class (ConnectionPool or BlockingConnectionPool)\n            max_connections: Maximum number of connections in the pool (default: 10)\n            maint_notifications_config: Optional MaintNotificationsConfig to use. If not provided,\n                                    uses self.config from setup_method (default: None)\n            setup_pool_handler: Whether to set up pool handler for moving notifications (default: False)\n\n        Returns:\n            test_redis_client\n        \"\"\"\n        config = (\n            maint_notifications_config\n            if maint_notifications_config is not None\n            else self.config\n        )\n        pool_kwargs = {}\n        if enable_cache:\n            pool_kwargs = {\"cache_config\": CacheConfig()}\n\n        test_pool = pool_class(\n            connection_class=connection_class,\n            host=DEFAULT_ADDRESS.split(\":\")[0],\n            port=int(DEFAULT_ADDRESS.split(\":\")[1]),\n            max_connections=max_connections,\n            protocol=3,  # Required for maintenance notifications\n            maint_notifications_config=config,\n            **pool_kwargs,\n        )\n        test_redis_client = Redis(connection_pool=test_pool)\n\n        return test_redis_client\n\n\nclass TestMaintenanceNotificationsHandshake(TestMaintenanceNotificationsBase):\n    \"\"\"Integration tests for maintenance notifications handling with real connection pool.\"\"\"\n\n    def test_handshake_success_when_enabled(self):\n        \"\"\"Test that handshake is performed correctly.\"\"\"\n        maint_notifications_config = MaintNotificationsConfig(\n            enabled=True, endpoint_type=EndpointType.EXTERNAL_IP\n        )\n        test_redis_client = self._get_client(\n            ConnectionPool, maint_notifications_config=maint_notifications_config\n        )\n\n        try:\n            # Perform Redis operations that should work with our improved mock responses\n            result_set = test_redis_client.set(\"hello\", \"world\")\n            result_get = test_redis_client.get(\"hello\")\n\n            # Verify operations completed successfully\n            assert result_set is True\n            assert result_get == b\"world\"\n\n        finally:\n            test_redis_client.close()\n\n    def test_handshake_success_when_auto_and_command_not_supported(self):\n        \"\"\"Test that when maintenance notifications are set to 'auto', the client gracefully handles unsupported MAINT_NOTIFICATIONS commands and normal Redis operations succeed.\"\"\"\n        maint_notifications_config = MaintNotificationsConfig(\n            enabled=\"auto\", endpoint_type=EndpointType.INTERNAL_IP\n        )\n        test_redis_client = self._get_client(\n            ConnectionPool, maint_notifications_config=maint_notifications_config\n        )\n\n        try:\n            # Perform Redis operations that should work with our improved mock responses\n            result_set = test_redis_client.set(\"hello\", \"world\")\n            result_get = test_redis_client.get(\"hello\")\n\n            # Verify operations completed successfully\n            assert result_set is True\n            assert result_get == b\"world\"\n\n        finally:\n            test_redis_client.close()\n\n    def test_handshake_failure_when_enabled(self):\n        \"\"\"Test that handshake is performed correctly.\"\"\"\n        maint_notifications_config = MaintNotificationsConfig(\n            enabled=True, endpoint_type=EndpointType.INTERNAL_IP\n        )\n        test_redis_client = self._get_client(\n            ConnectionPool, maint_notifications_config=maint_notifications_config\n        )\n        try:\n            with pytest.raises(ResponseError):\n                # handshake should fail\n                # socket mock will return error when enabling maint notifications\n                # for internal-ip\n                test_redis_client.set(\"hello\", \"world\")\n\n        finally:\n            test_redis_client.close()\n\n\nclass TestMaintenanceNotificationsHandlingSingleProxy(TestMaintenanceNotificationsBase):\n    \"\"\"Integration tests for maintenance notifications handling with real connection pool.\"\"\"\n\n    def _validate_connection_handlers(self, conn, pool_handler, config):\n        \"\"\"Helper method to validate connection handlers are properly set.\"\"\"\n        # Test that the node moving handler function is correctly set\n        parser_handler = conn._parser.node_moving_push_handler_func\n        assert parser_handler is not None\n        assert hasattr(parser_handler, \"__self__\")\n        assert hasattr(parser_handler, \"__func__\")\n        assert parser_handler.__self__.connection is conn\n        assert parser_handler.__self__.pool is pool_handler.pool\n        assert parser_handler.__self__._lock is pool_handler._lock\n        assert (\n            parser_handler.__self__._processed_notifications\n            is pool_handler._processed_notifications\n        )\n        assert parser_handler.__func__ is pool_handler.handle_notification.__func__\n\n        # Test that the maintenance handler function is correctly set\n        maintenance_handler = conn._parser.maintenance_push_handler_func\n        assert maintenance_handler is not None\n        assert hasattr(maintenance_handler, \"__self__\")\n        assert hasattr(maintenance_handler, \"__func__\")\n        # The maintenance handler should be bound to the connection's\n        # maintenance notification connection handler\n        assert (\n            maintenance_handler.__self__ is conn._maint_notifications_connection_handler\n        )\n        assert (\n            maintenance_handler.__func__\n            is conn._maint_notifications_connection_handler.handle_notification.__func__\n        )\n\n        # Validate that the connection's maintenance handler has the same config object\n        assert conn._maint_notifications_connection_handler.config is config\n\n    def _validate_current_timeout(self, expected_timeout, error_msg=None):\n        \"\"\"Helper method to validate the current timeout for the calling thread.\"\"\"\n        actual_timeout = None\n        # Get the actual thread ID from the current thread\n        current_thread_id = threading.current_thread().ident\n        for sock in self.mock_sockets:\n            if current_thread_id in sock.thread_timeouts:\n                actual_timeout = sock.thread_timeouts[current_thread_id]\n                break\n\n        assert actual_timeout == expected_timeout, (\n            f\"{error_msg or ''}\"\n            f\"Expected timeout ({expected_timeout}), \"\n            f\"but found timeout: {actual_timeout}. \"\n            f\"All thread timeouts: {[sock.thread_timeouts for sock in self.mock_sockets]}\",\n        )\n\n    def _validate_disconnected(self, expected_count):\n        \"\"\"Helper method to validate all socket timeouts\"\"\"\n        disconnected_sockets_count = 0\n        for sock in self.mock_sockets:\n            if sock.closed:\n                disconnected_sockets_count += 1\n        assert disconnected_sockets_count == expected_count\n\n    def _validate_connected(self, expected_count):\n        \"\"\"Helper method to validate all socket timeouts\"\"\"\n        connected_sockets_count = 0\n        for sock in self.mock_sockets:\n            if sock.connected:\n                connected_sockets_count += 1\n        assert connected_sockets_count == expected_count\n\n    def test_client_initialization(self):\n        \"\"\"Test that Redis client is created with maintenance notifications configuration.\"\"\"\n        # Create a pool and Redis client with maintenance notifications\n\n        test_redis_client = Redis(\n            protocol=3,  # Required for maintenance notifications\n            maint_notifications_config=self.config,\n        )\n\n        pool_handler = test_redis_client.connection_pool.connection_kwargs.get(\n            \"maint_notifications_pool_handler\"\n        )\n        assert pool_handler is not None\n        assert pool_handler.config == self.config\n\n        conn = test_redis_client.connection_pool.get_connection()\n\n        assert conn.should_reconnect() is False\n        assert conn.orig_host_address == \"localhost\"\n        assert conn.orig_socket_timeout is None\n\n        self._validate_connection_handlers(conn, pool_handler, self.config)\n\n    def test_maint_handler_init_for_existing_connections(self):\n        \"\"\"Test that maintenance notification handlers are properly set on existing and new connections\n        when configuration is enabled after client creation.\"\"\"\n\n        # Create a Redis client with disabled maintenance notifications configuration\n        disabled_config = MaintNotificationsConfig(enabled=False)\n        test_redis_client = Redis(\n            protocol=3,  # Required for maintenance notifications\n            maint_notifications_config=disabled_config,\n        )\n\n        # Extract an existing connection before enabling maintenance notifications\n        existing_conn = test_redis_client.connection_pool.get_connection()\n\n        # Verify that maintenance notifications are initially disabled\n        assert existing_conn._parser.node_moving_push_handler_func is None\n        assert existing_conn._maint_notifications_connection_handler is None\n        assert existing_conn._parser.maintenance_push_handler_func is None\n\n        # Create a new enabled configuration and set up pool handler\n        enabled_config = MaintNotificationsConfig(\n            enabled=True, proactive_reconnect=True, relaxed_timeout=30\n        )\n        test_redis_client.connection_pool.update_maint_notifications_config(\n            enabled_config\n        )\n\n        pool_handler = (\n            test_redis_client.connection_pool._maint_notifications_pool_handler\n        )\n        # Validate the existing connection after enabling maintenance notifications\n        # Both existing and new connections should now have full handler setup\n        self._validate_connection_handlers(existing_conn, pool_handler, enabled_config)\n\n        # Create a new connection and validate it has full handlers\n        new_conn = test_redis_client.connection_pool.get_connection()\n        self._validate_connection_handlers(new_conn, pool_handler, enabled_config)\n        self._validate_connection_handlers(existing_conn, pool_handler, enabled_config)\n\n        # Clean up connections\n        test_redis_client.connection_pool.release(existing_conn)\n        test_redis_client.connection_pool.release(new_conn)\n\n    @pytest.mark.parametrize(\"pool_class\", [ConnectionPool, BlockingConnectionPool])\n    def test_connection_pool_creation_with_maintenance_notifications(self, pool_class):\n        \"\"\"Test that connection pools are created with maintenance notifications configuration.\"\"\"\n        # Create a pool and Redis client with maintenance notifications\n        max_connections = 3 if pool_class == BlockingConnectionPool else 10\n        test_redis_client = self._get_client(\n            pool_class, max_connections=max_connections\n        )\n        test_pool = test_redis_client.connection_pool\n\n        try:\n            assert (\n                test_pool.connection_kwargs.get(\"maint_notifications_config\")\n                == self.config\n            )\n            # Pool should have maintenance notifications enabled\n            assert test_pool.maint_notifications_enabled() is True\n\n            # Create and set a pool handler\n            test_pool.update_maint_notifications_config(self.config)\n            pool_handler = test_pool._maint_notifications_pool_handler\n\n            # Validate that the handler is properly set on the pool\n            assert (\n                test_pool.connection_kwargs.get(\"maint_notifications_pool_handler\")\n                == pool_handler\n            )\n            assert (\n                test_pool.connection_kwargs.get(\"maint_notifications_config\")\n                == pool_handler.config\n            )\n\n            # Verify that the pool handler has the correct configuration\n            assert pool_handler.pool == test_pool\n            assert pool_handler.config == self.config\n\n        finally:\n            if hasattr(test_pool, \"disconnect\"):\n                test_pool.disconnect()\n\n    @pytest.mark.parametrize(\"pool_class\", [ConnectionPool, BlockingConnectionPool])\n    def test_redis_operations_with_mock_sockets(self, pool_class):\n        \"\"\"\n        Test basic Redis operations work with mocked sockets and proper response parsing.\n        Basically with test - the mocked socket is validated.\n        \"\"\"\n        # Create a pool and Redis client with maintenance notifications\n        test_redis_client = self._get_client(pool_class, max_connections=5)\n\n        try:\n            # Perform Redis operations that should work with our improved mock responses\n            result_set = test_redis_client.set(\"hello\", \"world\")\n            result_get = test_redis_client.get(\"hello\")\n\n            # Verify operations completed successfully\n            assert result_set is True\n            assert result_get == b\"world\"\n\n            # Verify socket interactions\n            assert len(self.mock_sockets) >= 1\n            assert self.mock_sockets[0].connected\n            assert len(self.mock_sockets[0].sent_data) >= 2  # HELLO, SET, GET commands\n\n            # Verify that the connection has maintenance notification handler\n            connection = test_redis_client.connection_pool.get_connection()\n            assert hasattr(connection, \"_maint_notifications_connection_handler\")\n            test_redis_client.connection_pool.release(connection)\n\n        finally:\n            if hasattr(test_redis_client.connection_pool, \"disconnect\"):\n                test_redis_client.connection_pool.disconnect()\n\n    def test_pool_handler_with_migrating_notification(self):\n        \"\"\"Test that pool handler correctly handles migrating notifications.\"\"\"\n        # Create a pool and Redis client with maintenance notifications\n        test_redis_client = self._get_client(ConnectionPool)\n        test_pool = test_redis_client.connection_pool\n\n        try:\n            # Create and set a pool handler\n            pool_handler = MaintNotificationsPoolHandler(test_pool, self.config)\n\n            # Create a migrating notification (not handled by pool handler)\n            migrating_notification = NodeMigratingNotification(id=1, ttl=5)\n\n            # Mock the required functions\n            with (\n                patch.object(\n                    pool_handler, \"remove_expired_notifications\"\n                ) as mock_remove_expired,\n                patch.object(\n                    pool_handler, \"handle_node_moving_notification\"\n                ) as mock_handle_moving,\n                patch(\"redis.maint_notifications.logger.error\") as mock_logging_error,\n            ):\n                # Pool handler should return None for migrating notifications (not its responsibility)\n                pool_handler.handle_notification(migrating_notification)\n\n                # Validate that remove_expired_notifications has been called once\n                mock_remove_expired.assert_called_once()\n\n                # Validate that handle_node_moving_notification hasn't been called\n                mock_handle_moving.assert_not_called()\n\n                # Validate that logging.error has been called once\n                mock_logging_error.assert_called_once()\n\n        finally:\n            if hasattr(test_pool, \"disconnect\"):\n                test_pool.disconnect()\n\n    @pytest.mark.parametrize(\"pool_class\", [ConnectionPool, BlockingConnectionPool])\n    def test_migration_related_notifications_handling_integration(self, pool_class):\n        \"\"\"\n        Test full integration of migration-related notifications (MIGRATING/MIGRATED) handling.\n\n        This test validates the complete migration lifecycle:\n        1. Executes 5 Redis commands sequentially\n        2. Injects MIGRATING push message before command 2 (SET key_receive_migrating)\n        3. Validates socket timeout is updated to relaxed value (30s) after MIGRATING\n        4. Executes commands 3-4 while timeout remains relaxed\n        5. Injects MIGRATED push message before command 5 (SET key_receive_migrated)\n        6. Validates socket timeout is restored after MIGRATED\n        7. Tests both ConnectionPool and BlockingConnectionPool implementations\n        8. Uses proper RESP3 push message format for realistic protocol simulation\n        \"\"\"\n        # Create a pool and Redis client with maintenance notifications\n        test_redis_client = self._get_client(pool_class, max_connections=10)\n\n        try:\n            # Command 1: Initial command\n            key1 = \"key1\"\n            value1 = \"value1\"\n            result1 = test_redis_client.set(key1, value1)\n\n            # Validate Command 1 result\n            assert result1 is True, \"Command 1 (SET key1) failed\"\n\n            # Command 2: This SET command will receive MIGRATING push message before response\n            key_migrating = \"key_receive_migrating\"\n            value_migrating = \"value2\"\n            result2 = test_redis_client.set(key_migrating, value_migrating)\n\n            # Validate Command 2 result\n            assert result2 is True, \"Command 2 (SET key_receive_migrating) failed\"\n\n            # Step 4: Validate timeout was updated to relaxed value after MIGRATING\n            self._validate_current_timeout(30, \"Right after MIGRATING is received. \")\n\n            # Command 3: Another command while timeout is still relaxed\n            result3 = test_redis_client.get(key1)\n\n            # Validate Command 3 result\n            expected_value3 = value1.encode()\n            assert result3 == expected_value3, (\n                f\"Command 3 (GET key1) failed. Expected {expected_value3}, got {result3}\"\n            )\n\n            # Command 4: Execute command (step 5)\n            result4 = test_redis_client.get(key_migrating)\n\n            # Validate Command 4 result\n            expected_value4 = value_migrating.encode()\n            assert result4 == expected_value4, (\n                f\"Command 4 (GET key_receive_migrating) failed. Expected {expected_value4}, got {result4}\"\n            )\n\n            # Step 6: Validate socket timeout is still relaxed during commands 3-4\n            self._validate_current_timeout(\n                30,\n                \"Execute a command with a connection extracted from the pool (after it has received MIGRATING)\",\n            )\n\n            # Command 5: This SET command will receive\n            # MIGRATED push message before actual response\n            key_migrated = \"key_receive_migrated\"\n            value_migrated = \"value3\"\n            result5 = test_redis_client.set(key_migrated, value_migrated)\n\n            # Validate Command 5 result\n            assert result5 is True, \"Command 5 (SET key_receive_migrated) failed\"\n\n            # Step 8: Validate socket timeout is reversed back to original after MIGRATED\n            self._validate_current_timeout(None)\n\n            # Verify maintenance notifications were processed correctly\n            # The key is that we have at least 1 socket and all operations succeeded\n            assert len(self.mock_sockets) >= 1, (\n                f\"Expected at least 1 socket for operations, got {len(self.mock_sockets)}\"\n            )\n\n        finally:\n            if hasattr(test_redis_client.connection_pool, \"disconnect\"):\n                test_redis_client.connection_pool.disconnect()\n\n    @pytest.mark.parametrize(\"pool_class\", [ConnectionPool, BlockingConnectionPool])\n    def test_migrating_notification_with_disabled_relaxed_timeout(self, pool_class):\n        \"\"\"\n        Test maintenance notifications handling when relaxed timeout is disabled.\n\n        This test validates that when relaxed_timeout is disabled (-1):\n        1. MIGRATING, MIGRATED, FAILING_OVER, and FAILED_OVER notifications are received and processed\n        2. No timeout updates are applied to connections\n        3. Socket timeouts remain unchanged during all maintenance notifications\n        4. Tests both ConnectionPool and BlockingConnectionPool implementations\n        5. Tests the complete lifecycle: MIGRATING -> MIGRATED -> FAILING_OVER -> FAILED_OVER\n        \"\"\"\n        # Create config with disabled relaxed timeout\n        disabled_config = MaintNotificationsConfig(\n            enabled=True,\n            relaxed_timeout=-1,  # This means the relaxed timeout is Disabled\n        )\n\n        # Create a pool and Redis client with disabled relaxed timeout config\n        test_redis_client = self._get_client(\n            pool_class, max_connections=5, maint_notifications_config=disabled_config\n        )\n\n        try:\n            # Command 1: Initial command\n            key1 = \"key1\"\n            value1 = \"value1\"\n            result1 = test_redis_client.set(key1, value1)\n\n            # Validate Command 1 result\n            assert result1 is True, \"Command 1 (SET key1) failed\"\n\n            # Command 2: This SET command will receive MIGRATING push message before response\n            key_migrating = \"key_receive_migrating\"\n            value_migrating = \"value2\"\n            result2 = test_redis_client.set(key_migrating, value_migrating)\n\n            # Validate Command 2 result\n            assert result2 is True, \"Command 2 (SET key_receive_migrating) failed\"\n\n            # Validate timeout was NOT updated (relaxed is disabled)\n            # Should remain at default timeout (None), not relaxed to 30s\n            self._validate_current_timeout(None)\n\n            # Command 3: Another command to verify timeout remains unchanged\n            result3 = test_redis_client.get(key1)\n\n            # Validate Command 3 result\n            expected_value3 = value1.encode()\n            assert result3 == expected_value3, (\n                f\"Command 3 (GET key1) failed. Expected: {expected_value3}, Got: {result3}\"\n            )\n\n            # Command 4: This SET command will receive MIGRATED push message before response\n            key_migrated = \"key_receive_migrated\"\n            value_migrated = \"value3\"\n            result4 = test_redis_client.set(key_migrated, value_migrated)\n\n            # Validate Command 4 result\n            assert result4 is True, \"Command 4 (SET key_receive_migrated) failed\"\n\n            # Validate timeout is still NOT updated after MIGRATED (relaxed is disabled)\n            self._validate_current_timeout(None)\n\n            # Command 5: This SET command will receive FAILING_OVER push message before response\n            key_failing_over = \"key_receive_failing_over\"\n            value_failing_over = \"value4\"\n            result5 = test_redis_client.set(key_failing_over, value_failing_over)\n\n            # Validate Command 5 result\n            assert result5 is True, \"Command 5 (SET key_receive_failing_over) failed\"\n\n            # Validate timeout is still NOT updated after FAILING_OVER (relaxed is disabled)\n            self._validate_current_timeout(None)\n\n            # Command 6: Another command to verify timeout remains unchanged during failover\n            result6 = test_redis_client.get(key_failing_over)\n\n            # Validate Command 6 result\n            expected_value6 = value_failing_over.encode()\n            assert result6 == expected_value6, (\n                f\"Command 6 (GET key_receive_failing_over) failed. Expected: {expected_value6}, Got: {result6}\"\n            )\n\n            # Command 7: This SET command will receive FAILED_OVER push message before response\n            key_failed_over = \"key_receive_failed_over\"\n            value_failed_over = \"value5\"\n            result7 = test_redis_client.set(key_failed_over, value_failed_over)\n\n            # Validate Command 7 result\n            assert result7 is True, \"Command 7 (SET key_receive_failed_over) failed\"\n\n            # Validate timeout is still NOT updated after FAILED_OVER (relaxed is disabled)\n            self._validate_current_timeout(None)\n\n            # Command 8: Final command to verify timeout remains unchanged after all notifications\n            result8 = test_redis_client.get(key_failed_over)\n\n            # Validate Command 8 result\n            expected_value8 = value_failed_over.encode()\n            assert result8 == expected_value8, (\n                f\"Command 8 (GET key_receive_failed_over) failed. Expected: {expected_value8}, Got: {result8}\"\n            )\n\n            # Verify maintenance notifications were processed correctly\n            # The key is that we have at least 1 socket and all operations succeeded\n            assert len(self.mock_sockets) >= 1, (\n                f\"Expected at least 1 socket for operations, got {len(self.mock_sockets)}\"\n            )\n\n        finally:\n            if hasattr(test_redis_client.connection_pool, \"disconnect\"):\n                test_redis_client.connection_pool.disconnect()\n\n    @pytest.mark.parametrize(\"pool_class\", [ConnectionPool, BlockingConnectionPool])\n    def test_failing_over_related_notifications_handling_integration(self, pool_class):\n        \"\"\"\n        Test full integration of failing-over-related notifications (FAILING_OVER/FAILED_OVER) handling.\n\n        This test validates the complete FAILING_OVER -> FAILED_OVER lifecycle:\n        1. Executes 5 Redis commands sequentially\n        2. Injects FAILING_OVER push message before command 2 (SET key_receive_failing_over)\n        3. Validates socket timeout is updated to relaxed value (30s) after FAILING_OVER\n        4. Executes commands 3-4 while timeout remains relaxed\n        5. Injects FAILED_OVER push message before command 5 (SET key_receive_failed_over)\n        6. Validates socket timeout is restored after FAILED_OVER\n        7. Tests both ConnectionPool and BlockingConnectionPool implementations\n        8. Uses proper RESP3 push message format for realistic protocol simulation\n        \"\"\"\n        # Create a pool and Redis client with maintenance notifications\n        test_redis_client = self._get_client(pool_class, max_connections=10)\n\n        try:\n            # Command 1: Initial command\n            key1 = \"key1\"\n            value1 = \"value1\"\n            result1 = test_redis_client.set(key1, value1)\n\n            # Validate Command 1 result\n            assert result1 is True, \"Command 1 (SET key1) failed\"\n\n            # Command 2: This SET command will receive FAILING_OVER push message before response\n            key_failing_over = \"key_receive_failing_over\"\n            value_failing_over = \"value4\"\n            result2 = test_redis_client.set(key_failing_over, value_failing_over)\n\n            # Validate Command 2 result\n            assert result2 is True, \"Command 2 (SET key_receive_failing_over) failed\"\n\n            # Step 4: Validate timeout was updated to relaxed value after MIGRATING\n            self._validate_current_timeout(30, \"Right after FAILING_OVER is received. \")\n\n            # Command 3: Another command while timeout is still relaxed\n            result3 = test_redis_client.get(key1)\n\n            # Validate Command 3 result\n            expected_value3 = value1.encode()\n            assert result3 == expected_value3, (\n                f\"Command 3 (GET key1) failed. Expected {expected_value3}, got {result3}\"\n            )\n\n            # Command 4: Execute command (step 5)\n            result4 = test_redis_client.get(key_failing_over)\n\n            # Validate Command 4 result\n            expected_value4 = value_failing_over.encode()\n            assert result4 == expected_value4, (\n                f\"Command 4 (GET key_receive_failing_over) failed. Expected {expected_value4}, got {result4}\"\n            )\n\n            # Step 6: Validate socket timeout is still relaxed during commands 3-4\n            self._validate_current_timeout(\n                30,\n                \"Execute a command with a connection extracted from the pool (after it has received FAILING_OVER)\",\n            )\n\n            # Command 5: This SET command will receive\n            # FAILED_OVER push message before actual response\n            key_failed_over = \"key_receive_failed_over\"\n            value_migrated = \"value3\"\n            result5 = test_redis_client.set(key_failed_over, value_migrated)\n\n            # Validate Command 5 result\n            assert result5 is True, \"Command 5 (SET key_receive_failed_over) failed\"\n\n            # Step 8: Validate socket timeout is reversed back to original after FAILED_OVER\n            self._validate_current_timeout(None)\n\n            # Verify maintenance notifications were processed correctly\n            # The key is that we have at least 1 socket and all operations succeeded\n            assert len(self.mock_sockets) >= 1, (\n                f\"Expected at least 1 socket for operations, got {len(self.mock_sockets)}\"\n            )\n\n        finally:\n            if hasattr(test_redis_client.connection_pool, \"disconnect\"):\n                test_redis_client.connection_pool.disconnect()\n\n    @pytest.mark.parametrize(\"pool_class\", [ConnectionPool, BlockingConnectionPool])\n    def test_moving_related_notifications_handling_integration(self, pool_class):\n        \"\"\"\n        Test full integration of moving-related notifications (MOVING) handling with Redis commands.\n\n        This test validates the complete MOVING notification lifecycle:\n        1. Creates multiple connections in the pool\n        2. Executes a Redis command that triggers a MOVING push message\n        3. Validates that pool configuration is updated with temporary\n           address and timeout - for new connections creation\n        4. Validates that existing connections are marked for disconnection\n        5. Tests both ConnectionPool and BlockingConnectionPool implementations\n        \"\"\"\n        # Create a pool and Redis client with maintenance notifications and pool handler\n        test_redis_client = self._get_client(pool_class, max_connections=10)\n\n        try:\n            # Create several connections and return them in the pool\n            connections = []\n            for _ in range(10):\n                connection = test_redis_client.connection_pool.get_connection()\n                connections.append(connection)\n\n            for connection in connections:\n                test_redis_client.connection_pool.release(connection)\n\n            # Take 5 connections to be \"in use\"\n            in_use_connections = []\n            for _ in range(5):\n                connection = test_redis_client.connection_pool.get_connection()\n                in_use_connections.append(connection)\n\n            # Validate all connections are connected prior MOVING notification\n            self._validate_disconnected(0)\n\n            # Run command that will receive and handle MOVING notification\n            key_moving = \"key_receive_moving_0\"\n            value_moving = \"value3_0\"\n            # the connection used for the command is expected to be reconnected to the new address\n            # before it is returned to the pool\n            result2 = test_redis_client.set(key_moving, value_moving)\n\n            # Validate Command 2 result\n            assert result2 is True, \"Command 2 (SET key_receive_moving) failed\"\n\n            # Validate pool and connections settings were updated according to MOVING notification\n            expected_notification_hash = hash(MOVING_NOTIFICATION)\n\n            Helpers.validate_conn_kwargs(\n                pool=test_redis_client.connection_pool,\n                expected_maintenance_state=MaintenanceState.MOVING,\n                expected_maintenance_notification_hash=expected_notification_hash,\n                expected_host_address=AFTER_MOVING_ADDRESS.split(\":\")[0],\n                expected_port=int(DEFAULT_ADDRESS.split(\":\")[1]),\n                expected_socket_timeout=self.config.relaxed_timeout,\n                expected_socket_connect_timeout=self.config.relaxed_timeout,\n                expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                expected_orig_socket_timeout=None,\n                expected_orig_socket_connect_timeout=None,\n            )\n            self._validate_disconnected(5)\n            self._validate_connected(6)\n            Helpers.validate_in_use_connections_state(\n                in_use_connections,\n                expected_state=MaintenanceState.MOVING,\n                expected_host_address=AFTER_MOVING_ADDRESS.split(\":\")[0],\n                expected_socket_timeout=self.config.relaxed_timeout,\n                expected_socket_connect_timeout=self.config.relaxed_timeout,\n                expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                expected_orig_socket_timeout=None,\n                expected_orig_socket_connect_timeout=None,\n                expected_current_socket_timeout=self.config.relaxed_timeout,\n                expected_current_peername=DEFAULT_ADDRESS.split(\":\")[\n                    0\n                ],  # the in use connections reconnect when they complete their current task\n            )\n            Helpers.validate_free_connections_state(\n                pool=test_redis_client.connection_pool,\n                expected_state=MaintenanceState.MOVING,\n                expected_host_address=AFTER_MOVING_ADDRESS.split(\":\")[0],\n                expected_socket_timeout=self.config.relaxed_timeout,\n                expected_socket_connect_timeout=self.config.relaxed_timeout,\n                expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                expected_orig_socket_timeout=None,\n                expected_orig_socket_connect_timeout=None,\n                should_be_connected_count=1,\n                connected_to_tmp_address=True,\n            )\n            # Wait for MOVING timeout to expire and the moving completed handler to run\n            sleep(MOVING_TIMEOUT + 0.5)\n\n            Helpers.validate_in_use_connections_state(\n                in_use_connections,\n                expected_state=MaintenanceState.NONE,\n                expected_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                expected_socket_timeout=None,\n                expected_socket_connect_timeout=None,\n                expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                expected_orig_socket_timeout=None,\n                expected_orig_socket_connect_timeout=None,\n                expected_current_socket_timeout=None,\n                expected_current_peername=DEFAULT_ADDRESS.split(\":\")[0],\n            )\n            Helpers.validate_conn_kwargs(\n                pool=test_redis_client.connection_pool,\n                expected_maintenance_state=MaintenanceState.NONE,\n                expected_maintenance_notification_hash=None,\n                expected_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                expected_port=int(DEFAULT_ADDRESS.split(\":\")[1]),\n                expected_socket_timeout=None,\n                expected_socket_connect_timeout=None,\n                expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                expected_orig_socket_timeout=None,\n                expected_orig_socket_connect_timeout=None,\n            )\n            Helpers.validate_free_connections_state(\n                pool=test_redis_client.connection_pool,\n                expected_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                expected_socket_timeout=None,\n                expected_socket_connect_timeout=None,\n                expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                expected_orig_socket_timeout=None,\n                expected_orig_socket_connect_timeout=None,\n                should_be_connected_count=1,\n                connected_to_tmp_address=True,\n                expected_state=MaintenanceState.NONE,\n            )\n        finally:\n            if hasattr(test_redis_client.connection_pool, \"disconnect\"):\n                test_redis_client.connection_pool.disconnect()\n\n    @pytest.mark.parametrize(\"pool_class\", [ConnectionPool, BlockingConnectionPool])\n    def test_moving_none_notifications_handling_integration(self, pool_class):\n        \"\"\"\n        Test full integration of moving-related notifications (MOVING) handling with Redis commands.\n\n        This test validates the complete MOVING notification lifecycle,\n        when the push notification doesn't contain host and port:\n        1. Creates multiple connections in the pool\n        2. Executes a Redis command that triggers a MOVING with \"null\" push message\n        3. Validates that pool configuration is updated with temporary\n           address and timeout - for new connections creation\n        4. Validates that existing connections are marked for disconnection after ttl/2 seconds\n        5. Tests both ConnectionPool and BlockingConnectionPool implementations\n        \"\"\"\n        # Create a pool and Redis client with maintenance notifications and pool handler\n        test_redis_client = self._get_client(pool_class, max_connections=10)\n\n        try:\n            # Create several connections and return them in the pool\n            connections = []\n            for _ in range(10):\n                connection = test_redis_client.connection_pool.get_connection()\n                connections.append(connection)\n\n            for connection in connections:\n                test_redis_client.connection_pool.release(connection)\n\n            # Take 5 connections to be \"in use\"\n            in_use_connections = []\n            for _ in range(5):\n                connection = test_redis_client.connection_pool.get_connection()\n                in_use_connections.append(connection)\n\n            # Validate all connections are connected prior MOVING notification\n            self._validate_disconnected(0)\n\n            # Run command that will receive and handle MOVING notification\n            key_moving = \"key_receive_moving_none_0\"\n            value_moving = \"value3_0\"\n\n            # the connection used for the command is expected to be reconnected to the new address\n            # before it is returned to the pool\n            result2 = test_redis_client.set(key_moving, value_moving)\n\n            # Validate Command 2 result\n            assert result2 is True, \"Command 2 (SET key_receive_moving) failed\"\n\n            # Validate pool and connections settings were updated according to MOVING notification\n            Helpers.validate_conn_kwargs(\n                pool=test_redis_client.connection_pool,\n                expected_maintenance_state=MaintenanceState.MOVING,\n                expected_maintenance_notification_hash=hash(MOVING_NONE_NOTIFICATION),\n                expected_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                expected_port=int(DEFAULT_ADDRESS.split(\":\")[1]),\n                expected_socket_timeout=self.config.relaxed_timeout,\n                expected_socket_connect_timeout=self.config.relaxed_timeout,\n                expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                expected_orig_socket_timeout=None,\n                expected_orig_socket_connect_timeout=None,\n            )\n            self._validate_disconnected(0)\n            self._validate_connected(10)\n            Helpers.validate_in_use_connections_state(\n                in_use_connections,\n                expected_should_reconnect=False,\n                expected_state=MaintenanceState.MOVING,\n                expected_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                expected_socket_timeout=self.config.relaxed_timeout,\n                expected_socket_connect_timeout=self.config.relaxed_timeout,\n                expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                expected_orig_socket_timeout=None,\n                expected_orig_socket_connect_timeout=None,\n                expected_current_socket_timeout=self.config.relaxed_timeout,\n                expected_current_peername=DEFAULT_ADDRESS.split(\":\")[\n                    0\n                ],  # the in use connections reconnect when they complete their current task\n            )\n            Helpers.validate_free_connections_state(\n                pool=test_redis_client.connection_pool,\n                expected_state=MaintenanceState.MOVING,\n                expected_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                expected_socket_timeout=self.config.relaxed_timeout,\n                expected_socket_connect_timeout=self.config.relaxed_timeout,\n                expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                expected_orig_socket_timeout=None,\n                expected_orig_socket_connect_timeout=None,\n                should_be_connected_count=5,\n                connected_to_tmp_address=False,\n            )\n            # Wait for half of MOVING timeout to expire and the proactive reconnect to run\n            sleep(MOVING_TIMEOUT / 2 + 0.2)\n            Helpers.validate_in_use_connections_state(\n                in_use_connections,\n                expected_should_reconnect=True,\n                expected_state=MaintenanceState.MOVING,\n                expected_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                expected_socket_timeout=self.config.relaxed_timeout,\n                expected_socket_connect_timeout=self.config.relaxed_timeout,\n                expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                expected_orig_socket_timeout=None,\n                expected_orig_socket_connect_timeout=None,\n                expected_current_socket_timeout=self.config.relaxed_timeout,\n                expected_current_peername=DEFAULT_ADDRESS.split(\":\")[\n                    0\n                ],  # the in use connections reconnect when they complete their current task\n            )\n            self._validate_disconnected(5)\n            self._validate_connected(5)\n\n            # Wait for MOVING timeout to expire and the moving completed handler to run\n            sleep(MOVING_TIMEOUT / 2 + 0.2)\n            Helpers.validate_in_use_connections_state(\n                in_use_connections,\n                expected_state=MaintenanceState.NONE,\n                expected_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                expected_socket_timeout=None,\n                expected_socket_connect_timeout=None,\n                expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                expected_orig_socket_timeout=None,\n                expected_orig_socket_connect_timeout=None,\n                expected_current_socket_timeout=None,\n                expected_current_peername=DEFAULT_ADDRESS.split(\":\")[0],\n            )\n            Helpers.validate_conn_kwargs(\n                pool=test_redis_client.connection_pool,\n                expected_maintenance_state=MaintenanceState.NONE,\n                expected_maintenance_notification_hash=None,\n                expected_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                expected_port=int(DEFAULT_ADDRESS.split(\":\")[1]),\n                expected_socket_timeout=None,\n                expected_socket_connect_timeout=None,\n                expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                expected_orig_socket_timeout=None,\n                expected_orig_socket_connect_timeout=None,\n            )\n            Helpers.validate_free_connections_state(\n                pool=test_redis_client.connection_pool,\n                expected_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                expected_socket_timeout=None,\n                expected_socket_connect_timeout=None,\n                expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                expected_orig_socket_timeout=None,\n                expected_orig_socket_connect_timeout=None,\n                should_be_connected_count=0,\n                connected_to_tmp_address=True,\n                expected_state=MaintenanceState.NONE,\n            )\n        finally:\n            if hasattr(test_redis_client.connection_pool, \"disconnect\"):\n                test_redis_client.connection_pool.disconnect()\n\n    @pytest.mark.parametrize(\"pool_class\", [ConnectionPool, BlockingConnectionPool])\n    def test_create_new_conn_while_moving_not_expired(self, pool_class):\n        \"\"\"\n        Test creating new connections while MOVING notification is active (not expired).\n\n        This test validates that:\n        1. After MOVING notification is processed, new connections are created with temporary address\n        2. New connections inherit the relaxed timeout settings\n        3. Pool configuration is properly applied to newly created connections\n        \"\"\"\n        # Create a pool and Redis client with maintenance notifications and pool handler\n        test_redis_client = self._get_client(pool_class, max_connections=10)\n\n        try:\n            # Create several connections and return them in the pool\n            connections = []\n            for _ in range(5):\n                connection = test_redis_client.connection_pool.get_connection()\n                connections.append(connection)\n\n            for connection in connections:\n                test_redis_client.connection_pool.release(connection)\n\n            # Take 3 connections to be \"in use\"\n            in_use_connections = []\n            for _ in range(3):\n                connection = test_redis_client.connection_pool.get_connection()\n                in_use_connections.append(connection)\n\n            # Validate all connections are connected prior MOVING notification\n            self._validate_disconnected(0)\n\n            # Run command that will receive and handle MOVING notification\n            key_moving = \"key_receive_moving_0\"\n            value_moving = \"value3_0\"\n            result = test_redis_client.set(key_moving, value_moving)\n\n            # Validate command result\n            assert result is True, \"SET key_receive_moving command failed\"\n\n            # Validate pool and connections settings were updated according to MOVING notification\n            Helpers.validate_conn_kwargs(\n                pool=test_redis_client.connection_pool,\n                expected_maintenance_state=MaintenanceState.MOVING,\n                expected_maintenance_notification_hash=hash(MOVING_NOTIFICATION),\n                expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                expected_port=int(DEFAULT_ADDRESS.split(\":\")[1]),\n                expected_orig_socket_timeout=None,\n                expected_orig_socket_connect_timeout=None,\n                expected_host_address=AFTER_MOVING_ADDRESS.split(\":\")[0],\n                expected_socket_timeout=self.config.relaxed_timeout,\n                expected_socket_connect_timeout=self.config.relaxed_timeout,\n            )\n\n            # Now get several more connections to force creation of new ones\n            # This should create new connections with the temporary address\n            old_connections = []\n            for _ in range(2):\n                connection = test_redis_client.connection_pool.get_connection()\n                old_connections.append(connection)\n\n            new_connection = test_redis_client.connection_pool.get_connection()\n\n            # Validate that new connections are created with temporary address and relaxed timeout\n            # and when connecting those configs are used\n            # get_connection() returns a connection that is already connected\n            assert new_connection.host == AFTER_MOVING_ADDRESS.split(\":\")[0]\n            assert new_connection.socket_timeout is self.config.relaxed_timeout\n            # New connections should be connected to the temporary address\n            assert new_connection._get_socket() is not None\n            assert new_connection._get_socket().connected is True\n            assert (\n                new_connection._get_socket().getpeername()[0]\n                == AFTER_MOVING_ADDRESS.split(\":\")[0]\n            )\n            assert (\n                new_connection._get_socket().gettimeout() == self.config.relaxed_timeout\n            )\n\n        finally:\n            if hasattr(test_redis_client.connection_pool, \"disconnect\"):\n                test_redis_client.connection_pool.disconnect()\n\n    @pytest.mark.parametrize(\"pool_class\", [ConnectionPool, BlockingConnectionPool])\n    def test_create_new_conn_after_moving_expires(self, pool_class):\n        \"\"\"\n        Test creating new connections after MOVING notification expires.\n\n        This test validates that:\n        1. After MOVING timeout expires, new connections use original address\n        2. Pool configuration is reset to original values\n        3. New connections don't inherit temporary settings\n        \"\"\"\n        # Create a pool and Redis client with maintenance notifications and pool handler\n        test_redis_client = self._get_client(pool_class, max_connections=10)\n\n        try:\n            # Create several connections and return them in the pool\n            connections = []\n            for _ in range(5):\n                connection = test_redis_client.connection_pool.get_connection()\n                connections.append(connection)\n\n            for connection in connections:\n                test_redis_client.connection_pool.release(connection)\n\n            # Take 3 connections to be \"in use\"\n            in_use_connections = []\n            for _ in range(3):\n                connection = test_redis_client.connection_pool.get_connection()\n                in_use_connections.append(connection)\n\n            # Run command that will receive and handle MOVING notification\n            key_moving = \"key_receive_moving_0\"\n            value_moving = \"value3_0\"\n            result = test_redis_client.set(key_moving, value_moving)\n\n            # Validate command result\n            assert result is True, \"SET key_receive_moving command failed\"\n\n            # Wait for MOVING timeout to expire\n            sleep(MOVING_TIMEOUT + 0.5)\n\n            # Now get several new connections after expiration\n            old_connections = []\n            for _ in range(2):\n                connection = test_redis_client.connection_pool.get_connection()\n                old_connections.append(connection)\n\n            new_connection = test_redis_client.connection_pool.get_connection()\n\n            # Validate that new connections are created with original address (no temporary settings)\n            assert new_connection.orig_host_address == DEFAULT_ADDRESS.split(\":\")[0]\n            assert new_connection.orig_socket_timeout is None\n            # New connections should be connected to the original address\n            assert new_connection._get_socket() is not None\n            assert new_connection._get_socket().connected is True\n            # Socket timeout should be None (original timeout)\n            assert new_connection._get_socket().gettimeout() is None\n\n        finally:\n            if hasattr(test_redis_client.connection_pool, \"disconnect\"):\n                test_redis_client.connection_pool.disconnect()\n\n    @pytest.mark.parametrize(\"pool_class\", [ConnectionPool, BlockingConnectionPool])\n    def test_receive_migrated_after_moving(self, pool_class):\n        \"\"\"\n        Test receiving MIGRATED notification after MOVING notification.\n\n        This test validates the complete MOVING -> MIGRATED lifecycle:\n        1. MOVING notification is processed and temporary settings are applied\n        2. MIGRATED notification is received during command execution\n        3. Temporary settings are cleared after MIGRATED\n        4. Pool configuration is restored to original values\n\n        Note: When MIGRATED comes after MOVING and MOVING hasn't yet expired,\n        it should not decrease timeouts (future refactoring consideration).\n        \"\"\"\n        # Create a pool and Redis client with maintenance notifications and pool handler\n        test_redis_client = self._get_client(pool_class, max_connections=10)\n\n        try:\n            # Create several connections and return them in the pool\n            connections = []\n            for _ in range(5):\n                connection = test_redis_client.connection_pool.get_connection()\n                connections.append(connection)\n\n            for connection in connections:\n                test_redis_client.connection_pool.release(connection)\n\n            # Take 3 connections to be \"in use\"\n            in_use_connections = []\n            for _ in range(3):\n                connection = test_redis_client.connection_pool.get_connection()\n                in_use_connections.append(connection)\n\n            # Validate all connections are connected prior MOVING notification\n            self._validate_disconnected(0)\n\n            # Step 1: Run command that will receive and handle MOVING notification\n            key_moving = \"key_receive_moving_0\"\n            value_moving = \"value3_0\"\n            result_moving = test_redis_client.set(key_moving, value_moving)\n\n            # Validate MOVING command result\n            assert result_moving is True, \"SET key_receive_moving command failed\"\n\n            # Validate pool and connections settings were updated according to MOVING notification\n            Helpers.validate_conn_kwargs(\n                pool=test_redis_client.connection_pool,\n                expected_maintenance_state=MaintenanceState.MOVING,\n                expected_maintenance_notification_hash=hash(MOVING_NOTIFICATION),\n                expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                expected_port=int(DEFAULT_ADDRESS.split(\":\")[1]),\n                expected_orig_socket_timeout=None,\n                expected_orig_socket_connect_timeout=None,\n                expected_host_address=AFTER_MOVING_ADDRESS.split(\":\")[0],\n                expected_socket_timeout=self.config.relaxed_timeout,\n                expected_socket_connect_timeout=self.config.relaxed_timeout,\n            )\n\n            # TODO validate current socket timeout\n\n            # Step 2: Run command that will receive and handle MIGRATED notification\n            # This should clear the temporary settings\n            key_migrated = \"key_receive_migrated_0\"\n            value_migrated = \"migrated_value\"\n            result_migrated = test_redis_client.set(key_migrated, value_migrated)\n\n            # Validate MIGRATED command result\n            assert result_migrated is True, \"SET key_receive_migrated command failed\"\n\n            # Step 3: Validate that MIGRATED notification was processed but MOVING settings remain\n            # (MIGRATED doesn't automatically clear MOVING settings - they are separate notifications)\n            # MOVING settings should still be active\n            # MOVING timeout should still be active\n            Helpers.validate_conn_kwargs(\n                pool=test_redis_client.connection_pool,\n                expected_maintenance_state=MaintenanceState.MOVING,\n                expected_maintenance_notification_hash=hash(MOVING_NOTIFICATION),\n                expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                expected_port=int(DEFAULT_ADDRESS.split(\":\")[1]),\n                expected_orig_socket_timeout=None,\n                expected_orig_socket_connect_timeout=None,\n                expected_host_address=AFTER_MOVING_ADDRESS.split(\":\")[0],\n                expected_socket_timeout=self.config.relaxed_timeout,\n                expected_socket_connect_timeout=self.config.relaxed_timeout,\n            )\n\n            # Step 4: Create new connections after MIGRATED to verify they still use MOVING settings\n            # (since MOVING settings are still active)\n            new_connections = []\n            for _ in range(2):\n                connection = test_redis_client.connection_pool.get_connection()\n                new_connections.append(connection)\n\n            # Validate that new connections are created with MOVING settings (still active)\n            for connection in new_connections:\n                assert connection.host == AFTER_MOVING_ADDRESS.split(\":\")[0]\n                # Note: New connections may not inherit the exact relaxed timeout value\n                # but they should have the temporary host address\n                # New connections should be connected\n                if connection._get_socket() is not None:\n                    assert connection._get_socket().connected is True\n\n            # Release the new connections\n            for connection in new_connections:\n                test_redis_client.connection_pool.release(connection)\n\n            # Validate free connections state with MOVING settings still active\n            # Note: We'll validate with the pool's current settings rather than individual connection settings\n            # since new connections may have different timeout values but still use the temporary address\n\n        finally:\n            if hasattr(test_redis_client.connection_pool, \"disconnect\"):\n                test_redis_client.connection_pool.disconnect()\n\n    @pytest.mark.parametrize(\"pool_class\", [ConnectionPool, BlockingConnectionPool])\n    def test_overlapping_moving_notifications(self, pool_class):\n        \"\"\"\n        Test handling of overlapping/duplicate MOVING notifications (e.g., two MOVING notifications before the first expires).\n        Ensures that the second MOVING notification updates the pool and connections as expected, and that expiry/cleanup works.\n        \"\"\"\n        global AFTER_MOVING_ADDRESS\n        test_redis_client = self._get_client(pool_class, max_connections=5)\n        try:\n            # Create and release some connections\n            in_use_connections = []\n            for _ in range(3):\n                in_use_connections.append(\n                    test_redis_client.connection_pool.get_connection()\n                )\n\n            for conn in in_use_connections:\n                test_redis_client.connection_pool.release(conn)\n\n            # Take 2 connections to be in use\n            in_use_connections = []\n            for _ in range(2):\n                conn = test_redis_client.connection_pool.get_connection()\n                in_use_connections.append(conn)\n\n            # Trigger first MOVING notification\n            key_moving1 = \"key_receive_moving_0\"\n            value_moving1 = \"value3_0\"\n            result1 = test_redis_client.set(key_moving1, value_moving1)\n            assert result1 is True\n            Helpers.validate_conn_kwargs(\n                pool=test_redis_client.connection_pool,\n                expected_maintenance_state=MaintenanceState.MOVING,\n                expected_maintenance_notification_hash=hash(MOVING_NOTIFICATION),\n                expected_host_address=AFTER_MOVING_ADDRESS.split(\":\")[0],\n                expected_port=int(DEFAULT_ADDRESS.split(\":\")[1]),\n                expected_socket_timeout=self.config.relaxed_timeout,\n                expected_socket_connect_timeout=self.config.relaxed_timeout,\n                expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                expected_orig_socket_timeout=None,\n                expected_orig_socket_connect_timeout=None,\n            )\n            # Validate all connections reflect the first MOVING notification\n            Helpers.validate_in_use_connections_state(\n                in_use_connections,\n                expected_state=MaintenanceState.MOVING,\n                expected_host_address=AFTER_MOVING_ADDRESS.split(\":\")[0],\n                expected_socket_timeout=self.config.relaxed_timeout,\n                expected_socket_connect_timeout=self.config.relaxed_timeout,\n                expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                expected_orig_socket_timeout=None,\n                expected_orig_socket_connect_timeout=None,\n                expected_current_socket_timeout=self.config.relaxed_timeout,\n                expected_current_peername=DEFAULT_ADDRESS.split(\":\")[0],\n            )\n            Helpers.validate_free_connections_state(\n                pool=test_redis_client.connection_pool,\n                should_be_connected_count=1,\n                connected_to_tmp_address=True,\n                expected_state=MaintenanceState.MOVING,\n                expected_host_address=AFTER_MOVING_ADDRESS.split(\":\")[0],\n                expected_socket_timeout=self.config.relaxed_timeout,\n                expected_socket_connect_timeout=self.config.relaxed_timeout,\n                expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                expected_orig_socket_timeout=None,\n                expected_orig_socket_connect_timeout=None,\n            )\n            # Reconnect in use connections\n            for conn in in_use_connections:\n                conn.disconnect()\n                conn.connect()\n\n            # Before the first MOVING expires, trigger a second MOVING notification (simulate new address)\n            # Validate the orig properties are not changed!\n            second_moving_address = \"5.6.7.8:6380\"\n            orig_after_moving = AFTER_MOVING_ADDRESS\n            # Temporarily modify the global constant for this test\n            AFTER_MOVING_ADDRESS = second_moving_address\n            second_moving_notification = NodeMovingNotification(\n                id=1,\n                new_node_host=second_moving_address.split(\":\")[0],\n                new_node_port=int(second_moving_address.split(\":\")[1]),\n                ttl=MOVING_TIMEOUT,\n            )\n            try:\n                key_moving2 = \"key_receive_moving_1\"\n                value_moving2 = \"value3_1\"\n                result2 = test_redis_client.set(key_moving2, value_moving2)\n                assert result2 is True\n                Helpers.validate_conn_kwargs(\n                    pool=test_redis_client.connection_pool,\n                    expected_maintenance_state=MaintenanceState.MOVING,\n                    expected_maintenance_notification_hash=hash(\n                        second_moving_notification\n                    ),\n                    expected_host_address=second_moving_address.split(\":\")[0],\n                    expected_port=int(DEFAULT_ADDRESS.split(\":\")[1]),\n                    expected_socket_timeout=self.config.relaxed_timeout,\n                    expected_socket_connect_timeout=self.config.relaxed_timeout,\n                    expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                    expected_orig_socket_timeout=None,\n                    expected_orig_socket_connect_timeout=None,\n                )\n                # Validate all connections reflect the second MOVING notification\n                Helpers.validate_in_use_connections_state(\n                    in_use_connections,\n                    expected_state=MaintenanceState.MOVING,\n                    expected_host_address=second_moving_address.split(\":\")[0],\n                    expected_socket_timeout=self.config.relaxed_timeout,\n                    expected_socket_connect_timeout=self.config.relaxed_timeout,\n                    expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                    expected_orig_socket_timeout=None,\n                    expected_orig_socket_connect_timeout=None,\n                    expected_current_socket_timeout=self.config.relaxed_timeout,\n                    expected_current_peername=orig_after_moving.split(\":\")[0],\n                )\n                Helpers.validate_free_connections_state(\n                    test_redis_client.connection_pool,\n                    should_be_connected_count=1,\n                    connected_to_tmp_address=True,\n                    tmp_address=second_moving_address.split(\":\")[0],\n                    expected_state=MaintenanceState.MOVING,\n                    expected_host_address=second_moving_address.split(\":\")[0],\n                    expected_socket_timeout=self.config.relaxed_timeout,\n                    expected_socket_connect_timeout=self.config.relaxed_timeout,\n                    expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                    expected_orig_socket_timeout=None,\n                    expected_orig_socket_connect_timeout=None,\n                )\n            finally:\n                AFTER_MOVING_ADDRESS = orig_after_moving\n\n            # Wait for both MOVING timeouts to expire\n            sleep(MOVING_TIMEOUT + 0.5)\n            Helpers.validate_conn_kwargs(\n                pool=test_redis_client.connection_pool,\n                expected_maintenance_state=MaintenanceState.NONE,\n                expected_maintenance_notification_hash=None,\n                expected_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                expected_port=int(DEFAULT_ADDRESS.split(\":\")[1]),\n                expected_socket_timeout=None,\n                expected_socket_connect_timeout=None,\n                expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n                expected_orig_socket_timeout=None,\n                expected_orig_socket_connect_timeout=None,\n            )\n        finally:\n            if hasattr(test_redis_client.connection_pool, \"disconnect\"):\n                test_redis_client.connection_pool.disconnect()\n\n    @pytest.mark.parametrize(\"pool_class\", [ConnectionPool, BlockingConnectionPool])\n    def test_thread_safety_concurrent_notification_handling(self, pool_class):\n        \"\"\"\n        Test thread-safety under concurrent maintenance notification handling.\n        Simulates multiple threads triggering MOVING notifications and performing operations concurrently.\n        \"\"\"\n        import threading\n\n        test_redis_client = self._get_client(pool_class, max_connections=5)\n        results = []\n        errors = []\n\n        def worker(idx):\n            try:\n                key = f\"key_receive_moving_{idx}\"\n                value = f\"value3_{idx}\"\n                result = test_redis_client.set(key, value)\n                results.append(result)\n            except Exception as e:\n                errors.append(e)\n\n        threads = [threading.Thread(target=worker, args=(i,)) for i in range(5)]\n        for t in threads:\n            t.start()\n        for t in threads:\n            t.join()\n        assert all(results), f\"Not all threads succeeded: {results}\"\n        assert not errors, f\"Errors occurred in threads: {errors}\"\n        # After all threads, MOVING notification should have been handled safely\n        Helpers.validate_conn_kwargs(\n            pool=test_redis_client.connection_pool,\n            expected_maintenance_state=MaintenanceState.MOVING,\n            expected_maintenance_notification_hash=hash(MOVING_NOTIFICATION),\n            expected_host_address=AFTER_MOVING_ADDRESS.split(\":\")[0],\n            expected_port=int(DEFAULT_ADDRESS.split(\":\")[1]),\n            expected_socket_timeout=self.config.relaxed_timeout,\n            expected_socket_connect_timeout=self.config.relaxed_timeout,\n            expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n            expected_orig_socket_timeout=None,\n            expected_orig_socket_connect_timeout=None,\n        )\n\n        if hasattr(test_redis_client.connection_pool, \"disconnect\"):\n            test_redis_client.connection_pool.disconnect()\n\n    @pytest.mark.parametrize(\n        \"pool_class,enable_cache\",\n        [\n            (ConnectionPool, False),\n            (ConnectionPool, True),\n            (BlockingConnectionPool, False),\n            (BlockingConnectionPool, True),\n        ],\n    )\n    def test_moving_migrating_migrated_moved_state_transitions(\n        self, pool_class, enable_cache\n    ):\n        \"\"\"\n        Test moving configs are not lost if the per connection notifications get picked up after moving is handled.\n        Sequence of notifications: MOVING, MIGRATING, MIGRATED, FAILING_OVER, FAILED_OVER, MOVED.\n        Note: FAILING_OVER and FAILED_OVER notifications do not change the connection state when already in MOVING state.\n        Checks the state after each notification for all connections and for new connections created during each state.\n        \"\"\"\n        # Setup\n        test_redis_client = self._get_client(\n            pool_class,\n            max_connections=5,\n            enable_cache=enable_cache,\n        )\n        pool = test_redis_client.connection_pool\n\n        # Create and release some connections\n        in_use_connections = []\n        for _ in range(3):\n            in_use_connections.append(pool.get_connection())\n\n        pool_handler = in_use_connections[0]._maint_notifications_pool_handler\n\n        while len(in_use_connections) > 0:\n            pool.release(in_use_connections.pop())\n\n        # Take 2 connections to be in use\n        in_use_connections = []\n        for _ in range(2):\n            conn = pool.get_connection()\n            in_use_connections.append(conn)\n\n        # 1. MOVING notification\n        tmp_address = \"22.23.24.25\"\n        moving_notification = NodeMovingNotification(\n            id=1, new_node_host=tmp_address, new_node_port=6379, ttl=1\n        )\n        pool_handler.handle_notification(moving_notification)\n\n        Helpers.validate_in_use_connections_state(\n            in_use_connections,\n            expected_state=MaintenanceState.MOVING,\n            expected_host_address=tmp_address,\n            expected_socket_timeout=self.config.relaxed_timeout,\n            expected_socket_connect_timeout=self.config.relaxed_timeout,\n            expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n            expected_orig_socket_timeout=None,\n            expected_orig_socket_connect_timeout=None,\n            expected_current_socket_timeout=self.config.relaxed_timeout,\n            expected_current_peername=DEFAULT_ADDRESS.split(\":\")[0],\n        )\n        Helpers.validate_free_connections_state(\n            pool=pool,\n            should_be_connected_count=0,\n            connected_to_tmp_address=False,\n            expected_state=MaintenanceState.MOVING,\n            expected_host_address=tmp_address,\n            expected_socket_timeout=self.config.relaxed_timeout,\n            expected_socket_connect_timeout=self.config.relaxed_timeout,\n            expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n            expected_orig_socket_timeout=None,\n            expected_orig_socket_connect_timeout=None,\n        )\n\n        # 2. MIGRATING notification (simulate direct connection handler call)\n        for conn in in_use_connections:\n            conn._maint_notifications_connection_handler.handle_notification(\n                NodeMigratingNotification(id=2, ttl=1)\n            )\n        Helpers.validate_in_use_connections_state(\n            in_use_connections,\n            expected_state=MaintenanceState.MOVING,\n            expected_host_address=tmp_address,\n            expected_socket_timeout=self.config.relaxed_timeout,\n            expected_socket_connect_timeout=self.config.relaxed_timeout,\n            expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n            expected_orig_socket_timeout=None,\n            expected_orig_socket_connect_timeout=None,\n            expected_current_socket_timeout=self.config.relaxed_timeout,\n            expected_current_peername=DEFAULT_ADDRESS.split(\":\")[0],\n        )\n\n        # 3. MIGRATED notification (simulate direct connection handler call)\n        for conn in in_use_connections:\n            conn._maint_notifications_connection_handler.handle_notification(\n                NodeMigratedNotification(id=2)\n            )\n        # State should not change for connections that are in MOVING state\n        Helpers.validate_in_use_connections_state(\n            in_use_connections,\n            expected_state=MaintenanceState.MOVING,\n            expected_host_address=tmp_address,\n            expected_socket_timeout=self.config.relaxed_timeout,\n            expected_socket_connect_timeout=self.config.relaxed_timeout,\n            expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n            expected_orig_socket_timeout=None,\n            expected_orig_socket_connect_timeout=None,\n            expected_current_socket_timeout=self.config.relaxed_timeout,\n            expected_current_peername=DEFAULT_ADDRESS.split(\":\")[0],\n        )\n\n        # 4. FAILING_OVER notification (simulate direct connection handler call)\n        for conn in in_use_connections:\n            conn._maint_notifications_connection_handler.handle_notification(\n                NodeFailingOverNotification(id=3, ttl=1)\n            )\n        # State should not change for connections that are in MOVING state\n        Helpers.validate_in_use_connections_state(\n            in_use_connections,\n            expected_state=MaintenanceState.MOVING,\n            expected_host_address=tmp_address,\n            expected_socket_timeout=self.config.relaxed_timeout,\n            expected_socket_connect_timeout=self.config.relaxed_timeout,\n            expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n            expected_orig_socket_timeout=None,\n            expected_orig_socket_connect_timeout=None,\n            expected_current_socket_timeout=self.config.relaxed_timeout,\n            expected_current_peername=DEFAULT_ADDRESS.split(\":\")[0],\n        )\n\n        # 5. FAILED_OVER notification (simulate direct connection handler call)\n        for conn in in_use_connections:\n            conn._maint_notifications_connection_handler.handle_notification(\n                NodeFailedOverNotification(id=3)\n            )\n        # State should not change for connections that are in MOVING state\n        Helpers.validate_in_use_connections_state(\n            in_use_connections,\n            expected_state=MaintenanceState.MOVING,\n            expected_host_address=tmp_address,\n            expected_socket_timeout=self.config.relaxed_timeout,\n            expected_socket_connect_timeout=self.config.relaxed_timeout,\n            expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n            expected_orig_socket_timeout=None,\n            expected_orig_socket_connect_timeout=None,\n            expected_current_socket_timeout=self.config.relaxed_timeout,\n            expected_current_peername=DEFAULT_ADDRESS.split(\":\")[0],\n        )\n\n        # 6. MOVED notification (simulate timer expiry)\n        pool_handler.handle_node_moved_notification(moving_notification)\n        Helpers.validate_in_use_connections_state(\n            in_use_connections,\n            expected_state=MaintenanceState.NONE,\n            expected_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n            expected_socket_timeout=None,\n            expected_socket_connect_timeout=None,\n            expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n            expected_orig_socket_timeout=None,\n            expected_orig_socket_connect_timeout=None,\n            expected_current_socket_timeout=None,\n            expected_current_peername=DEFAULT_ADDRESS.split(\":\")[0],\n        )\n        Helpers.validate_free_connections_state(\n            pool=pool,\n            should_be_connected_count=0,\n            connected_to_tmp_address=False,\n            expected_state=MaintenanceState.NONE,\n            expected_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n            expected_socket_timeout=None,\n            expected_socket_connect_timeout=None,\n            expected_orig_host_address=DEFAULT_ADDRESS.split(\":\")[0],\n            expected_orig_socket_timeout=None,\n            expected_orig_socket_connect_timeout=None,\n        )\n        # New connection after MOVED\n        new_conn_none = pool.get_connection()\n        assert new_conn_none.maintenance_state == MaintenanceState.NONE\n        pool.release(new_conn_none)\n        # Cleanup\n        for conn in in_use_connections:\n            pool.release(conn)\n        if hasattr(pool, \"disconnect\"):\n            pool.disconnect()\n\n\nclass TestMaintenanceNotificationsHandlingMultipleProxies(\n    TestMaintenanceNotificationsBase\n):\n    \"\"\"Integration tests for maintenance notifications handling with real connection pool.\"\"\"\n\n    def setup_method(self):\n        \"\"\"Set up test fixtures with mocked sockets.\"\"\"\n        super().setup_method()\n        self.orig_host = \"test.address.com\"\n\n        ips = [\"1.2.3.4\", \"5.6.7.8\", \"9.10.11.12\"]\n        ips = ips * 3\n\n        # Mock socket creation to return our mock sockets\n        def mock_socket_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0):\n            if host == self.orig_host:\n                ip_address = ips.pop(0)\n            else:\n                ip_address = host\n\n            # Return the standard getaddrinfo format\n            # (family, type, proto, canonname, sockaddr)\n            return [\n                (\n                    socket.AF_INET,\n                    socket.SOCK_STREAM,\n                    socket.IPPROTO_TCP,\n                    \"\",\n                    (ip_address, port),\n                )\n            ]\n\n        self.getaddrinfo_patcher = patch(\n            \"socket.getaddrinfo\", side_effect=mock_socket_getaddrinfo\n        )\n        self.getaddrinfo_patcher.start()\n\n    def teardown_method(self):\n        \"\"\"Clean up test fixtures.\"\"\"\n        super().teardown_method()\n        self.getaddrinfo_patcher.stop()\n\n    @pytest.mark.parametrize(\"pool_class\", [ConnectionPool, BlockingConnectionPool])\n    def test_migrating_after_moving_multiple_proxies(self, pool_class):\n        \"\"\" \"\"\"\n        # Setup\n\n        pool = pool_class(\n            host=self.orig_host,\n            port=12345,\n            max_connections=10,\n            protocol=3,  # Required for maintenance notifications\n            maint_notifications_config=self.config,\n        )\n\n        pool_handler = pool._maint_notifications_pool_handler\n\n        # Create and release some connections\n        key1 = \"1.2.3.4\"\n        key2 = \"5.6.7.8\"\n        key3 = \"9.10.11.12\"\n        in_use_connections = {key1: [], key2: [], key3: []}\n        # Create 7 connections\n        for _ in range(7):\n            conn = pool.get_connection()\n            in_use_connections[conn.getpeername()].append(conn)\n\n        for _, conns in in_use_connections.items():\n            while len(conns) > 1:\n                pool.release(conns.pop())\n\n        # Send MOVING notification to con with ip = key1\n        conn = in_use_connections[key1][0]\n        pool_handler.set_connection(conn)\n        new_ip = \"13.14.15.16\"\n        pool_handler.handle_notification(\n            NodeMovingNotification(\n                id=1, new_node_host=new_ip, new_node_port=6379, ttl=1\n            )\n        )\n\n        # validate in use connection and ip1\n        Helpers.validate_in_use_connections_state(\n            in_use_connections[key1],\n            expected_state=MaintenanceState.MOVING,\n            expected_host_address=new_ip,\n            expected_socket_timeout=self.config.relaxed_timeout,\n            expected_socket_connect_timeout=self.config.relaxed_timeout,\n            expected_orig_host_address=self.orig_host,\n            expected_orig_socket_timeout=None,\n            expected_orig_socket_connect_timeout=None,\n            expected_current_socket_timeout=self.config.relaxed_timeout,\n            expected_current_peername=key1,\n        )\n        # validate free connections for ip1\n        changed_free_connections = 0\n        free_connections = pool._get_free_connections()\n\n        for conn in free_connections:\n            if conn.host == new_ip:\n                changed_free_connections += 1\n                assert conn.maintenance_state == MaintenanceState.MOVING\n                assert conn.host == new_ip\n                assert conn.socket_timeout == self.config.relaxed_timeout\n                assert conn.socket_connect_timeout == self.config.relaxed_timeout\n                assert conn.orig_host_address == self.orig_host\n                assert conn.orig_socket_timeout is None\n                assert conn.orig_socket_connect_timeout is None\n            else:\n                assert conn.maintenance_state == MaintenanceState.NONE\n                assert conn.host == self.orig_host\n                assert conn.socket_timeout is None\n                assert conn.socket_connect_timeout is None\n                assert conn.orig_host_address == self.orig_host\n                assert conn.orig_socket_timeout is None\n                assert conn.orig_socket_connect_timeout is None\n        assert changed_free_connections == 2\n        assert len(free_connections) == 4\n\n        # Send second MOVING notification to con with ip = key2\n        conn = in_use_connections[key2][0]\n        pool_handler.set_connection(conn)\n        new_ip_2 = \"17.18.19.20\"\n        pool_handler.handle_notification(\n            NodeMovingNotification(\n                id=2, new_node_host=new_ip_2, new_node_port=6379, ttl=2\n            )\n        )\n\n        # validate in use connection and ip2\n        Helpers.validate_in_use_connections_state(\n            in_use_connections[key2],\n            expected_state=MaintenanceState.MOVING,\n            expected_host_address=new_ip_2,\n            expected_socket_timeout=self.config.relaxed_timeout,\n            expected_socket_connect_timeout=self.config.relaxed_timeout,\n            expected_orig_host_address=self.orig_host,\n            expected_orig_socket_timeout=None,\n            expected_orig_socket_connect_timeout=None,\n            expected_current_socket_timeout=self.config.relaxed_timeout,\n            expected_current_peername=key2,\n        )\n        # validate free connections for ip2\n        changed_free_connections = 0\n        free_connections = pool._get_free_connections()\n\n        for conn in free_connections:\n            if conn.host == new_ip_2:\n                changed_free_connections += 1\n                assert conn.maintenance_state == MaintenanceState.MOVING\n                assert conn.host == new_ip_2\n                assert conn.socket_timeout == self.config.relaxed_timeout\n                assert conn.socket_connect_timeout == self.config.relaxed_timeout\n                assert conn.orig_host_address == self.orig_host\n                assert conn.orig_socket_timeout is None\n                assert conn.orig_socket_connect_timeout is None\n            # here I can't validate the other connections since some of\n            # them are in MOVING state from the first notification\n            # and some are in NONE state\n        assert changed_free_connections == 1\n\n        # MIGRATING notification on connection that has already been marked as MOVING\n        conn = in_use_connections[key2][0]\n        conn_notification_handler = conn._maint_notifications_connection_handler\n        conn_notification_handler.handle_notification(\n            NodeMigratingNotification(id=3, ttl=1)\n        )\n        # validate connection does not lose its MOVING state\n        assert conn.maintenance_state == MaintenanceState.MOVING\n        # MIGRATED notification\n        conn_notification_handler.handle_notification(NodeMigratedNotification(id=3))\n        # validate connection does not lose its MOVING state and relaxed timeout\n        assert conn.maintenance_state == MaintenanceState.MOVING\n        assert conn.socket_timeout == self.config.relaxed_timeout\n\n        # Send Migrating notification to con with ip = key3\n        conn = in_use_connections[key3][0]\n        conn_notification_handler = conn._maint_notifications_connection_handler\n        conn_notification_handler.handle_notification(\n            NodeMigratingNotification(id=3, ttl=1)\n        )\n        # validate connection is in MIGRATING state\n        assert conn.maintenance_state == MaintenanceState.MAINTENANCE\n\n        assert conn.socket_timeout == self.config.relaxed_timeout\n\n        # Send MIGRATED notification to con with ip = key3\n        conn_notification_handler.handle_notification(NodeMigratedNotification(id=3))\n        # validate connection is in MOVING state\n        assert conn.maintenance_state == MaintenanceState.NONE\n        assert conn.socket_timeout is None\n\n        # sleep to expire only the first MOVING notifications\n        sleep(1.3)\n        # validate only the connections affected by the first MOVING notification\n        # have lost their MOVING state\n        Helpers.validate_in_use_connections_state(\n            in_use_connections[key1],\n            expected_state=MaintenanceState.NONE,\n            expected_host_address=self.orig_host,\n            expected_socket_timeout=None,\n            expected_socket_connect_timeout=None,\n            expected_orig_host_address=self.orig_host,\n            expected_orig_socket_timeout=None,\n            expected_orig_socket_connect_timeout=None,\n            expected_current_socket_timeout=None,\n            expected_current_peername=key1,\n        )\n        Helpers.validate_in_use_connections_state(\n            in_use_connections[key2],\n            expected_state=MaintenanceState.MOVING,\n            expected_host_address=new_ip_2,\n            expected_socket_timeout=self.config.relaxed_timeout,\n            expected_socket_connect_timeout=self.config.relaxed_timeout,\n            expected_orig_host_address=self.orig_host,\n            expected_orig_socket_timeout=None,\n            expected_orig_socket_connect_timeout=None,\n            expected_current_socket_timeout=self.config.relaxed_timeout,\n            expected_current_peername=key2,\n        )\n        Helpers.validate_in_use_connections_state(\n            in_use_connections[key3],\n            expected_state=MaintenanceState.NONE,\n            expected_should_reconnect=False,\n            expected_host_address=self.orig_host,\n            expected_socket_timeout=None,\n            expected_socket_connect_timeout=None,\n            expected_orig_host_address=self.orig_host,\n            expected_orig_socket_timeout=None,\n            expected_orig_socket_connect_timeout=None,\n            expected_current_socket_timeout=None,\n            expected_current_peername=key3,\n        )\n        # TODO validate free connections\n\n        # sleep to expire the second MOVING notifications\n        sleep(1)\n        # validate all connections have lost their MOVING state\n        Helpers.validate_in_use_connections_state(\n            [\n                *in_use_connections[key1],\n                *in_use_connections[key2],\n                *in_use_connections[key3],\n            ],\n            expected_state=MaintenanceState.NONE,\n            expected_should_reconnect=\"any\",\n            expected_host_address=self.orig_host,\n            expected_socket_timeout=None,\n            expected_socket_connect_timeout=None,\n            expected_orig_host_address=self.orig_host,\n            expected_orig_socket_timeout=None,\n            expected_orig_socket_connect_timeout=None,\n            expected_current_socket_timeout=None,\n            expected_current_peername=\"any\",\n        )\n        # TODO validate free connections\n"
  },
  {
    "path": "tests/mocks.py",
    "content": "# Various mocks for testing\n\n\nclass MockSocket:\n    \"\"\"\n    A class simulating an readable socket, optionally raising a\n    special exception every other read.\n    \"\"\"\n\n    class TestError(BaseException):\n        pass\n\n    def __init__(self, data, interrupt_every=0):\n        self.data = data\n        self.counter = 0\n        self.pos = 0\n        self.interrupt_every = interrupt_every\n\n    def tick(self):\n        self.counter += 1\n        if not self.interrupt_every:\n            return\n        if (self.counter % self.interrupt_every) == 0:\n            raise self.TestError()\n\n    def recv(self, bufsize):\n        self.tick()\n        bufsize = min(5, bufsize)  # truncate the read size\n        result = self.data[self.pos : self.pos + bufsize]\n        self.pos += len(result)\n        return result\n\n    def recv_into(self, buffer, nbytes=0, flags=0):\n        self.tick()\n        if nbytes == 0:\n            nbytes = len(buffer)\n        nbytes = min(5, nbytes)  # truncate the read size\n        result = self.data[self.pos : self.pos + nbytes]\n        self.pos += len(result)\n        buffer[: len(result)] = result\n        return len(result)\n"
  },
  {
    "path": "tests/ssl_utils.py",
    "content": "import enum\nimport os\nfrom collections import namedtuple\n\nCN_USERNAME = \"test_user\"\nCLIENT_CERT_NAME = \"client.crt\"\nCLIENT_CN_CERT_NAME = f\"{CN_USERNAME}.crt\"\nCLIENT_KEY_NAME = \"client.key\"\nCLIENT_CN_KEY_NAME = f\"{CN_USERNAME}.key\"\nSERVER_CERT_NAME = \"redis.crt\"\nSERVER_KEY_NAME = \"redis.key\"\nCA_CERT_NAME = \"ca.crt\"\n\n\nclass CertificateType(str, enum.Enum):\n    client = \"client\"\n    server = \"server\"\n    client_cn = \"client-cn\"\n\n\nTLSFiles = namedtuple(\"TLSFiles\", [\"certfile\", \"keyfile\", \"ca_certfile\"])\n\n\ndef get_tls_certificates(\n    subdir: str = \"standalone\",\n    cert_type: CertificateType = CertificateType.client,\n):\n    root = os.path.join(os.path.dirname(__file__), \"..\")\n    cert_subdir = (\"dockers\", subdir, \"tls\")\n    cert_dir = os.path.abspath(os.path.join(root, *cert_subdir))\n    if not os.path.isdir(cert_dir):  # github actions package validation case\n        cert_dir = os.path.abspath(os.path.join(root, \"..\", *cert_subdir))\n        if not os.path.isdir(cert_dir):\n            raise OSError(f\"No SSL certificates found. They should be in {cert_dir}\")\n\n    if cert_type == CertificateType.client:\n        return TLSFiles(\n            os.path.join(cert_dir, CLIENT_CERT_NAME),\n            os.path.join(cert_dir, CLIENT_KEY_NAME),\n            os.path.join(cert_dir, CA_CERT_NAME),\n        )\n    elif cert_type == CertificateType.server:\n        return TLSFiles(\n            os.path.join(cert_dir, SERVER_CERT_NAME),\n            os.path.join(cert_dir, SERVER_KEY_NAME),\n            os.path.join(cert_dir, CA_CERT_NAME),\n        )\n    elif cert_type == CertificateType.client_cn:\n        return TLSFiles(\n            os.path.join(cert_dir, CLIENT_CN_CERT_NAME),\n            os.path.join(cert_dir, CLIENT_CN_KEY_NAME),\n            os.path.join(cert_dir, CA_CERT_NAME),\n        )\n"
  },
  {
    "path": "tests/test_asyncio/__init__.py",
    "content": ""
  },
  {
    "path": "tests/test_asyncio/compat.py",
    "content": "import asyncio\n\ntry:\n    from contextlib import aclosing\nexcept ImportError:\n    import contextlib\n\n    @contextlib.asynccontextmanager\n    async def aclosing(thing):\n        try:\n            yield thing\n        finally:\n            await thing.aclose()\n\n\ndef create_task(coroutine):\n    return asyncio.create_task(coroutine)\n"
  },
  {
    "path": "tests/test_asyncio/conftest.py",
    "content": "import random\nfrom contextlib import asynccontextmanager as _asynccontextmanager\nfrom typing import Union\nfrom unittest import mock\n\nimport pytest\nimport pytest_asyncio\nimport redis.asyncio as redis\nfrom packaging.version import Version\nfrom redis.asyncio import Sentinel\nfrom redis.asyncio.client import Monitor\nfrom redis.asyncio.connection import Connection, parse_url\nfrom redis.asyncio.retry import Retry\nfrom redis.backoff import NoBackoff\nfrom redis.credentials import CredentialProvider\nfrom tests.conftest import REDIS_INFO, get_credential_provider\n\n\nasync def _get_info(redis_url):\n    client = redis.Redis.from_url(redis_url)\n    info = await client.info()\n    await client.connection_pool.disconnect()\n    return info\n\n\n@pytest_asyncio.fixture(\n    params=[\n        pytest.param(\n            (True,),\n            marks=pytest.mark.skipif(\n                'config.REDIS_INFO[\"cluster_enabled\"]', reason=\"cluster mode enabled\"\n            ),\n        ),\n        (False,),\n    ],\n    ids=[\n        \"single\",\n        \"pool\",\n    ],\n)\nasync def create_redis(request):\n    \"\"\"Wrapper around redis.create_redis.\"\"\"\n    (single_connection,) = request.param\n\n    teardown_clients = []\n\n    async def client_factory(\n        url: str = request.config.getoption(\"--redis-url\"),\n        cls=redis.Redis,\n        flushdb=True,\n        **kwargs,\n    ):\n        if \"protocol\" not in url and kwargs.get(\"protocol\") is None:\n            kwargs[\"protocol\"] = request.config.getoption(\"--protocol\")\n\n        cluster_mode = REDIS_INFO[\"cluster_enabled\"]\n        if not cluster_mode:\n            single = kwargs.pop(\"single_connection_client\", False) or single_connection\n            url_options = parse_url(url)\n            url_options.update(kwargs)\n            pool = redis.ConnectionPool(**url_options)\n            client = cls(connection_pool=pool)\n        else:\n            client = redis.RedisCluster.from_url(url, **kwargs)\n            await client.initialize()\n            single = False\n        if single:\n            client = client.client()\n            await client.initialize()\n\n        async def teardown():\n            if not cluster_mode:\n                if flushdb and \"username\" not in kwargs:\n                    try:\n                        await client.flushdb()\n                    except redis.ConnectionError:\n                        # handle cases where a test disconnected a client\n                        # just manually retry the flushdb\n                        await client.flushdb()\n                await client.aclose()\n                await client.connection_pool.disconnect()\n            else:\n                if flushdb:\n                    try:\n                        await client.flushdb(target_nodes=\"primaries\")\n                    except redis.ConnectionError:\n                        # handle cases where a test disconnected a client\n                        # just manually retry the flushdb\n                        await client.flushdb(target_nodes=\"primaries\")\n                await client.aclose()\n\n        teardown_clients.append(teardown)\n        return client\n\n    yield client_factory\n\n    for teardown in teardown_clients:\n        await teardown()\n\n\n@pytest_asyncio.fixture()\nasync def r(create_redis):\n    return await create_redis()\n\n\n@pytest_asyncio.fixture()\nasync def r2(create_redis):\n    \"\"\"A second client for tests that need multiple\"\"\"\n    return await create_redis()\n\n\n@pytest_asyncio.fixture()\nasync def decoded_r(create_redis):\n    return await create_redis(decode_responses=True)\n\n\n@pytest_asyncio.fixture()\nasync def sentinel_setup(local_cache, request):\n    sentinel_ips = request.config.getoption(\"--sentinels\")\n    sentinel_endpoints = [\n        (ip.strip(), int(port.strip()))\n        for ip, port in (endpoint.split(\":\") for endpoint in sentinel_ips.split(\",\"))\n    ]\n    kwargs = request.param.get(\"kwargs\", {}) if hasattr(request, \"param\") else {}\n    force_master_ip = request.param.get(\"force_master_ip\", None)\n    sentinel = Sentinel(\n        sentinel_endpoints,\n        force_master_ip=force_master_ip,\n        socket_timeout=0.1,\n        client_cache=local_cache,\n        protocol=3,\n        **kwargs,\n    )\n    yield sentinel\n    for s in sentinel.sentinels:\n        await s.aclose()\n\n\n@pytest_asyncio.fixture()\nasync def master(request, sentinel_setup):\n    master_service = request.config.getoption(\"--master-service\")\n    master = sentinel_setup.master_for(master_service)\n    yield master\n    await master.aclose()\n\n\ndef _gen_cluster_mock_resp(r, response):\n    connection = mock.AsyncMock(spec=Connection)\n    connection.retry = Retry(NoBackoff(), 0)\n    connection.read_response.return_value = response\n    connection.host = \"localhost\"\n    connection.port = 6379\n    connection.db = 0\n    with mock.patch.object(r, \"connection\", connection):\n        yield r\n\n\n@pytest_asyncio.fixture()\nasync def mock_cluster_resp_ok(create_redis, **kwargs):\n    r = await create_redis(**kwargs)\n    for mocked in _gen_cluster_mock_resp(r, \"OK\"):\n        yield mocked\n\n\n@pytest_asyncio.fixture()\nasync def mock_cluster_resp_int(create_redis, **kwargs):\n    r = await create_redis(**kwargs)\n    for mocked in _gen_cluster_mock_resp(r, 2):\n        yield mocked\n\n\n@pytest_asyncio.fixture()\nasync def mock_cluster_resp_info(create_redis, **kwargs):\n    r = await create_redis(**kwargs)\n    response = (\n        \"cluster_state:ok\\r\\ncluster_slots_assigned:16384\\r\\n\"\n        \"cluster_slots_ok:16384\\r\\ncluster_slots_pfail:0\\r\\n\"\n        \"cluster_slots_fail:0\\r\\ncluster_known_nodes:7\\r\\n\"\n        \"cluster_size:3\\r\\ncluster_current_epoch:7\\r\\n\"\n        \"cluster_my_epoch:2\\r\\ncluster_stats_messages_sent:170262\\r\\n\"\n        \"cluster_stats_messages_received:105653\\r\\n\"\n    )\n    for mocked in _gen_cluster_mock_resp(r, response):\n        yield mocked\n\n\n@pytest_asyncio.fixture()\nasync def mock_cluster_resp_nodes(create_redis, **kwargs):\n    r = await create_redis(**kwargs)\n    response = (\n        \"c8253bae761cb1ecb2b61857d85dfe455a0fec8b 172.17.0.7:7006 \"\n        \"slave aa90da731f673a99617dfe930306549a09f83a6b 0 \"\n        \"1447836263059 5 connected\\n\"\n        \"9bd595fe4821a0e8d6b99d70faa660638a7612b3 172.17.0.7:7008 \"\n        \"master - 0 1447836264065 0 connected\\n\"\n        \"aa90da731f673a99617dfe930306549a09f83a6b 172.17.0.7:7003 \"\n        \"myself,master - 0 0 2 connected 5461-10922\\n\"\n        \"1df047e5a594f945d82fc140be97a1452bcbf93e 172.17.0.7:7007 \"\n        \"slave 19efe5a631f3296fdf21a5441680f893e8cc96ec 0 \"\n        \"1447836262556 3 connected\\n\"\n        \"4ad9a12e63e8f0207025eeba2354bcf4c85e5b22 172.17.0.7:7005 \"\n        \"master - 0 1447836262555 7 connected 0-5460\\n\"\n        \"19efe5a631f3296fdf21a5441680f893e8cc96ec 172.17.0.7:7004 \"\n        \"master - 0 1447836263562 3 connected 10923-16383\\n\"\n        \"fbb23ed8cfa23f17eaf27ff7d0c410492a1093d6 172.17.0.7:7002 \"\n        \"master,fail - 1447829446956 1447829444948 1 disconnected\\n\"\n    )\n    for mocked in _gen_cluster_mock_resp(r, response):\n        yield mocked\n\n\n@pytest_asyncio.fixture()\nasync def mock_cluster_resp_slaves(create_redis, **kwargs):\n    r = await create_redis(**kwargs)\n    response = (\n        \"['1df047e5a594f945d82fc140be97a1452bcbf93e 172.17.0.7:7007 \"\n        \"slave 19efe5a631f3296fdf21a5441680f893e8cc96ec 0 \"\n        \"1447836789290 3 connected']\"\n    )\n    for mocked in _gen_cluster_mock_resp(r, response):\n        yield mocked\n\n\n@pytest_asyncio.fixture()\nasync def credential_provider(request) -> CredentialProvider:\n    return get_credential_provider(request)\n\n\nasync def wait_for_command(\n    client: redis.Redis, monitor: Monitor, command: str, key: Union[str, None] = None\n):\n    # issue a command with a key name that's local to this process.\n    # if we find a command with our key before the command we're waiting\n    # for, something went wrong\n    if key is None:\n        # generate key\n        redis_version = REDIS_INFO[\"version\"]\n        if Version(redis_version) >= Version(\"5.0.0\"):\n            id_str = str(await client.client_id())\n        else:\n            id_str = f\"{random.randrange(2**32):08x}\"\n        key = f\"__REDIS-PY-{id_str}__\"\n    await client.get(key)\n    while True:\n        monitor_response = await monitor.next_command()\n        if command in monitor_response[\"command\"]:\n            return monitor_response\n        if key in monitor_response[\"command\"]:\n            return None\n\n\n# python 3.6 doesn't have the asynccontextmanager decorator.  Provide it here.\nclass AsyncContextManager:\n    def __init__(self, async_generator):\n        self.gen = async_generator\n\n    async def __aenter__(self):\n        try:\n            return await self.gen.__anext__()\n        except StopAsyncIteration as err:\n            raise RuntimeError(\"Pickles\") from err\n\n    async def __aexit__(self, exc_type, exc_inst, tb):\n        if exc_type:\n            await self.gen.athrow(exc_type, exc_inst, tb)\n            return True\n        try:\n            await self.gen.__anext__()\n        except StopAsyncIteration:\n            return\n        raise RuntimeError(\"More pickles\")\n\n\ndef asynccontextmanager(func):\n    return _asynccontextmanager(func)\n\n\n# helpers to get the connection arguments for this run\n@pytest.fixture()\ndef redis_url(request):\n    return request.config.getoption(\"--redis-url\")\n\n\n@pytest.fixture()\ndef connect_args(request):\n    url = request.config.getoption(\"--redis-url\")\n    return parse_url(url)\n"
  },
  {
    "path": "tests/test_asyncio/helpers.py",
    "content": "import asyncio\nimport logging\nfrom typing import Callable\n\n\nasync def wait_for_condition(\n    predicate: Callable[[], bool],\n    timeout: float = 0.2,\n    check_interval: float = 0.01,\n    error_message: str = \"Timeout waiting for condition\",\n) -> None:\n    \"\"\"\n    Poll a condition until it becomes True or timeout is reached.\n\n    Args:\n        predicate: A callable that returns True when the condition is met\n        timeout: Maximum time to wait in seconds (default: 0.2s = 20 * 0.01s)\n        check_interval: Time to sleep between checks in seconds (default: 0.01s)\n        error_message: Error message to raise if timeout occurs\n\n    Raises:\n        AssertionError: If the condition is not met within the timeout period\n\n    Example:\n        # Wait for circuit breaker to open\n        await wait_for_condition(\n            lambda: cb2.state == CBState.OPEN,\n            timeout=0.2,\n            error_message=\"Timeout waiting for cb2 to open\"\n        )\n\n        # Wait for failover strategy to select a specific database\n        await wait_for_condition(\n            lambda: client.command_executor.active_database is mock_db,\n            timeout=0.2,\n            error_message=\"Timeout waiting for active database to change\"\n        )\n    \"\"\"\n    max_retries = int(timeout / check_interval)\n\n    for attempt in range(max_retries):\n        if predicate():\n            logging.debug(f\"Condition met after {attempt} attempts\")\n            return\n        await asyncio.sleep(check_interval)\n\n    raise AssertionError(error_message)\n"
  },
  {
    "path": "tests/test_asyncio/mocks.py",
    "content": "import asyncio\n\n# Helper Mocking classes for the tests.\n\n\nclass MockStream:\n    \"\"\"\n    A class simulating an asyncio input buffer, optionally raising a\n    special exception every other read.\n    \"\"\"\n\n    class TestError(BaseException):\n        pass\n\n    def __init__(self, data, interrupt_every=0):\n        self.data = data\n        self.counter = 0\n        self.pos = 0\n        self.interrupt_every = interrupt_every\n\n    def tick(self):\n        self.counter += 1\n        if not self.interrupt_every:\n            return\n        if (self.counter % self.interrupt_every) == 0:\n            raise self.TestError()\n\n    async def read(self, want):\n        self.tick()\n        want = 5\n        result = self.data[self.pos : self.pos + want]\n        self.pos += len(result)\n        return result\n\n    async def readline(self):\n        self.tick()\n        find = self.data.find(b\"\\n\", self.pos)\n        if find >= 0:\n            result = self.data[self.pos : find + 1]\n        else:\n            result = self.data[self.pos :]\n        self.pos += len(result)\n        return result\n\n    async def readexactly(self, length):\n        self.tick()\n        result = self.data[self.pos : self.pos + length]\n        if len(result) < length:\n            raise asyncio.IncompleteReadError(result, None)\n        self.pos += len(result)\n        return result\n"
  },
  {
    "path": "tests/test_asyncio/test_bloom.py",
    "content": "from math import inf\n\nimport pytest\nimport pytest_asyncio\nimport redis.asyncio as redis\nfrom redis.exceptions import RedisError\nfrom tests.conftest import (\n    assert_resp_response,\n    is_resp2_connection,\n    skip_ifmodversion_lt,\n)\n\n\n@pytest_asyncio.fixture()\nasync def decoded_r(create_redis, stack_url):\n    return await create_redis(decode_responses=True, url=stack_url)\n\n\ndef intlist(obj):\n    return [int(v) for v in obj]\n\n\n@pytest.mark.redismod\nasync def test_create(decoded_r: redis.Redis):\n    \"\"\"Test CREATE/RESERVE calls\"\"\"\n    assert await decoded_r.bf().create(\"bloom\", 0.01, 1000)\n    assert await decoded_r.bf().create(\"bloom_e\", 0.01, 1000, expansion=1)\n    assert await decoded_r.bf().create(\"bloom_ns\", 0.01, 1000, noScale=True)\n    assert await decoded_r.cf().create(\"cuckoo\", 1000)\n    assert await decoded_r.cf().create(\"cuckoo_e\", 1000, expansion=1)\n    assert await decoded_r.cf().create(\"cuckoo_bs\", 1000, bucket_size=4)\n    assert await decoded_r.cf().create(\"cuckoo_mi\", 1000, max_iterations=10)\n    assert await decoded_r.cms().initbydim(\"cmsDim\", 100, 5)\n    assert await decoded_r.cms().initbyprob(\"cmsProb\", 0.01, 0.01)\n    assert await decoded_r.topk().reserve(\"topk\", 5, 100, 5, 0.9)\n\n\n@pytest.mark.experimental\n@pytest.mark.redismod\nasync def test_tdigest_create(decoded_r: redis.Redis):\n    assert await decoded_r.tdigest().create(\"tDigest\", 100)\n\n\n@pytest.mark.redismod\nasync def test_bf_add(decoded_r: redis.Redis):\n    assert await decoded_r.bf().create(\"bloom\", 0.01, 1000)\n    assert 1 == await decoded_r.bf().add(\"bloom\", \"foo\")\n    assert 0 == await decoded_r.bf().add(\"bloom\", \"foo\")\n    assert [0] == intlist(await decoded_r.bf().madd(\"bloom\", \"foo\"))\n    assert [0, 1] == await decoded_r.bf().madd(\"bloom\", \"foo\", \"bar\")\n    assert [0, 0, 1] == await decoded_r.bf().madd(\"bloom\", \"foo\", \"bar\", \"baz\")\n    assert 1 == await decoded_r.bf().exists(\"bloom\", \"foo\")\n    assert 0 == await decoded_r.bf().exists(\"bloom\", \"noexist\")\n    assert [1, 0] == intlist(await decoded_r.bf().mexists(\"bloom\", \"foo\", \"noexist\"))\n\n\n@pytest.mark.redismod\nasync def test_bf_insert(decoded_r: redis.Redis):\n    assert await decoded_r.bf().create(\"bloom\", 0.01, 1000)\n    assert [1] == intlist(await decoded_r.bf().insert(\"bloom\", [\"foo\"]))\n    assert [0, 1] == intlist(await decoded_r.bf().insert(\"bloom\", [\"foo\", \"bar\"]))\n    assert [1] == intlist(await decoded_r.bf().insert(\"captest\", [\"foo\"], capacity=10))\n    assert [1] == intlist(await decoded_r.bf().insert(\"errtest\", [\"foo\"], error=0.01))\n    assert 1 == await decoded_r.bf().exists(\"bloom\", \"foo\")\n    assert 0 == await decoded_r.bf().exists(\"bloom\", \"noexist\")\n    assert [1, 0] == intlist(await decoded_r.bf().mexists(\"bloom\", \"foo\", \"noexist\"))\n    info = await decoded_r.bf().info(\"bloom\")\n    assert_resp_response(\n        decoded_r,\n        2,\n        info.get(\"insertedNum\"),\n        info.get(\"Number of items inserted\"),\n    )\n    assert_resp_response(\n        decoded_r,\n        1000,\n        info.get(\"capacity\"),\n        info.get(\"Capacity\"),\n    )\n    assert_resp_response(\n        decoded_r,\n        1,\n        info.get(\"filterNum\"),\n        info.get(\"Number of filters\"),\n    )\n\n\n@pytest.mark.redismod\nasync def test_bf_scandump_and_loadchunk(decoded_r: redis.Redis):\n    # Store a filter\n    await decoded_r.bf().create(\"myBloom\", \"0.0001\", \"1000\")\n\n    # test is probabilistic and might fail. It is OK to change variables if\n    # certain to not break anything\n    async def do_verify():\n        res = 0\n        for x in range(1000):\n            await decoded_r.bf().add(\"myBloom\", x)\n            rv = await decoded_r.bf().exists(\"myBloom\", x)\n            assert rv\n            rv = await decoded_r.bf().exists(\"myBloom\", f\"nonexist_{x}\")\n            res += rv == x\n        assert res < 5\n\n    await do_verify()\n    cmds = []\n\n    cur = await decoded_r.bf().scandump(\"myBloom\", 0)\n    first = cur[0]\n    cmds.append(cur)\n\n    while True:\n        cur = await decoded_r.bf().scandump(\"myBloom\", first)\n        first = cur[0]\n        if first == 0:\n            break\n        else:\n            cmds.append(cur)\n    prev_info = await decoded_r.bf().execute_command(\"bf.debug\", \"myBloom\")\n\n    # Remove the filter\n    await decoded_r.bf().client.delete(\"myBloom\")\n\n    # Now, load all the commands:\n    for cmd in cmds:\n        await decoded_r.bf().loadchunk(\"myBloom\", *cmd)\n\n    cur_info = await decoded_r.bf().execute_command(\"bf.debug\", \"myBloom\")\n    assert prev_info == cur_info\n    await do_verify()\n\n    await decoded_r.bf().client.delete(\"myBloom\")\n    await decoded_r.bf().create(\"myBloom\", \"0.0001\", \"10000000\")\n\n\n@pytest.mark.redismod\nasync def test_bf_info(decoded_r: redis.Redis):\n    expansion = 4\n    # Store a filter\n    await decoded_r.bf().create(\"nonscaling\", \"0.0001\", \"1000\", noScale=True)\n    info = await decoded_r.bf().info(\"nonscaling\")\n    assert_resp_response(\n        decoded_r,\n        None,\n        info.get(\"expansionRate\"),\n        info.get(\"Expansion rate\"),\n    )\n\n    await decoded_r.bf().create(\"expanding\", \"0.0001\", \"1000\", expansion=expansion)\n    info = await decoded_r.bf().info(\"expanding\")\n    assert_resp_response(\n        decoded_r,\n        4,\n        info.get(\"expansionRate\"),\n        info.get(\"Expansion rate\"),\n    )\n\n    try:\n        # noScale mean no expansion\n        await decoded_r.bf().create(\n            \"myBloom\", \"0.0001\", \"1000\", expansion=expansion, noScale=True\n        )\n        assert False\n    except RedisError:\n        assert True\n\n\n@pytest.mark.redismod\nasync def test_bf_card(decoded_r: redis.Redis):\n    # return 0 if the key does not exist\n    assert await decoded_r.bf().card(\"not_exist\") == 0\n\n    # Store a filter\n    assert await decoded_r.bf().add(\"bf1\", \"item_foo\") == 1\n    assert await decoded_r.bf().card(\"bf1\") == 1\n\n    # Error when key is of a type other than Bloom filtedecoded_r.\n    with pytest.raises(redis.ResponseError):\n        await decoded_r.set(\"setKey\", \"value\")\n        await decoded_r.bf().card(\"setKey\")\n\n\n@pytest.mark.redismod\nasync def test_cf_add_and_insert(decoded_r: redis.Redis):\n    assert await decoded_r.cf().create(\"cuckoo\", 1000)\n    assert await decoded_r.cf().add(\"cuckoo\", \"filter\")\n    assert not await decoded_r.cf().addnx(\"cuckoo\", \"filter\")\n    assert 1 == await decoded_r.cf().addnx(\"cuckoo\", \"newItem\")\n    assert [1] == await decoded_r.cf().insert(\"captest\", [\"foo\"])\n    assert [1] == await decoded_r.cf().insert(\"captest\", [\"foo\"], capacity=1000)\n    assert [1] == await decoded_r.cf().insertnx(\"captest\", [\"bar\"])\n    assert [1] == await decoded_r.cf().insertnx(\"captest\", [\"food\"], nocreate=\"1\")\n    assert [0, 0, 1] == await decoded_r.cf().insertnx(\"captest\", [\"foo\", \"bar\", \"baz\"])\n    assert [0] == await decoded_r.cf().insertnx(\"captest\", [\"bar\"], capacity=1000)\n    assert [1] == await decoded_r.cf().insert(\"empty1\", [\"foo\"], capacity=1000)\n    assert [1] == await decoded_r.cf().insertnx(\"empty2\", [\"bar\"], capacity=1000)\n    info = await decoded_r.cf().info(\"captest\")\n    assert_resp_response(\n        decoded_r, 5, info.get(\"insertedNum\"), info.get(\"Number of items inserted\")\n    )\n    assert_resp_response(\n        decoded_r, 0, info.get(\"deletedNum\"), info.get(\"Number of items deleted\")\n    )\n    assert_resp_response(\n        decoded_r, 1, info.get(\"filterNum\"), info.get(\"Number of filters\")\n    )\n\n\n@pytest.mark.redismod\nasync def test_cf_exists_and_del(decoded_r: redis.Redis):\n    assert await decoded_r.cf().create(\"cuckoo\", 1000)\n    assert await decoded_r.cf().add(\"cuckoo\", \"filter\")\n    assert await decoded_r.cf().exists(\"cuckoo\", \"filter\")\n    assert not await decoded_r.cf().exists(\"cuckoo\", \"notexist\")\n    assert 1 == await decoded_r.cf().count(\"cuckoo\", \"filter\")\n    assert 0 == await decoded_r.cf().count(\"cuckoo\", \"notexist\")\n    assert await decoded_r.cf().delete(\"cuckoo\", \"filter\")\n    assert 0 == await decoded_r.cf().count(\"cuckoo\", \"filter\")\n\n\n@pytest.mark.redismod\nasync def test_cms(decoded_r: redis.Redis):\n    assert await decoded_r.cms().initbydim(\"dim\", 1000, 5)\n    assert await decoded_r.cms().initbyprob(\"prob\", 0.01, 0.01)\n    assert await decoded_r.cms().incrby(\"dim\", [\"foo\"], [5])\n    assert [0] == await decoded_r.cms().query(\"dim\", \"notexist\")\n    assert [5] == await decoded_r.cms().query(\"dim\", \"foo\")\n    assert [10, 15] == await decoded_r.cms().incrby(\"dim\", [\"foo\", \"bar\"], [5, 15])\n    assert [10, 15] == await decoded_r.cms().query(\"dim\", \"foo\", \"bar\")\n    info = await decoded_r.cms().info(\"dim\")\n    assert info[\"width\"]\n    assert 1000 == info[\"width\"]\n    assert 5 == info[\"depth\"]\n    assert 25 == info[\"count\"]\n\n\n@pytest.mark.onlynoncluster\n@pytest.mark.redismod\nasync def test_cms_merge(decoded_r: redis.Redis):\n    assert await decoded_r.cms().initbydim(\"A\", 1000, 5)\n    assert await decoded_r.cms().initbydim(\"B\", 1000, 5)\n    assert await decoded_r.cms().initbydim(\"C\", 1000, 5)\n    assert await decoded_r.cms().incrby(\"A\", [\"foo\", \"bar\", \"baz\"], [5, 3, 9])\n    assert await decoded_r.cms().incrby(\"B\", [\"foo\", \"bar\", \"baz\"], [2, 3, 1])\n    assert [5, 3, 9] == await decoded_r.cms().query(\"A\", \"foo\", \"bar\", \"baz\")\n    assert [2, 3, 1] == await decoded_r.cms().query(\"B\", \"foo\", \"bar\", \"baz\")\n    assert await decoded_r.cms().merge(\"C\", 2, [\"A\", \"B\"])\n    assert [7, 6, 10] == await decoded_r.cms().query(\"C\", \"foo\", \"bar\", \"baz\")\n    assert await decoded_r.cms().merge(\"C\", 2, [\"A\", \"B\"], [\"1\", \"2\"])\n    assert [9, 9, 11] == await decoded_r.cms().query(\"C\", \"foo\", \"bar\", \"baz\")\n    assert await decoded_r.cms().merge(\"C\", 2, [\"A\", \"B\"], [\"2\", \"3\"])\n    assert [16, 15, 21] == await decoded_r.cms().query(\"C\", \"foo\", \"bar\", \"baz\")\n\n\n@pytest.mark.redismod\nasync def test_topk(decoded_r: redis.Redis):\n    # test list with empty buckets\n    assert await decoded_r.topk().reserve(\"topk\", 3, 50, 4, 0.9)\n    assert [\n        None,\n        None,\n        None,\n        \"A\",\n        \"C\",\n        \"D\",\n        None,\n        None,\n        \"E\",\n        None,\n        \"B\",\n        \"C\",\n        None,\n        None,\n        None,\n        \"D\",\n        None,\n    ] == await decoded_r.topk().add(\n        \"topk\",\n        \"A\",\n        \"B\",\n        \"C\",\n        \"D\",\n        \"E\",\n        \"A\",\n        \"A\",\n        \"B\",\n        \"C\",\n        \"G\",\n        \"D\",\n        \"B\",\n        \"D\",\n        \"A\",\n        \"E\",\n        \"E\",\n        1,\n    )\n    assert [1, 1, 0, 0, 1, 0, 0] == await decoded_r.topk().query(\n        \"topk\", \"A\", \"B\", \"C\", \"D\", \"E\", \"F\", \"G\"\n    )\n    with pytest.deprecated_call():\n        assert [4, 3, 2, 3, 3, 0, 1] == await decoded_r.topk().count(\n            \"topk\", \"A\", \"B\", \"C\", \"D\", \"E\", \"F\", \"G\"\n        )\n\n    # test full list\n    assert await decoded_r.topk().reserve(\"topklist\", 3, 50, 3, 0.9)\n    assert await decoded_r.topk().add(\n        \"topklist\",\n        \"A\",\n        \"B\",\n        \"C\",\n        \"D\",\n        \"E\",\n        \"A\",\n        \"A\",\n        \"B\",\n        \"C\",\n        \"G\",\n        \"D\",\n        \"B\",\n        \"D\",\n        \"A\",\n        \"E\",\n        \"E\",\n    )\n    assert [\"A\", \"B\", \"E\"] == await decoded_r.topk().list(\"topklist\")\n    res = await decoded_r.topk().list(\"topklist\", withcount=True)\n    assert [\"A\", 4, \"B\", 3, \"E\", 3] == res\n    info = await decoded_r.topk().info(\"topklist\")\n    assert 3 == info[\"k\"]\n    assert 50 == info[\"width\"]\n    assert 3 == info[\"depth\"]\n    assert 0.9 == round(float(info[\"decay\"]), 1)\n\n\n@pytest.mark.redismod\nasync def test_topk_list_with_special_words(decoded_r: redis.Redis):\n    # test list with empty buckets\n    assert await decoded_r.topk().reserve(\"topklist:specialwords\", 5, 20, 4, 0.9)\n    assert await decoded_r.topk().add(\n        \"topklist:specialwords\",\n        \"infinity\",\n        \"B\",\n        \"nan\",\n        \"D\",\n        \"-infinity\",\n        \"infinity\",\n        \"infinity\",\n        \"B\",\n        \"nan\",\n        \"G\",\n        \"D\",\n        \"B\",\n        \"D\",\n        \"infinity\",\n        \"-infinity\",\n        \"-infinity\",\n    )\n    assert [\"infinity\", \"B\", \"D\", \"-infinity\", \"nan\"] == await decoded_r.topk().list(\n        \"topklist:specialwords\"\n    )\n\n\n@pytest.mark.redismod\nasync def test_topk_incrby(decoded_r: redis.Redis):\n    await decoded_r.flushdb()\n    assert await decoded_r.topk().reserve(\"topk\", 3, 10, 3, 1)\n    assert [None, None, None] == await decoded_r.topk().incrby(\n        \"topk\", [\"bar\", \"baz\", \"42\"], [3, 6, 2]\n    )\n    res = await decoded_r.topk().incrby(\"topk\", [\"42\", \"xyzzy\"], [8, 4])\n    assert [None, \"bar\"] == res\n    with pytest.deprecated_call():\n        assert [3, 6, 10, 4, 0] == await decoded_r.topk().count(\n            \"topk\", \"bar\", \"baz\", \"42\", \"xyzzy\", 4\n        )\n\n\n@pytest.mark.experimental\n@pytest.mark.redismod\nasync def test_tdigest_reset(decoded_r: redis.Redis):\n    assert await decoded_r.tdigest().create(\"tDigest\", 10)\n    # reset on empty histogram\n    assert await decoded_r.tdigest().reset(\"tDigest\")\n    # insert data-points into sketch\n    assert await decoded_r.tdigest().add(\"tDigest\", list(range(10)))\n\n    assert await decoded_r.tdigest().reset(\"tDigest\")\n    # assert we have 0 unmerged nodes\n    info = await decoded_r.tdigest().info(\"tDigest\")\n    assert_resp_response(\n        decoded_r, 0, info.get(\"unmerged_nodes\"), info.get(\"Unmerged nodes\")\n    )\n\n\n@pytest.mark.onlynoncluster\n@pytest.mark.redismod\nasync def test_tdigest_merge(decoded_r: redis.Redis):\n    assert await decoded_r.tdigest().create(\"to-tDigest\", 10)\n    assert await decoded_r.tdigest().create(\"from-tDigest\", 10)\n    # insert data-points into sketch\n    assert await decoded_r.tdigest().add(\"from-tDigest\", [1.0] * 10)\n    assert await decoded_r.tdigest().add(\"to-tDigest\", [2.0] * 10)\n    # merge from-tdigest into to-tdigest\n    assert await decoded_r.tdigest().merge(\"to-tDigest\", 1, \"from-tDigest\")\n    # we should now have 110 weight on to-histogram\n    info = await decoded_r.tdigest().info(\"to-tDigest\")\n    if is_resp2_connection(decoded_r):\n        assert 20 == float(info[\"merged_weight\"]) + float(info[\"unmerged_weight\"])\n    else:\n        assert 20 == float(info[\"Merged weight\"]) + float(info[\"Unmerged weight\"])\n    # test override\n    assert await decoded_r.tdigest().create(\"from-override\", 10)\n    assert await decoded_r.tdigest().create(\"from-override-2\", 10)\n    assert await decoded_r.tdigest().add(\"from-override\", [3.0] * 10)\n    assert await decoded_r.tdigest().add(\"from-override-2\", [4.0] * 10)\n    assert await decoded_r.tdigest().merge(\n        \"to-tDigest\", 2, \"from-override\", \"from-override-2\", override=True\n    )\n    assert 3.0 == await decoded_r.tdigest().min(\"to-tDigest\")\n    assert 4.0 == await decoded_r.tdigest().max(\"to-tDigest\")\n\n\n@pytest.mark.experimental\n@pytest.mark.redismod\nasync def test_tdigest_min_and_max(decoded_r: redis.Redis):\n    assert await decoded_r.tdigest().create(\"tDigest\", 100)\n    # insert data-points into sketch\n    assert await decoded_r.tdigest().add(\"tDigest\", [1, 2, 3])\n    # min/max\n    assert 3 == await decoded_r.tdigest().max(\"tDigest\")\n    assert 1 == await decoded_r.tdigest().min(\"tDigest\")\n\n\n@pytest.mark.experimental\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"2.4.0\", \"bf\")\nasync def test_tdigest_quantile(decoded_r: redis.Redis):\n    assert await decoded_r.tdigest().create(\"tDigest\", 500)\n    # insert data-points into sketch\n    assert await decoded_r.tdigest().add(\n        \"tDigest\", list([x * 0.01 for x in range(1, 10000)])\n    )\n    # assert min min/max have same result as quantile 0 and 1\n    assert (\n        await decoded_r.tdigest().max(\"tDigest\")\n        == (await decoded_r.tdigest().quantile(\"tDigest\", 1))[0]\n    )\n    assert (\n        await decoded_r.tdigest().min(\"tDigest\")\n        == (await decoded_r.tdigest().quantile(\"tDigest\", 0.0))[0]\n    )\n\n    assert 1.0 == round((await decoded_r.tdigest().quantile(\"tDigest\", 0.01))[0], 2)\n    assert 99.0 == round((await decoded_r.tdigest().quantile(\"tDigest\", 0.99))[0], 2)\n\n    # test multiple quantiles\n    assert await decoded_r.tdigest().create(\"t-digest\", 100)\n    assert await decoded_r.tdigest().add(\"t-digest\", [1, 2, 3, 4, 5])\n    res = await decoded_r.tdigest().quantile(\"t-digest\", 0.5, 0.8)\n    assert [3.0, 5.0] == res\n\n\n@pytest.mark.experimental\n@pytest.mark.redismod\nasync def test_tdigest_cdf(decoded_r: redis.Redis):\n    assert await decoded_r.tdigest().create(\"tDigest\", 100)\n    # insert data-points into sketch\n    assert await decoded_r.tdigest().add(\"tDigest\", list(range(1, 10)))\n    assert 0.1 == round((await decoded_r.tdigest().cdf(\"tDigest\", 1.0))[0], 1)\n    assert 0.9 == round((await decoded_r.tdigest().cdf(\"tDigest\", 9.0))[0], 1)\n    res = await decoded_r.tdigest().cdf(\"tDigest\", 1.0, 9.0)\n    assert [0.1, 0.9] == [round(x, 1) for x in res]\n\n\n@pytest.mark.experimental\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"2.4.0\", \"bf\")\nasync def test_tdigest_trimmed_mean(decoded_r: redis.Redis):\n    assert await decoded_r.tdigest().create(\"tDigest\", 100)\n    # insert data-points into sketch\n    assert await decoded_r.tdigest().add(\"tDigest\", list(range(1, 10)))\n    assert 5 == await decoded_r.tdigest().trimmed_mean(\"tDigest\", 0.1, 0.9)\n    assert 4.5 == await decoded_r.tdigest().trimmed_mean(\"tDigest\", 0.4, 0.5)\n\n\n@pytest.mark.experimental\n@pytest.mark.redismod\nasync def test_tdigest_rank(decoded_r: redis.Redis):\n    assert await decoded_r.tdigest().create(\"t-digest\", 500)\n    assert await decoded_r.tdigest().add(\"t-digest\", list(range(0, 20)))\n    assert -1 == (await decoded_r.tdigest().rank(\"t-digest\", -1))[0]\n    assert 0 == (await decoded_r.tdigest().rank(\"t-digest\", 0))[0]\n    assert 10 == (await decoded_r.tdigest().rank(\"t-digest\", 10))[0]\n    assert [-1, 20, 9] == await decoded_r.tdigest().rank(\"t-digest\", -20, 20, 9)\n\n\n@pytest.mark.experimental\n@pytest.mark.redismod\nasync def test_tdigest_revrank(decoded_r: redis.Redis):\n    assert await decoded_r.tdigest().create(\"t-digest\", 500)\n    assert await decoded_r.tdigest().add(\"t-digest\", list(range(0, 20)))\n    assert -1 == (await decoded_r.tdigest().revrank(\"t-digest\", 20))[0]\n    assert 19 == (await decoded_r.tdigest().revrank(\"t-digest\", 0))[0]\n    assert [-1, 19, 9] == await decoded_r.tdigest().revrank(\"t-digest\", 21, 0, 10)\n\n\n@pytest.mark.experimental\n@pytest.mark.redismod\nasync def test_tdigest_byrank(decoded_r: redis.Redis):\n    assert await decoded_r.tdigest().create(\"t-digest\", 500)\n    assert await decoded_r.tdigest().add(\"t-digest\", list(range(1, 11)))\n    assert 1 == (await decoded_r.tdigest().byrank(\"t-digest\", 0))[0]\n    assert 10 == (await decoded_r.tdigest().byrank(\"t-digest\", 9))[0]\n    assert (await decoded_r.tdigest().byrank(\"t-digest\", 100))[0] == inf\n    with pytest.raises(redis.ResponseError):\n        (await decoded_r.tdigest().byrank(\"t-digest\", -1))[0]\n\n\n@pytest.mark.experimental\n@pytest.mark.redismod\nasync def test_tdigest_byrevrank(decoded_r: redis.Redis):\n    assert await decoded_r.tdigest().create(\"t-digest\", 500)\n    assert await decoded_r.tdigest().add(\"t-digest\", list(range(1, 11)))\n    assert 10 == (await decoded_r.tdigest().byrevrank(\"t-digest\", 0))[0]\n    assert 1 == (await decoded_r.tdigest().byrevrank(\"t-digest\", 9))[0]\n    assert (await decoded_r.tdigest().byrevrank(\"t-digest\", 100))[0] == -inf\n    with pytest.raises(redis.ResponseError):\n        (await decoded_r.tdigest().byrevrank(\"t-digest\", -1))[0]\n"
  },
  {
    "path": "tests/test_asyncio/test_client.py",
    "content": "\"\"\"\nUnit tests that verify metrics are properly recorded from async Redis client\nvia record_* function calls.\n\nThese tests use fully mocked connection and connection pool - no real Redis\nor OTel integration is used.\n\"\"\"\n\nimport pytest\nfrom unittest.mock import MagicMock, patch, AsyncMock\n\nimport redis.asyncio as redis\nfrom redis.asyncio.observability import recorder as async_recorder\nfrom redis.observability.attributes import (\n    SERVER_ADDRESS,\n    SERVER_PORT,\n)\nfrom redis.observability.config import OTelConfig, MetricGroup\nfrom redis.observability.metrics import RedisMetricsCollector\n\n\n@pytest.mark.asyncio\nclass TestAsyncRedisClientOperationDurationMetricsRecording:\n    \"\"\"\n    Unit tests that verify operation duration metrics are properly recorded\n    from async Redis client via record_operation_duration function calls.\n\n    These tests use fully mocked connection and connection pool - no real Redis\n    or OTel integration is used.\n    \"\"\"\n\n    @pytest.fixture\n    def mock_async_connection(self):\n        \"\"\"Create a mock async connection with required attributes.\"\"\"\n        conn = MagicMock()\n        conn.host = \"localhost\"\n        conn.port = 6379\n        conn.db = 0\n        conn.should_reconnect.return_value = False\n\n        # Create a real Retry object that just executes the function directly\n        async def mock_call_with_retry(\n            do, fail, is_retryable=None, with_failure_count=False\n        ):\n            return await do()\n\n        conn.retry = MagicMock()\n        conn.retry.call_with_retry = mock_call_with_retry\n        conn.retry.get_retries.return_value = 0\n\n        return conn\n\n    @pytest.fixture\n    def mock_async_connection_pool(self, mock_async_connection):\n        \"\"\"Create a mock async connection pool.\"\"\"\n        pool = MagicMock()\n        pool.get_connection = AsyncMock(return_value=mock_async_connection)\n        pool.release = AsyncMock()\n        pool.get_encoder.return_value = MagicMock()\n        pool.get_protocol.return_value = 2\n        return pool\n\n    @pytest.fixture\n    def mock_meter(self):\n        \"\"\"Create a mock Meter that tracks all instrument calls.\"\"\"\n        meter = MagicMock()\n\n        # Create mock histogram for operation duration\n        self.operation_duration = MagicMock()\n        # Create mock counter for client errors\n        self.client_errors = MagicMock()\n\n        def create_histogram_side_effect(name, **kwargs):\n            if name == \"db.client.operation.duration\":\n                return self.operation_duration\n            return MagicMock()\n\n        def create_counter_side_effect(name, **kwargs):\n            if name == \"redis.client.errors\":\n                return self.client_errors\n            return MagicMock()\n\n        meter.create_counter.side_effect = create_counter_side_effect\n        meter.create_up_down_counter.return_value = MagicMock()\n        meter.create_histogram.side_effect = create_histogram_side_effect\n        meter.create_observable_gauge.return_value = MagicMock()\n\n        return meter\n\n    async def test_execute_command_records_operation_duration(\n        self, mock_async_connection_pool, mock_async_connection, mock_meter\n    ):\n        \"\"\"\n        Test that executing a command records operation duration metric\n        via the Meter's histogram.record() method.\n        \"\"\"\n        async_recorder.reset_collector()\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND])\n\n        with patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        # Note: _get_or_create_collector is now sync\n        with patch.object(\n            async_recorder,\n            \"_get_or_create_collector\",\n            return_value=collector,\n        ):\n            client = redis.Redis(connection_pool=mock_async_connection_pool)\n\n            # Mock _send_command_parse_response to return a successful response\n            async def mock_send(*args, **kwargs):\n                return True\n\n            client._send_command_parse_response = mock_send\n\n            # Execute a command\n            await client.execute_command(\"SET\", \"key1\", \"value1\")\n\n            # Verify the Meter's histogram.record() was called\n            self.operation_duration.record.assert_called_once()\n\n            # Get the call arguments\n            call_args = self.operation_duration.record.call_args\n\n            # Verify duration was recorded (first positional arg)\n            duration = call_args[0][0]\n            assert isinstance(duration, float)\n            assert duration >= 0\n\n            # Verify attributes\n            attrs = call_args[1][\"attributes\"]\n            assert attrs[\"db.operation.name\"] == \"SET\"\n            assert attrs[\"server.address\"] == \"localhost\"\n            assert attrs[\"server.port\"] == 6379\n            assert attrs[\"db.namespace\"] == \"0\"\n\n        async_recorder.reset_collector()\n\n    async def test_get_command_records_operation_duration(\n        self, mock_async_connection_pool, mock_async_connection, mock_meter\n    ):\n        \"\"\"\n        Test that GET command records operation duration with correct command name.\n        \"\"\"\n        async_recorder.reset_collector()\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND])\n\n        with patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        # Note: _get_or_create_collector is now sync\n        with patch.object(\n            async_recorder,\n            \"_get_or_create_collector\",\n            return_value=collector,\n        ):\n            client = redis.Redis(connection_pool=mock_async_connection_pool)\n\n            async def mock_send(*args, **kwargs):\n                return b\"value1\"\n\n            client._send_command_parse_response = mock_send\n\n            # Execute GET command\n            await client.execute_command(\"GET\", \"key1\")\n\n            # Verify command name is GET\n            call_args = self.operation_duration.record.call_args\n            attrs = call_args[1][\"attributes\"]\n            assert attrs[\"db.operation.name\"] == \"GET\"\n\n        async_recorder.reset_collector()\n\n    async def test_multiple_commands_record_multiple_metrics(\n        self, mock_async_connection_pool, mock_async_connection, mock_meter\n    ):\n        \"\"\"\n        Test that multiple command executions record multiple metrics.\n        \"\"\"\n        async_recorder.reset_collector()\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND])\n\n        with patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        # Note: _get_or_create_collector is now sync\n        with patch.object(\n            async_recorder,\n            \"_get_or_create_collector\",\n            return_value=collector,\n        ):\n            client = redis.Redis(connection_pool=mock_async_connection_pool)\n\n            async def mock_send(*args, **kwargs):\n                return True\n\n            client._send_command_parse_response = mock_send\n\n            # Execute multiple commands\n            await client.execute_command(\"SET\", \"key1\", \"value1\")\n            await client.execute_command(\"GET\", \"key1\")\n            await client.execute_command(\"DEL\", \"key1\")\n\n            # Verify histogram.record() was called 3 times\n            assert self.operation_duration.record.call_count == 3\n\n            # Verify command names\n            calls = self.operation_duration.record.call_args_list\n            assert calls[0][1][\"attributes\"][\"db.operation.name\"] == \"SET\"\n            assert calls[1][1][\"attributes\"][\"db.operation.name\"] == \"GET\"\n            assert calls[2][1][\"attributes\"][\"db.operation.name\"] == \"DEL\"\n\n        async_recorder.reset_collector()\n\n\n@pytest.mark.asyncio\nclass TestAsyncRedisClientErrorMetricsRecording:\n    \"\"\"\n    Unit tests that verify error metrics are properly recorded from async Redis client\n    via record_error_count function calls.\n\n    These tests use fully mocked connection and connection pool - no real Redis\n    or OTel integration is used.\n    \"\"\"\n\n    @pytest.fixture\n    def mock_async_connection(self):\n        \"\"\"Create a mock async connection with required attributes.\"\"\"\n        conn = MagicMock()\n        conn.host = \"localhost\"\n        conn.port = 6379\n        conn.db = 0\n        conn.should_reconnect.return_value = False\n\n        # Create a real Retry object that just executes the function directly\n        async def mock_call_with_retry(\n            do, fail, is_retryable=None, with_failure_count=False\n        ):\n            return await do()\n\n        conn.retry = MagicMock()\n        conn.retry.call_with_retry = mock_call_with_retry\n        conn.retry.get_retries.return_value = 0\n\n        return conn\n\n    @pytest.fixture\n    def mock_async_connection_pool(self, mock_async_connection):\n        \"\"\"Create a mock async connection pool.\"\"\"\n        pool = MagicMock()\n        pool.get_connection = AsyncMock(return_value=mock_async_connection)\n        pool.release = AsyncMock()\n        pool.get_encoder.return_value = MagicMock()\n        pool.get_protocol.return_value = 2\n        return pool\n\n    @pytest.fixture\n    def mock_error_meter(self):\n        \"\"\"Create a mock Meter that tracks error counter calls.\"\"\"\n        meter = MagicMock()\n\n        # Create mock counter for client errors\n        self.client_errors = MagicMock()\n\n        def create_counter_side_effect(name, **kwargs):\n            if name == \"redis.client.errors\":\n                return self.client_errors\n            return MagicMock()\n\n        meter.create_counter.side_effect = create_counter_side_effect\n        meter.create_up_down_counter.return_value = MagicMock()\n        meter.create_histogram.return_value = MagicMock()\n        meter.create_observable_gauge.return_value = MagicMock()\n\n        return meter\n\n    async def test_execute_command_error_records_error_count(\n        self, mock_async_connection_pool, mock_async_connection, mock_error_meter\n    ):\n        \"\"\"\n        Test that when a command execution raises an exception,\n        error count is recorded via record_error_count.\n        \"\"\"\n        async_recorder.reset_collector()\n        # Enable RESILIENCY metric group for error counting\n        config = OTelConfig(metric_groups=[MetricGroup.RESILIENCY])\n\n        with patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_error_meter, config)\n\n        # Note: _get_or_create_collector is now sync\n        with patch.object(\n            async_recorder,\n            \"_get_or_create_collector\",\n            return_value=collector,\n        ):\n            client = redis.Redis(connection_pool=mock_async_connection_pool)\n\n            # Make command raise an exception\n            test_error = redis.ResponseError(\"WRONGTYPE Operation error\")\n\n            async def raise_error(*args, **kwargs):\n                raise test_error\n\n            client._send_command_parse_response = raise_error\n\n            # Execute should raise the error\n            with pytest.raises(redis.ResponseError):\n                await client.execute_command(\"LPUSH\", \"string_key\", \"value\")\n\n            # Verify record_error_count was called (via client_errors counter)\n            self.client_errors.add.assert_called_once()\n\n            # Verify error type is recorded in attributes\n            call_args = self.client_errors.add.call_args\n            attrs = call_args[1][\"attributes\"]\n            assert \"error.type\" in attrs\n            assert attrs[\"error.type\"] == \"ResponseError\"\n\n        async_recorder.reset_collector()\n\n    async def test_connection_error_records_error_count(\n        self, mock_async_connection_pool, mock_async_connection, mock_error_meter\n    ):\n        \"\"\"\n        Test that ConnectionError is recorded via record_error_count.\n        \"\"\"\n        async_recorder.reset_collector()\n        config = OTelConfig(metric_groups=[MetricGroup.RESILIENCY])\n\n        with patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_error_meter, config)\n\n        # Note: _get_or_create_collector is now sync\n        with patch.object(\n            async_recorder,\n            \"_get_or_create_collector\",\n            return_value=collector,\n        ):\n            client = redis.Redis(connection_pool=mock_async_connection_pool)\n\n            # Make command raise a ConnectionError\n            async def raise_connection_error(*args, **kwargs):\n                raise redis.ConnectionError(\"Connection refused\")\n\n            client._send_command_parse_response = raise_connection_error\n\n            # Execute should raise the error\n            with pytest.raises(redis.ConnectionError):\n                await client.execute_command(\"GET\", \"key\")\n\n            # Verify record_error_count was called\n            self.client_errors.add.assert_called_once()\n\n            # Verify error type is ConnectionError\n            call_args = self.client_errors.add.call_args\n            attrs = call_args[1][\"attributes\"]\n            assert attrs[\"error.type\"] == \"ConnectionError\"\n\n        async_recorder.reset_collector()\n\n    async def test_timeout_error_records_error_count(\n        self, mock_async_connection_pool, mock_async_connection, mock_error_meter\n    ):\n        \"\"\"\n        Test that TimeoutError is recorded via record_error_count.\n        \"\"\"\n        async_recorder.reset_collector()\n        config = OTelConfig(metric_groups=[MetricGroup.RESILIENCY])\n\n        with patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_error_meter, config)\n\n        # Note: _get_or_create_collector is now sync\n        with patch.object(\n            async_recorder,\n            \"_get_or_create_collector\",\n            return_value=collector,\n        ):\n            client = redis.Redis(connection_pool=mock_async_connection_pool)\n\n            # Make command raise a TimeoutError\n            async def raise_timeout_error(*args, **kwargs):\n                raise redis.TimeoutError(\"Connection timed out\")\n\n            client._send_command_parse_response = raise_timeout_error\n\n            # Execute should raise the error\n            with pytest.raises(redis.TimeoutError):\n                await client.execute_command(\"GET\", \"key\")\n\n            # Verify record_error_count was called\n            self.client_errors.add.assert_called_once()\n\n            # Verify error type is TimeoutError\n            call_args = self.client_errors.add.call_args\n            attrs = call_args[1][\"attributes\"]\n            assert attrs[\"error.type\"] == \"TimeoutError\"\n\n        async_recorder.reset_collector()\n\n    async def test_error_count_includes_server_attributes(\n        self, mock_async_connection_pool, mock_async_connection, mock_error_meter\n    ):\n        \"\"\"\n        Test that error count includes server address and port attributes.\n        \"\"\"\n        async_recorder.reset_collector()\n        config = OTelConfig(metric_groups=[MetricGroup.RESILIENCY])\n\n        with patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_error_meter, config)\n\n        # Note: _get_or_create_collector is now sync\n        with patch.object(\n            async_recorder,\n            \"_get_or_create_collector\",\n            return_value=collector,\n        ):\n            client = redis.Redis(connection_pool=mock_async_connection_pool)\n\n            async def raise_error(*args, **kwargs):\n                raise redis.ResponseError(\"Error\")\n\n            client._send_command_parse_response = raise_error\n\n            with pytest.raises(redis.ResponseError):\n                await client.execute_command(\"SET\", \"key\", \"value\")\n\n            # Verify server attributes are recorded\n            call_args = self.client_errors.add.call_args\n            attrs = call_args[1][\"attributes\"]\n            assert attrs[SERVER_ADDRESS] == \"localhost\"\n            assert attrs[SERVER_PORT] == 6379\n\n        async_recorder.reset_collector()\n"
  },
  {
    "path": "tests/test_asyncio/test_cluster.py",
    "content": "import asyncio\nimport binascii\nimport datetime\nimport ssl\nimport warnings\nfrom typing import Any, Awaitable, Callable, Dict, List, Optional, Type, Union\nfrom unittest import mock\nfrom urllib.parse import urlparse\n\nimport pytest\nimport pytest_asyncio\nfrom _pytest.fixtures import FixtureRequest\nfrom redis._parsers import AsyncCommandsParser\nfrom redis.asyncio.cluster import ClusterNode, NodesManager, RedisCluster\nfrom redis.asyncio.connection import Connection, SSLConnection, async_timeout\nfrom redis.asyncio.retry import Retry\nfrom redis.backoff import (\n    ExponentialBackoff,\n    ExponentialWithJitterBackoff,\n    NoBackoff,\n)\nfrom redis.cluster import (\n    PIPELINE_BLOCKED_COMMANDS,\n    PRIMARY,\n    REPLICA,\n    LoadBalancingStrategy,\n    get_node_name,\n)\nfrom redis.commands.core import HotkeysMetricsTypes\nfrom redis.crc import REDIS_CLUSTER_HASH_SLOTS, key_slot\nfrom redis.event import EventDispatcher\nfrom redis.exceptions import (\n    AskError,\n    ClusterDownError,\n    ConnectionError,\n    DataError,\n    MaxConnectionsError,\n    MovedError,\n    NoPermissionError,\n    RedisClusterException,\n    RedisError,\n    ResponseError,\n)\nfrom redis.utils import str_if_bytes\nfrom tests.conftest import (\n    assert_resp_response,\n    is_resp2_connection,\n    skip_if_redis_enterprise,\n    skip_if_server_version_lt,\n    skip_unless_arch_bits,\n)\n\nfrom unittest.mock import patch, Mock\nfrom redis.asyncio.observability import recorder as async_recorder\nfrom redis.observability.config import OTelConfig, MetricGroup\nfrom redis.observability.metrics import RedisMetricsCollector\n\nfrom ..ssl_utils import get_tls_certificates\nfrom .compat import aclosing\n\npytestmark = pytest.mark.onlycluster\n\n\ndefault_host = \"127.0.0.1\"\ndefault_port = 7000\ndefault_cluster_slots = [\n    [0, 8191, [\"127.0.0.1\", 7000, \"node_0\"], [\"127.0.0.1\", 7003, \"node_3\"]],\n    [8192, 16383, [\"127.0.0.1\", 7001, \"node_1\"], [\"127.0.0.1\", 7002, \"node_2\"]],\n]\n\n\nclass NodeProxy:\n    \"\"\"A class to proxy a node connection to a different port\"\"\"\n\n    def __init__(self, addr, redis_addr):\n        self.addr = addr\n        self.redis_addr = redis_addr\n        self.server = None\n        self.task = None\n        self.n_connections = 0\n\n    async def start(self):\n        # test that we can connect to redis\n        async with async_timeout(2):\n            _, redis_writer = await asyncio.open_connection(*self.redis_addr)\n        redis_writer.close()\n        self.server = await asyncio.start_server(\n            self.handle, *self.addr, reuse_address=True\n        )\n        self.task = asyncio.create_task(self.server.serve_forever())\n\n    async def handle(self, reader, writer):\n        # establish connection to redis\n        redis_reader, redis_writer = await asyncio.open_connection(*self.redis_addr)\n        try:\n            self.n_connections += 1\n            pipe1 = asyncio.create_task(self.pipe(reader, redis_writer))\n            pipe2 = asyncio.create_task(self.pipe(redis_reader, writer))\n            await asyncio.gather(pipe1, pipe2)\n        finally:\n            redis_writer.close()\n            await self.redis_writer.wait_closed()\n            writer.close()\n            await writer.wait_closed()\n\n    async def aclose(self):\n        try:\n            self.task.cancel()\n            await asyncio.wait_for(self.task, timeout=1)\n            self.server.close()\n            await self.server.wait_closed()\n        except asyncio.TimeoutError:\n            pass\n        except asyncio.CancelledError:\n            pass\n\n    async def pipe(\n        self,\n        reader: asyncio.StreamReader,\n        writer: asyncio.StreamWriter,\n    ):\n        while True:\n            data = await reader.read(1000)\n            if not data:\n                break\n            writer.write(data)\n            await writer.drain()\n\n\n@pytest_asyncio.fixture()\nasync def slowlog(r: RedisCluster) -> None:\n    \"\"\"\n    Set the slowlog threshold to 0, and the\n    max length to 128. This will force every\n    command into the slowlog and allow us\n    to test it\n    \"\"\"\n    # Save old values\n    current_config = await r.config_get(target_nodes=r.get_primaries()[0])\n    old_slower_than_value = current_config[\"slowlog-log-slower-than\"]\n    old_max_length_value = current_config[\"slowlog-max-len\"]\n\n    # Set the new values\n    await r.config_set(\"slowlog-log-slower-than\", 0)\n    await r.config_set(\"slowlog-max-len\", 128)\n\n    yield\n\n    await r.config_set(\"slowlog-log-slower-than\", old_slower_than_value)\n    await r.config_set(\"slowlog-max-len\", old_max_length_value)\n\n\nasync def get_mocked_redis_client(\n    cluster_slots_raise_error=False, *args, **kwargs\n) -> RedisCluster:\n    \"\"\"\n    Return a stable RedisCluster object that have deterministic\n    nodes and slots setup to remove the problem of different IP addresses\n    on different installations and machines.\n    \"\"\"\n    cluster_slots = kwargs.pop(\"cluster_slots\", default_cluster_slots)\n    coverage_res = kwargs.pop(\"coverage_result\", \"yes\")\n    cluster_enabled = kwargs.pop(\"cluster_enabled\", True)\n    with mock.patch.object(ClusterNode, \"execute_command\") as execute_command_mock:\n\n        async def execute_command(*_args, **_kwargs):\n            if _args[0] == \"CLUSTER SLOTS\":\n                if cluster_slots_raise_error:\n                    raise ResponseError()\n                else:\n                    mock_cluster_slots = cluster_slots\n                    return mock_cluster_slots\n            elif _args[0] == \"COMMAND\":\n                return {\"get\": [], \"set\": []}\n            elif _args[0] == \"INFO\":\n                return {\"cluster_enabled\": cluster_enabled}\n            elif len(_args) > 1 and _args[1] == \"cluster-require-full-coverage\":\n                return {\"cluster-require-full-coverage\": coverage_res}\n            else:\n                return await execute_command_mock(*_args, **_kwargs)\n\n        execute_command_mock.side_effect = execute_command\n\n        with mock.patch.object(\n            AsyncCommandsParser, \"initialize\", autospec=True\n        ) as cmd_parser_initialize:\n\n            def cmd_init_mock(self, r: ClusterNode) -> None:\n                self.commands = {\n                    \"get\": {\n                        \"name\": \"get\",\n                        \"arity\": 2,\n                        \"flags\": [\"readonly\", \"fast\"],\n                        \"first_key_pos\": 1,\n                        \"last_key_pos\": 1,\n                        \"step_count\": 1,\n                    }\n                }\n\n            cmd_parser_initialize.side_effect = cmd_init_mock\n\n            # Create a subclass of RedisCluster that overrides __del__\n            class MockedRedisCluster(RedisCluster):\n                def __del__(self):\n                    # Override to prevent connection cleanup attempts\n                    pass\n\n                @property\n                def connection_pool(self):\n                    # Required abstract property implementation\n                    return self.nodes_manager.get_default_node().redis_connection.connection_pool\n\n            return await MockedRedisCluster(*args, **kwargs)\n\n\ndef mock_node_resp(node: ClusterNode, response: Any) -> ClusterNode:\n    connection = mock.AsyncMock(spec=Connection)\n    connection.is_connected = True\n    connection.read_response.return_value = response\n    while node._free:\n        node._free.pop()\n    node._free.append(connection)\n    return node\n\n\ndef mock_node_resp_exc(node: ClusterNode, exc: Exception) -> ClusterNode:\n    connection = mock.AsyncMock(spec=Connection)\n    connection.is_connected = True\n    connection.read_response.side_effect = exc\n    while node._free:\n        node._free.pop()\n    node._free.append(connection)\n    return node\n\n\ndef mock_all_nodes_resp(rc: RedisCluster, response: Any) -> RedisCluster:\n    for node in rc.get_nodes():\n        mock_node_resp(node, response)\n    return rc\n\n\nasync def moved_redirection_helper(\n    create_redis: Callable[..., RedisCluster],\n    failover: bool = False,\n    circular_moved=False,\n) -> None:\n    \"\"\"\n    Test that the client correctly handles MOVED responses in the following scenarios:\n    1.\tSlot migration to a different shard (failover=False, circular_moved=False) —\n        a standard slot move between shards.\n    2.\tFailover event (failover=True, circular_moved=False) —\n        the redirect target is a replica that has just been promoted to primary.\n    3.\tCircular MOVED (failover=False, circular_moved=True) —\n        the redirect points to a node already known to be the primary of its shard.\n\n    At first call it should return a MOVED ResponseError that will point\n    the client to the next server it should talk to.\n\n    Verify that:\n    1. it tries to talk to the redirected node\n    2. it updates the slot's primary to the redirected node, if required\n\n    For a failover, also verify:\n    3. the redirected node's server type updated to 'primary'\n    4. the server type of the previous slot owner updated to 'replica'\n    \"\"\"\n    rc = await create_redis(cls=RedisCluster, flushdb=False)\n    slot = 12182\n    redirect_node = None\n    # Get the current primary that holds this slot\n    prev_primary = rc.nodes_manager.get_node_from_slot(slot)\n    if failover:\n        if len(rc.nodes_manager.slots_cache[slot]) < 2:\n            warnings.warn(\"Skipping this test since it requires to have a replica\")\n            return\n        redirect_node = rc.nodes_manager.slots_cache[slot][1]\n    elif circular_moved:\n        redirect_node = prev_primary\n    else:\n        # Use one of the primaries to be the redirected node\n        redirect_node = rc.get_primaries()[0]\n    r_host = redirect_node.host\n    r_port = redirect_node.port\n    with mock.patch.object(\n        ClusterNode, \"execute_command\", autospec=True\n    ) as execute_command:\n\n        def moved_redirect_effect(self, *args, **options):\n            def ok_response(self, *args, **options):\n                assert self.host == r_host\n                assert self.port == r_port\n\n                return \"MOCK_OK\"\n\n            execute_command.side_effect = ok_response\n            raise MovedError(f\"{slot} {r_host}:{r_port}\")\n\n        execute_command.side_effect = moved_redirect_effect\n        assert await rc.execute_command(\"SET\", \"foo\", \"bar\") == \"MOCK_OK\"\n        slot_primary = rc.nodes_manager.slots_cache[slot][0]\n        assert slot_primary == redirect_node\n        if failover:\n            assert rc.get_node(host=r_host, port=r_port).server_type == PRIMARY\n            assert prev_primary.server_type == REPLICA\n        elif circular_moved:\n            fetched_node = rc.get_node(host=r_host, port=r_port)\n            assert fetched_node == prev_primary\n            assert fetched_node.server_type == PRIMARY\n\n\nclass TestRedisClusterObj:\n    \"\"\"\n    Tests for the RedisCluster class\n    \"\"\"\n\n    async def test_host_port_startup_node(self) -> None:\n        \"\"\"\n        Test that it is possible to use host & port arguments as startup node\n        args\n        \"\"\"\n        cluster = await get_mocked_redis_client(host=default_host, port=default_port)\n        assert cluster.get_node(host=default_host, port=default_port) is not None\n\n        await cluster.aclose()\n\n    async def test_aclosing(self) -> None:\n        cluster = await get_mocked_redis_client(host=default_host, port=default_port)\n        called = 0\n\n        async def mock_aclose():\n            nonlocal called\n            called += 1\n\n        with mock.patch.object(cluster, \"aclose\", mock_aclose):\n            async with aclosing(cluster):\n                pass\n            assert called == 1\n        await cluster.aclose()\n\n    async def test_close_is_aclose(self) -> None:\n        \"\"\"\n        Test that it is possible to use host & port arguments as startup node\n        args\n        \"\"\"\n        cluster = await get_mocked_redis_client(host=default_host, port=default_port)\n        called = 0\n\n        async def mock_aclose():\n            nonlocal called\n            called += 1\n\n        with mock.patch.object(cluster, \"aclose\", mock_aclose):\n            with pytest.warns(DeprecationWarning, match=r\"Use aclose\\(\\) instead\"):\n                await cluster.close()\n            assert called == 1\n        await cluster.aclose()\n\n    async def test_startup_nodes(self) -> None:\n        \"\"\"\n        Test that it is possible to use startup_nodes\n        argument to init the cluster\n        \"\"\"\n        port_1 = 7000\n        port_2 = 7001\n        startup_nodes = [\n            ClusterNode(default_host, port_1),\n            ClusterNode(default_host, port_2),\n        ]\n        cluster = await get_mocked_redis_client(startup_nodes=startup_nodes)\n        assert (\n            cluster.get_node(host=default_host, port=port_1) is not None\n            and cluster.get_node(host=default_host, port=port_2) is not None\n        )\n\n        await cluster.aclose()\n\n        startup_node = ClusterNode(\"127.0.0.1\", 16379)\n        async with RedisCluster(startup_nodes=[startup_node], client_name=\"test\") as rc:\n            assert await rc.set(\"A\", 1)\n            assert await rc.get(\"A\") == b\"1\"\n            assert all(\n                [\n                    name == \"test\"\n                    for name in (\n                        await rc.client_getname(target_nodes=rc.ALL_NODES)\n                    ).values()\n                ]\n            )\n\n    async def test_cluster_set_get_retry_object(self, request: FixtureRequest):\n        retry = Retry(NoBackoff(), 2)\n        url = request.config.getoption(\"--redis-url\")\n        async with RedisCluster.from_url(url, retry=retry) as r:\n            assert r.retry.get_retries() == retry.get_retries()\n            assert isinstance(r.retry._backoff, NoBackoff)\n            for node in r.get_nodes():\n                # validate nodes lower level connections default\n                # retry policy is applied\n                n_retry = node.acquire_connection().retry\n                assert n_retry is not None\n                assert n_retry._retries == 0\n                assert isinstance(n_retry._backoff, NoBackoff)\n            rand_cluster_node = r.get_random_node()\n            existing_conn = rand_cluster_node.acquire_connection()\n            # Change retry policy\n            new_retry = Retry(ExponentialBackoff(), 3)\n            r.set_retry(new_retry)\n            assert r.retry.get_retries() == new_retry.get_retries()\n            assert isinstance(r.retry._backoff, ExponentialBackoff)\n            for node in r.get_nodes():\n                # validate nodes lower level connections are not affected\n                n_retry = node.acquire_connection().retry\n                assert n_retry is not None\n                assert n_retry._retries == 0\n                assert isinstance(n_retry._backoff, NoBackoff)\n            assert existing_conn.retry.get_retries() == 0\n            new_conn = rand_cluster_node.acquire_connection()\n            assert new_conn.retry._retries == 0\n\n    async def test_cluster_retry_object(self, request: FixtureRequest) -> None:\n        url = request.config.getoption(\"--redis-url\")\n        async with RedisCluster.from_url(url) as rc_default:\n            # Test default retry\n            retry = rc_default.retry\n\n            # FIXME: Workaround for https://github.com/redis/redis-py/issues/3030\n            host = rc_default.get_default_node().host\n\n            assert isinstance(retry, Retry)\n            assert retry._retries == 3\n            assert isinstance(retry._backoff, type(ExponentialWithJitterBackoff()))\n\n            # validate nodes connections are using the default retry for\n            # lower level connections when client is created through 'from_url' method\n            # without specified retry object\n            node1_retry = rc_default.get_node(host, 16379).acquire_connection().retry\n            node2_retry = rc_default.get_node(host, 16380).acquire_connection().retry\n            for node_retry in (node1_retry, node2_retry):\n                assert node_retry.get_retries() == 0\n                assert isinstance(node_retry._backoff, NoBackoff)\n                assert node_retry._supported_errors == (ConnectionError,)\n\n        retry = Retry(ExponentialBackoff(10, 5), 5)\n        async with RedisCluster.from_url(url, retry=retry) as rc_custom_retry:\n            # Test custom retry\n            assert rc_custom_retry.retry == retry\n            # validate nodes connections are using the default retry for\n            # lower level connections when client is created through 'from_url' method\n            # with specified retry object\n            node1_retry = rc_default.get_node(host, 16379).acquire_connection().retry\n            node2_retry = rc_default.get_node(host, 16380).acquire_connection().retry\n            for node_retry in (node1_retry, node2_retry):\n                assert node_retry.get_retries() == 0\n                assert isinstance(node_retry._backoff, NoBackoff)\n                assert node_retry._supported_errors == (ConnectionError,)\n\n        async with RedisCluster.from_url(\n            url, cluster_error_retry_attempts=0\n        ) as rc_no_retries:\n            # Test no cluster retries\n            assert rc_no_retries.retry.get_retries() == 0\n\n        async with RedisCluster.from_url(\n            url, retry=Retry(NoBackoff(), 0)\n        ) as rc_no_retries:\n            assert rc_no_retries.retry.get_retries() == 0\n\n    async def test_empty_startup_nodes(self) -> None:\n        \"\"\"\n        Test that exception is raised when empty providing empty startup_nodes\n        \"\"\"\n        with pytest.raises(RedisClusterException) as ex:\n            RedisCluster(startup_nodes=[])\n\n        assert str(ex.value).startswith(\n            \"RedisCluster requires at least one node to discover the cluster\"\n        ), str_if_bytes(ex.value)\n\n    async def test_from_url(self, request: FixtureRequest) -> None:\n        url = request.config.getoption(\"--redis-url\")\n\n        async with RedisCluster.from_url(url) as rc:\n            await rc.set(\"a\", 1)\n            await rc.get(\"a\") == 1\n\n        rc = RedisCluster.from_url(\"rediss://localhost:16379\")\n        assert rc.connection_kwargs[\"connection_class\"] is SSLConnection\n\n    async def test_max_connections(\n        self, create_redis: Callable[..., RedisCluster]\n    ) -> None:\n        rc = await create_redis(cls=RedisCluster, max_connections=10)\n        for node in rc.get_nodes():\n            assert node.max_connections == 10\n\n        with mock.patch.object(Connection, \"read_response\") as read_response:\n\n            async def read_response_mocked(*args: Any, **kwargs: Any) -> None:\n                await asyncio.sleep(0.1)\n\n            read_response.side_effect = read_response_mocked\n\n            with pytest.raises(MaxConnectionsError):\n                await asyncio.gather(\n                    *(\n                        rc.ping(target_nodes=RedisCluster.DEFAULT_NODE)\n                        for _ in range(11)\n                    )\n                )\n\n        # Wait for background tasks to complete and release their connections.\n        # When asyncio.gather() raises MaxConnectionsError, the other 10 tasks\n        # continue running in the background. Since commit f6bbfb45 added\n        # 'await disconnect_if_needed()' to the finally block, we must wait\n        # for tasks to complete naturally before teardown, otherwise we get\n        # race conditions with connections being disconnected while still in use.\n        await asyncio.sleep(0.2)\n\n        await rc.aclose()\n\n    async def test_execute_command_errors(self, r: RedisCluster) -> None:\n        \"\"\"\n        Test that if no key is provided then exception should be raised.\n        \"\"\"\n        with pytest.raises(RedisClusterException) as ex:\n            await r.execute_command(\"GET\")\n        assert str(ex.value).startswith(\n            \"No way to dispatch this command to Redis Cluster. Missing key.\"\n        )\n\n    async def test_execute_command_node_flag_primaries(self, r: RedisCluster) -> None:\n        \"\"\"\n        Test command execution with nodes flag PRIMARIES\n        \"\"\"\n        primaries = r.get_primaries()\n        replicas = r.get_replicas()\n        mock_all_nodes_resp(r, \"PONG\")\n        assert await r.ping(target_nodes=RedisCluster.PRIMARIES) is True\n        for primary in primaries:\n            conn = primary._free.pop()\n            assert conn.read_response.called is True\n        for replica in replicas:\n            conn = replica._free.pop()\n            assert conn.read_response.called is not True\n\n    async def test_execute_command_node_flag_replicas(self, r: RedisCluster) -> None:\n        \"\"\"\n        Test command execution with nodes flag REPLICAS\n        \"\"\"\n        replicas = r.get_replicas()\n        assert len(replicas) != 0, \"This test requires Cluster with 1 replica\"\n\n        primaries = r.get_primaries()\n        mock_all_nodes_resp(r, \"PONG\")\n        assert await r.ping(target_nodes=RedisCluster.REPLICAS) is True\n        for replica in replicas:\n            conn = replica._free.pop()\n            assert conn.read_response.called is True\n        for primary in primaries:\n            conn = primary._free.pop()\n            assert conn.read_response.called is not True\n\n        await r.aclose()\n\n    async def test_execute_command_node_flag_all_nodes(self, r: RedisCluster) -> None:\n        \"\"\"\n        Test command execution with nodes flag ALL_NODES\n        \"\"\"\n        mock_all_nodes_resp(r, \"PONG\")\n        assert await r.ping(target_nodes=RedisCluster.ALL_NODES) is True\n        for node in r.get_nodes():\n            conn = node._free.pop()\n            assert conn.read_response.called is True\n\n    async def test_execute_command_node_flag_random(self, r: RedisCluster) -> None:\n        \"\"\"\n        Test command execution with nodes flag RANDOM\n        \"\"\"\n        mock_all_nodes_resp(r, \"PONG\")\n        assert await r.ping(target_nodes=RedisCluster.RANDOM) is True\n        called_count = 0\n        for node in r.get_nodes():\n            conn = node._free.pop()\n            if conn.read_response.called is True:\n                called_count += 1\n        assert called_count == 1\n\n    async def test_execute_command_default_node(self, r: RedisCluster) -> None:\n        \"\"\"\n        Test command execution without node flag is being executed on the\n        default node\n        \"\"\"\n        def_node = r.get_default_node()\n        mock_node_resp(def_node, \"PONG\")\n        assert await r.ping() is True\n        conn = def_node._free.pop()\n        assert conn.read_response.called\n\n    async def test_ask_redirection(self, r: RedisCluster) -> None:\n        \"\"\"\n        Test that the server handles ASK response.\n\n        At first call it should return a ASK ResponseError that will point\n        the client to the next server it should talk to.\n\n        Important thing to verify is that it tries to talk to the second node.\n        \"\"\"\n        redirect_node = r.get_nodes()[0]\n        with mock.patch.object(\n            ClusterNode, \"execute_command\", autospec=True\n        ) as execute_command:\n\n            def ask_redirect_effect(self, *args, **options):\n                def ok_response(self, *args, **options):\n                    assert self.host == redirect_node.host\n                    assert self.port == redirect_node.port\n\n                    return \"MOCK_OK\"\n\n                execute_command.side_effect = ok_response\n                raise AskError(f\"12182 {redirect_node.host}:{redirect_node.port}\")\n\n            execute_command.side_effect = ask_redirect_effect\n\n            assert await r.execute_command(\"SET\", \"foo\", \"bar\") == \"MOCK_OK\"\n\n    async def test_moved_redirection(\n        self, create_redis: Callable[..., RedisCluster]\n    ) -> None:\n        \"\"\"\n        Test that the client handles MOVED response.\n        \"\"\"\n        await moved_redirection_helper(create_redis, failover=False)\n\n    async def test_moved_redirection_after_failover(\n        self, create_redis: Callable[..., RedisCluster]\n    ) -> None:\n        \"\"\"\n        Test that the client handles MOVED response after a failover.\n        \"\"\"\n        await moved_redirection_helper(create_redis, failover=True)\n\n    async def test_moved_redirection_circular_moved(\n        self, create_redis: Callable[..., RedisCluster]\n    ) -> None:\n        \"\"\"\n        Verify that the client does not update its slot map when receiving a circular MOVED response\n        (i.e., a MOVED redirect pointing back to the same node), and retries again the same node.\n        \"\"\"\n        await moved_redirection_helper(\n            create_redis, failover=False, circular_moved=True\n        )\n\n    async def test_refresh_using_specific_nodes(\n        self, create_redis: Callable[..., RedisCluster]\n    ) -> None:\n        \"\"\"\n        Test making calls on specific nodes when the cluster has failed over to\n        another node\n        \"\"\"\n        node_7006 = ClusterNode(host=default_host, port=7006, server_type=PRIMARY)\n        node_7007 = ClusterNode(host=default_host, port=7007, server_type=PRIMARY)\n        with mock.patch.object(\n            ClusterNode, \"execute_command\", autospec=True\n        ) as execute_command:\n            with mock.patch.object(\n                NodesManager, \"initialize\", autospec=True\n            ) as initialize:\n                with mock.patch.multiple(\n                    Connection,\n                    send_packed_command=mock.DEFAULT,\n                    connect=mock.DEFAULT,\n                    can_read_destructive=mock.DEFAULT,\n                ) as mocks:\n                    # simulate 7006 as a failed node\n                    def execute_command_mock(self, *args, **options):\n                        if self.port == 7006:\n                            execute_command.failed_calls += 1\n                            raise ClusterDownError(\n                                \"CLUSTERDOWN The cluster is \"\n                                \"down. Use CLUSTER INFO for \"\n                                \"more information\"\n                            )\n                        elif self.port == 7007:\n                            execute_command.successful_calls += 1\n\n                    def initialize_mock(self):\n                        # start with all slots mapped to 7006\n                        self.nodes_cache = {node_7006.name: node_7006}\n                        self.default_node = node_7006\n                        self.slots_cache = {}\n\n                        for i in range(0, 16383):\n                            self.slots_cache[i] = [node_7006]\n\n                        # After the first connection fails, a reinitialize\n                        # should follow the cluster to 7007\n                        def map_7007(self):\n                            self.nodes_cache = {node_7007.name: node_7007}\n                            self.default_node = node_7007\n                            self.slots_cache = {}\n\n                            for i in range(0, 16383):\n                                self.slots_cache[i] = [node_7007]\n\n                        # Change initialize side effect for the second call\n                        initialize.side_effect = map_7007\n\n                    execute_command.side_effect = execute_command_mock\n                    execute_command.successful_calls = 0\n                    execute_command.failed_calls = 0\n                    initialize.side_effect = initialize_mock\n                    mocks[\"can_read_destructive\"].return_value = False\n                    mocks[\"send_packed_command\"].return_value = \"MOCK_OK\"\n                    mocks[\"connect\"].return_value = None\n                    with mock.patch.object(\n                        AsyncCommandsParser, \"initialize\", autospec=True\n                    ) as cmd_parser_initialize:\n\n                        def cmd_init_mock(self, r: ClusterNode) -> None:\n                            self.commands = {\n                                \"get\": {\n                                    \"name\": \"get\",\n                                    \"arity\": 2,\n                                    \"flags\": [\"readonly\", \"fast\"],\n                                    \"first_key_pos\": 1,\n                                    \"last_key_pos\": 1,\n                                    \"step_count\": 1,\n                                }\n                            }\n\n                        cmd_parser_initialize.side_effect = cmd_init_mock\n\n                        rc = await create_redis(cls=RedisCluster, flushdb=False)\n                        assert len(rc.get_nodes()) == 1\n                        assert rc.get_node(node_name=node_7006.name) is not None\n\n                        await rc.get(\"foo\")\n\n                        # Cluster should now point to 7007, and there should be\n                        # one failed and one successful call\n                        assert len(rc.get_nodes()) == 1\n                        assert rc.get_node(node_name=node_7007.name) is not None\n                        assert rc.get_node(node_name=node_7006.name) is None\n                        assert execute_command.failed_calls == 1\n                        assert execute_command.successful_calls == 1\n\n    @pytest.mark.parametrize(\n        \"read_from_replicas,load_balancing_strategy,mocks_srv_ports\",\n        [\n            (True, None, [7001, 7002, 7001]),\n            (True, LoadBalancingStrategy.ROUND_ROBIN, [7001, 7002, 7001]),\n            (True, LoadBalancingStrategy.ROUND_ROBIN_REPLICAS, [7002, 7002, 7002]),\n            (True, LoadBalancingStrategy.RANDOM_REPLICA, [7002, 7002, 7002]),\n            (False, LoadBalancingStrategy.ROUND_ROBIN, [7001, 7002, 7001]),\n            (False, LoadBalancingStrategy.ROUND_ROBIN_REPLICAS, [7002, 7002, 7002]),\n            (False, LoadBalancingStrategy.RANDOM_REPLICA, [7002, 7002, 7002]),\n        ],\n    )\n    async def test_reading_with_load_balancing_strategies(\n        self,\n        read_from_replicas: bool,\n        load_balancing_strategy: LoadBalancingStrategy,\n        mocks_srv_ports: List[int],\n    ) -> None:\n        with mock.patch.multiple(\n            Connection,\n            send_command=mock.DEFAULT,\n            read_response=mock.DEFAULT,\n            _connect=mock.DEFAULT,\n            can_read_destructive=mock.DEFAULT,\n            on_connect=mock.DEFAULT,\n        ) as mocks:\n            with mock.patch.object(\n                ClusterNode, \"execute_command\", autospec=True\n            ) as execute_command:\n\n                async def execute_command_mock_first(self, *args, **options):\n                    await self.connection_class(**self.connection_kwargs).connect()\n                    # Primary\n                    assert self.port == mocks_srv_ports[0]\n                    execute_command.side_effect = execute_command_mock_second\n                    return \"MOCK_OK\"\n\n                def execute_command_mock_second(self, *args, **options):\n                    # Replica\n                    assert self.port == mocks_srv_ports[1]\n                    execute_command.side_effect = execute_command_mock_third\n                    return \"MOCK_OK\"\n\n                def execute_command_mock_third(self, *args, **options):\n                    # Primary\n                    assert self.port == mocks_srv_ports[2]\n                    return \"MOCK_OK\"\n\n                # We don't need to create a real cluster connection but we\n                # do want RedisCluster.on_connect function to get called,\n                # so we'll mock some of the Connection's functions to allow it\n                execute_command.side_effect = execute_command_mock_first\n                mocks[\"send_command\"].return_value = True\n                mocks[\"read_response\"].return_value = \"OK\"\n                mocks[\"_connect\"].return_value = True\n                mocks[\"can_read_destructive\"].return_value = False\n                mocks[\"on_connect\"].return_value = True\n\n                # Create a cluster with reading from replications\n                read_cluster = await get_mocked_redis_client(\n                    host=default_host,\n                    port=default_port,\n                    read_from_replicas=read_from_replicas,\n                    load_balancing_strategy=load_balancing_strategy,\n                )\n                assert read_cluster.read_from_replicas is read_from_replicas\n                assert read_cluster.load_balancing_strategy is load_balancing_strategy\n                # Check that we read from the slot's nodes in a round robin\n                # matter.\n                # 'foo' belongs to slot 12182 and the slot's nodes are:\n                # [(127.0.0.1,7001,primary), (127.0.0.1,7002,replica)]\n                await read_cluster.get(\"foo\")\n                await read_cluster.get(\"foo\")\n                await read_cluster.get(\"foo\")\n                mocks[\"send_command\"].assert_has_calls([mock.call(\"READONLY\")])\n\n                await read_cluster.aclose()\n\n    async def test_keyslot(self, r: RedisCluster) -> None:\n        \"\"\"\n        Test that method will compute correct key in all supported cases\n        \"\"\"\n        assert r.keyslot(\"foo\") == 12182\n        assert r.keyslot(\"{foo}bar\") == 12182\n        assert r.keyslot(\"{foo}\") == 12182\n        assert r.keyslot(1337) == 4314\n\n        assert r.keyslot(125) == r.keyslot(b\"125\")\n        assert r.keyslot(125) == r.keyslot(\"\\x31\\x32\\x35\")\n        assert r.keyslot(\"大奖\") == r.keyslot(b\"\\xe5\\xa4\\xa7\\xe5\\xa5\\x96\")\n        assert r.keyslot(\"大奖\") == r.keyslot(b\"\\xe5\\xa4\\xa7\\xe5\\xa5\\x96\")\n        assert r.keyslot(1337.1234) == r.keyslot(\"1337.1234\")\n        assert r.keyslot(1337) == r.keyslot(\"1337\")\n        assert r.keyslot(b\"abc\") == r.keyslot(\"abc\")\n\n    async def test_get_node_name(self) -> None:\n        assert (\n            get_node_name(default_host, default_port)\n            == f\"{default_host}:{default_port}\"\n        )\n\n    async def test_all_nodes(self, r: RedisCluster) -> None:\n        \"\"\"\n        Set a list of nodes and it should be possible to iterate over all\n        \"\"\"\n        nodes = [node for node in r.nodes_manager.nodes_cache.values()]\n\n        for i, node in enumerate(r.get_nodes()):\n            assert node in nodes\n\n    async def test_all_nodes_masters(self, r: RedisCluster) -> None:\n        \"\"\"\n        Set a list of nodes with random primaries/replicas config and it shold\n        be possible to iterate over all of them.\n        \"\"\"\n        nodes = [\n            node\n            for node in r.nodes_manager.nodes_cache.values()\n            if node.server_type == PRIMARY\n        ]\n\n        for node in r.get_primaries():\n            assert node in nodes\n\n    @pytest.mark.parametrize(\"error\", RedisCluster.ERRORS_ALLOW_RETRY)\n    async def test_cluster_down_overreaches_retry_attempts(\n        self,\n        error: Union[Type[TimeoutError], Type[ClusterDownError], Type[ConnectionError]],\n    ) -> None:\n        \"\"\"\n        When error that allows retry is thrown, test that we retry executing\n        the command as many times as configured in cluster_error_retry_attempts\n        and then raise the exception\n        \"\"\"\n        with mock.patch.object(RedisCluster, \"_execute_command\") as execute_command:\n\n            def raise_error(target_node, *args, **kwargs):\n                execute_command.failed_calls += 1\n                raise error(\"mocked error\")\n\n            execute_command.side_effect = raise_error\n\n            rc = await get_mocked_redis_client(host=default_host, port=default_port)\n\n            with pytest.raises(error):\n                await rc.get(\"bar\")\n                assert execute_command.failed_calls == rc.cluster_error_retry_attempts\n\n            await rc.aclose()\n\n    async def test_set_default_node_success(self, r: RedisCluster) -> None:\n        \"\"\"\n        test successful replacement of the default cluster node\n        \"\"\"\n        default_node = r.get_default_node()\n        # get a different node\n        new_def_node = None\n        for node in r.get_nodes():\n            if node != default_node:\n                new_def_node = node\n                break\n        r.set_default_node(new_def_node)\n        assert r.get_default_node() == new_def_node\n\n    async def test_set_default_node_failure(self, r: RedisCluster) -> None:\n        \"\"\"\n        test failed replacement of the default cluster node\n        \"\"\"\n        default_node = r.get_default_node()\n        new_def_node = ClusterNode(\"1.1.1.1\", 1111)\n        with pytest.raises(DataError):\n            r.set_default_node(None)\n        with pytest.raises(DataError):\n            r.set_default_node(new_def_node)\n        assert r.get_default_node() == default_node\n\n    async def test_get_node_from_key(self, r: RedisCluster) -> None:\n        \"\"\"\n        Test that get_node_from_key function returns the correct node\n        \"\"\"\n        key = \"bar\"\n        slot = r.keyslot(key)\n        slot_nodes = r.nodes_manager.slots_cache.get(slot)\n        primary = slot_nodes[0]\n        assert r.get_node_from_key(key, replica=False) == primary\n        replica = r.get_node_from_key(key, replica=True)\n        if replica is not None:\n            assert replica.server_type == REPLICA\n            assert replica in slot_nodes\n\n    @skip_if_redis_enterprise()\n    async def test_not_require_full_coverage_cluster_down_error(\n        self, r: RedisCluster\n    ) -> None:\n        \"\"\"\n        When require_full_coverage is set to False (default client config) and not\n        all slots are covered, if one of the nodes has 'cluster-require_full_coverage'\n        config set to 'yes' some key-based commands should throw ClusterDownError\n        \"\"\"\n        node = r.get_node_from_key(\"foo\")\n        missing_slot = r.keyslot(\"foo\")\n        assert await r.set(\"foo\", \"bar\") is True\n        try:\n            assert all(await r.cluster_delslots(missing_slot))\n            with pytest.raises(ClusterDownError):\n                await r.exists(\"foo\")\n        except ResponseError as e:\n            assert \"CLUSTERDOWN\" in str(e)\n        finally:\n            try:\n                # Add back the missing slot\n                assert await r.cluster_addslots(node, missing_slot) is True\n                # Make sure we are not getting ClusterDownError anymore\n                assert await r.exists(\"foo\") == 1\n            except ResponseError as e:\n                if f\"Slot {missing_slot} is already busy\" in str(e):\n                    # It can happen if the test failed to delete this slot\n                    pass\n                else:\n                    raise e\n\n    async def test_can_run_concurrent_commands(self, request: FixtureRequest) -> None:\n        url = request.config.getoption(\"--redis-url\")\n        rc = RedisCluster.from_url(url)\n        assert all(\n            await asyncio.gather(\n                *(rc.echo(\"i\", target_nodes=RedisCluster.ALL_NODES) for i in range(100))\n            )\n        )\n        await rc.aclose()\n\n    def test_replace_cluster_node(self, r: RedisCluster) -> None:\n        prev_default_node = r.get_default_node()\n        r.replace_default_node()\n        assert r.get_default_node() != prev_default_node\n        r.replace_default_node(prev_default_node)\n        assert r.get_default_node() == prev_default_node\n\n    async def test_default_node_is_replaced_after_exception(self, r):\n        curr_default_node = r.get_default_node()\n        # CLUSTER NODES command is being executed on the default node\n        nodes = await r.cluster_nodes()\n        assert \"myself\" in nodes.get(curr_default_node.name).get(\"flags\")\n        # Save original free connections to restore later\n        original_free = list(curr_default_node._free)\n        try:\n            # Mock connection error for the default node\n            mock_node_resp_exc(curr_default_node, ConnectionError(\"error\"))\n            # Test that the command succeed from a different node\n            nodes = await r.cluster_nodes()\n            assert \"myself\" not in nodes.get(curr_default_node.name).get(\"flags\")\n            assert r.get_default_node() != curr_default_node\n        finally:\n            # Restore original connections so teardown can work\n            while curr_default_node._free:\n                curr_default_node._free.pop()\n            for conn in original_free:\n                curr_default_node._free.append(conn)\n            # Rollback to the old default node\n            r.replace_default_node(curr_default_node)\n\n    async def test_address_remap(self, create_redis, master_host):\n        \"\"\"Test that we can create a rediscluster object with\n        a host-port remapper and map connections through proxy objects\n        \"\"\"\n\n        # we remap the first n nodes\n        offset = 1000\n        n = 6\n        hostname, master_port = master_host\n        ports = [master_port + i for i in range(n)]\n\n        def address_remap(address):\n            # remap first three nodes to our local proxy\n            # old = host, port\n            host, port = address\n            if int(port) in ports:\n                host, port = \"127.0.0.1\", int(port) + offset\n            # print(f\"{old} {host, port}\")\n            return host, port\n\n        # create the proxies\n        proxies = [\n            NodeProxy((\"127.0.0.1\", port + offset), (hostname, port)) for port in ports\n        ]\n        await asyncio.gather(*[p.start() for p in proxies])\n        try:\n            # create cluster:\n            r = await create_redis(\n                cls=RedisCluster, flushdb=False, address_remap=address_remap\n            )\n            try:\n                assert await r.ping() is True\n                assert await r.set(\"byte_string\", b\"giraffe\")\n                assert await r.get(\"byte_string\") == b\"giraffe\"\n            finally:\n                await r.aclose()\n        finally:\n            await asyncio.gather(*[p.aclose() for p in proxies])\n\n        # verify that the proxies were indeed used\n        n_used = sum((1 if p.n_connections else 0) for p in proxies)\n        assert n_used > 1\n\n\nclass TestClusterRedisCommands:\n    \"\"\"\n    Tests for RedisCluster unique commands\n    \"\"\"\n\n    async def test_get_and_set(self, r: RedisCluster) -> None:\n        # get and set can't be tested independently of each other\n        assert await r.get(\"a\") is None\n        byte_string = b\"value\"\n        integer = 5\n        unicode_string = chr(3456) + \"abcd\" + chr(3421)\n        assert await r.set(\"byte_string\", byte_string)\n        assert await r.set(\"integer\", 5)\n        assert await r.set(\"unicode_string\", unicode_string)\n        assert await r.get(\"byte_string\") == byte_string\n        assert await r.get(\"integer\") == str(integer).encode()\n        assert (await r.get(\"unicode_string\")).decode(\"utf-8\") == unicode_string\n\n    @pytest.mark.parametrize(\n        \"load_balancing_strategy\",\n        [\n            LoadBalancingStrategy.ROUND_ROBIN,\n            LoadBalancingStrategy.ROUND_ROBIN_REPLICAS,\n            LoadBalancingStrategy.RANDOM_REPLICA,\n        ],\n    )\n    async def test_get_and_set_with_load_balanced_client(\n        self, create_redis, load_balancing_strategy: LoadBalancingStrategy\n    ) -> None:\n        r = await create_redis(\n            cls=RedisCluster,\n            load_balancing_strategy=load_balancing_strategy,\n        )\n\n        # get and set can't be tested independently of each other\n        assert await r.get(\"a\") is None\n\n        byte_string = b\"value\"\n        assert await r.set(\"byte_string\", byte_string)\n\n        # run the get command for the same key several times\n        # to iterate over the read nodes\n        assert await r.get(\"byte_string\") == byte_string\n        assert await r.get(\"byte_string\") == byte_string\n        assert await r.get(\"byte_string\") == byte_string\n\n    async def test_mget_nonatomic(self, r: RedisCluster) -> None:\n        assert await r.mget_nonatomic([]) == []\n        assert await r.mget_nonatomic([\"a\", \"b\"]) == [None, None]\n        await r.set(\"a\", \"1\")\n        await r.set(\"b\", \"2\")\n        await r.set(\"c\", \"3\")\n\n        assert await r.mget_nonatomic(\"a\", \"other\", \"b\", \"c\") == [\n            b\"1\",\n            None,\n            b\"2\",\n            b\"3\",\n        ]\n\n    async def test_mset_nonatomic(self, r: RedisCluster) -> None:\n        d = {\"a\": b\"1\", \"b\": b\"2\", \"c\": b\"3\", \"d\": b\"4\"}\n        assert await r.mset_nonatomic(d)\n        for k, v in d.items():\n            assert await r.get(k) == v\n\n    async def test_config_set(self, r: RedisCluster) -> None:\n        assert await r.config_set(\"slowlog-log-slower-than\", 0)\n\n    async def test_cluster_config_resetstat(self, r: RedisCluster) -> None:\n        await r.ping(target_nodes=\"all\")\n        all_info = await r.info(target_nodes=\"all\")\n        prior_commands_processed = -1\n        for node_info in all_info.values():\n            prior_commands_processed = node_info[\"total_commands_processed\"]\n            assert prior_commands_processed >= 1\n        await r.config_resetstat(target_nodes=\"all\")\n        all_info = await r.info(target_nodes=\"all\")\n        for node_info in all_info.values():\n            reset_commands_processed = node_info[\"total_commands_processed\"]\n            assert reset_commands_processed < prior_commands_processed\n\n    async def test_client_setname(self, r: RedisCluster) -> None:\n        node = r.get_random_node()\n        await r.client_setname(\"redis_py_test\", target_nodes=node)\n        client_name = await r.client_getname(target_nodes=node)\n        assert_resp_response(r, client_name, \"redis_py_test\", b\"redis_py_test\")\n\n    async def test_exists(self, r: RedisCluster) -> None:\n        d = {\"a\": b\"1\", \"b\": b\"2\", \"c\": b\"3\", \"d\": b\"4\"}\n        await r.mset_nonatomic(d)\n        assert await r.exists(*d.keys()) == len(d)\n\n    async def test_delete(self, r: RedisCluster) -> None:\n        d = {\"a\": b\"1\", \"b\": b\"2\", \"c\": b\"3\", \"d\": b\"4\"}\n        await r.mset_nonatomic(d)\n        assert await r.delete(*d.keys()) == len(d)\n        assert await r.delete(*d.keys()) == 0\n\n    async def test_touch(self, r: RedisCluster) -> None:\n        d = {\"a\": b\"1\", \"b\": b\"2\", \"c\": b\"3\", \"d\": b\"4\"}\n        await r.mset_nonatomic(d)\n        assert await r.touch(*d.keys()) == len(d)\n\n    async def test_unlink(self, r: RedisCluster) -> None:\n        d = {\"a\": b\"1\", \"b\": b\"2\", \"c\": b\"3\", \"d\": b\"4\"}\n        await r.mset_nonatomic(d)\n        assert await r.unlink(*d.keys()) == len(d)\n        # Unlink is non-blocking so we sleep before\n        # verifying the deletion\n        await asyncio.sleep(0.1)\n        assert await r.unlink(*d.keys()) == 0\n\n    async def test_initialize_before_execute_multi_key_command(\n        self, request: FixtureRequest\n    ) -> None:\n        # Test for issue https://github.com/redis/redis-py/issues/2437\n        url = request.config.getoption(\"--redis-url\")\n        r = RedisCluster.from_url(url)\n        assert 0 == await r.exists(\"a\", \"b\", \"c\")\n        await r.aclose()\n\n    @skip_if_redis_enterprise()\n    async def test_cluster_myid(self, r: RedisCluster) -> None:\n        node = r.get_random_node()\n        myid = await r.cluster_myid(node)\n        assert len(myid) == 40\n\n    @skip_if_server_version_lt(\"7.2.0\")\n    @skip_if_redis_enterprise()\n    async def test_cluster_myshardid(self, r: RedisCluster) -> None:\n        node = r.get_random_node()\n        myshardid = await r.cluster_myshardid(node)\n        assert len(myshardid) == 40\n\n    @skip_if_redis_enterprise()\n    async def test_cluster_slots(self, r: RedisCluster) -> None:\n        mock_all_nodes_resp(r, default_cluster_slots)\n        cluster_slots = await r.cluster_slots()\n        assert isinstance(cluster_slots, dict)\n        assert len(default_cluster_slots) == len(cluster_slots)\n        assert cluster_slots.get((0, 8191)) is not None\n        assert cluster_slots.get((0, 8191)).get(\"primary\") == (\"127.0.0.1\", 7000)\n\n    @skip_if_redis_enterprise()\n    async def test_cluster_addslots(self, r: RedisCluster) -> None:\n        node = r.get_random_node()\n        mock_node_resp(node, \"OK\")\n        assert await r.cluster_addslots(node, 1, 2, 3) is True\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    @skip_if_redis_enterprise()\n    async def test_cluster_addslotsrange(self, r: RedisCluster):\n        node = r.get_random_node()\n        mock_node_resp(node, \"OK\")\n        assert await r.cluster_addslotsrange(node, 1, 5)\n\n    @skip_if_redis_enterprise()\n    async def test_cluster_countkeysinslot(self, r: RedisCluster) -> None:\n        node = r.nodes_manager.get_node_from_slot(1)\n        mock_node_resp(node, 2)\n        assert await r.cluster_countkeysinslot(1) == 2\n\n    async def test_cluster_count_failure_report(self, r: RedisCluster) -> None:\n        mock_all_nodes_resp(r, 0)\n        assert await r.cluster_count_failure_report(\"node_0\") == 0\n\n    @skip_if_redis_enterprise()\n    async def test_cluster_delslots(self) -> None:\n        cluster_slots = [\n            [0, 8191, [\"127.0.0.1\", 7000, \"node_0\"]],\n            [8192, 16383, [\"127.0.0.1\", 7001, \"node_1\"]],\n        ]\n        r = await get_mocked_redis_client(\n            host=default_host, port=default_port, cluster_slots=cluster_slots\n        )\n        mock_all_nodes_resp(r, \"OK\")\n        node0 = r.get_node(default_host, 7000)\n        node1 = r.get_node(default_host, 7001)\n        assert await r.cluster_delslots(0, 8192) == [True, True]\n        assert node0._free.pop().read_response.called\n        assert node1._free.pop().read_response.called\n\n        await r.aclose()\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    @skip_if_redis_enterprise()\n    async def test_cluster_delslotsrange(self):\n        r = await get_mocked_redis_client(host=default_host, port=default_port)\n        mock_all_nodes_resp(r, \"OK\")\n        node = r.get_random_node()\n        await r.cluster_addslots(node, 1, 2, 3, 4, 5)\n        assert await r.cluster_delslotsrange(1, 5)\n        assert node._free.pop().read_response.called\n        await r.aclose()\n\n    @skip_if_redis_enterprise()\n    async def test_cluster_failover(self, r: RedisCluster) -> None:\n        node = r.get_random_node()\n        mock_node_resp(node, \"OK\")\n        assert await r.cluster_failover(node) is True\n        assert await r.cluster_failover(node, \"FORCE\") is True\n        assert await r.cluster_failover(node, \"TAKEOVER\") is True\n        with pytest.raises(RedisError):\n            await r.cluster_failover(node, \"FORCT\")\n\n    @skip_if_redis_enterprise()\n    async def test_cluster_info(self, r: RedisCluster) -> None:\n        info = await r.cluster_info()\n        assert isinstance(info, dict)\n        assert info[\"cluster_state\"] == \"ok\"\n\n    @skip_if_redis_enterprise()\n    async def test_cluster_keyslot(self, r: RedisCluster) -> None:\n        mock_all_nodes_resp(r, 12182)\n        assert await r.cluster_keyslot(\"foo\") == 12182\n\n    @skip_if_redis_enterprise()\n    async def test_cluster_meet(self, r: RedisCluster) -> None:\n        node = r.get_default_node()\n        mock_node_resp(node, \"OK\")\n        assert await r.cluster_meet(\"127.0.0.1\", 6379) is True\n\n    @skip_if_redis_enterprise()\n    async def test_cluster_nodes(self, r: RedisCluster) -> None:\n        response = (\n            \"c8253bae761cb1ecb2b61857d85dfe455a0fec8b 172.17.0.7:7006 \"\n            \"slave aa90da731f673a99617dfe930306549a09f83a6b 0 \"\n            \"1447836263059 5 connected\\n\"\n            \"9bd595fe4821a0e8d6b99d70faa660638a7612b3 172.17.0.7:7008 \"\n            \"master - 0 1447836264065 0 connected\\n\"\n            \"aa90da731f673a99617dfe930306549a09f83a6b 172.17.0.7:7003 \"\n            \"myself,master - 0 0 2 connected 5461-10922\\n\"\n            \"1df047e5a594f945d82fc140be97a1452bcbf93e 172.17.0.7:7007 \"\n            \"slave 19efe5a631f3296fdf21a5441680f893e8cc96ec 0 \"\n            \"1447836262556 3 connected\\n\"\n            \"4ad9a12e63e8f0207025eeba2354bcf4c85e5b22 172.17.0.7:7005 \"\n            \"master - 0 1447836262555 7 connected 0-5460\\n\"\n            \"19efe5a631f3296fdf21a5441680f893e8cc96ec 172.17.0.7:7004 \"\n            \"master - 0 1447836263562 3 connected 10923-16383\\n\"\n            \"fbb23ed8cfa23f17eaf27ff7d0c410492a1093d6 172.17.0.7:7002 \"\n            \"master,fail - 1447829446956 1447829444948 1 disconnected\\n\"\n        )\n        mock_all_nodes_resp(r, response)\n        nodes = await r.cluster_nodes()\n        assert len(nodes) == 7\n        assert nodes.get(\"172.17.0.7:7006\") is not None\n        assert (\n            nodes.get(\"172.17.0.7:7006\").get(\"node_id\")\n            == \"c8253bae761cb1ecb2b61857d85dfe455a0fec8b\"\n        )\n\n    @skip_if_redis_enterprise()\n    async def test_cluster_nodes_importing_migrating(self, r: RedisCluster) -> None:\n        response = (\n            \"488ead2fcce24d8c0f158f9172cb1f4a9e040fe5 127.0.0.1:16381@26381 \"\n            \"master - 0 1648975557664 3 connected 10923-16383\\n\"\n            \"8ae2e70812db80776f739a72374e57fc4ae6f89d 127.0.0.1:16380@26380 \"\n            \"master - 0 1648975555000 2 connected 1 5461-10922 [\"\n            \"2-<-ed8007ccfa2d91a7b76f8e6fba7ba7e257034a16]\\n\"\n            \"ed8007ccfa2d91a7b76f8e6fba7ba7e257034a16 127.0.0.1:16379@26379 \"\n            \"myself,master - 0 1648975556000 1 connected 0 2-5460 [\"\n            \"2->-8ae2e70812db80776f739a72374e57fc4ae6f89d]\\n\"\n        )\n        mock_all_nodes_resp(r, response)\n        nodes = await r.cluster_nodes()\n        assert len(nodes) == 3\n        node_16379 = nodes.get(\"127.0.0.1:16379\")\n        node_16380 = nodes.get(\"127.0.0.1:16380\")\n        node_16381 = nodes.get(\"127.0.0.1:16381\")\n        assert node_16379.get(\"migrations\") == [\n            {\n                \"slot\": \"2\",\n                \"node_id\": \"8ae2e70812db80776f739a72374e57fc4ae6f89d\",\n                \"state\": \"migrating\",\n            }\n        ]\n        assert node_16379.get(\"slots\") == [[\"0\"], [\"2\", \"5460\"]]\n        assert node_16380.get(\"migrations\") == [\n            {\n                \"slot\": \"2\",\n                \"node_id\": \"ed8007ccfa2d91a7b76f8e6fba7ba7e257034a16\",\n                \"state\": \"importing\",\n            }\n        ]\n        assert node_16380.get(\"slots\") == [[\"1\"], [\"5461\", \"10922\"]]\n        assert node_16381.get(\"slots\") == [[\"10923\", \"16383\"]]\n        assert node_16381.get(\"migrations\") == []\n\n    @skip_if_redis_enterprise()\n    async def test_cluster_replicate(self, r: RedisCluster) -> None:\n        node = r.get_random_node()\n        all_replicas = r.get_replicas()\n        mock_all_nodes_resp(r, \"OK\")\n        assert await r.cluster_replicate(node, \"c8253bae761cb61857d\") is True\n        results = await r.cluster_replicate(all_replicas, \"c8253bae761cb61857d\")\n        if isinstance(results, dict):\n            for res in results.values():\n                assert res is True\n        else:\n            assert results is True\n\n    @skip_if_redis_enterprise()\n    async def test_cluster_reset(self, r: RedisCluster) -> None:\n        mock_all_nodes_resp(r, \"OK\")\n        assert await r.cluster_reset() is True\n        assert await r.cluster_reset(False) is True\n        all_results = await r.cluster_reset(False, target_nodes=\"all\")\n        for res in all_results.values():\n            assert res is True\n\n    @skip_if_redis_enterprise()\n    async def test_cluster_save_config(self, r: RedisCluster) -> None:\n        node = r.get_random_node()\n        all_nodes = r.get_nodes()\n        mock_all_nodes_resp(r, \"OK\")\n        assert await r.cluster_save_config(node) is True\n        all_results = await r.cluster_save_config(all_nodes)\n        for res in all_results.values():\n            assert res is True\n\n    @skip_if_redis_enterprise()\n    async def test_cluster_get_keys_in_slot(self, r: RedisCluster) -> None:\n        response = [\"{foo}1\", \"{foo}2\"]\n        node = r.nodes_manager.get_node_from_slot(12182)\n        mock_node_resp(node, response)\n        keys = await r.cluster_get_keys_in_slot(12182, 4)\n        assert keys == response\n\n    @skip_if_redis_enterprise()\n    async def test_cluster_set_config_epoch(self, r: RedisCluster) -> None:\n        mock_all_nodes_resp(r, \"OK\")\n        assert await r.cluster_set_config_epoch(3) is True\n        all_results = await r.cluster_set_config_epoch(3, target_nodes=\"all\")\n        for res in all_results.values():\n            assert res is True\n\n    @skip_if_redis_enterprise()\n    async def test_cluster_setslot(self, r: RedisCluster) -> None:\n        node = r.get_random_node()\n        mock_node_resp(node, \"OK\")\n        assert await r.cluster_setslot(node, \"node_0\", 1218, \"IMPORTING\") is True\n        assert await r.cluster_setslot(node, \"node_0\", 1218, \"NODE\") is True\n        assert await r.cluster_setslot(node, \"node_0\", 1218, \"MIGRATING\") is True\n        with pytest.raises(RedisError):\n            await r.cluster_failover(node, \"STABLE\")\n        with pytest.raises(RedisError):\n            await r.cluster_failover(node, \"STATE\")\n\n    async def test_cluster_setslot_stable(self, r: RedisCluster) -> None:\n        node = r.nodes_manager.get_node_from_slot(12182)\n        mock_node_resp(node, \"OK\")\n        assert await r.cluster_setslot_stable(12182) is True\n        assert node._free.pop().read_response.called\n\n    @skip_if_redis_enterprise()\n    async def test_cluster_replicas(self, r: RedisCluster) -> None:\n        response = [\n            b\"01eca22229cf3c652b6fca0d09ff6941e0d2e3 \"\n            b\"127.0.0.1:6377@16377 slave \"\n            b\"52611e796814b78e90ad94be9d769a4f668f9a 0 \"\n            b\"1634550063436 4 connected\",\n            b\"r4xfga22229cf3c652b6fca0d09ff69f3e0d4d \"\n            b\"127.0.0.1:6378@16378 slave \"\n            b\"52611e796814b78e90ad94be9d769a4f668f9a 0 \"\n            b\"1634550063436 4 connected\",\n        ]\n        mock_all_nodes_resp(r, response)\n        replicas = await r.cluster_replicas(\"52611e796814b78e90ad94be9d769a4f668f9a\")\n        assert replicas.get(\"127.0.0.1:6377\") is not None\n        assert replicas.get(\"127.0.0.1:6378\") is not None\n        assert (\n            replicas.get(\"127.0.0.1:6378\").get(\"node_id\")\n            == \"r4xfga22229cf3c652b6fca0d09ff69f3e0d4d\"\n        )\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    async def test_cluster_links(self, r: RedisCluster):\n        node = r.get_random_node()\n        res = await r.cluster_links(node)\n        if is_resp2_connection(r):\n            links_to = sum(x.count(b\"to\") for x in res)\n            links_for = sum(x.count(b\"from\") for x in res)\n            assert links_to == links_for\n            for i in range(0, len(res) - 1, 2):\n                assert res[i][3] == res[i + 1][3]\n        else:\n            links_to = len(list(filter(lambda x: x[b\"direction\"] == b\"to\", res)))\n            links_for = len(list(filter(lambda x: x[b\"direction\"] == b\"from\", res)))\n            assert links_to == links_for\n            for i in range(0, len(res) - 1, 2):\n                assert res[i][b\"node\"] == res[i + 1][b\"node\"]\n\n    @skip_if_redis_enterprise()\n    async def test_readonly(self) -> None:\n        r = await get_mocked_redis_client(host=default_host, port=default_port)\n        mock_all_nodes_resp(r, \"OK\")\n        assert await r.readonly() is True\n        all_replicas_results = await r.readonly(target_nodes=\"replicas\")\n        for res in all_replicas_results.values():\n            assert res is True\n        for replica in r.get_replicas():\n            assert replica._free.pop().read_response.called\n\n        await r.aclose()\n\n    @skip_if_redis_enterprise()\n    async def test_readwrite(self) -> None:\n        r = await get_mocked_redis_client(host=default_host, port=default_port)\n        mock_all_nodes_resp(r, \"OK\")\n        assert await r.readwrite() is True\n        all_replicas_results = await r.readwrite(target_nodes=\"replicas\")\n        for res in all_replicas_results.values():\n            assert res is True\n        for replica in r.get_replicas():\n            assert replica._free.pop().read_response.called\n\n        await r.aclose()\n\n    @skip_if_redis_enterprise()\n    async def test_bgsave(self, r: RedisCluster) -> None:\n        try:\n            assert await r.bgsave()\n            await asyncio.sleep(0.3)\n            assert await r.bgsave(True)\n        except ResponseError as e:\n            if \"Background save already in progress\" not in e.__str__():\n                raise\n\n    async def test_info(self, r: RedisCluster) -> None:\n        # Map keys to same slot\n        await r.set(\"x{1}\", 1)\n        await r.set(\"y{1}\", 2)\n        await r.set(\"z{1}\", 3)\n        # Get node that handles the slot\n        slot = r.keyslot(\"x{1}\")\n        node = r.nodes_manager.get_node_from_slot(slot)\n        # Run info on that node\n        info = await r.info(target_nodes=node)\n        assert isinstance(info, dict)\n        assert info[\"db0\"][\"keys\"] == 3\n\n    async def _init_slowlog_test(self, r: RedisCluster, node: ClusterNode) -> str:\n        slowlog_lim = await r.config_get(\"slowlog-log-slower-than\", target_nodes=node)\n        assert (\n            await r.config_set(\"slowlog-log-slower-than\", 0, target_nodes=node) is True\n        )\n        return slowlog_lim[\"slowlog-log-slower-than\"]\n\n    async def _teardown_slowlog_test(\n        self, r: RedisCluster, node: ClusterNode, prev_limit: str\n    ) -> None:\n        assert (\n            await r.config_set(\"slowlog-log-slower-than\", prev_limit, target_nodes=node)\n            is True\n        )\n\n    async def test_slowlog_get(\n        self, r: RedisCluster, slowlog: Optional[List[Dict[str, Union[int, bytes]]]]\n    ) -> None:\n        unicode_string = chr(3456) + \"abcd\" + chr(3421)\n        node = r.get_node_from_key(unicode_string)\n        slowlog_limit = await self._init_slowlog_test(r, node)\n        assert await r.slowlog_reset(target_nodes=node)\n        await r.get(unicode_string)\n        slowlog = await r.slowlog_get(target_nodes=node)\n        assert isinstance(slowlog, list)\n        commands = [log[\"command\"] for log in slowlog]\n\n        get_command = b\" \".join((b\"GET\", unicode_string.encode(\"utf-8\")))\n        assert get_command in commands\n        assert b\"SLOWLOG RESET\" in commands\n\n        # the order should be ['GET <uni string>', 'SLOWLOG RESET'],\n        # but if other clients are executing commands at the same time, there\n        # could be commands, before, between, or after, so just check that\n        # the two we care about are in the appropriate order.\n        assert commands.index(get_command) < commands.index(b\"SLOWLOG RESET\")\n\n        # make sure other attributes are typed correctly\n        assert isinstance(slowlog[0][\"start_time\"], int)\n        assert isinstance(slowlog[0][\"duration\"], int)\n        # rollback the slowlog limit to its original value\n        await self._teardown_slowlog_test(r, node, slowlog_limit)\n\n    async def test_slowlog_get_limit(\n        self, r: RedisCluster, slowlog: Optional[List[Dict[str, Union[int, bytes]]]]\n    ) -> None:\n        assert await r.slowlog_reset()\n        node = r.get_node_from_key(\"foo\")\n        slowlog_limit = await self._init_slowlog_test(r, node)\n        await r.get(\"foo\")\n        slowlog = await r.slowlog_get(1, target_nodes=node)\n        assert isinstance(slowlog, list)\n        # only one command, based on the number we passed to slowlog_get()\n        assert len(slowlog) == 1\n        await self._teardown_slowlog_test(r, node, slowlog_limit)\n\n    async def test_slowlog_length(self, r: RedisCluster, slowlog: None) -> None:\n        await r.get(\"foo\")\n        node = r.nodes_manager.get_node_from_slot(key_slot(b\"foo\"))\n        slowlog_len = await r.slowlog_len(target_nodes=node)\n        assert isinstance(slowlog_len, int)\n\n    async def test_time(self, r: RedisCluster) -> None:\n        t = await r.time(target_nodes=r.get_primaries()[0])\n        assert len(t) == 2\n        assert isinstance(t[0], int)\n        assert isinstance(t[1], int)\n\n    @skip_if_server_version_lt(\"4.0.0\")\n    async def test_memory_usage(self, r: RedisCluster) -> None:\n        await r.set(\"foo\", \"bar\")\n        assert isinstance(await r.memory_usage(\"foo\"), int)\n\n    @skip_if_server_version_lt(\"4.0.0\")\n    @skip_if_redis_enterprise()\n    async def test_memory_malloc_stats(self, r: RedisCluster) -> None:\n        assert await r.memory_malloc_stats()\n\n    @skip_if_server_version_lt(\"4.0.0\")\n    @skip_if_redis_enterprise()\n    async def test_memory_stats(self, r: RedisCluster) -> None:\n        # put a key into the current db to make sure that \"db.<current-db>\"\n        # has data\n        await r.set(\"foo\", \"bar\")\n        node = r.nodes_manager.get_node_from_slot(key_slot(b\"foo\"))\n        stats = await r.memory_stats(target_nodes=node)\n        assert isinstance(stats, dict)\n        for key, value in stats.items():\n            if key.startswith(\"db.\"):\n                assert not isinstance(value, list)\n\n    @skip_if_server_version_lt(\"4.0.0\")\n    async def test_memory_help(self, r: RedisCluster) -> None:\n        with pytest.raises(NotImplementedError):\n            await r.memory_help()\n\n    @skip_if_server_version_lt(\"4.0.0\")\n    async def test_memory_doctor(self, r: RedisCluster) -> None:\n        with pytest.raises(NotImplementedError):\n            await r.memory_doctor()\n\n    @skip_if_redis_enterprise()\n    async def test_lastsave(self, r: RedisCluster) -> None:\n        node = r.get_primaries()[0]\n        assert isinstance(await r.lastsave(target_nodes=node), datetime.datetime)\n\n    async def test_cluster_echo(self, r: RedisCluster) -> None:\n        node = r.get_primaries()[0]\n        assert await r.echo(\"foo bar\", target_nodes=node) == b\"foo bar\"\n\n    @skip_if_server_version_lt(\"1.0.0\")\n    async def test_debug_segfault(self, r: RedisCluster) -> None:\n        with pytest.raises(NotImplementedError):\n            await r.debug_segfault()\n\n    async def test_config_resetstat(self, r: RedisCluster) -> None:\n        node = r.get_primaries()[0]\n        await r.ping(target_nodes=node)\n        prior_commands_processed = int(\n            (await r.info(target_nodes=node))[\"total_commands_processed\"]\n        )\n        assert prior_commands_processed >= 1\n        await r.config_resetstat(target_nodes=node)\n        reset_commands_processed = int(\n            (await r.info(target_nodes=node))[\"total_commands_processed\"]\n        )\n        assert reset_commands_processed < prior_commands_processed\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    async def test_client_trackinginfo(self, r: RedisCluster) -> None:\n        node = r.get_primaries()[0]\n        res = await r.client_trackinginfo(target_nodes=node)\n        assert len(res) > 2\n        assert \"prefixes\" in res or b\"prefixes\" in res\n\n    @skip_if_server_version_lt(\"2.9.50\")\n    async def test_client_pause(self, r: RedisCluster) -> None:\n        node = r.get_primaries()[0]\n        assert await r.client_pause(1, target_nodes=node)\n        assert await r.client_pause(timeout=1, target_nodes=node)\n        with pytest.raises(RedisError):\n            await r.client_pause(timeout=\"not an integer\", target_nodes=node)\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    @skip_if_redis_enterprise()\n    async def test_client_unpause(self, r: RedisCluster) -> None:\n        assert await r.client_unpause()\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    async def test_client_id(self, r: RedisCluster) -> None:\n        node = r.get_primaries()[0]\n        assert await r.client_id(target_nodes=node) > 0\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    async def test_client_unblock(self, r: RedisCluster) -> None:\n        node = r.get_primaries()[0]\n        myid = await r.client_id(target_nodes=node)\n        assert not await r.client_unblock(myid, target_nodes=node)\n        assert not await r.client_unblock(myid, error=True, target_nodes=node)\n        assert not await r.client_unblock(myid, error=False, target_nodes=node)\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    async def test_client_getredir(self, r: RedisCluster) -> None:\n        node = r.get_primaries()[0]\n        assert isinstance(await r.client_getredir(target_nodes=node), int)\n        assert await r.client_getredir(target_nodes=node) == -1\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    async def test_client_info(self, r: RedisCluster) -> None:\n        node = r.get_primaries()[0]\n        info = await r.client_info(target_nodes=node)\n        assert isinstance(info, dict)\n        assert \"addr\" in info\n\n    @skip_if_server_version_lt(\"2.6.9\")\n    async def test_client_kill(\n        self, r: RedisCluster, create_redis: Callable[..., RedisCluster]\n    ) -> None:\n        node = r.get_primaries()[0]\n        r2 = await create_redis(cls=RedisCluster, flushdb=False)\n        await r.client_setname(\"redis-py-c1\", target_nodes=\"all\")\n        await r2.client_setname(\"redis-py-c2\", target_nodes=\"all\")\n        clients = [\n            client\n            for client in await r.client_list(target_nodes=node)\n            if client.get(\"name\") in [\"redis-py-c1\", \"redis-py-c2\"]\n        ]\n        assert len(clients) == 2\n        clients_by_name = {client.get(\"name\"): client for client in clients}\n\n        client_addr = clients_by_name[\"redis-py-c2\"].get(\"addr\")\n        assert await r.client_kill(client_addr, target_nodes=node) is True\n\n        clients = [\n            client\n            for client in await r.client_list(target_nodes=node)\n            if client.get(\"name\") in [\"redis-py-c1\", \"redis-py-c2\"]\n        ]\n        assert len(clients) == 1\n        assert clients[0].get(\"name\") == \"redis-py-c1\"\n        await r2.aclose()\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    async def test_cluster_bitop_not_empty_string(self, r: RedisCluster) -> None:\n        await r.set(\"{foo}a\", \"\")\n        await r.bitop(\"not\", \"{foo}r\", \"{foo}a\")\n        assert await r.get(\"{foo}r\") is None\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    async def test_cluster_bitop_not(self, r: RedisCluster) -> None:\n        test_str = b\"\\xaa\\x00\\xff\\x55\"\n        correct = ~0xAA00FF55 & 0xFFFFFFFF\n        await r.set(\"{foo}a\", test_str)\n        await r.bitop(\"not\", \"{foo}r\", \"{foo}a\")\n        assert int(binascii.hexlify(await r.get(\"{foo}r\")), 16) == correct\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    async def test_cluster_bitop_not_in_place(self, r: RedisCluster) -> None:\n        test_str = b\"\\xaa\\x00\\xff\\x55\"\n        correct = ~0xAA00FF55 & 0xFFFFFFFF\n        await r.set(\"{foo}a\", test_str)\n        await r.bitop(\"not\", \"{foo}a\", \"{foo}a\")\n        assert int(binascii.hexlify(await r.get(\"{foo}a\")), 16) == correct\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    async def test_cluster_bitop_single_string(self, r: RedisCluster) -> None:\n        test_str = b\"\\x01\\x02\\xff\"\n        await r.set(\"{foo}a\", test_str)\n        await r.bitop(\"and\", \"{foo}res1\", \"{foo}a\")\n        await r.bitop(\"or\", \"{foo}res2\", \"{foo}a\")\n        await r.bitop(\"xor\", \"{foo}res3\", \"{foo}a\")\n        assert await r.get(\"{foo}res1\") == test_str\n        assert await r.get(\"{foo}res2\") == test_str\n        assert await r.get(\"{foo}res3\") == test_str\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    async def test_cluster_bitop_string_operands(self, r: RedisCluster) -> None:\n        await r.set(\"{foo}a\", b\"\\x01\\x02\\xff\\xff\")\n        await r.set(\"{foo}b\", b\"\\x01\\x02\\xff\")\n        await r.bitop(\"and\", \"{foo}res1\", \"{foo}a\", \"{foo}b\")\n        await r.bitop(\"or\", \"{foo}res2\", \"{foo}a\", \"{foo}b\")\n        await r.bitop(\"xor\", \"{foo}res3\", \"{foo}a\", \"{foo}b\")\n        assert int(binascii.hexlify(await r.get(\"{foo}res1\")), 16) == 0x0102FF00\n        assert int(binascii.hexlify(await r.get(\"{foo}res2\")), 16) == 0x0102FFFF\n        assert int(binascii.hexlify(await r.get(\"{foo}res3\")), 16) == 0x000000FF\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    async def test_cluster_copy(self, r: RedisCluster) -> None:\n        assert await r.copy(\"{foo}a\", \"{foo}b\") == 0\n        await r.set(\"{foo}a\", \"bar\")\n        assert await r.copy(\"{foo}a\", \"{foo}b\") == 1\n        assert await r.get(\"{foo}a\") == b\"bar\"\n        assert await r.get(\"{foo}b\") == b\"bar\"\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    async def test_cluster_copy_and_replace(self, r: RedisCluster) -> None:\n        await r.set(\"{foo}a\", \"foo1\")\n        await r.set(\"{foo}b\", \"foo2\")\n        assert await r.copy(\"{foo}a\", \"{foo}b\") == 0\n        assert await r.copy(\"{foo}a\", \"{foo}b\", replace=True) == 1\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    async def test_cluster_lmove(self, r: RedisCluster) -> None:\n        await r.rpush(\"{foo}a\", \"one\", \"two\", \"three\", \"four\")\n        assert await r.lmove(\"{foo}a\", \"{foo}b\")\n        assert await r.lmove(\"{foo}a\", \"{foo}b\", \"right\", \"left\")\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    async def test_cluster_blmove(self, r: RedisCluster) -> None:\n        await r.rpush(\"{foo}a\", \"one\", \"two\", \"three\", \"four\")\n        assert await r.blmove(\"{foo}a\", \"{foo}b\", 5)\n        assert await r.blmove(\"{foo}a\", \"{foo}b\", 1, \"RIGHT\", \"LEFT\")\n\n    async def test_cluster_msetnx(self, r: RedisCluster) -> None:\n        d = {\"{foo}a\": b\"1\", \"{foo}b\": b\"2\", \"{foo}c\": b\"3\"}\n        assert await r.msetnx(d)\n        d2 = {\"{foo}a\": b\"x\", \"{foo}d\": b\"4\"}\n        assert not await r.msetnx(d2)\n        for k, v in d.items():\n            assert await r.get(k) == v\n        assert await r.get(\"{foo}d\") is None\n\n    async def test_cluster_rename(self, r: RedisCluster) -> None:\n        await r.set(\"{foo}a\", \"1\")\n        assert await r.rename(\"{foo}a\", \"{foo}b\")\n        assert await r.get(\"{foo}a\") is None\n        assert await r.get(\"{foo}b\") == b\"1\"\n\n    async def test_cluster_renamenx(self, r: RedisCluster) -> None:\n        await r.set(\"{foo}a\", \"1\")\n        await r.set(\"{foo}b\", \"2\")\n        assert not await r.renamenx(\"{foo}a\", \"{foo}b\")\n        assert await r.get(\"{foo}a\") == b\"1\"\n        assert await r.get(\"{foo}b\") == b\"2\"\n\n    # LIST COMMANDS\n    async def test_cluster_blpop(self, r: RedisCluster) -> None:\n        await r.rpush(\"{foo}a\", \"1\", \"2\")\n        await r.rpush(\"{foo}b\", \"3\", \"4\")\n        assert_resp_response(\n            r,\n            await r.blpop([\"{foo}b\", \"{foo}a\"], timeout=1),\n            (b\"{foo}b\", b\"3\"),\n            [b\"{foo}b\", b\"3\"],\n        )\n        assert_resp_response(\n            r,\n            await r.blpop([\"{foo}b\", \"{foo}a\"], timeout=1),\n            (b\"{foo}b\", b\"4\"),\n            [b\"{foo}b\", b\"4\"],\n        )\n        assert_resp_response(\n            r,\n            await r.blpop([\"{foo}b\", \"{foo}a\"], timeout=1),\n            (b\"{foo}a\", b\"1\"),\n            [b\"{foo}a\", b\"1\"],\n        )\n        assert_resp_response(\n            r,\n            await r.blpop([\"{foo}b\", \"{foo}a\"], timeout=1),\n            (b\"{foo}a\", b\"2\"),\n            [b\"{foo}a\", b\"2\"],\n        )\n        assert await r.blpop([\"{foo}b\", \"{foo}a\"], timeout=1) is None\n        await r.rpush(\"{foo}c\", \"1\")\n        assert_resp_response(\n            r, await r.blpop(\"{foo}c\", timeout=1), (b\"{foo}c\", b\"1\"), [b\"{foo}c\", b\"1\"]\n        )\n\n    async def test_cluster_brpop(self, r: RedisCluster) -> None:\n        await r.rpush(\"{foo}a\", \"1\", \"2\")\n        await r.rpush(\"{foo}b\", \"3\", \"4\")\n        assert_resp_response(\n            r,\n            await r.brpop([\"{foo}b\", \"{foo}a\"], timeout=1),\n            (b\"{foo}b\", b\"4\"),\n            [b\"{foo}b\", b\"4\"],\n        )\n        assert_resp_response(\n            r,\n            await r.brpop([\"{foo}b\", \"{foo}a\"], timeout=1),\n            (b\"{foo}b\", b\"3\"),\n            [b\"{foo}b\", b\"3\"],\n        )\n        assert_resp_response(\n            r,\n            await r.brpop([\"{foo}b\", \"{foo}a\"], timeout=1),\n            (b\"{foo}a\", b\"2\"),\n            [b\"{foo}a\", b\"2\"],\n        )\n        assert_resp_response(\n            r,\n            await r.brpop([\"{foo}b\", \"{foo}a\"], timeout=1),\n            (b\"{foo}a\", b\"1\"),\n            [b\"{foo}a\", b\"1\"],\n        )\n        assert await r.brpop([\"{foo}b\", \"{foo}a\"], timeout=1) is None\n        await r.rpush(\"{foo}c\", \"1\")\n        assert_resp_response(\n            r, await r.brpop(\"{foo}c\", timeout=1), (b\"{foo}c\", b\"1\"), [b\"{foo}c\", b\"1\"]\n        )\n\n    async def test_cluster_brpoplpush(self, r: RedisCluster) -> None:\n        await r.rpush(\"{foo}a\", \"1\", \"2\")\n        await r.rpush(\"{foo}b\", \"3\", \"4\")\n        assert await r.brpoplpush(\"{foo}a\", \"{foo}b\") == b\"2\"\n        assert await r.brpoplpush(\"{foo}a\", \"{foo}b\") == b\"1\"\n        assert await r.brpoplpush(\"{foo}a\", \"{foo}b\", timeout=1) is None\n        assert await r.lrange(\"{foo}a\", 0, -1) == []\n        assert await r.lrange(\"{foo}b\", 0, -1) == [b\"1\", b\"2\", b\"3\", b\"4\"]\n\n    async def test_cluster_brpoplpush_empty_string(self, r: RedisCluster) -> None:\n        await r.rpush(\"{foo}a\", \"\")\n        assert await r.brpoplpush(\"{foo}a\", \"{foo}b\") == b\"\"\n\n    async def test_cluster_rpoplpush(self, r: RedisCluster) -> None:\n        await r.rpush(\"{foo}a\", \"a1\", \"a2\", \"a3\")\n        await r.rpush(\"{foo}b\", \"b1\", \"b2\", \"b3\")\n        assert await r.rpoplpush(\"{foo}a\", \"{foo}b\") == b\"a3\"\n        assert await r.lrange(\"{foo}a\", 0, -1) == [b\"a1\", b\"a2\"]\n        assert await r.lrange(\"{foo}b\", 0, -1) == [b\"a3\", b\"b1\", b\"b2\", b\"b3\"]\n\n    async def test_cluster_sdiff(self, r: RedisCluster) -> None:\n        await r.sadd(\"{foo}a\", \"1\", \"2\", \"3\")\n        assert await r.sdiff(\"{foo}a\", \"{foo}b\") == {b\"1\", b\"2\", b\"3\"}\n        await r.sadd(\"{foo}b\", \"2\", \"3\")\n        assert await r.sdiff(\"{foo}a\", \"{foo}b\") == {b\"1\"}\n\n    async def test_cluster_sdiffstore(self, r: RedisCluster) -> None:\n        await r.sadd(\"{foo}a\", \"1\", \"2\", \"3\")\n        assert await r.sdiffstore(\"{foo}c\", \"{foo}a\", \"{foo}b\") == 3\n        assert await r.smembers(\"{foo}c\") == {b\"1\", b\"2\", b\"3\"}\n        await r.sadd(\"{foo}b\", \"2\", \"3\")\n        assert await r.sdiffstore(\"{foo}c\", \"{foo}a\", \"{foo}b\") == 1\n        assert await r.smembers(\"{foo}c\") == {b\"1\"}\n\n    async def test_cluster_sinter(self, r: RedisCluster) -> None:\n        await r.sadd(\"{foo}a\", \"1\", \"2\", \"3\")\n        assert await r.sinter(\"{foo}a\", \"{foo}b\") == set()\n        await r.sadd(\"{foo}b\", \"2\", \"3\")\n        assert await r.sinter(\"{foo}a\", \"{foo}b\") == {b\"2\", b\"3\"}\n\n    async def test_cluster_sinterstore(self, r: RedisCluster) -> None:\n        await r.sadd(\"{foo}a\", \"1\", \"2\", \"3\")\n        assert await r.sinterstore(\"{foo}c\", \"{foo}a\", \"{foo}b\") == 0\n        assert await r.smembers(\"{foo}c\") == set()\n        await r.sadd(\"{foo}b\", \"2\", \"3\")\n        assert await r.sinterstore(\"{foo}c\", \"{foo}a\", \"{foo}b\") == 2\n        assert await r.smembers(\"{foo}c\") == {b\"2\", b\"3\"}\n\n    async def test_cluster_smove(self, r: RedisCluster) -> None:\n        await r.sadd(\"{foo}a\", \"a1\", \"a2\")\n        await r.sadd(\"{foo}b\", \"b1\", \"b2\")\n        assert await r.smove(\"{foo}a\", \"{foo}b\", \"a1\")\n        assert await r.smembers(\"{foo}a\") == {b\"a2\"}\n        assert await r.smembers(\"{foo}b\") == {b\"b1\", b\"b2\", b\"a1\"}\n\n    async def test_cluster_sunion(self, r: RedisCluster) -> None:\n        await r.sadd(\"{foo}a\", \"1\", \"2\")\n        await r.sadd(\"{foo}b\", \"2\", \"3\")\n        assert set(await r.sunion(\"{foo}a\", \"{foo}b\")) == {b\"1\", b\"2\", b\"3\"}\n\n    async def test_cluster_sunionstore(self, r: RedisCluster) -> None:\n        await r.sadd(\"{foo}a\", \"1\", \"2\")\n        await r.sadd(\"{foo}b\", \"2\", \"3\")\n        assert await r.sunionstore(\"{foo}c\", \"{foo}a\", \"{foo}b\") == 3\n        assert set(await r.smembers(\"{foo}c\")) == {b\"1\", b\"2\", b\"3\"}\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    async def test_cluster_zdiff(self, r: RedisCluster) -> None:\n        await r.zadd(\"{foo}a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        await r.zadd(\"{foo}b\", {\"a1\": 1, \"a2\": 2})\n        assert await r.zdiff([\"{foo}a\", \"{foo}b\"]) == [b\"a3\"]\n        response = await r.zdiff([\"{foo}a\", \"{foo}b\"], withscores=True)\n        assert_resp_response(r, response, [b\"a3\", b\"3\"], [[b\"a3\", 3.0]])\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    async def test_cluster_zdiffstore(self, r: RedisCluster) -> None:\n        await r.zadd(\"{foo}a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        await r.zadd(\"{foo}b\", {\"a1\": 1, \"a2\": 2})\n        assert await r.zdiffstore(\"{foo}out\", [\"{foo}a\", \"{foo}b\"])\n        assert await r.zrange(\"{foo}out\", 0, -1) == [b\"a3\"]\n        response = await r.zrange(\"{foo}out\", 0, -1, withscores=True)\n        assert_resp_response(r, response, [(b\"a3\", 3.0)], [[b\"a3\", 3.0]])\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    async def test_cluster_zinter(self, r: RedisCluster) -> None:\n        await r.zadd(\"{foo}a\", {\"a1\": 1, \"a2\": 2, \"a3\": 1})\n        await r.zadd(\"{foo}b\", {\"a1\": 2, \"a2\": 2, \"a3\": 2})\n        await r.zadd(\"{foo}c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert await r.zinter([\"{foo}a\", \"{foo}b\", \"{foo}c\"]) == [b\"a3\", b\"a1\"]\n        # invalid aggregation\n        with pytest.raises(DataError):\n            await r.zinter(\n                [\"{foo}a\", \"{foo}b\", \"{foo}c\"], aggregate=\"foo\", withscores=True\n            )\n        # aggregate with SUM\n        response = await r.zinter([\"{foo}a\", \"{foo}b\", \"{foo}c\"], withscores=True)\n        assert_resp_response(\n            r, response, [(b\"a3\", 8), (b\"a1\", 9)], [[b\"a3\", 8], [b\"a1\", 9]]\n        )\n        # aggregate with MAX\n        response = await r.zinter(\n            [\"{foo}a\", \"{foo}b\", \"{foo}c\"], aggregate=\"MAX\", withscores=True\n        )\n        assert_resp_response(\n            r, response, [(b\"a3\", 5), (b\"a1\", 6)], [[b\"a3\", 5], [b\"a1\", 6]]\n        )\n        # aggregate with MIN\n        response = await r.zinter(\n            [\"{foo}a\", \"{foo}b\", \"{foo}c\"], aggregate=\"MIN\", withscores=True\n        )\n        assert_resp_response(\n            r, response, [(b\"a1\", 1), (b\"a3\", 1)], [[b\"a1\", 1], [b\"a3\", 1]]\n        )\n        # with weights\n        res = await r.zinter({\"{foo}a\": 1, \"{foo}b\": 2, \"{foo}c\": 3}, withscores=True)\n        assert_resp_response(\n            r, res, [(b\"a3\", 20), (b\"a1\", 23)], [[b\"a3\", 20], [b\"a1\", 23]]\n        )\n\n    async def test_cluster_zinterstore_sum(self, r: RedisCluster) -> None:\n        await r.zadd(\"{foo}a\", {\"a1\": 1, \"a2\": 1, \"a3\": 1})\n        await r.zadd(\"{foo}b\", {\"a1\": 2, \"a2\": 2, \"a3\": 2})\n        await r.zadd(\"{foo}c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert await r.zinterstore(\"{foo}d\", [\"{foo}a\", \"{foo}b\", \"{foo}c\"]) == 2\n        assert_resp_response(\n            r,\n            await r.zrange(\"{foo}d\", 0, -1, withscores=True),\n            [(b\"a3\", 8), (b\"a1\", 9)],\n            [[b\"a3\", 8.0], [b\"a1\", 9.0]],\n        )\n\n    async def test_cluster_zinterstore_max(self, r: RedisCluster) -> None:\n        await r.zadd(\"{foo}a\", {\"a1\": 1, \"a2\": 1, \"a3\": 1})\n        await r.zadd(\"{foo}b\", {\"a1\": 2, \"a2\": 2, \"a3\": 2})\n        await r.zadd(\"{foo}c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert (\n            await r.zinterstore(\n                \"{foo}d\", [\"{foo}a\", \"{foo}b\", \"{foo}c\"], aggregate=\"MAX\"\n            )\n            == 2\n        )\n        assert_resp_response(\n            r,\n            await r.zrange(\"{foo}d\", 0, -1, withscores=True),\n            [(b\"a3\", 5), (b\"a1\", 6)],\n            [[b\"a3\", 5.0], [b\"a1\", 6.0]],\n        )\n\n    async def test_cluster_zinterstore_min(self, r: RedisCluster) -> None:\n        await r.zadd(\"{foo}a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        await r.zadd(\"{foo}b\", {\"a1\": 2, \"a2\": 3, \"a3\": 5})\n        await r.zadd(\"{foo}c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert (\n            await r.zinterstore(\n                \"{foo}d\", [\"{foo}a\", \"{foo}b\", \"{foo}c\"], aggregate=\"MIN\"\n            )\n            == 2\n        )\n        assert_resp_response(\n            r,\n            await r.zrange(\"{foo}d\", 0, -1, withscores=True),\n            [(b\"a1\", 1), (b\"a3\", 3)],\n            [[b\"a1\", 1.0], [b\"a3\", 3.0]],\n        )\n\n    async def test_cluster_zinterstore_with_weight(self, r: RedisCluster) -> None:\n        await r.zadd(\"{foo}a\", {\"a1\": 1, \"a2\": 1, \"a3\": 1})\n        await r.zadd(\"{foo}b\", {\"a1\": 2, \"a2\": 2, \"a3\": 2})\n        await r.zadd(\"{foo}c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert (\n            await r.zinterstore(\"{foo}d\", {\"{foo}a\": 1, \"{foo}b\": 2, \"{foo}c\": 3}) == 2\n        )\n        assert_resp_response(\n            r,\n            await r.zrange(\"{foo}d\", 0, -1, withscores=True),\n            [(b\"a3\", 20), (b\"a1\", 23)],\n            [[b\"a3\", 20.0], [b\"a1\", 23.0]],\n        )\n\n    @skip_if_server_version_lt(\"4.9.0\")\n    async def test_cluster_bzpopmax(self, r: RedisCluster) -> None:\n        await r.zadd(\"{foo}a\", {\"a1\": 1, \"a2\": 2})\n        await r.zadd(\"{foo}b\", {\"b1\": 10, \"b2\": 20})\n        assert_resp_response(\n            r,\n            await r.bzpopmax([\"{foo}b\", \"{foo}a\"], timeout=1),\n            (b\"{foo}b\", b\"b2\", 20),\n            [b\"{foo}b\", b\"b2\", 20],\n        )\n        assert_resp_response(\n            r,\n            await r.bzpopmax([\"{foo}b\", \"{foo}a\"], timeout=1),\n            (b\"{foo}b\", b\"b1\", 10),\n            [b\"{foo}b\", b\"b1\", 10],\n        )\n        assert_resp_response(\n            r,\n            await r.bzpopmax([\"{foo}b\", \"{foo}a\"], timeout=1),\n            (b\"{foo}a\", b\"a2\", 2),\n            [b\"{foo}a\", b\"a2\", 2],\n        )\n        assert_resp_response(\n            r,\n            await r.bzpopmax([\"{foo}b\", \"{foo}a\"], timeout=1),\n            (b\"{foo}a\", b\"a1\", 1),\n            [b\"{foo}a\", b\"a1\", 1],\n        )\n        assert await r.bzpopmax([\"{foo}b\", \"{foo}a\"], timeout=1) is None\n        await r.zadd(\"{foo}c\", {\"c1\": 100})\n        assert_resp_response(\n            r,\n            await r.bzpopmax(\"{foo}c\", timeout=1),\n            (b\"{foo}c\", b\"c1\", 100),\n            [b\"{foo}c\", b\"c1\", 100],\n        )\n\n    @skip_if_server_version_lt(\"4.9.0\")\n    async def test_cluster_bzpopmin(self, r: RedisCluster) -> None:\n        await r.zadd(\"{foo}a\", {\"a1\": 1, \"a2\": 2})\n        await r.zadd(\"{foo}b\", {\"b1\": 10, \"b2\": 20})\n        assert_resp_response(\n            r,\n            await r.bzpopmin([\"{foo}b\", \"{foo}a\"], timeout=1),\n            (b\"{foo}b\", b\"b1\", 10),\n            [b\"{foo}b\", b\"b1\", 10],\n        )\n        assert_resp_response(\n            r,\n            await r.bzpopmin([\"{foo}b\", \"{foo}a\"], timeout=1),\n            (b\"{foo}b\", b\"b2\", 20),\n            [b\"{foo}b\", b\"b2\", 20],\n        )\n        assert_resp_response(\n            r,\n            await r.bzpopmin([\"{foo}b\", \"{foo}a\"], timeout=1),\n            (b\"{foo}a\", b\"a1\", 1),\n            [b\"{foo}a\", b\"a1\", 1],\n        )\n        assert_resp_response(\n            r,\n            await r.bzpopmin([\"{foo}b\", \"{foo}a\"], timeout=1),\n            (b\"{foo}a\", b\"a2\", 2),\n            [b\"{foo}a\", b\"a2\", 2],\n        )\n        assert await r.bzpopmin([\"{foo}b\", \"{foo}a\"], timeout=1) is None\n        await r.zadd(\"{foo}c\", {\"c1\": 100})\n        assert_resp_response(\n            r,\n            await r.bzpopmin(\"{foo}c\", timeout=1),\n            (b\"{foo}c\", b\"c1\", 100),\n            [b\"{foo}c\", b\"c1\", 100],\n        )\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    async def test_cluster_zrangestore(self, r: RedisCluster) -> None:\n        await r.zadd(\"{foo}a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        assert await r.zrangestore(\"{foo}b\", \"{foo}a\", 0, 1)\n        assert await r.zrange(\"{foo}b\", 0, -1) == [b\"a1\", b\"a2\"]\n        assert await r.zrangestore(\"{foo}b\", \"{foo}a\", 1, 2)\n        assert await r.zrange(\"{foo}b\", 0, -1) == [b\"a2\", b\"a3\"]\n        assert_resp_response(\n            r,\n            await r.zrange(\"{foo}b\", 0, -1, withscores=True),\n            [(b\"a2\", 2), (b\"a3\", 3)],\n            [[b\"a2\", 2.0], [b\"a3\", 3.0]],\n        )\n        # reversed order\n        assert await r.zrangestore(\"{foo}b\", \"{foo}a\", 1, 2, desc=True)\n        assert await r.zrange(\"{foo}b\", 0, -1) == [b\"a1\", b\"a2\"]\n        # by score\n        assert await r.zrangestore(\n            \"{foo}b\", \"{foo}a\", 2, 1, byscore=True, offset=0, num=1, desc=True\n        )\n        assert await r.zrange(\"{foo}b\", 0, -1) == [b\"a2\"]\n        # by lex\n        assert await r.zrangestore(\n            \"{foo}b\", \"{foo}a\", \"[a2\", \"(a3\", bylex=True, offset=0, num=1\n        )\n        assert await r.zrange(\"{foo}b\", 0, -1) == [b\"a2\"]\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    async def test_cluster_zunion(self, r: RedisCluster) -> None:\n        await r.zadd(\"{foo}a\", {\"a1\": 1, \"a2\": 1, \"a3\": 1})\n        await r.zadd(\"{foo}b\", {\"a1\": 2, \"a2\": 2, \"a3\": 2})\n        await r.zadd(\"{foo}c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        # sum\n        assert await r.zunion([\"{foo}a\", \"{foo}b\", \"{foo}c\"]) == [\n            b\"a2\",\n            b\"a4\",\n            b\"a3\",\n            b\"a1\",\n        ]\n        assert_resp_response(\n            r,\n            await r.zunion([\"{foo}a\", \"{foo}b\", \"{foo}c\"], withscores=True),\n            [(b\"a2\", 3), (b\"a4\", 4), (b\"a3\", 8), (b\"a1\", 9)],\n            [[b\"a2\", 3.0], [b\"a4\", 4.0], [b\"a3\", 8.0], [b\"a1\", 9.0]],\n        )\n        # max\n        assert_resp_response(\n            r,\n            await r.zunion(\n                [\"{foo}a\", \"{foo}b\", \"{foo}c\"], aggregate=\"MAX\", withscores=True\n            ),\n            [(b\"a2\", 2), (b\"a4\", 4), (b\"a3\", 5), (b\"a1\", 6)],\n            [[b\"a2\", 2.0], [b\"a4\", 4.0], [b\"a3\", 5.0], [b\"a1\", 6.0]],\n        )\n        # min\n        assert_resp_response(\n            r,\n            await r.zunion(\n                [\"{foo}a\", \"{foo}b\", \"{foo}c\"], aggregate=\"MIN\", withscores=True\n            ),\n            [(b\"a1\", 1), (b\"a2\", 1), (b\"a3\", 1), (b\"a4\", 4)],\n            [[b\"a1\", 1.0], [b\"a2\", 1.0], [b\"a3\", 1.0], [b\"a4\", 4.0]],\n        )\n        # with weight\n        assert_resp_response(\n            r,\n            await r.zunion({\"{foo}a\": 1, \"{foo}b\": 2, \"{foo}c\": 3}, withscores=True),\n            [(b\"a2\", 5), (b\"a4\", 12), (b\"a3\", 20), (b\"a1\", 23)],\n            [[b\"a2\", 5.0], [b\"a4\", 12.0], [b\"a3\", 20.0], [b\"a1\", 23.0]],\n        )\n\n    async def test_cluster_zunionstore_sum(self, r: RedisCluster) -> None:\n        await r.zadd(\"{foo}a\", {\"a1\": 1, \"a2\": 1, \"a3\": 1})\n        await r.zadd(\"{foo}b\", {\"a1\": 2, \"a2\": 2, \"a3\": 2})\n        await r.zadd(\"{foo}c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert await r.zunionstore(\"{foo}d\", [\"{foo}a\", \"{foo}b\", \"{foo}c\"]) == 4\n        assert_resp_response(\n            r,\n            await r.zrange(\"{foo}d\", 0, -1, withscores=True),\n            [(b\"a2\", 3), (b\"a4\", 4), (b\"a3\", 8), (b\"a1\", 9)],\n            [[b\"a2\", 3.0], [b\"a4\", 4.0], [b\"a3\", 8.0], [b\"a1\", 9.0]],\n        )\n\n    async def test_cluster_zunionstore_max(self, r: RedisCluster) -> None:\n        await r.zadd(\"{foo}a\", {\"a1\": 1, \"a2\": 1, \"a3\": 1})\n        await r.zadd(\"{foo}b\", {\"a1\": 2, \"a2\": 2, \"a3\": 2})\n        await r.zadd(\"{foo}c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert (\n            await r.zunionstore(\n                \"{foo}d\", [\"{foo}a\", \"{foo}b\", \"{foo}c\"], aggregate=\"MAX\"\n            )\n            == 4\n        )\n        assert_resp_response(\n            r,\n            await r.zrange(\"{foo}d\", 0, -1, withscores=True),\n            [(b\"a2\", 2), (b\"a4\", 4), (b\"a3\", 5), (b\"a1\", 6)],\n            [[b\"a2\", 2.0], [b\"a4\", 4.0], [b\"a3\", 5.0], [b\"a1\", 6.0]],\n        )\n\n    async def test_cluster_zunionstore_min(self, r: RedisCluster) -> None:\n        await r.zadd(\"{foo}a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        await r.zadd(\"{foo}b\", {\"a1\": 2, \"a2\": 2, \"a3\": 4})\n        await r.zadd(\"{foo}c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert (\n            await r.zunionstore(\n                \"{foo}d\", [\"{foo}a\", \"{foo}b\", \"{foo}c\"], aggregate=\"MIN\"\n            )\n            == 4\n        )\n        assert_resp_response(\n            r,\n            await r.zrange(\"{foo}d\", 0, -1, withscores=True),\n            [(b\"a1\", 1), (b\"a2\", 2), (b\"a3\", 3), (b\"a4\", 4)],\n            [[b\"a1\", 1.0], [b\"a2\", 2.0], [b\"a3\", 3.0], [b\"a4\", 4.0]],\n        )\n\n    async def test_cluster_zunionstore_with_weight(self, r: RedisCluster) -> None:\n        await r.zadd(\"{foo}a\", {\"a1\": 1, \"a2\": 1, \"a3\": 1})\n        await r.zadd(\"{foo}b\", {\"a1\": 2, \"a2\": 2, \"a3\": 2})\n        await r.zadd(\"{foo}c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert (\n            await r.zunionstore(\"{foo}d\", {\"{foo}a\": 1, \"{foo}b\": 2, \"{foo}c\": 3}) == 4\n        )\n        assert_resp_response(\n            r,\n            await r.zrange(\"{foo}d\", 0, -1, withscores=True),\n            [(b\"a2\", 5), (b\"a4\", 12), (b\"a3\", 20), (b\"a1\", 23)],\n            [[b\"a2\", 5.0], [b\"a4\", 12.0], [b\"a3\", 20.0], [b\"a1\", 23.0]],\n        )\n\n    @skip_if_server_version_lt(\"2.8.9\")\n    async def test_cluster_pfcount(self, r: RedisCluster) -> None:\n        members = {b\"1\", b\"2\", b\"3\"}\n        await r.pfadd(\"{foo}a\", *members)\n        assert await r.pfcount(\"{foo}a\") == len(members)\n        members_b = {b\"2\", b\"3\", b\"4\"}\n        await r.pfadd(\"{foo}b\", *members_b)\n        assert await r.pfcount(\"{foo}b\") == len(members_b)\n        assert await r.pfcount(\"{foo}a\", \"{foo}b\") == len(members_b.union(members))\n\n    @skip_if_server_version_lt(\"2.8.9\")\n    async def test_cluster_pfmerge(self, r: RedisCluster) -> None:\n        mema = {b\"1\", b\"2\", b\"3\"}\n        memb = {b\"2\", b\"3\", b\"4\"}\n        memc = {b\"5\", b\"6\", b\"7\"}\n        await r.pfadd(\"{foo}a\", *mema)\n        await r.pfadd(\"{foo}b\", *memb)\n        await r.pfadd(\"{foo}c\", *memc)\n        await r.pfmerge(\"{foo}d\", \"{foo}c\", \"{foo}a\")\n        assert await r.pfcount(\"{foo}d\") == 6\n        await r.pfmerge(\"{foo}d\", \"{foo}b\")\n        assert await r.pfcount(\"{foo}d\") == 7\n\n    async def test_cluster_sort_store(self, r: RedisCluster) -> None:\n        await r.rpush(\"{foo}a\", \"2\", \"3\", \"1\")\n        assert await r.sort(\"{foo}a\", store=\"{foo}sorted_values\") == 3\n        assert await r.lrange(\"{foo}sorted_values\", 0, -1) == [b\"1\", b\"2\", b\"3\"]\n\n    # GEO COMMANDS\n    @skip_if_server_version_lt(\"6.2.0\")\n    async def test_cluster_geosearchstore(self, r: RedisCluster) -> None:\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n\n        await r.geoadd(\"{foo}barcelona\", values)\n        await r.geosearchstore(\n            \"{foo}places_barcelona\",\n            \"{foo}barcelona\",\n            longitude=2.191,\n            latitude=41.433,\n            radius=1000,\n        )\n        assert await r.zrange(\"{foo}places_barcelona\", 0, -1) == [b\"place1\"]\n\n    @skip_unless_arch_bits(64)\n    @skip_if_server_version_lt(\"6.2.0\")\n    async def test_geosearchstore_dist(self, r: RedisCluster) -> None:\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n\n        await r.geoadd(\"{foo}barcelona\", values)\n        await r.geosearchstore(\n            \"{foo}places_barcelona\",\n            \"{foo}barcelona\",\n            longitude=2.191,\n            latitude=41.433,\n            radius=1000,\n            storedist=True,\n        )\n        # instead of save the geo score, the distance is saved.\n        assert await r.zscore(\"{foo}places_barcelona\", \"place1\") == 88.05060698409301\n\n    @skip_if_server_version_lt(\"3.2.0\")\n    async def test_cluster_georadius_store(self, r: RedisCluster) -> None:\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n\n        await r.geoadd(\"{foo}barcelona\", values)\n        await r.georadius(\n            \"{foo}barcelona\", 2.191, 41.433, 1000, store=\"{foo}places_barcelona\"\n        )\n        assert await r.zrange(\"{foo}places_barcelona\", 0, -1) == [b\"place1\"]\n\n    @skip_unless_arch_bits(64)\n    @skip_if_server_version_lt(\"3.2.0\")\n    async def test_cluster_georadius_store_dist(self, r: RedisCluster) -> None:\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n\n        await r.geoadd(\"{foo}barcelona\", values)\n        await r.georadius(\n            \"{foo}barcelona\", 2.191, 41.433, 1000, store_dist=\"{foo}places_barcelona\"\n        )\n        # instead of save the geo score, the distance is saved.\n        assert await r.zscore(\"{foo}places_barcelona\", \"place1\") == 88.05060698409301\n\n    async def test_cluster_dbsize(self, r: RedisCluster) -> None:\n        d = {\"a\": b\"1\", \"b\": b\"2\", \"c\": b\"3\", \"d\": b\"4\"}\n        assert await r.mset_nonatomic(d)\n        assert await r.dbsize(target_nodes=\"primaries\") == len(d)\n\n    async def test_cluster_keys(self, r: RedisCluster) -> None:\n        assert await r.keys() == []\n        keys_with_underscores = {b\"test_a\", b\"test_b\"}\n        keys = keys_with_underscores.union({b\"testc\"})\n        for key in keys:\n            await r.set(key, 1)\n        assert (\n            set(await r.keys(pattern=\"test_*\", target_nodes=\"primaries\"))\n            == keys_with_underscores\n        )\n        assert set(await r.keys(pattern=\"test*\", target_nodes=\"primaries\")) == keys\n\n    # SCAN COMMANDS\n    @skip_if_server_version_lt(\"2.8.0\")\n    async def test_cluster_scan(self, r: RedisCluster) -> None:\n        await r.set(\"a\", 1)\n        await r.set(\"b\", 2)\n        await r.set(\"c\", 3)\n\n        for target_nodes, nodes in zip(\n            [\"primaries\", \"replicas\"], [r.get_primaries(), r.get_replicas()]\n        ):\n            cursors, keys = await r.scan(target_nodes=target_nodes)\n            assert sorted(keys) == [b\"a\", b\"b\", b\"c\"]\n            assert sorted(cursors.keys()) == sorted(node.name for node in nodes)\n            assert all(cursor == 0 for cursor in cursors.values())\n\n            cursors, keys = await r.scan(match=\"a*\", target_nodes=target_nodes)\n            assert sorted(keys) == [b\"a\"]\n            assert sorted(cursors.keys()) == sorted(node.name for node in nodes)\n            assert all(cursor == 0 for cursor in cursors.values())\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    async def test_cluster_scan_type(self, r: RedisCluster) -> None:\n        await r.sadd(\"a-set\", 1)\n        await r.sadd(\"b-set\", 1)\n        await r.sadd(\"c-set\", 1)\n        await r.hset(\"a-hash\", \"foo\", 2)\n        await r.lpush(\"a-list\", \"aux\", 3)\n\n        for target_nodes, nodes in zip(\n            [\"primaries\", \"replicas\"], [r.get_primaries(), r.get_replicas()]\n        ):\n            cursors, keys = await r.scan(_type=\"SET\", target_nodes=target_nodes)\n            assert sorted(keys) == [b\"a-set\", b\"b-set\", b\"c-set\"]\n            assert sorted(cursors.keys()) == sorted(node.name for node in nodes)\n            assert all(cursor == 0 for cursor in cursors.values())\n\n            cursors, keys = await r.scan(\n                _type=\"SET\", match=\"a*\", target_nodes=target_nodes\n            )\n            assert sorted(keys) == [b\"a-set\"]\n            assert sorted(cursors.keys()) == sorted(node.name for node in nodes)\n            assert all(cursor == 0 for cursor in cursors.values())\n\n    @skip_if_server_version_lt(\"2.8.0\")\n    async def test_cluster_scan_iter(self, r: RedisCluster) -> None:\n        keys_all = []\n        keys_1 = []\n        for i in range(100):\n            s = str(i)\n            await r.set(s, 1)\n            keys_all.append(s.encode(\"utf-8\"))\n            if s.startswith(\"1\"):\n                keys_1.append(s.encode(\"utf-8\"))\n        keys_all.sort()\n        keys_1.sort()\n\n        for target_nodes in [\"primaries\", \"replicas\"]:\n            keys = [key async for key in r.scan_iter(target_nodes=target_nodes)]\n            assert sorted(keys) == keys_all\n\n            keys = [\n                key async for key in r.scan_iter(match=\"1*\", target_nodes=target_nodes)\n            ]\n            assert sorted(keys) == keys_1\n\n    async def test_cluster_randomkey(self, r: RedisCluster) -> None:\n        node = r.get_node_from_key(\"{foo}\")\n        assert await r.randomkey(target_nodes=node) is None\n        for key in (\"{foo}a\", \"{foo}b\", \"{foo}c\"):\n            await r.set(key, 1)\n        assert await r.randomkey(target_nodes=node) in (b\"{foo}a\", b\"{foo}b\", b\"{foo}c\")\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    @skip_if_redis_enterprise()\n    async def test_acl_log(\n        self, r: RedisCluster, create_redis: Callable[..., RedisCluster]\n    ) -> None:\n        key = \"{cache}:\"\n        node = r.get_node_from_key(key)\n        username = \"redis-py-user\"\n\n        await r.acl_setuser(\n            username,\n            enabled=True,\n            reset=True,\n            commands=[\"+get\", \"+set\", \"+select\", \"+cluster\", \"+command\", \"+info\"],\n            keys=[\"{cache}:*\"],\n            nopass=True,\n            target_nodes=\"primaries\",\n        )\n        await r.acl_log_reset(target_nodes=node)\n\n        user_client = await create_redis(\n            cls=RedisCluster, flushdb=False, username=username\n        )\n\n        # Valid operation and key\n        assert await user_client.set(\"{cache}:0\", 1)\n        assert await user_client.get(\"{cache}:0\") == b\"1\"\n\n        # Invalid key\n        with pytest.raises(NoPermissionError):\n            await user_client.get(\"{cache}violated_cache:0\")\n\n        # Invalid operation\n        with pytest.raises(NoPermissionError):\n            await user_client.hset(\"{cache}:0\", \"hkey\", \"hval\")\n\n        assert isinstance(await r.acl_log(target_nodes=node), list)\n        assert len(await r.acl_log(target_nodes=node)) == 3\n        assert len(await r.acl_log(count=1, target_nodes=node)) == 1\n        assert isinstance((await r.acl_log(target_nodes=node))[0], dict)\n        assert \"client-info\" in (await r.acl_log(count=1, target_nodes=node))[0]\n        assert await r.acl_log_reset(target_nodes=node)\n\n        await r.acl_deluser(username, target_nodes=\"primaries\")\n\n        await user_client.aclose()\n\n    @skip_if_server_version_lt(\"8.5.240\")\n    async def test_hotkeys_cluster(self, r: RedisCluster) -> None:\n        \"\"\"Test all HOTKEYS commands in cluster are raising an error\"\"\"\n\n        with pytest.raises(NotImplementedError):\n            await r.hotkeys_start(count=10, metrics=[HotkeysMetricsTypes.CPU])\n        with pytest.raises(NotImplementedError):\n            await r.hotkeys_get()\n        with pytest.raises(NotImplementedError):\n            await r.hotkeys_reset()\n        with pytest.raises(NotImplementedError):\n            await r.hotkeys_stop()\n\n\nclass TestNodesManager:\n    \"\"\"\n    Tests for the NodesManager class\n    \"\"\"\n\n    async def test_load_balancer(self, r: RedisCluster) -> None:\n        n_manager = r.nodes_manager\n        lb = n_manager.read_load_balancer\n        slot_1 = 1257\n        slot_2 = 8975\n        node_1 = ClusterNode(default_host, 6379, PRIMARY)\n        node_2 = ClusterNode(default_host, 6378, REPLICA)\n        node_3 = ClusterNode(default_host, 6377, REPLICA)\n        node_4 = ClusterNode(default_host, 6376, PRIMARY)\n        node_5 = ClusterNode(default_host, 6375, REPLICA)\n        n_manager.slots_cache = {\n            slot_1: [node_1, node_2, node_3],\n            slot_2: [node_4, node_5],\n        }\n        primary1_name = n_manager.slots_cache[slot_1][0].name\n        primary2_name = n_manager.slots_cache[slot_2][0].name\n        list1_size = len(n_manager.slots_cache[slot_1])\n        list2_size = len(n_manager.slots_cache[slot_2])\n\n        # default load balancer strategy: LoadBalancerStrategy.ROUND_ROBIN\n        # slot 1\n        assert lb.get_server_index(primary1_name, list1_size) == 0\n        assert lb.get_server_index(primary1_name, list1_size) == 1\n        assert lb.get_server_index(primary1_name, list1_size) == 2\n        assert lb.get_server_index(primary1_name, list1_size) == 0\n\n        # slot 2\n        assert lb.get_server_index(primary2_name, list2_size) == 0\n        assert lb.get_server_index(primary2_name, list2_size) == 1\n        assert lb.get_server_index(primary2_name, list2_size) == 0\n\n        lb.reset()\n        assert lb.get_server_index(primary1_name, list1_size) == 0\n        assert lb.get_server_index(primary2_name, list2_size) == 0\n\n        # reset the indexes before load balancing strategy test\n        lb.reset()\n        # load balancer strategy: LoadBalancerStrategy.ROUND_ROBIN_REPLICAS\n        for i in [1, 2, 1]:\n            srv_index = lb.get_server_index(\n                primary1_name,\n                list1_size,\n                load_balancing_strategy=LoadBalancingStrategy.ROUND_ROBIN_REPLICAS,\n            )\n            assert srv_index == i\n\n        # reset the indexes before load balancing strategy test\n        lb.reset()\n        # load balancer strategy: LoadBalancerStrategy.RANDOM_REPLICA\n        for i in range(5):\n            srv_index = lb.get_server_index(\n                primary1_name,\n                list1_size,\n                load_balancing_strategy=LoadBalancingStrategy.RANDOM_REPLICA,\n            )\n\n            assert srv_index > 0 and srv_index <= 2\n\n    async def test_init_slots_cache_not_all_slots_covered(self) -> None:\n        \"\"\"\n        Test that if not all slots are covered it should raise an exception\n        \"\"\"\n        # Missing slot 5460\n        cluster_slots = [\n            [0, 5459, [\"127.0.0.1\", 7000], [\"127.0.0.1\", 7003]],\n            [5461, 10922, [\"127.0.0.1\", 7001], [\"127.0.0.1\", 7004]],\n            [10923, 16383, [\"127.0.0.1\", 7002], [\"127.0.0.1\", 7005]],\n        ]\n        with pytest.raises(RedisClusterException) as ex:\n            rc = await get_mocked_redis_client(\n                host=default_host,\n                port=default_port,\n                cluster_slots=cluster_slots,\n                require_full_coverage=True,\n            )\n            await rc.aclose()\n        assert str(ex.value).startswith(\n            \"All slots are not covered after query all startup_nodes.\"\n        )\n\n    async def test_init_slots_cache_not_require_full_coverage_success(self) -> None:\n        \"\"\"\n        When require_full_coverage is set to False and not all slots are\n        covered the cluster client initialization should succeed\n        \"\"\"\n        # Missing slot 5460\n        cluster_slots = [\n            [0, 5459, [\"127.0.0.1\", 7000], [\"127.0.0.1\", 7003]],\n            [5461, 10922, [\"127.0.0.1\", 7001], [\"127.0.0.1\", 7004]],\n            [10923, 16383, [\"127.0.0.1\", 7002], [\"127.0.0.1\", 7005]],\n        ]\n\n        rc = await get_mocked_redis_client(\n            host=default_host,\n            port=default_port,\n            cluster_slots=cluster_slots,\n            require_full_coverage=False,\n        )\n\n        assert 5460 not in rc.nodes_manager.slots_cache\n\n        await rc.aclose()\n\n    async def test_init_slots_cache(self) -> None:\n        \"\"\"\n        Test that slots cache can in initialized and all slots are covered\n        \"\"\"\n        good_slots_resp = [\n            [0, 5460, [\"127.0.0.1\", 7000], [\"127.0.0.2\", 7003]],\n            [5461, 10922, [\"127.0.0.1\", 7001], [\"127.0.0.2\", 7004]],\n            [10923, 16383, [\"127.0.0.1\", 7002], [\"127.0.0.2\", 7005]],\n        ]\n\n        rc = await get_mocked_redis_client(\n            host=default_host, port=default_port, cluster_slots=good_slots_resp\n        )\n        n_manager = rc.nodes_manager\n        assert len(n_manager.slots_cache) == REDIS_CLUSTER_HASH_SLOTS\n        for slot_info in good_slots_resp:\n            all_hosts = [\"127.0.0.1\", \"127.0.0.2\"]\n            all_ports = [7000, 7001, 7002, 7003, 7004, 7005]\n            slot_start = slot_info[0]\n            slot_end = slot_info[1]\n            for i in range(slot_start, slot_end + 1):\n                assert len(n_manager.slots_cache[i]) == len(slot_info[2:])\n                assert n_manager.slots_cache[i][0].host in all_hosts\n                assert n_manager.slots_cache[i][1].host in all_hosts\n                assert n_manager.slots_cache[i][0].port in all_ports\n                assert n_manager.slots_cache[i][1].port in all_ports\n\n        assert len(n_manager.nodes_cache) == 6\n\n        await rc.aclose()\n\n    async def test_init_slots_cache_cluster_mode_disabled(self) -> None:\n        \"\"\"\n        Test that creating a RedisCluster failes if one of the startup nodes\n        has cluster mode disabled\n        \"\"\"\n        with pytest.raises(RedisClusterException) as e:\n            rc = await get_mocked_redis_client(\n                cluster_slots_raise_error=True,\n                host=default_host,\n                port=default_port,\n                cluster_enabled=False,\n            )\n            await rc.aclose()\n        assert \"Cluster mode is not enabled on this node\" in str(e.value)\n\n    async def test_empty_startup_nodes(self) -> None:\n        \"\"\"\n        It should not be possible to create a node manager with no nodes\n        specified\n        \"\"\"\n        with pytest.raises(RedisClusterException):\n            await NodesManager([], False, {}).initialize()\n\n    async def test_wrong_startup_nodes_type(self) -> None:\n        \"\"\"\n        If something other then a list type itteratable is provided it should\n        fail\n        \"\"\"\n        with pytest.raises(RedisClusterException):\n            await NodesManager({}, False, {}).initialize()\n\n    async def test_init_slots_cache_slots_collision(self) -> None:\n        \"\"\"\n        Test that if 2 nodes do not agree on the same slots setup it should\n        raise an error. In this test both nodes will say that the first\n        slots block should be bound to different servers.\n        \"\"\"\n        with mock.patch.object(\n            ClusterNode, \"execute_command\", autospec=True\n        ) as execute_command:\n\n            async def mocked_execute_command(self, *args, **kwargs):\n                \"\"\"\n                Helper function to return custom slots cache data from\n                different redis nodes\n                \"\"\"\n                if self.port == 7000:\n                    result = [\n                        [0, 5460, [\"127.0.0.1\", 7000], [\"127.0.0.1\", 7003]],\n                        [5461, 10922, [\"127.0.0.1\", 7001], [\"127.0.0.1\", 7004]],\n                    ]\n\n                elif self.port == 7001:\n                    result = [\n                        [0, 5460, [\"127.0.0.1\", 7001], [\"127.0.0.1\", 7003]],\n                        [5461, 10922, [\"127.0.0.1\", 7000], [\"127.0.0.1\", 7004]],\n                    ]\n                else:\n                    result = []\n\n                if args[0] == \"CLUSTER SLOTS\":\n                    return result\n                elif args[0] == \"INFO\":\n                    return {\"cluster_enabled\": True}\n                elif args[1] == \"cluster-require-full-coverage\":\n                    return {\"cluster-require-full-coverage\": \"yes\"}\n\n            execute_command.side_effect = mocked_execute_command\n\n            with pytest.raises(RedisClusterException) as ex:\n                node_1 = ClusterNode(\"127.0.0.1\", 7000)\n                node_2 = ClusterNode(\"127.0.0.1\", 7001)\n                async with RedisCluster(startup_nodes=[node_1, node_2]):\n                    ...\n            assert str(ex.value).startswith(\n                \"startup_nodes could not agree on a valid slots cache\"\n            ), str(ex.value)\n\n    async def test_cluster_one_instance(self) -> None:\n        \"\"\"\n        If the cluster exists of only 1 node then there is some hacks that must\n        be validated they work.\n        \"\"\"\n        node = ClusterNode(default_host, default_port)\n        cluster_slots = [[0, 16383, [\"\", default_port]]]\n        rc = await get_mocked_redis_client(\n            startup_nodes=[node], cluster_slots=cluster_slots\n        )\n\n        n = rc.nodes_manager\n        assert len(n.nodes_cache) == 1\n        n_node = rc.get_node(node_name=node.name)\n        assert n_node is not None\n        assert n_node == node\n        assert n_node.server_type == PRIMARY\n        assert len(n.slots_cache) == REDIS_CLUSTER_HASH_SLOTS\n        for i in range(0, REDIS_CLUSTER_HASH_SLOTS):\n            assert n.slots_cache[i] == [n_node]\n\n        await rc.aclose()\n\n    async def test_init_with_down_node(self) -> None:\n        \"\"\"\n        If I can't connect to one of the nodes, everything should still work.\n        But if I can't connect to any of the nodes, exception should be thrown.\n        \"\"\"\n        with mock.patch.object(\n            ClusterNode, \"execute_command\", autospec=True\n        ) as execute_command:\n\n            async def mocked_execute_command(self, *args, **kwargs):\n                if self.port == 7000:\n                    raise ConnectionError(\"mock connection error for 7000\")\n\n                if args[0] == \"CLUSTER SLOTS\":\n                    return [\n                        [0, 8191, [\"127.0.0.1\", 7001, \"node_1\"]],\n                        [8192, 16383, [\"127.0.0.1\", 7002, \"node_2\"]],\n                    ]\n                elif args[0] == \"INFO\":\n                    return {\"cluster_enabled\": True}\n                elif args[1] == \"cluster-require-full-coverage\":\n                    return {\"cluster-require-full-coverage\": \"yes\"}\n\n            execute_command.side_effect = mocked_execute_command\n\n            node_1 = ClusterNode(\"127.0.0.1\", 7000)\n            node_2 = ClusterNode(\"127.0.0.1\", 7001)\n\n            # If all startup nodes fail to connect, connection error should be\n            # thrown\n            with pytest.raises(RedisClusterException) as e:\n                async with RedisCluster(startup_nodes=[node_1]):\n                    ...\n            assert \"Redis Cluster cannot be connected\" in str(e.value)\n\n            with mock.patch.object(\n                AsyncCommandsParser, \"initialize\", autospec=True\n            ) as cmd_parser_initialize:\n\n                def cmd_init_mock(self, r: ClusterNode) -> None:\n                    self.commands = {\n                        \"GET\": {\n                            \"name\": \"get\",\n                            \"arity\": 2,\n                            \"flags\": [\"readonly\", \"fast\"],\n                            \"first_key_pos\": 1,\n                            \"last_key_pos\": 1,\n                            \"step_count\": 1,\n                        }\n                    }\n\n                cmd_parser_initialize.side_effect = cmd_init_mock\n                # When at least one startup node is reachable, the cluster\n                # initialization should succeeds\n                async with RedisCluster(startup_nodes=[node_1, node_2]) as rc:\n                    assert rc.get_node(host=default_host, port=7001) is not None\n                    assert rc.get_node(host=default_host, port=7002) is not None\n\n    @pytest.mark.parametrize(\"dynamic_startup_nodes\", [True, False])\n    async def test_init_slots_dynamic_startup_nodes(self, dynamic_startup_nodes):\n        rc = await get_mocked_redis_client(\n            host=\"my@DNS.com\",\n            port=7000,\n            cluster_slots=default_cluster_slots,\n            dynamic_startup_nodes=dynamic_startup_nodes,\n        )\n        # Nodes are taken from default_cluster_slots\n        discovered_nodes = [\n            \"127.0.0.1:7000\",\n            \"127.0.0.1:7001\",\n            \"127.0.0.1:7002\",\n            \"127.0.0.1:7003\",\n        ]\n        startup_nodes = list(rc.nodes_manager.startup_nodes.keys())\n        if dynamic_startup_nodes is True:\n            assert sorted(startup_nodes) == sorted(discovered_nodes)\n        else:\n            assert startup_nodes == [\"my@DNS.com:7000\"]\n\n    async def test_move_node_to_end_of_cached_nodes(self) -> None:\n        \"\"\"\n        Test that move_node_to_end_of_cached_nodes moves a node to the end of\n        startup_nodes and nodes_cache.\n        \"\"\"\n        node1 = ClusterNode(default_host, 7000)\n        node2 = ClusterNode(default_host, 7001)\n        node3 = ClusterNode(default_host, 7002)\n\n        nodes_manager = NodesManager(\n            startup_nodes=[node1, node2, node3],\n            require_full_coverage=False,\n            connection_kwargs={},\n        )\n        # Also populate nodes_cache with the same nodes\n        nodes_manager.nodes_cache = {\n            node1.name: node1,\n            node2.name: node2,\n            node3.name: node3,\n        }\n\n        # Verify initial order\n        startup_node_names = list(nodes_manager.startup_nodes.keys())\n        nodes_cache_names = list(nodes_manager.nodes_cache.keys())\n        assert startup_node_names == [node1.name, node2.name, node3.name]\n        assert nodes_cache_names == [node1.name, node2.name, node3.name]\n\n        # Move first node to end\n        nodes_manager.move_node_to_end_of_cached_nodes(node1.name)\n        startup_node_names = list(nodes_manager.startup_nodes.keys())\n        nodes_cache_names = list(nodes_manager.nodes_cache.keys())\n        assert startup_node_names == [node2.name, node3.name, node1.name]\n        assert nodes_cache_names == [node2.name, node3.name, node1.name]\n\n        # Move middle node to end\n        nodes_manager.move_node_to_end_of_cached_nodes(node3.name)\n        startup_node_names = list(nodes_manager.startup_nodes.keys())\n        nodes_cache_names = list(nodes_manager.nodes_cache.keys())\n        assert startup_node_names == [node2.name, node1.name, node3.name]\n        assert nodes_cache_names == [node2.name, node1.name, node3.name]\n\n        # Moving last node should keep it at the end\n        nodes_manager.move_node_to_end_of_cached_nodes(node3.name)\n        startup_node_names = list(nodes_manager.startup_nodes.keys())\n        nodes_cache_names = list(nodes_manager.nodes_cache.keys())\n        assert startup_node_names == [node2.name, node1.name, node3.name]\n        assert nodes_cache_names == [node2.name, node1.name, node3.name]\n\n    async def test_move_node_to_end_of_cached_nodes_nonexistent(self) -> None:\n        \"\"\"\n        Test that move_node_to_end_of_cached_nodes does nothing for a\n        nonexistent node.\n        \"\"\"\n        node1 = ClusterNode(default_host, 7000)\n        node2 = ClusterNode(default_host, 7001)\n\n        nodes_manager = NodesManager(\n            startup_nodes=[node1, node2],\n            require_full_coverage=False,\n            connection_kwargs={},\n        )\n        # Also populate nodes_cache\n        nodes_manager.nodes_cache = {node1.name: node1, node2.name: node2}\n\n        # Try to move a non-existent node - should not raise\n        nodes_manager.move_node_to_end_of_cached_nodes(\"nonexistent:9999\")\n        startup_node_names = list(nodes_manager.startup_nodes.keys())\n        nodes_cache_names = list(nodes_manager.nodes_cache.keys())\n        assert startup_node_names == [node1.name, node2.name]\n        assert nodes_cache_names == [node1.name, node2.name]\n\n    async def test_move_node_to_end_of_cached_nodes_single_node(self) -> None:\n        \"\"\"\n        Test that move_node_to_end_of_cached_nodes does nothing when there's\n        only one node.\n        \"\"\"\n        node1 = ClusterNode(default_host, 7000)\n\n        nodes_manager = NodesManager(\n            startup_nodes=[node1],\n            require_full_coverage=False,\n            connection_kwargs={},\n        )\n        # Also populate nodes_cache\n        nodes_manager.nodes_cache = {node1.name: node1}\n\n        # Should not raise or change anything with single node\n        nodes_manager.move_node_to_end_of_cached_nodes(node1.name)\n        startup_node_names = list(nodes_manager.startup_nodes.keys())\n        nodes_cache_names = list(nodes_manager.nodes_cache.keys())\n        assert startup_node_names == [node1.name]\n        assert nodes_cache_names == [node1.name]\n\n\nclass TestClusterNodeConnectionHandling:\n    \"\"\"Tests for ClusterNode connection handling methods.\"\"\"\n\n    async def test_update_active_connections_for_reconnect(self) -> None:\n        \"\"\"\n        Test that update_active_connections_for_reconnect marks in-use connections.\n        \"\"\"\n        node = ClusterNode(default_host, 7000)\n\n        # Create mock connections\n        conn1 = mock.AsyncMock(spec=Connection)\n        conn2 = mock.AsyncMock(spec=Connection)\n        conn3 = mock.AsyncMock(spec=Connection)\n\n        # Add all connections to _connections\n        node._connections = [conn1, conn2, conn3]\n        # Only conn1 is free, conn2 and conn3 are \"in-use\"\n        node._free.append(conn1)\n\n        # Mark active connections for reconnect\n        node.update_active_connections_for_reconnect()\n\n        # conn1 is free, should NOT be marked\n        conn1.mark_for_reconnect.assert_not_called()\n        # conn2 and conn3 are in-use, should be marked\n        conn2.mark_for_reconnect.assert_called_once()\n        conn3.mark_for_reconnect.assert_called_once()\n\n    async def test_disconnect_free_connections(self) -> None:\n        \"\"\"\n        Test that disconnect_free_connections disconnects all free connections.\n        \"\"\"\n        node = ClusterNode(default_host, 7000)\n\n        # Create mock connections\n        conn1 = mock.AsyncMock(spec=Connection)\n        conn2 = mock.AsyncMock(spec=Connection)\n        conn3 = mock.AsyncMock(spec=Connection)\n\n        # Add all connections to _connections\n        node._connections = [conn1, conn2, conn3]\n        # conn1 and conn2 are free, conn3 is \"in-use\"\n        node._free.append(conn1)\n        node._free.append(conn2)\n\n        # Disconnect free connections\n        await node.disconnect_free_connections()\n\n        # conn1 and conn2 should be disconnected\n        conn1.disconnect.assert_called_once()\n        conn2.disconnect.assert_called_once()\n        # conn3 is in-use, should NOT be disconnected\n        conn3.disconnect.assert_not_called()\n\n        # Connections should still be in _free (not removed)\n        assert conn1 in node._free\n        assert conn2 in node._free\n\n    async def test_disconnect_free_connections_empty(self) -> None:\n        \"\"\"\n        Test that disconnect_free_connections handles empty _free gracefully.\n        \"\"\"\n        node = ClusterNode(default_host, 7000)\n\n        # No free connections\n        assert len(node._free) == 0\n\n        # Should not raise\n        await node.disconnect_free_connections()\n\n    async def test_release_with_reconnect_flag(self) -> None:\n        \"\"\"\n        Test that release() adds connection to _free even if marked for reconnect.\n        Disconnect happens lazily via disconnect_if_needed() when next acquired.\n        \"\"\"\n        node = ClusterNode(default_host, 7000)\n\n        # Create a mock connection marked for reconnect\n        conn = mock.AsyncMock(spec=Connection)\n        conn.should_reconnect.return_value = True\n\n        node._connections = [conn]\n\n        # Release the connection - sync, just adds to _free\n        node.release(conn)\n\n        # Connection should be in _free, disconnect happens lazily on acquire\n        assert conn in node._free\n        conn.disconnect.assert_not_called()\n\n    async def test_release_without_reconnect_flag(self) -> None:\n        \"\"\"\n        Test that release() adds connection to _free without disconnect.\n        \"\"\"\n        node = ClusterNode(default_host, 7000)\n\n        # Create a mock connection NOT marked for reconnect\n        conn = mock.AsyncMock(spec=Connection)\n        conn.should_reconnect.return_value = False\n\n        node._connections = [conn]\n\n        # Release the connection\n        node.release(conn)\n\n        # Connection should NOT be disconnected but added to _free\n        conn.disconnect.assert_not_called()\n        assert conn in node._free\n\n    async def test_disconnect_if_needed_disconnects_when_reconnect_needed(\n        self,\n    ) -> None:\n        \"\"\"\n        Test that disconnect_if_needed() disconnects a connection marked for reconnect.\n        This implements lazy disconnect to avoid race conditions.\n        \"\"\"\n        node = ClusterNode(default_host, 7000)\n\n        # Create a mock connection marked for reconnect\n        conn = mock.AsyncMock(spec=Connection)\n        conn.should_reconnect.return_value = True\n\n        # disconnect_if_needed should disconnect the connection\n        await node.disconnect_if_needed(conn)\n\n        conn.disconnect.assert_called_once()\n\n    async def test_disconnect_if_needed_skips_when_no_reconnect_needed(self) -> None:\n        \"\"\"\n        Test that disconnect_if_needed() does not disconnect if no reconnect needed.\n        \"\"\"\n        node = ClusterNode(default_host, 7000)\n\n        # Create a mock connection NOT marked for reconnect\n        conn = mock.AsyncMock(spec=Connection)\n        conn.should_reconnect.return_value = False\n\n        # disconnect_if_needed should not disconnect\n        await node.disconnect_if_needed(conn)\n\n        conn.disconnect.assert_not_called()\n\n\nclass TestClusterConnectionErrorHandling:\n    \"\"\"Tests for cluster connection error handling behavior.\"\"\"\n\n    async def test_connection_error_calls_move_node_to_end_of_cached_nodes(\n        self,\n    ) -> None:\n        \"\"\"\n        Test that ConnectionError triggers move_node_to_end_of_cached_nodes\n        instead of pop.\n        \"\"\"\n        with mock.patch.object(\n            NodesManager, \"move_node_to_end_of_cached_nodes\", autospec=True\n        ) as move_node_to_end_of_cached_nodes:\n            with mock.patch.object(ClusterNode, \"execute_command\") as execute_command:\n\n                async def execute_command_mock(*args, **kwargs):\n                    if args[0] == \"CLUSTER SLOTS\":\n                        return default_cluster_slots\n                    elif args[0] == \"COMMAND\":\n                        return {\"get\": [], \"set\": []}\n                    elif args[0] == \"INFO\":\n                        return {\"cluster_enabled\": True}\n                    elif len(args) > 1 and args[1] == \"cluster-require-full-coverage\":\n                        return {\"cluster-require-full-coverage\": \"yes\"}\n                    elif args[0] == \"GET\":\n                        raise ConnectionError(\"Connection failed\")\n                    return None\n\n                execute_command.side_effect = execute_command_mock\n\n                with mock.patch.object(\n                    AsyncCommandsParser, \"initialize\", autospec=True\n                ) as cmd_parser_initialize:\n\n                    def cmd_init_mock(self, node: Optional[ClusterNode] = None) -> None:\n                        self.commands = {\n                            \"get\": {\n                                \"name\": \"get\",\n                                \"arity\": 2,\n                                \"flags\": [\"readonly\", \"fast\"],\n                                \"first_key_pos\": 1,\n                                \"last_key_pos\": 1,\n                                \"step_count\": 1,\n                            }\n                        }\n\n                    cmd_parser_initialize.side_effect = cmd_init_mock\n\n                    rc = await RedisCluster(host=default_host, port=7000)\n                    with pytest.raises(ConnectionError):\n                        await rc.get(\"foo\")\n\n                    # Verify move_node_to_end_of_cached_nodes was called\n                    move_node_to_end_of_cached_nodes.assert_called()\n\n    async def test_connection_error_handles_node_connections(self) -> None:\n        \"\"\"\n        Test that ConnectionError triggers proper connection handling on the node.\n        \"\"\"\n        with mock.patch.object(\n            ClusterNode,\n            \"update_active_connections_for_reconnect\",\n            autospec=True,\n        ) as update_active:\n            with mock.patch.object(\n                ClusterNode, \"disconnect_free_connections\", autospec=True\n            ) as disconnect_free:\n                with mock.patch.object(\n                    ClusterNode, \"execute_command\"\n                ) as execute_command:\n\n                    async def execute_command_mock(*args, **kwargs):\n                        if args[0] == \"CLUSTER SLOTS\":\n                            return default_cluster_slots\n                        elif args[0] == \"COMMAND\":\n                            return {\"get\": [], \"set\": []}\n                        elif args[0] == \"INFO\":\n                            return {\"cluster_enabled\": True}\n                        elif (\n                            len(args) > 1 and args[1] == \"cluster-require-full-coverage\"\n                        ):\n                            return {\"cluster-require-full-coverage\": \"yes\"}\n                        elif args[0] == \"GET\":\n                            raise ConnectionError(\"Connection failed\")\n                        return None\n\n                    execute_command.side_effect = execute_command_mock\n\n                    with mock.patch.object(\n                        AsyncCommandsParser, \"initialize\", autospec=True\n                    ) as cmd_parser_initialize:\n\n                        def cmd_init_mock(\n                            self, node: Optional[ClusterNode] = None\n                        ) -> None:\n                            self.commands = {\n                                \"get\": {\n                                    \"name\": \"get\",\n                                    \"arity\": 2,\n                                    \"flags\": [\"readonly\", \"fast\"],\n                                    \"first_key_pos\": 1,\n                                    \"last_key_pos\": 1,\n                                    \"step_count\": 1,\n                                }\n                            }\n\n                        cmd_parser_initialize.side_effect = cmd_init_mock\n\n                        rc = await RedisCluster(host=default_host, port=7000)\n                        with pytest.raises(ConnectionError):\n                            await rc.get(\"foo\")\n\n                        # Verify connection handling methods were called\n                        update_active.assert_called()\n                        disconnect_free.assert_called()\n\n\nclass TestClusterPipeline:\n    \"\"\"Tests for the ClusterPipeline class.\"\"\"\n\n    async def test_pipeline_nodes_manager_property(self) -> None:\n        \"\"\"\n        Test that ClusterPipeline exposes nodes_manager property\n        that delegates to the cluster client's nodes_manager.\n        \"\"\"\n        r = await get_mocked_redis_client(host=default_host, port=default_port)\n        try:\n            pipeline = r.pipeline()\n            # Verify that nodes_manager property exists and returns the same object\n            # as the cluster client's nodes_manager\n            assert pipeline.nodes_manager is r.nodes_manager\n            # Verify that we can access nodes_manager attributes\n            assert pipeline.nodes_manager.default_node is not None\n        finally:\n            await r.aclose()\n\n    async def test_pipeline_set_response_callback(self) -> None:\n        \"\"\"\n        Test that ClusterPipeline exposes set_response_callback method\n        that delegates to the cluster client's set_response_callback.\n        \"\"\"\n        r = await get_mocked_redis_client(host=default_host, port=default_port)\n        try:\n            pipeline = r.pipeline()\n\n            # Define a custom callback\n            def custom_callback(response):\n                return f\"custom_{response}\"\n\n            # Set the callback via the pipeline\n            pipeline.set_response_callback(\"CUSTOM_CMD\", custom_callback)\n\n            # Verify that the callback was set on the cluster client\n            assert \"CUSTOM_CMD\" in r.response_callbacks\n            assert r.response_callbacks[\"CUSTOM_CMD\"] is custom_callback\n        finally:\n            await r.aclose()\n\n    async def test_blocked_arguments(self, r: RedisCluster) -> None:\n        \"\"\"Test handling for blocked pipeline arguments.\"\"\"\n\n        with pytest.raises(RedisClusterException) as ex:\n            r.pipeline(shard_hint=True)\n\n        assert str(ex.value) == \"shard_hint is deprecated in cluster mode\"\n\n    async def test_blocked_methods(self, r: RedisCluster) -> None:\n        \"\"\"Test handling for blocked pipeline commands.\"\"\"\n        pipeline = r.pipeline()\n        for command in PIPELINE_BLOCKED_COMMANDS:\n            command = command.replace(\" \", \"_\").lower()\n            if command == \"mset_nonatomic\":\n                continue\n\n            with pytest.raises(RedisClusterException) as exc:\n                getattr(pipeline, command)()\n\n            assert str(exc.value) == (\n                f\"ERROR: Calling pipelined function {command} is blocked \"\n                \"when running redis in cluster mode...\"\n            )\n\n    async def test_empty_stack(self, r: RedisCluster) -> None:\n        \"\"\"If a pipeline is executed with no commands it should return a empty list.\"\"\"\n        p = r.pipeline()\n        result = await p.execute()\n        assert result == []\n\n    async def test_redis_cluster_pipeline(self, r: RedisCluster) -> None:\n        \"\"\"Test that we can use a pipeline with the RedisCluster class\"\"\"\n        result = await (\n            r.pipeline()\n            .set(\"A\", 1)\n            .get(\"A\")\n            .hset(\"K\", \"F\", \"V\")\n            .hgetall(\"K\")\n            .mset_nonatomic({\"A\": 2, \"B\": 3})\n            .get(\"A\")\n            .get(\"B\")\n            .delete(\"A\", \"B\", \"K\")\n            .execute()\n        )\n        assert result == [True, b\"1\", 1, {b\"F\": b\"V\"}, True, True, b\"2\", b\"3\", 1, 1, 1]\n\n    async def test_cluster_pipeline_execution_zero_cluster_err_retries(\n        self, r: RedisCluster\n    ) -> None:\n        \"\"\"\n        Test that we can run successfully cluster pipeline execute at least once when\n        cluster_error_retry_attempts is set to 0\n        \"\"\"\n        r.cluster_error_retry_attempts = 0\n        result = await r.pipeline().set(\"A\", 1).get(\"A\").delete(\"A\").execute()\n        assert result == [True, b\"1\", 1]\n\n    async def test_multi_key_operation_with_a_single_slot(\n        self, r: RedisCluster\n    ) -> None:\n        \"\"\"Test multi key operation with a single slot.\"\"\"\n        pipe = r.pipeline()\n        pipe.set(\"a{foo}\", 1)\n        pipe.set(\"b{foo}\", 2)\n        pipe.set(\"c{foo}\", 3)\n        pipe.get(\"a{foo}\")\n        pipe.get(\"b{foo}\")\n        pipe.get(\"c{foo}\")\n\n        res = await pipe.execute()\n        assert res == [True, True, True, b\"1\", b\"2\", b\"3\"]\n\n    async def test_multi_key_operation_with_multi_slots(self, r: RedisCluster) -> None:\n        \"\"\"Test multi key operation with more than one slot.\"\"\"\n        pipe = r.pipeline()\n        pipe.set(\"a{foo}\", 1)\n        pipe.set(\"b{foo}\", 2)\n        pipe.set(\"c{foo}\", 3)\n        pipe.set(\"bar\", 4)\n        pipe.set(\"bazz\", 5)\n        pipe.get(\"a{foo}\")\n        pipe.get(\"b{foo}\")\n        pipe.get(\"c{foo}\")\n        pipe.get(\"bar\")\n        pipe.get(\"bazz\")\n        res = await pipe.execute()\n        assert res == [True, True, True, True, True, b\"1\", b\"2\", b\"3\", b\"4\", b\"5\"]\n\n    async def test_cluster_down_error(self, r: RedisCluster) -> None:\n        \"\"\"\n        Test that the pipeline retries the specified in retry object times before raising\n        an error.\n        \"\"\"\n        key = \"foo\"\n        node = r.get_node_from_key(key, False)\n\n        parse_response_orig = node.parse_response\n        with mock.patch.object(\n            ClusterNode, \"parse_response\", autospec=True\n        ) as parse_response_mock:\n\n            async def parse_response(\n                self, connection: Connection, command: str, **kwargs: Any\n            ) -> Any:\n                if command == \"GET\":\n                    raise ClusterDownError(\"error\")\n                return await parse_response_orig(connection, command, **kwargs)\n\n            parse_response_mock.side_effect = parse_response\n\n            # For each ClusterDownError, we launch 4 commands: INFO, CLUSTER SLOTS,\n            # COMMAND, GET. Before any errors, the first 3 commands are already run\n            async with r.pipeline() as pipe:\n                with pytest.raises(ClusterDownError):\n                    await pipe.get(key).execute()\n                assert node.parse_response.await_count == 3 * r.retry.get_retries() + 1\n\n    async def test_connection_error_not_raised(self, r: RedisCluster) -> None:\n        \"\"\"Test ConnectionError handling with raise_on_error=False.\"\"\"\n        key = \"foo\"\n        node = r.get_node_from_key(key, False)\n\n        parse_response_orig = node.parse_response\n        with mock.patch.object(\n            ClusterNode, \"parse_response\", autospec=True\n        ) as parse_response_mock:\n\n            async def parse_response(\n                self, connection: Connection, command: str, **kwargs: Any\n            ) -> Any:\n                if command == \"GET\":\n                    raise ConnectionError(\"error\")\n                return await parse_response_orig(connection, command, **kwargs)\n\n            parse_response_mock.side_effect = parse_response\n\n            async with r.pipeline() as pipe:\n                res = await pipe.get(key).get(key).execute(raise_on_error=False)\n                assert node.parse_response.await_count\n                assert isinstance(res[0], ConnectionError)\n\n    async def test_connection_error_raised(self, r: RedisCluster) -> None:\n        \"\"\"Test ConnectionError handling with raise_on_error=True.\"\"\"\n        key = \"foo\"\n        node = r.get_node_from_key(key, False)\n\n        parse_response_orig = node.parse_response\n        with mock.patch.object(\n            ClusterNode, \"parse_response\", autospec=True\n        ) as parse_response_mock:\n\n            async def parse_response(\n                self, connection: Connection, command: str, **kwargs: Any\n            ) -> Any:\n                if command == \"GET\":\n                    raise ConnectionError(\"error\")\n                return await parse_response_orig(connection, command, **kwargs)\n\n            parse_response_mock.side_effect = parse_response\n\n            async with r.pipeline() as pipe:\n                with pytest.raises(ConnectionError):\n                    await pipe.get(key).get(key).execute(raise_on_error=True)\n\n    async def test_asking_error(self, r: RedisCluster) -> None:\n        \"\"\"Test AskError handling.\"\"\"\n        key = \"foo\"\n        first_node = r.get_node_from_key(key, False)\n        ask_node = None\n        for node in r.get_nodes():\n            if node != first_node:\n                ask_node = node\n                break\n        ask_msg = f\"{r.keyslot(key)} {ask_node.host}:{ask_node.port}\"\n\n        async with r.pipeline() as pipe:\n            mock_node_resp_exc(first_node, AskError(ask_msg))\n            mock_node_resp(ask_node, \"MOCK_OK\")\n            res = await pipe.get(key).execute()\n            assert first_node._free.pop().read_response.await_count\n            assert ask_node._free.pop().read_response.await_count\n            assert res == [\"MOCK_OK\"]\n\n    async def test_error_is_truncated(self, r) -> None:\n        \"\"\"\n        Test that an error from the pipeline is truncated correctly.\n        \"\"\"\n        key = \"a\" * 50\n        a_value = \"a\" * 20\n        b_value = \"b\" * 20\n\n        async with r.pipeline() as pipe:\n            pipe.set(key, 1)\n            pipe.hset(key, mapping={\"field_a\": a_value, \"field_b\": b_value})\n            pipe.expire(key, 100)\n\n            with pytest.raises(Exception) as ex:\n                await pipe.execute()\n\n            expected = f\"Command # 2 (HSET {key} field_a {a_value} field_b...) of pipeline caused error: \"\n            assert str(ex.value).startswith(expected)\n\n    async def test_moved_redirection_on_slave_with_default(\n        self, r: RedisCluster\n    ) -> None:\n        \"\"\"Test MovedError handling.\"\"\"\n        key = \"foo\"\n        await r.set(\"foo\", \"bar\")\n        # set read_from_replicas to True\n        r.read_from_replicas = True\n        primary = r.get_node_from_key(key, False)\n        moved_error = f\"{r.keyslot(key)} {primary.host}:{primary.port}\"\n\n        parse_response_orig = primary.parse_response\n        with mock.patch.object(\n            ClusterNode, \"parse_response\", autospec=True\n        ) as parse_response_mock:\n\n            async def parse_response(\n                self, connection: Connection, command: str, **kwargs: Any\n            ) -> Any:\n                if command == \"GET\" and self.port != primary.port:\n                    raise MovedError(moved_error)\n\n                return await parse_response_orig(connection, command, **kwargs)\n\n            parse_response_mock.side_effect = parse_response\n\n            async with r.pipeline() as readwrite_pipe:\n                assert r.reinitialize_counter == 0\n                readwrite_pipe.get(key).get(key)\n                assert r.reinitialize_counter == 0\n                assert await readwrite_pipe.execute() == [b\"bar\", b\"bar\"]\n\n    async def test_readonly_pipeline_from_readonly_client(\n        self, r: RedisCluster\n    ) -> None:\n        \"\"\"Test that the pipeline uses replicas for read_from_replicas clients.\"\"\"\n        # Create a cluster with reading from replications\n        r.read_from_replicas = True\n        key = \"bar\"\n        await r.set(key, \"foo\")\n\n        async with r.pipeline() as pipe:\n            mock_all_nodes_resp(r, \"MOCK_OK\")\n            assert await pipe.get(key).get(key).execute() == [\"MOCK_OK\", \"MOCK_OK\"]\n            slot_nodes = r.nodes_manager.slots_cache[r.keyslot(key)]\n            executed_on_replica = False\n            for node in slot_nodes:\n                if node.server_type == REPLICA:\n                    if node._free.pop().read_response.await_count:\n                        executed_on_replica = True\n                        break\n            assert executed_on_replica\n\n    @pytest.mark.parametrize(\n        \"load_balancing_strategy\",\n        [\n            LoadBalancingStrategy.ROUND_ROBIN_REPLICAS,\n            LoadBalancingStrategy.RANDOM_REPLICA,\n        ],\n    )\n    async def test_readonly_pipeline_with_reading_from_replicas_strategies(\n        self, r: RedisCluster, load_balancing_strategy: LoadBalancingStrategy\n    ) -> None:\n        \"\"\"\n        Test that the pipeline uses replicas for different replica-based\n        load balancing strategies.\n        \"\"\"\n        # Set the load balancing strategy\n        r.load_balancing_strategy = load_balancing_strategy\n        key = \"bar\"\n        await r.set(key, \"foo\")\n\n        async with r.pipeline() as pipe:\n            mock_all_nodes_resp(r, \"MOCK_OK\")\n            assert await pipe.get(key).get(key).execute() == [\"MOCK_OK\", \"MOCK_OK\"]\n            slot_nodes = r.nodes_manager.slots_cache[r.keyslot(key)]\n            executed_on_replicas_only = True\n            for node in slot_nodes:\n                if node.server_type == PRIMARY:\n                    if node._free.pop().read_response.await_count > 0:\n                        executed_on_replicas_only = False\n                        break\n            assert executed_on_replicas_only\n\n    async def test_can_run_concurrent_pipelines(self, r: RedisCluster) -> None:\n        \"\"\"Test that the pipeline can be used concurrently.\"\"\"\n        await asyncio.gather(\n            *(self.test_redis_cluster_pipeline(r) for i in range(100)),\n            *(self.test_multi_key_operation_with_a_single_slot(r) for i in range(100)),\n            *(self.test_multi_key_operation_with_multi_slots(r) for i in range(100)),\n        )\n\n    @pytest.mark.onlycluster\n    async def test_pipeline_with_default_node_error_command(self, create_redis):\n        \"\"\"\n        Test that the default node is being replaced when it raises a relevant exception\n        \"\"\"\n        r = await create_redis(cls=RedisCluster, flushdb=False)\n        curr_default_node = r.get_default_node()\n        err = ConnectionError(\"error\")\n        cmd_count = await r.command_count()\n        mock_node_resp_exc(curr_default_node, err)\n        async with r.pipeline(transaction=False) as pipe:\n            pipe.command_count()\n            result = await pipe.execute(raise_on_error=False)\n            assert result[0] == err\n            assert r.get_default_node() != curr_default_node\n            pipe.command_count()\n            result = await pipe.execute(raise_on_error=False)\n            assert result[0] == cmd_count\n\n\n@pytest.mark.ssl\nclass TestSSL:\n    \"\"\"\n    Tests for SSL connections.\n\n    This relies on the --redis-ssl-url for building the client and connecting to the\n    appropriate port.\n    \"\"\"\n\n    @pytest_asyncio.fixture()\n    def create_client(self, request: FixtureRequest) -> Callable[..., RedisCluster]:\n        ssl_url = request.config.option.redis_ssl_url\n        ssl_host, ssl_port = urlparse(ssl_url)[1].split(\":\")\n        self.client_cert, self.client_key, self.ca_cert = get_tls_certificates(\n            \"cluster\"\n        )\n\n        async def _create_client(mocked: bool = True, **kwargs: Any) -> RedisCluster:\n            if mocked:\n                with mock.patch.object(\n                    ClusterNode, \"execute_command\", autospec=True\n                ) as execute_command_mock:\n\n                    async def execute_command(self, *args, **kwargs):\n                        if args[0] == \"INFO\":\n                            return {\"cluster_enabled\": True}\n                        if args[0] == \"CLUSTER SLOTS\":\n                            return [[0, 16383, [ssl_host, ssl_port, \"ssl_node\"]]]\n                        if args[0] == \"COMMAND\":\n                            return {\n                                \"ping\": {\n                                    \"name\": \"ping\",\n                                    \"arity\": -1,\n                                    \"flags\": [\"stale\", \"fast\"],\n                                    \"first_key_pos\": 0,\n                                    \"last_key_pos\": 0,\n                                    \"step_count\": 0,\n                                }\n                            }\n                        raise NotImplementedError()\n\n                    execute_command_mock.side_effect = execute_command\n\n                    rc = await RedisCluster(host=ssl_host, port=ssl_port, **kwargs)\n\n                assert len(rc.get_nodes()) == 1\n                node = rc.get_default_node()\n                assert node.port == int(ssl_port)\n                return rc\n\n            return await RedisCluster(host=ssl_host, port=ssl_port, **kwargs)\n\n        return _create_client\n\n    async def test_ssl_connection_without_ssl(\n        self, create_client: Callable[..., Awaitable[RedisCluster]]\n    ) -> None:\n        with pytest.raises(RedisClusterException) as e:\n            await create_client(mocked=False, ssl=False)\n        e = e.value.__cause__\n        assert \"Connection closed by server\" in str(e)\n\n    async def test_ssl_with_invalid_cert(\n        self, create_client: Callable[..., Awaitable[RedisCluster]]\n    ) -> None:\n        with pytest.raises(RedisClusterException) as e:\n            await create_client(mocked=False, ssl=True)\n        e = e.value.__cause__.__context__\n        assert \"SSL: CERTIFICATE_VERIFY_FAILED\" in str(e)\n\n    async def test_ssl_connection(\n        self, create_client: Callable[..., Awaitable[RedisCluster]]\n    ) -> None:\n        async with await create_client(ssl=True, ssl_cert_reqs=\"none\") as rc:\n            assert await rc.ping()\n\n    @pytest.mark.parametrize(\n        \"ssl_ciphers\",\n        [\n            \"AES256-SHA:DHE-RSA-AES256-SHA:AES128-SHA:DHE-RSA-AES128-SHA\",\n            \"ECDHE-ECDSA-AES256-GCM-SHA384\",\n            \"ECDHE-RSA-AES128-GCM-SHA256\",\n        ],\n    )\n    async def test_ssl_connection_tls12_custom_ciphers(\n        self, ssl_ciphers, create_client: Callable[..., Awaitable[RedisCluster]]\n    ) -> None:\n        async with await create_client(\n            ssl=True,\n            ssl_cert_reqs=\"none\",\n            ssl_min_version=ssl.TLSVersion.TLSv1_2,\n            ssl_ciphers=ssl_ciphers,\n        ) as rc:\n            assert await rc.ping()\n\n    async def test_ssl_connection_tls12_custom_ciphers_invalid(\n        self, create_client: Callable[..., Awaitable[RedisCluster]]\n    ) -> None:\n        async with await create_client(\n            ssl=True,\n            ssl_cert_reqs=\"none\",\n            ssl_min_version=ssl.TLSVersion.TLSv1_2,\n            ssl_ciphers=\"foo:bar\",\n        ) as rc:\n            with pytest.raises(RedisClusterException) as e:\n                assert await rc.ping()\n            assert \"Redis Cluster cannot be connected\" in str(e.value)\n\n    @pytest.mark.parametrize(\n        \"ssl_ciphers\",\n        [\n            \"TLS_CHACHA20_POLY1305_SHA256\",\n            \"TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256\",\n        ],\n    )\n    async def test_ssl_connection_tls13_custom_ciphers(\n        self, ssl_ciphers, create_client: Callable[..., Awaitable[RedisCluster]]\n    ) -> None:\n        # TLSv1.3 does not support changing the ciphers\n        async with await create_client(\n            ssl=True,\n            ssl_cert_reqs=\"none\",\n            ssl_min_version=ssl.TLSVersion.TLSv1_2,\n            ssl_ciphers=ssl_ciphers,\n        ) as rc:\n            with pytest.raises(RedisClusterException) as e:\n                assert await rc.ping()\n            assert \"Redis Cluster cannot be connected\" in str(e.value)\n\n    async def test_validating_self_signed_certificate(\n        self, create_client: Callable[..., Awaitable[RedisCluster]]\n    ) -> None:\n        # ssl_check_hostname=False is used to avoid hostname verification\n        # in the test environment, where the server certificate is self-signed\n        # and does not match the hostname that is extracted for the cluster.\n        # Cert hostname is 'localhost' in the cluster initialization when using\n        # 'localhost' it gets transformed into 127.0.0.1\n        # In production code, ssl_check_hostname should be set to True\n        # to ensure proper hostname verification.\n        async with await create_client(\n            ssl=True,\n            ssl_ca_certs=self.ca_cert,\n            ssl_cert_reqs=\"required\",\n            ssl_certfile=self.client_cert,\n            ssl_keyfile=self.client_key,\n            ssl_check_hostname=False,\n        ) as rc:\n            assert await rc.ping()\n\n    async def test_validating_self_signed_string_certificate(\n        self, create_client: Callable[..., Awaitable[RedisCluster]]\n    ) -> None:\n        with open(self.ca_cert) as f:\n            cert_data = f.read()\n\n        # ssl_check_hostname=False is used to avoid hostname verification\n        # in the test environment, where the server certificate is self-signed\n        # and does not match the hostname that is extracted for the cluster.\n        # Cert hostname is 'localhost' in the cluster initialization when using\n        # 'localhost' it gets transformed into 127.0.0.1\n        # In production code, ssl_check_hostname should be set to True\n        # to ensure proper hostname verification.\n        async with await create_client(\n            ssl=True,\n            ssl_ca_data=cert_data,\n            ssl_cert_reqs=\"required\",\n            ssl_check_hostname=False,\n            ssl_certfile=self.client_cert,\n            ssl_keyfile=self.client_key,\n        ) as rc:\n            assert await rc.ping()\n\n\n@pytest.mark.onlycluster\nclass TestAsyncClusterMetricsRecording:\n    \"\"\"\n    Integration tests that verify metrics are properly recorded\n    from async RedisCluster and delivered to the Meter through the observability recorder.\n\n    These tests use a real Redis cluster connection but mock the OTel Meter\n    to verify metrics are correctly recorded.\n    \"\"\"\n\n    @pytest.fixture\n    def mock_meter(self):\n        \"\"\"Create a mock Meter that tracks all instrument calls.\"\"\"\n        meter = Mock()\n\n        # Create mock histogram for operation duration\n        self.operation_duration = Mock()\n\n        def create_histogram_side_effect(name, **kwargs):\n            if name == \"db.client.operation.duration\":\n                return self.operation_duration\n            return Mock()\n\n        meter.create_counter.return_value = Mock()\n        meter.create_up_down_counter.return_value = Mock()\n        meter.create_histogram.side_effect = create_histogram_side_effect\n        meter.create_observable_gauge.return_value = Mock()\n\n        return meter\n\n    @pytest.fixture\n    async def cluster_with_otel(self, r, mock_meter):\n        \"\"\"\n        Setup a RedisCluster with real connection and mocked OTel collector.\n        Returns tuple of (cluster, operation_duration_mock).\n        \"\"\"\n\n        # Reset any existing collector state\n        async_recorder.reset_collector()\n\n        # Create config with COMMAND group enabled\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND])\n\n        # Create collector with mocked meter\n        with patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        # Patch the recorder to use our collector\n        # Note: _get_or_create_collector is now sync\n        with patch.object(\n            async_recorder,\n            \"_get_or_create_collector\",\n            return_value=collector,\n        ):\n            # Also set the collector directly to ensure it's used\n            async_recorder._async_metrics_collector = collector\n\n            # Create a new event dispatcher and attach it to the cluster\n            event_dispatcher = EventDispatcher()\n            r._event_dispatcher = event_dispatcher\n\n            yield r, self.operation_duration\n\n        # Cleanup\n        async_recorder.reset_collector()\n\n    async def test_execute_command_records_metric(self, cluster_with_otel):\n        \"\"\"\n        Test that execute_command records operation duration metric to Meter.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_with_otel\n\n        # Execute a command\n        await cluster.set(\"test_key\", \"test_value\")\n\n        # Verify the Meter's histogram.record() was called\n        operation_duration_mock.record.assert_called()\n\n        # Get the last call arguments\n        call_args = operation_duration_mock.record.call_args\n\n        # Verify duration was recorded\n        duration = call_args[0][0]\n        assert isinstance(duration, float)\n        assert duration >= 0\n\n        # Verify attributes\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[\"db.operation.name\"] == \"SET\"\n        assert \"server.address\" in attrs\n        assert \"server.port\" in attrs\n        assert \"db.namespace\" in attrs\n\n    async def test_get_command_records_metric(self, cluster_with_otel):\n        \"\"\"\n        Test that GET command records metric with correct command name.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_with_otel\n\n        # Execute GET command\n        await cluster.get(\"test_key\")\n\n        # Verify command name is GET\n        call_args = operation_duration_mock.record.call_args\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[\"db.operation.name\"] == \"GET\"\n\n    async def test_multiple_commands_record_multiple_metrics(self, cluster_with_otel):\n        \"\"\"\n        Test that multiple command executions record multiple metrics.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_with_otel\n\n        # Execute multiple commands\n        await cluster.set(\"key1\", \"value1\")\n        await cluster.get(\"key1\")\n        await cluster.delete(\"key1\")\n\n        # Verify histogram.record() was called 3 times\n        assert operation_duration_mock.record.call_count == 3\n\n    async def test_server_attributes_recorded(self, cluster_with_otel):\n        \"\"\"\n        Test that server address, port, and db namespace are recorded.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_with_otel\n\n        await cluster.ping()\n\n        call_args = operation_duration_mock.record.call_args\n        attrs = call_args[1][\"attributes\"]\n\n        # Verify server attributes are present and have valid values\n        assert \"server.address\" in attrs\n        assert isinstance(attrs[\"server.address\"], str)\n        assert len(attrs[\"server.address\"]) > 0\n\n        assert \"server.port\" in attrs\n        assert isinstance(attrs[\"server.port\"], int)\n        assert attrs[\"server.port\"] > 0\n\n        assert \"db.namespace\" in attrs\n\n    async def test_duration_is_positive(self, cluster_with_otel):\n        \"\"\"\n        Test that the recorded duration is a positive float.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_with_otel\n\n        await cluster.set(\"duration_test\", \"value\")\n\n        call_args = operation_duration_mock.record.call_args\n        duration = call_args[0][0]\n\n        assert isinstance(duration, float)\n        assert duration >= 0\n\n    async def test_no_batch_size_for_single_command(self, cluster_with_otel):\n        \"\"\"\n        Test that single commands don't include batch_size attribute.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_with_otel\n\n        await cluster.get(\"single_command_key\")\n\n        call_args = operation_duration_mock.record.call_args\n        attrs = call_args[1][\"attributes\"]\n\n        # batch_size should not be present for single commands\n        assert \"db.operation.batch.size\" not in attrs\n\n    async def test_command_error_records_metric_with_error_type(\n        self, cluster_with_otel\n    ):\n        \"\"\"\n        Test that when a command fails, the recorded metric includes error.type attribute.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_with_otel\n\n        # Execute a command that will fail (wrong type operation)\n        await cluster.set(\"error_test_key\", \"string_value\")\n\n        try:\n            # Try to use LPUSH on a string key - this will fail\n            await cluster.lpush(\"error_test_key\", \"value\")\n        except ResponseError:\n            pass\n\n        # Find the LPUSH event in recorded calls\n        lpush_calls = [\n            call_obj\n            for call_obj in operation_duration_mock.record.call_args_list\n            if call_obj[1][\"attributes\"].get(\"db.operation.name\") == \"LPUSH\"\n        ]\n\n        assert len(lpush_calls) >= 1\n        attrs = lpush_calls[0][1][\"attributes\"]\n        assert \"error.type\" in attrs\n\n\n@pytest.mark.onlycluster\nclass TestAsyncClusterPipelineMetricsRecording:\n    \"\"\"\n    Integration tests that verify metrics are properly recorded\n    from async ClusterPipeline and delivered to the Meter through the observability recorder.\n\n    These tests use a real Redis cluster connection but mock the OTel Meter\n    to verify metrics are correctly recorded.\n    \"\"\"\n\n    @pytest.fixture\n    def mock_meter(self):\n        \"\"\"Create a mock Meter that tracks all instrument calls.\"\"\"\n        meter = Mock()\n\n        # Create mock histogram for operation duration\n        self.operation_duration = Mock()\n\n        def create_histogram_side_effect(name, **kwargs):\n            if name == \"db.client.operation.duration\":\n                return self.operation_duration\n            return Mock()\n\n        meter.create_counter.return_value = Mock()\n        meter.create_up_down_counter.return_value = Mock()\n        meter.create_histogram.side_effect = create_histogram_side_effect\n        meter.create_observable_gauge.return_value = Mock()\n\n        return meter\n\n    @pytest.fixture\n    async def cluster_pipeline_with_otel(self, r, mock_meter):\n        \"\"\"\n        Setup a ClusterPipeline with real connection and mocked OTel collector.\n        Returns tuple of (cluster, operation_duration_mock).\n        \"\"\"\n\n        # Reset any existing collector state\n        async_recorder.reset_collector()\n\n        # Create config with COMMAND group enabled\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND])\n\n        # Create collector with mocked meter\n        with patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        # Patch the recorder to use our collector\n        # Note: _get_or_create_collector is now sync\n        with patch.object(\n            async_recorder,\n            \"_get_or_create_collector\",\n            return_value=collector,\n        ):\n            # Also set the collector directly to ensure it's used\n            async_recorder._async_metrics_collector = collector\n\n            # Create a new event dispatcher and attach it to the cluster\n            event_dispatcher = EventDispatcher()\n            r._event_dispatcher = event_dispatcher\n\n            yield r, self.operation_duration\n\n        # Cleanup\n        async_recorder.reset_collector()\n\n    async def test_pipeline_execute_records_metric(self, cluster_pipeline_with_otel):\n        \"\"\"\n        Test that pipeline execute records operation duration metric to Meter.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_pipeline_with_otel\n\n        # Execute a pipeline\n        pipe = cluster.pipeline()\n        pipe.set(\"pipe_key1\", \"value1\")\n        pipe.get(\"pipe_key1\")\n        await pipe.execute()\n\n        # Verify the Meter's histogram.record() was called\n        operation_duration_mock.record.assert_called()\n\n        # Get the last call arguments (pipeline event)\n        call_args = operation_duration_mock.record.call_args\n\n        # Verify duration was recorded\n        duration = call_args[0][0]\n        assert isinstance(duration, float)\n        assert duration >= 0\n\n        # Verify attributes\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[\"db.operation.name\"] == \"PIPELINE\"\n\n    async def test_pipeline_server_attributes_recorded(\n        self, cluster_pipeline_with_otel\n    ):\n        \"\"\"\n        Test that server address, port, and db namespace are recorded for pipeline.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_pipeline_with_otel\n\n        pipe = cluster.pipeline()\n        pipe.set(\"server_attr_key\", \"value\")\n        await pipe.execute()\n\n        # Find the PIPELINE event call\n        pipeline_call = None\n        for call_obj in operation_duration_mock.record.call_args_list:\n            attrs = call_obj[1][\"attributes\"]\n            if attrs.get(\"db.operation.name\") == \"PIPELINE\":\n                pipeline_call = call_obj\n                break\n\n        assert pipeline_call is not None\n        attrs = pipeline_call[1][\"attributes\"]\n\n        # Verify server attributes are present\n        assert \"server.address\" in attrs\n        assert isinstance(attrs[\"server.address\"], str)\n\n        assert \"server.port\" in attrs\n        assert isinstance(attrs[\"server.port\"], int)\n\n        assert \"db.namespace\" in attrs\n\n    async def test_pipeline_duration_is_positive(self, cluster_pipeline_with_otel):\n        \"\"\"\n        Test that the recorded duration for pipeline is a positive float.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_pipeline_with_otel\n\n        pipe = cluster.pipeline()\n        pipe.set(\"duration_key\", \"value\")\n        await pipe.execute()\n\n        # Find the PIPELINE event call\n        pipeline_call = None\n        for call_obj in operation_duration_mock.record.call_args_list:\n            attrs = call_obj[1][\"attributes\"]\n            if attrs.get(\"db.operation.name\") == \"PIPELINE\":\n                pipeline_call = call_obj\n                break\n\n        assert pipeline_call is not None\n        duration = pipeline_call[0][0]\n\n        assert isinstance(duration, float)\n        assert duration >= 0\n\n    async def test_multiple_pipeline_executions_record_multiple_metrics(\n        self, cluster_pipeline_with_otel\n    ):\n        \"\"\"\n        Test that multiple pipeline executions record multiple metrics.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_pipeline_with_otel\n\n        # First pipeline\n        pipe1 = cluster.pipeline()\n        pipe1.set(\"multi_pipe_key1\", \"value1\")\n        await pipe1.execute()\n\n        # Second pipeline\n        pipe2 = cluster.pipeline()\n        pipe2.set(\"multi_pipe_key2\", \"value2\")\n        pipe2.get(\"multi_pipe_key2\")\n        await pipe2.execute()\n\n        # Find all PIPELINE event calls\n        pipeline_calls = [\n            call_obj\n            for call_obj in operation_duration_mock.record.call_args_list\n            if call_obj[1][\"attributes\"].get(\"db.operation.name\") == \"PIPELINE\"\n        ]\n\n        # Should have at least 2 pipeline events\n        assert len(pipeline_calls) >= 2\n"
  },
  {
    "path": "tests/test_asyncio/test_cluster_transaction.py",
    "content": "from typing import Tuple\nfrom unittest.mock import patch, Mock\n\nimport pytest\n\nimport redis\nfrom redis import CrossSlotTransactionError, RedisClusterException\nfrom redis.asyncio import RedisCluster, Connection\nfrom redis.asyncio.cluster import ClusterNode, NodesManager\nfrom redis.asyncio.retry import Retry\nfrom redis.backoff import NoBackoff\nfrom redis.cluster import PRIMARY\nfrom tests.conftest import skip_if_server_version_lt\n\n\ndef _find_source_and_target_node_for_slot(\n    r: RedisCluster, slot: int\n) -> Tuple[ClusterNode, ClusterNode]:\n    \"\"\"Returns a pair of ClusterNodes, where the first node is the\n    one that owns the slot and the second is a possible target\n    for that slot, i.e. a primary node different from the first\n    one.\n    \"\"\"\n    node_migrating = r.nodes_manager.get_node_from_slot(slot)\n    assert node_migrating, f\"No node could be found that owns slot #{slot}\"\n\n    available_targets = [\n        n\n        for n in r.nodes_manager.startup_nodes.values()\n        if node_migrating.name != n.name and n.server_type == PRIMARY\n    ]\n\n    assert available_targets, f\"No possible target nodes for slot #{slot}\"\n    return node_migrating, available_targets[0]\n\n\n@pytest.mark.onlycluster\nclass TestClusterTransaction:\n    @pytest.mark.onlycluster\n    async def test_pipeline_is_true(self, r) -> None:\n        \"Ensure pipeline instances are not false-y\"\n        async with r.pipeline(transaction=True) as pipe:\n            assert pipe\n\n    @pytest.mark.onlycluster\n    async def test_pipeline_empty_transaction(self, r):\n        await r.set(\"a\", 0)\n\n        async with r.pipeline(transaction=True) as pipe:\n            assert await pipe.execute() == []\n\n    @pytest.mark.onlycluster\n    async def test_executes_transaction_against_cluster(self, r) -> None:\n        async with r.pipeline(transaction=True) as tx:\n            tx.set(\"{foo}bar\", \"value1\")\n            tx.set(\"{foo}baz\", \"value2\")\n            tx.set(\"{foo}bad\", \"value3\")\n            tx.get(\"{foo}bar\")\n            tx.get(\"{foo}baz\")\n            tx.get(\"{foo}bad\")\n            assert await tx.execute() == [\n                True,\n                True,\n                True,\n                b\"value1\",\n                b\"value2\",\n                b\"value3\",\n            ]\n\n        await r.flushall()\n\n        tx = r.pipeline(transaction=True)\n        tx.set(\"{foo}bar\", \"value1\")\n        tx.set(\"{foo}baz\", \"value2\")\n        tx.set(\"{foo}bad\", \"value3\")\n        tx.get(\"{foo}bar\")\n        tx.get(\"{foo}baz\")\n        tx.get(\"{foo}bad\")\n        assert await tx.execute() == [True, True, True, b\"value1\", b\"value2\", b\"value3\"]\n\n    @pytest.mark.onlycluster\n    async def test_throws_exception_on_different_hash_slots(self, r):\n        async with r.pipeline(transaction=True) as tx:\n            tx.set(\"{foo}bar\", \"value1\")\n            tx.set(\"{foobar}baz\", \"value2\")\n\n            with pytest.raises(\n                CrossSlotTransactionError,\n                match=\"All keys involved in a cluster transaction must map to the same slot\",\n            ):\n                await tx.execute()\n\n    @pytest.mark.onlycluster\n    async def test_throws_exception_with_watch_on_different_hash_slots(self, r):\n        async with r.pipeline(transaction=True) as tx:\n            with pytest.raises(\n                RedisClusterException,\n                match=\"WATCH - all keys must map to the same key slot\",\n            ):\n                await tx.watch(\"key1\", \"key2\")\n\n    @pytest.mark.onlycluster\n    async def test_transaction_with_watched_keys(self, r):\n        await r.set(\"a\", 0)\n\n        async with r.pipeline(transaction=True) as pipe:\n            await pipe.watch(\"a\")\n            a = await pipe.get(\"a\")\n\n            pipe.multi()\n            pipe.set(\"a\", int(a) + 1)\n            assert await pipe.execute() == [True]\n\n    @pytest.mark.onlycluster\n    async def test_retry_transaction_during_unfinished_slot_migration(self, r):\n        \"\"\"\n        When a transaction is triggered during a migration, MovedError\n        or AskError may appear (depends on the key being already migrated\n        or the key not existing already). The patch on parse_response\n        simulates such an error, but the slot cache is not updated\n        (meaning the migration is still ongoing) so the pipeline eventually\n        fails as if it was retried but the migration is not yet complete.\n        \"\"\"\n        key = \"book\"\n        slot = r.keyslot(key)\n        node_migrating, node_importing = _find_source_and_target_node_for_slot(r, slot)\n\n        with (\n            patch.object(ClusterNode, \"parse_response\") as parse_response,\n            patch.object(NodesManager, \"move_slot\") as manager_move_slot,\n        ):\n\n            def ask_redirect_effect(connection, *args, **options):\n                if \"MULTI\" in args:\n                    return\n                elif \"EXEC\" in args:\n                    raise redis.exceptions.ExecAbortError()\n\n                raise redis.exceptions.AskError(f\"{slot} {node_importing.name}\")\n\n            parse_response.side_effect = ask_redirect_effect\n\n            async with r.pipeline(transaction=True) as pipe:\n                pipe.set(key, \"val\")\n                with pytest.raises(redis.exceptions.AskError) as ex:\n                    await pipe.execute()\n\n                assert str(ex.value).startswith(\n                    \"Command # 1 (SET book val) of pipeline caused error:\"\n                    f\" {slot} {node_importing.name}\"\n                )\n\n            manager_move_slot.assert_called()\n\n    @pytest.mark.onlycluster\n    async def test_retry_transaction_during_slot_migration_successful(\n        self, create_redis\n    ):\n        \"\"\"\n        If a MovedError or AskError appears when calling EXEC and no key is watched,\n        the pipeline is retried after updating the node manager slot table. If the\n        migration was completed, the transaction may then complete successfully.\n        \"\"\"\n        r = await create_redis(flushdb=False)\n        key = \"book\"\n        slot = r.keyslot(key)\n        node_migrating, node_importing = _find_source_and_target_node_for_slot(r, slot)\n\n        with (\n            patch.object(ClusterNode, \"parse_response\") as parse_response,\n        ):\n\n            def ask_redirect_effect(conn, *args, **options):\n                # first call should go here, we trigger an AskError\n                if f\"{conn.host}:{conn.port}\" == node_migrating.name:\n                    if \"MULTI\" in args:\n                        return\n                    elif \"EXEC\" in args:\n                        raise redis.exceptions.ExecAbortError()\n\n                    raise redis.exceptions.AskError(f\"{slot} {node_importing.name}\")\n                # if the slot table is updated, the next call will go here\n                elif f\"{conn.host}:{conn.port}\" == node_importing.name:\n                    if \"EXEC\" in args:\n                        return [\"OK\"]  # mock value to validate this section was called\n                    return\n                else:\n                    assert False, f\"unexpected node {conn.host}:{conn.port} was called\"\n\n            parse_response.side_effect = ask_redirect_effect\n\n            result = None\n            async with r.pipeline(transaction=True) as pipe:\n                pipe.multi()\n                pipe.set(key, \"val\")\n                result = await pipe.execute()\n\n            assert result and True in result, \"Target node was not called\"\n\n    @pytest.mark.onlycluster\n    async def test_retry_transaction_with_watch_after_slot_migration(self, r):\n        \"\"\"\n        If a MovedError or AskError appears when calling WATCH, the client\n        must attempt to recover itself before proceeding and no WatchError\n        should appear.\n        \"\"\"\n        key = \"book\"\n        slot = r.keyslot(key)\n        r.reinitialize_steps = 1\n\n        # force a MovedError on the first call to pipe.watch()\n        # by switching the node that owns the slot to another one\n        _node_migrating, node_importing = _find_source_and_target_node_for_slot(r, slot)\n        r.nodes_manager.slots_cache[slot] = [node_importing]\n\n        async with r.pipeline(transaction=True) as pipe:\n            await pipe.watch(key)\n            pipe.multi()\n            pipe.set(key, \"val\")\n            assert await pipe.execute() == [True]\n\n    @pytest.mark.onlycluster\n    async def test_retry_transaction_with_watch_during_slot_migration(self, r):\n        \"\"\"\n        If a MovedError or AskError appears when calling EXEC and keys were\n        being watched before the migration started, a WatchError should appear.\n        These errors imply resetting the connection and connecting to a new node,\n        so watches are lost anyway and the client code must be notified.\n        \"\"\"\n        key = \"book\"\n        slot = r.keyslot(key)\n        node_migrating, node_importing = _find_source_and_target_node_for_slot(r, slot)\n\n        with patch.object(ClusterNode, \"parse_response\") as parse_response:\n\n            def ask_redirect_effect(conn, *args, **options):\n                if f\"{conn.host}:{conn.port}\" == node_migrating.name:\n                    # we simulate the watch was sent before the migration started\n                    if \"WATCH\" in args:\n                        return b\"OK\"\n                    # but the pipeline was triggered after the migration started\n                    elif \"MULTI\" in args:\n                        return\n                    elif \"EXEC\" in args:\n                        raise redis.exceptions.ExecAbortError()\n\n                    raise redis.exceptions.AskError(f\"{slot} {node_importing.name}\")\n                # we should not try to connect to any other node\n                else:\n                    assert False, f\"unexpected node {conn.host}:{conn.port} was called\"\n\n            parse_response.side_effect = ask_redirect_effect\n\n            async with r.pipeline(transaction=True) as pipe:\n                await pipe.watch(key)\n\n                pipe.multi()\n                pipe.set(key, \"val\")\n                with pytest.raises(redis.exceptions.WatchError) as ex:\n                    await pipe.execute()\n\n                assert str(ex.value).startswith(\n                    \"Slot rebalancing occurred while watching keys\"\n                )\n\n    @pytest.mark.onlycluster\n    async def test_retry_transaction_on_connection_error(self, r):\n        key = \"book\"\n        slot = r.keyslot(key)\n\n        _node_migrating, node_importing = _find_source_and_target_node_for_slot(r, slot)\n        original_slots_cache = r.nodes_manager.slots_cache[slot]\n\n        mock_connection = Mock(spec=Connection)\n        mock_connection.read_response.side_effect = redis.exceptions.ConnectionError(\n            \"Conn error\"\n        )\n        mock_connection.retry = Retry(NoBackoff(), 0)\n        mock_connection.host = node_importing.host\n        mock_connection.port = node_importing.port\n        mock_connection.db = 0\n\n        node_importing._free.append(mock_connection)\n        r.nodes_manager.slots_cache[slot] = [node_importing]\n        r.reinitialize_steps = 1\n\n        try:\n            async with r.pipeline(transaction=True) as pipe:\n                pipe.set(key, \"val\")\n                assert await pipe.execute() == [True]\n\n            assert mock_connection.read_response.call_count == 1\n        finally:\n            # Clean up mock connection from node so teardown can work\n            if mock_connection in node_importing._free:\n                node_importing._free.remove(mock_connection)\n            r.nodes_manager.slots_cache[slot] = original_slots_cache\n\n    @pytest.mark.onlycluster\n    async def test_retry_transaction_on_connection_error_with_watched_keys(self, r):\n        key = \"book\"\n        slot = r.keyslot(key)\n\n        _node_migrating, node_importing = _find_source_and_target_node_for_slot(r, slot)\n        original_slots_cache = r.nodes_manager.slots_cache[slot]\n\n        mock_connection = Mock(spec=Connection)\n        mock_connection.read_response.side_effect = redis.exceptions.ConnectionError(\n            \"Conn error\"\n        )\n        mock_connection.retry = Retry(NoBackoff(), 0)\n        mock_connection.host = node_importing.host\n        mock_connection.port = node_importing.port\n        mock_connection.db = 0\n\n        node_importing._free.append(mock_connection)\n        r.nodes_manager.slots_cache[slot] = [node_importing]\n        r.reinitialize_steps = 1\n\n        try:\n            async with r.pipeline(transaction=True) as pipe:\n                await pipe.watch(key)\n\n                pipe.multi()\n                pipe.set(key, \"val\")\n                assert await pipe.execute() == [True]\n\n            assert mock_connection.read_response.call_count == 1\n        finally:\n            # Clean up mock connection from node so teardown can work\n            if mock_connection in node_importing._free:\n                node_importing._free.remove(mock_connection)\n            r.nodes_manager.slots_cache[slot] = original_slots_cache\n\n    @pytest.mark.onlycluster\n    async def test_exec_error_raised(self, r):\n        hashkey = \"{key}\"\n        await r.set(f\"{hashkey}:c\", \"a\")\n\n        async with r.pipeline(transaction=True) as pipe:\n            pipe.set(f\"{hashkey}:a\", 1).set(f\"{hashkey}:b\", 2)\n            pipe.lpush(f\"{hashkey}:c\", 3).set(f\"{hashkey}:d\", 4)\n            with pytest.raises(redis.ResponseError) as ex:\n                await pipe.execute()\n            assert str(ex.value).startswith(\n                \"Command # 3 (LPUSH {key}:c 3) of pipeline caused error: \"\n            )\n\n            # make sure the pipe was restored to a working state\n            assert await pipe.set(f\"{hashkey}:z\", \"zzz\").execute() == [True]\n            assert await r.get(f\"{hashkey}:z\") == b\"zzz\"\n\n    @pytest.mark.onlycluster\n    async def test_parse_error_raised(self, r):\n        hashkey = \"{key}\"\n        async with r.pipeline(transaction=True) as pipe:\n            # the zrem is invalid because we don't pass any keys to it\n            pipe.set(f\"{hashkey}:a\", 1).zrem(f\"{hashkey}:b\").set(f\"{hashkey}:b\", 2)\n            with pytest.raises(redis.ResponseError) as ex:\n                await pipe.execute()\n\n            assert str(ex.value).startswith(\n                \"Command # 2 (ZREM {key}:b) of pipeline caused error: wrong number\"\n            )\n\n            # make sure the pipe was restored to a working state\n            assert await pipe.set(f\"{hashkey}:z\", \"zzz\").execute() == [True]\n            assert await r.get(f\"{hashkey}:z\") == b\"zzz\"\n\n    @pytest.mark.onlycluster\n    async def test_transaction_callable(self, r):\n        hashkey = \"{key}\"\n        await r.set(f\"{hashkey}:a\", 1)\n        await r.set(f\"{hashkey}:b\", 2)\n        has_run = []\n\n        async def my_transaction(pipe):\n            a_value = await pipe.get(f\"{hashkey}:a\")\n            assert a_value in (b\"1\", b\"2\")\n            b_value = await pipe.get(f\"{hashkey}:b\")\n            assert b_value == b\"2\"\n\n            # silly run-once code... incr's \"a\" so WatchError should be raised\n            # forcing this all to run again. this should incr \"a\" once to \"2\"\n            if not has_run:\n                await r.incr(f\"{hashkey}:a\")\n                has_run.append(\"it has\")\n\n            pipe.multi()\n            pipe.set(f\"{hashkey}:c\", int(a_value) + int(b_value))\n\n        result = await r.transaction(my_transaction, f\"{hashkey}:a\", f\"{hashkey}:b\")\n        assert result == [True]\n        assert await r.get(f\"{hashkey}:c\") == b\"4\"\n\n    @pytest.mark.onlycluster\n    @skip_if_server_version_lt(\"2.0.0\")\n    async def test_transaction_discard(self, r):\n        hashkey = \"{key}\"\n\n        # pipelines enabled as transactions can be discarded at any point\n        async with r.pipeline(transaction=True) as pipe:\n            await pipe.watch(f\"{hashkey}:key\")\n            await pipe.set(f\"{hashkey}:key\", \"someval\")\n            await pipe.discard()\n\n            assert not pipe._execution_strategy._watching\n            assert not len(pipe)\n"
  },
  {
    "path": "tests/test_asyncio/test_command_parser.py",
    "content": "import pytest\n\nfrom redis._parsers import AsyncCommandsParser\nfrom redis._parsers.commands import RequestPolicy, ResponsePolicy\nfrom tests.conftest import skip_if_server_version_gte, skip_if_server_version_lt\nfrom tests.helpers import get_expected_command_policies\n\n\n@pytest.mark.onlycluster\n@skip_if_server_version_lt(\"8.0.0\")\nclass TestAsyncCommandParser:\n    @pytest.mark.asyncio\n    @skip_if_server_version_gte(\"8.5.240\")\n    async def test_get_command_policies(self, r):\n        commands_parser = AsyncCommandsParser()\n        await commands_parser.initialize(node=r.get_default_node())\n        expected_command_policies = get_expected_command_policies()\n\n        actual_policies = await commands_parser.get_command_policies()\n        assert len(actual_policies) > 0\n\n        for module_name, commands in expected_command_policies.items():\n            for command, command_policies in commands.items():\n                assert command in actual_policies[module_name]\n                assert command_policies == [\n                    command,\n                    actual_policies[module_name][command].request_policy,\n                    actual_policies[module_name][command].response_policy,\n                ]\n\n    @skip_if_server_version_lt(\"8.5.240\")\n    @pytest.mark.asyncio\n    async def test_get_command_policies_json_debug_updated(self, r):\n        commands_parser = AsyncCommandsParser()\n        await commands_parser.initialize(node=r.get_default_node())\n        changes_in_defaults = {\n            \"json\": {\n                \"debug\": [\n                    \"debug\",\n                    RequestPolicy.DEFAULT_KEYLESS,\n                    ResponsePolicy.DEFAULT_KEYLESS,\n                ],\n            },\n        }\n        expected_command_policies = get_expected_command_policies(changes_in_defaults)\n\n        actual_policies = await commands_parser.get_command_policies()\n        assert len(actual_policies) > 0\n\n        for module_name, commands in expected_command_policies.items():\n            for command, command_policies in commands.items():\n                assert command in actual_policies[module_name]\n                assert command_policies == [\n                    command,\n                    actual_policies[module_name][command].request_policy,\n                    actual_policies[module_name][command].response_policy,\n                ]\n"
  },
  {
    "path": "tests/test_asyncio/test_command_policies.py",
    "content": "import random\nfrom unittest.mock import patch\n\nimport pytest\n\nfrom redis import ResponseError\nfrom redis._parsers.commands import CommandPolicies, RequestPolicy, ResponsePolicy\nfrom redis.asyncio import RedisCluster\nfrom redis.commands.policies import (\n    AsyncDynamicPolicyResolver,\n    AsyncStaticPolicyResolver,\n)\nfrom redis.commands.search.aggregation import AggregateRequest, Cursor\nfrom redis.commands.search.field import NumericField, TextField\nfrom tests.conftest import skip_if_server_version_lt, is_resp2_connection\n\n\n@pytest.mark.asyncio\n@pytest.mark.onlycluster\nclass TestBasePolicyResolver:\n    async def test_resolve(self):\n        zcount_policy = CommandPolicies(\n            request_policy=RequestPolicy.DEFAULT_KEYED,\n            response_policy=ResponsePolicy.DEFAULT_KEYED,\n        )\n        rpoplpush_policy = CommandPolicies(\n            request_policy=RequestPolicy.DEFAULT_KEYED,\n            response_policy=ResponsePolicy.DEFAULT_KEYED,\n        )\n\n        dynamic_resolver = AsyncDynamicPolicyResolver(\n            {\n                \"core\": {\n                    \"zcount\": zcount_policy,\n                    \"rpoplpush\": rpoplpush_policy,\n                }\n            }\n        )\n        assert await dynamic_resolver.resolve(\"zcount\") == zcount_policy\n        assert await dynamic_resolver.resolve(\"rpoplpush\") == rpoplpush_policy\n\n        with pytest.raises(\n            ValueError, match=\"Wrong command or module name: foo.bar.baz\"\n        ):\n            await dynamic_resolver.resolve(\"foo.bar.baz\")\n\n        assert await dynamic_resolver.resolve(\"foo.bar\") is None\n        assert await dynamic_resolver.resolve(\"core.foo\") is None\n\n        # Test that policy fallback correctly\n        static_resolver = AsyncStaticPolicyResolver()\n        with_fallback_dynamic_resolver = dynamic_resolver.with_fallback(static_resolver)\n        resolved_policies = await with_fallback_dynamic_resolver.resolve(\"ft.aggregate\")\n\n        assert resolved_policies.request_policy == RequestPolicy.DEFAULT_KEYLESS\n        assert resolved_policies.response_policy == ResponsePolicy.DEFAULT_KEYLESS\n\n        # Extended chain with one more resolver\n        foo_bar_policy = CommandPolicies(\n            request_policy=RequestPolicy.DEFAULT_KEYLESS,\n            response_policy=ResponsePolicy.DEFAULT_KEYLESS,\n        )\n\n        another_dynamic_resolver = AsyncDynamicPolicyResolver(\n            {\n                \"foo\": {\n                    \"bar\": foo_bar_policy,\n                }\n            }\n        )\n        with_fallback_static_resolver = static_resolver.with_fallback(\n            another_dynamic_resolver\n        )\n        with_double_fallback_dynamic_resolver = dynamic_resolver.with_fallback(\n            with_fallback_static_resolver\n        )\n\n        assert (\n            await with_double_fallback_dynamic_resolver.resolve(\"foo.bar\")\n            == foo_bar_policy\n        )\n\n\n@pytest.mark.onlycluster\n@pytest.mark.asyncio\n@skip_if_server_version_lt(\"8.0.0\")\nclass TestClusterWithPolicies:\n    async def test_resolves_correctly_policies(self, r: RedisCluster, monkeypatch):\n        # original nodes selection method\n        determine_nodes = r._determine_nodes\n        determined_nodes = []\n        primary_nodes = r.get_primaries()\n        calls = iter(list(range(len(primary_nodes))))\n\n        async def wrapper(*args, request_policy: RequestPolicy, **kwargs):\n            nonlocal determined_nodes\n            determined_nodes = await determine_nodes(\n                *args, request_policy=request_policy, **kwargs\n            )\n            return determined_nodes\n\n        # Mock random.choice to always return a pre-defined sequence of nodes\n        monkeypatch.setattr(random, \"choice\", lambda seq: seq[next(calls)])\n\n        with patch.object(r, \"_determine_nodes\", side_effect=wrapper, autospec=True):\n            # Routed to a random primary node\n            await r.ft().create_index(\n                [\n                    NumericField(\"random_num\"),\n                    TextField(\"title\"),\n                    TextField(\"body\"),\n                    TextField(\"parent\"),\n                ]\n            )\n            assert determined_nodes[0] == primary_nodes[0]\n\n            # Routed to another random primary node\n            info = await r.ft().info()\n\n            if is_resp2_connection(r):\n                assert info[\"index_name\"] == \"idx\"\n            else:\n                assert info[b\"index_name\"] == b\"idx\"\n\n            assert determined_nodes[0] == primary_nodes[1]\n\n            expected_node = await r.get_nodes_from_slot(\"FT.SUGLEN\", *[\"foo\"])\n            await r.ft().suglen(\"foo\")\n            assert determined_nodes[0] == expected_node[0]\n\n            # Indexing a document\n            await r.hset(\n                \"search\",\n                mapping={\n                    \"title\": \"RediSearch\",\n                    \"body\": \"Redisearch impements a search engine on top of redis\",\n                    \"parent\": \"redis\",\n                    \"random_num\": 10,\n                },\n            )\n            await r.hset(\n                \"ai\",\n                mapping={\n                    \"title\": \"RedisAI\",\n                    \"body\": \"RedisAI executes Deep Learning/Machine Learning models and managing their data.\",  # noqa\n                    \"parent\": \"redis\",\n                    \"random_num\": 3,\n                },\n            )\n            await r.hset(\n                \"json\",\n                mapping={\n                    \"title\": \"RedisJson\",\n                    \"body\": \"RedisJSON implements ECMA-404 The JSON Data Interchange Standard as a native data type.\",  # noqa\n                    \"parent\": \"redis\",\n                    \"random_num\": 8,\n                },\n            )\n\n            req = AggregateRequest(\"redis\").group_by(\"@parent\").cursor(1)\n            res = await r.ft().aggregate(req)\n\n            if is_resp2_connection(r):\n                cursor = res.cursor\n            else:\n                cursor = Cursor(res[1])\n\n            # Ensure that aggregate node was cached.\n            assert determined_nodes[0] == r._aggregate_nodes[0]\n\n            await r.ft().aggregate(cursor)\n\n            # Verify that FT.CURSOR dispatched to the same node.\n            assert determined_nodes[0] == r._aggregate_nodes[0]\n\n            # Error propagates to a user\n            with pytest.raises(ResponseError, match=\"Cursor not found, id:\"):\n                await r.ft().aggregate(cursor)\n\n            assert determined_nodes[0] == primary_nodes[2]\n\n            # Core commands also randomly distributed across masters\n            await r.randomkey()\n            assert determined_nodes[0] == primary_nodes[0]\n"
  },
  {
    "path": "tests/test_asyncio/test_commands.py",
    "content": "\"\"\"\nTests async overrides of commands from their mixins\n\"\"\"\n\nimport asyncio\nimport binascii\nimport datetime\nimport re\nimport sys\nfrom string import ascii_letters\nfrom unittest.mock import AsyncMock, patch\n\nimport pytest\nimport pytest_asyncio\nfrom redis import DataError, RedisClusterException, ResponseError\n\nfrom redis import exceptions\nfrom redis._parsers.helpers import (\n    _RedisCallbacks,\n    _RedisCallbacksRESP2,\n    _RedisCallbacksRESP3,\n    parse_info,\n)\nfrom redis.client import EMPTY_RESPONSE, NEVER_DECODE\nfrom redis.commands.core import DataPersistOptions, HotkeysMetricsTypes\nfrom redis.commands.json.path import Path\nfrom redis.commands.search.field import TextField\nfrom redis.commands.search.query import Query\nfrom redis.utils import safe_str\n\nimport redis.asyncio as redis\nfrom tests.conftest import (\n    assert_resp_response,\n    assert_resp_response_in,\n    is_resp2_connection,\n    skip_if_server_version_gte,\n    skip_if_server_version_lt,\n    skip_unless_arch_bits,\n)\nfrom tests.test_asyncio.test_utils import redis_server_time\n\nif sys.version_info >= (3, 11, 3):\n    from asyncio import timeout as async_timeout\nelse:\n    from async_timeout import timeout as async_timeout\n\nREDIS_6_VERSION = \"5.9.0\"\n\n\n@pytest_asyncio.fixture()\nasync def r_teardown(r: redis.Redis):\n    \"\"\"\n    A special fixture which removes the provided names from the database after use\n    \"\"\"\n    usernames = []\n\n    def factory(username):\n        usernames.append(username)\n        return r\n\n    yield factory\n    try:\n        client_info = await r.client_info()\n    except exceptions.NoPermissionError:\n        client_info = {}\n    if \"default\" != client_info.get(\"user\", \"\"):\n        await r.auth(\"\", \"default\")\n    for username in usernames:\n        await r.acl_deluser(username)\n\n\n@pytest_asyncio.fixture()\nasync def slowlog(r: redis.Redis):\n    current_config = await r.config_get()\n    old_slower_than_value = current_config[\"slowlog-log-slower-than\"]\n    old_max_legnth_value = current_config[\"slowlog-max-len\"]\n\n    await r.config_set(\"slowlog-log-slower-than\", 0)\n    await r.config_set(\"slowlog-max-len\", 128)\n\n    yield\n\n    await r.config_set(\"slowlog-log-slower-than\", old_slower_than_value)\n    await r.config_set(\"slowlog-max-len\", old_max_legnth_value)\n\n\nasync def get_stream_message(client: redis.Redis, stream: str, message_id: str):\n    \"\"\"Fetch a stream message and format it as a (message_id, fields) pair\"\"\"\n    response = await client.xrange(stream, min=message_id, max=message_id)\n    assert len(response) == 1\n    return response[0]\n\n\n# RESPONSE CALLBACKS\n@pytest.mark.onlynoncluster\nclass TestResponseCallbacks:\n    \"\"\"Tests for the response callback system\"\"\"\n\n    async def test_response_callbacks(self, r: redis.Redis):\n        callbacks = _RedisCallbacks\n        if is_resp2_connection(r):\n            callbacks.update(_RedisCallbacksRESP2)\n        else:\n            callbacks.update(_RedisCallbacksRESP3)\n        assert r.response_callbacks == callbacks\n        assert id(r.response_callbacks) != id(_RedisCallbacks)\n        r.set_response_callback(\"GET\", lambda x: \"static\")\n        await r.set(\"a\", \"foo\")\n        assert await r.get(\"a\") == \"static\"\n\n    async def test_case_insensitive_command_names(self, r: redis.Redis):\n        assert r.response_callbacks[\"ping\"] == r.response_callbacks[\"PING\"]\n\n\nclass TestRedisCommands:\n    async def test_command_on_invalid_key_type(self, r: redis.Redis):\n        await r.lpush(\"a\", \"1\")\n        with pytest.raises(redis.ResponseError):\n            await r.get(\"a\")\n\n    # SERVER INFORMATION\n    @skip_if_server_version_lt(REDIS_6_VERSION)\n    async def test_acl_cat_no_category(self, r: redis.Redis):\n        categories = await r.acl_cat()\n        assert isinstance(categories, list)\n        assert \"read\" in categories or b\"read\" in categories\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"7.9.0\")\n    async def test_acl_cat_contain_modules_no_category(self, r: redis.Redis):\n        modules_list = [\n            \"search\",\n            \"bloom\",\n            \"json\",\n            \"cuckoo\",\n            \"timeseries\",\n            \"cms\",\n            \"topk\",\n            \"tdigest\",\n        ]\n        categories = await r.acl_cat()\n        assert isinstance(categories, list)\n        for module_cat in modules_list:\n            assert module_cat in categories or module_cat.encode() in categories\n\n    @skip_if_server_version_lt(REDIS_6_VERSION)\n    async def test_acl_cat_with_category(self, r: redis.Redis):\n        commands = await r.acl_cat(\"read\")\n        assert isinstance(commands, list)\n        assert \"get\" in commands or b\"get\" in commands\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"7.9.0\")\n    async def test_acl_modules_cat_with_category(self, r: redis.Redis):\n        search_commands = await r.acl_cat(\"search\")\n        assert isinstance(search_commands, list)\n        assert \"FT.SEARCH\" in search_commands or b\"FT.SEARCH\" in search_commands\n\n        bloom_commands = await r.acl_cat(\"bloom\")\n        assert isinstance(bloom_commands, list)\n        assert \"bf.add\" in bloom_commands or b\"bf.add\" in bloom_commands\n\n        json_commands = await r.acl_cat(\"json\")\n        assert isinstance(json_commands, list)\n        assert \"json.get\" in json_commands or b\"json.get\" in json_commands\n\n        cuckoo_commands = await r.acl_cat(\"cuckoo\")\n        assert isinstance(cuckoo_commands, list)\n        assert \"cf.insert\" in cuckoo_commands or b\"cf.insert\" in cuckoo_commands\n\n        cms_commands = await r.acl_cat(\"cms\")\n        assert isinstance(cms_commands, list)\n        assert \"cms.query\" in cms_commands or b\"cms.query\" in cms_commands\n\n        topk_commands = await r.acl_cat(\"topk\")\n        assert isinstance(topk_commands, list)\n        assert \"topk.list\" in topk_commands or b\"topk.list\" in topk_commands\n\n        tdigest_commands = await r.acl_cat(\"tdigest\")\n        assert isinstance(tdigest_commands, list)\n        assert \"tdigest.rank\" in tdigest_commands or b\"tdigest.rank\" in tdigest_commands\n\n        timeseries_commands = await r.acl_cat(\"timeseries\")\n        assert isinstance(timeseries_commands, list)\n        assert \"ts.range\" in timeseries_commands or b\"ts.range\" in timeseries_commands\n\n    @skip_if_server_version_lt(REDIS_6_VERSION)\n    async def test_acl_deluser(self, r_teardown):\n        username = \"redis-py-user\"\n        r = r_teardown(username)\n\n        assert await r.acl_deluser(username) == 0\n        assert await r.acl_setuser(username, enabled=False, reset=True)\n        assert await r.acl_deluser(username) == 1\n\n    @skip_if_server_version_lt(REDIS_6_VERSION)\n    async def test_acl_genpass(self, r: redis.Redis):\n        password = await r.acl_genpass()\n        assert isinstance(password, (str, bytes))\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    async def test_acl_getuser_setuser(self, r_teardown):\n        username = \"redis-py-user\"\n        r = r_teardown(username)\n        # test enabled=False\n        assert await r.acl_setuser(username, enabled=False, reset=True)\n        acl = await r.acl_getuser(username)\n        assert acl[\"categories\"] == [\"-@all\"]\n        assert acl[\"commands\"] == []\n        assert acl[\"keys\"] == []\n        assert acl[\"passwords\"] == []\n        assert \"off\" in acl[\"flags\"]\n        assert acl[\"enabled\"] is False\n\n        # test nopass=True\n        assert await r.acl_setuser(username, enabled=True, reset=True, nopass=True)\n        acl = await r.acl_getuser(username)\n        assert acl[\"categories\"] == [\"-@all\"]\n        assert acl[\"commands\"] == []\n        assert acl[\"keys\"] == []\n        assert acl[\"passwords\"] == []\n        assert \"on\" in acl[\"flags\"]\n        assert \"nopass\" in acl[\"flags\"]\n        assert acl[\"enabled\"] is True\n\n        # test all args\n        assert await r.acl_setuser(\n            username,\n            enabled=True,\n            reset=True,\n            passwords=[\"+pass1\", \"+pass2\"],\n            categories=[\"+set\", \"+@hash\", \"-geo\"],\n            commands=[\"+get\", \"+mget\", \"-hset\"],\n            keys=[\"cache:*\", \"objects:*\"],\n        )\n        acl = await r.acl_getuser(username)\n        assert set(acl[\"categories\"]) == {\"-@all\", \"+@set\", \"+@hash\", \"-@geo\"}\n        assert set(acl[\"commands\"]) == {\"+get\", \"+mget\", \"-hset\"}\n        assert acl[\"enabled\"] is True\n        assert \"on\" in acl[\"flags\"]\n        assert set(acl[\"keys\"]) == {\"~cache:*\", \"~objects:*\"}\n        assert len(acl[\"passwords\"]) == 2\n\n        # test reset=False keeps existing ACL and applies new ACL on top\n        assert await r.acl_setuser(\n            username,\n            enabled=True,\n            reset=True,\n            passwords=[\"+pass1\"],\n            categories=[\"+@set\"],\n            commands=[\"+get\"],\n            keys=[\"cache:*\"],\n        )\n        assert await r.acl_setuser(\n            username,\n            enabled=True,\n            passwords=[\"+pass2\"],\n            categories=[\"+@hash\"],\n            commands=[\"+mget\"],\n            keys=[\"objects:*\"],\n        )\n        acl = await r.acl_getuser(username)\n        assert set(acl[\"commands\"]) == {\"+get\", \"+mget\"}\n        assert acl[\"enabled\"] is True\n        assert \"on\" in acl[\"flags\"]\n        assert set(acl[\"keys\"]) == {\"~cache:*\", \"~objects:*\"}\n        assert len(acl[\"passwords\"]) == 2\n\n        # test removal of passwords\n        assert await r.acl_setuser(\n            username, enabled=True, reset=True, passwords=[\"+pass1\", \"+pass2\"]\n        )\n        assert len((await r.acl_getuser(username))[\"passwords\"]) == 2\n        assert await r.acl_setuser(username, enabled=True, passwords=[\"-pass2\"])\n        assert len((await r.acl_getuser(username))[\"passwords\"]) == 1\n\n        # Resets and tests that hashed passwords are set properly.\n        hashed_password = (\n            \"5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8\"\n        )\n        assert await r.acl_setuser(\n            username, enabled=True, reset=True, hashed_passwords=[\"+\" + hashed_password]\n        )\n        acl = await r.acl_getuser(username)\n        assert acl[\"passwords\"] == [hashed_password]\n\n        # test removal of hashed passwords\n        assert await r.acl_setuser(\n            username,\n            enabled=True,\n            reset=True,\n            hashed_passwords=[\"+\" + hashed_password],\n            passwords=[\"+pass1\"],\n        )\n        assert len((await r.acl_getuser(username))[\"passwords\"]) == 2\n        assert await r.acl_setuser(\n            username, enabled=True, hashed_passwords=[\"-\" + hashed_password]\n        )\n        assert len((await r.acl_getuser(username))[\"passwords\"]) == 1\n\n    @skip_if_server_version_lt(REDIS_6_VERSION)\n    async def test_acl_list(self, r_teardown):\n        username = \"redis-py-user\"\n        r = r_teardown(username)\n        start = await r.acl_list()\n        assert await r.acl_setuser(username, enabled=False, reset=True)\n        users = await r.acl_list()\n        assert len(users) == len(start) + 1\n\n    @skip_if_server_version_lt(REDIS_6_VERSION)\n    @pytest.mark.onlynoncluster\n    async def test_acl_log(self, r_teardown, create_redis):\n        username = \"redis-py-user\"\n        r = r_teardown(username)\n        await r.acl_setuser(\n            username,\n            enabled=True,\n            reset=True,\n            commands=[\"+get\", \"+set\", \"+select\"],\n            keys=[\"cache:*\"],\n            nopass=True,\n        )\n        await r.acl_log_reset()\n\n        user_client = await create_redis(username=username)\n\n        # Valid operation and key\n        assert await user_client.set(\"cache:0\", 1)\n        assert await user_client.get(\"cache:0\") == b\"1\"\n\n        # Invalid key\n        with pytest.raises(exceptions.NoPermissionError):\n            await user_client.get(\"violated_cache:0\")\n\n        # Invalid operation\n        with pytest.raises(exceptions.NoPermissionError):\n            await user_client.hset(\"cache:0\", \"hkey\", \"hval\")\n\n        assert isinstance(await r.acl_log(), list)\n        assert len(await r.acl_log()) == 3\n        assert len(await r.acl_log(count=1)) == 1\n        assert isinstance((await r.acl_log())[0], dict)\n        expected = (await r.acl_log(count=1))[0]\n        assert_resp_response_in(r, \"client-info\", expected, expected.keys())\n        assert await r.acl_log_reset()\n\n    @skip_if_server_version_lt(REDIS_6_VERSION)\n    async def test_acl_setuser_categories_without_prefix_fails(self, r_teardown):\n        username = \"redis-py-user\"\n        r = r_teardown(username)\n\n        with pytest.raises(exceptions.DataError):\n            await r.acl_setuser(username, categories=[\"list\"])\n\n    @skip_if_server_version_lt(REDIS_6_VERSION)\n    async def test_acl_setuser_commands_without_prefix_fails(self, r_teardown):\n        username = \"redis-py-user\"\n        r = r_teardown(username)\n\n        with pytest.raises(exceptions.DataError):\n            await r.acl_setuser(username, commands=[\"get\"])\n\n    @skip_if_server_version_lt(REDIS_6_VERSION)\n    async def test_acl_setuser_add_passwords_and_nopass_fails(self, r_teardown):\n        username = \"redis-py-user\"\n        r = r_teardown(username)\n\n        with pytest.raises(exceptions.DataError):\n            await r.acl_setuser(username, passwords=\"+mypass\", nopass=True)\n\n    @skip_if_server_version_lt(REDIS_6_VERSION)\n    async def test_acl_users(self, r: redis.Redis):\n        users = await r.acl_users()\n        assert isinstance(users, list)\n        assert len(users) > 0\n\n    @skip_if_server_version_lt(REDIS_6_VERSION)\n    async def test_acl_whoami(self, r: redis.Redis):\n        username = await r.acl_whoami()\n        assert isinstance(username, (str, bytes))\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"7.9.0\")\n    async def test_acl_modules_commands(self, r_teardown):\n        username = \"redis-py-user\"\n        password = \"pass-for-test-user\"\n\n        r = r_teardown(username)\n        await r.flushdb()\n\n        await r.ft().create_index((TextField(\"txt\"),))\n        await r.hset(\"doc1\", mapping={\"txt\": \"foo baz\"})\n        await r.hset(\"doc2\", mapping={\"txt\": \"foo bar\"})\n\n        await r.acl_setuser(\n            username,\n            enabled=True,\n            reset=True,\n            passwords=[f\"+{password}\"],\n            categories=[\"-all\"],\n            commands=[\n                \"+FT.SEARCH\",\n                \"-FT.DROPINDEX\",\n                \"+json.set\",\n                \"+json.get\",\n                \"-json.clear\",\n                \"+bf.reserve\",\n                \"-bf.info\",\n                \"+cf.reserve\",\n                \"+cms.initbydim\",\n                \"+topk.reserve\",\n                \"+tdigest.create\",\n                \"+ts.create\",\n                \"-ts.info\",\n            ],\n            keys=[\"*\"],\n        )\n\n        await r.auth(password, username)\n\n        assert await r.ft().search(Query(\"foo ~bar\"))\n        with pytest.raises(exceptions.NoPermissionError):\n            await r.ft().dropindex()\n\n        await r.json().set(\"foo\", Path.root_path(), \"bar\")\n        assert await r.json().get(\"foo\") == \"bar\"\n        with pytest.raises(exceptions.NoPermissionError):\n            await r.json().clear(\"foo\")\n\n        assert await r.bf().create(\"bloom\", 0.01, 1000)\n        assert await r.cf().create(\"cuckoo\", 1000)\n        assert await r.cms().initbydim(\"cmsDim\", 100, 5)\n        assert await r.topk().reserve(\"topk\", 5, 100, 5, 0.9)\n        assert await r.tdigest().create(\"to-tDigest\", 10)\n        with pytest.raises(exceptions.NoPermissionError):\n            await r.bf().info(\"bloom\")\n\n        assert await r.ts().create(1, labels={\"Redis\": \"Labs\"})\n        with pytest.raises(exceptions.NoPermissionError):\n            await r.ts().info(1)\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"7.9.0\")\n    async def test_acl_modules_category_commands(self, r_teardown):\n        username = \"redis-py-user\"\n        password = \"pass-for-test-user\"\n\n        r = r_teardown(username)\n        await r.flushdb()\n\n        # validate modules categories acl config\n        await r.acl_setuser(\n            username,\n            enabled=True,\n            reset=True,\n            passwords=[f\"+{password}\"],\n            categories=[\n                \"-all\",\n                \"+@search\",\n                \"+@json\",\n                \"+@bloom\",\n                \"+@cuckoo\",\n                \"+@topk\",\n                \"+@cms\",\n                \"+@timeseries\",\n                \"+@tdigest\",\n            ],\n            keys=[\"*\"],\n        )\n        await r.ft().create_index((TextField(\"txt\"),))\n        await r.hset(\"doc1\", mapping={\"txt\": \"foo baz\"})\n        await r.hset(\"doc2\", mapping={\"txt\": \"foo bar\"})\n\n        await r.auth(password, username)\n\n        assert await r.ft().search(Query(\"foo ~bar\"))\n        assert await r.ft().dropindex()\n\n        assert await r.json().set(\"foo\", Path.root_path(), \"bar\")\n        assert await r.json().get(\"foo\") == \"bar\"\n\n        assert await r.bf().create(\"bloom\", 0.01, 1000)\n        assert await r.bf().info(\"bloom\")\n        assert await r.cf().create(\"cuckoo\", 1000)\n        assert await r.cms().initbydim(\"cmsDim\", 100, 5)\n        assert await r.topk().reserve(\"topk\", 5, 100, 5, 0.9)\n        assert await r.tdigest().create(\"to-tDigest\", 10)\n\n        assert await r.ts().create(1, labels={\"Redis\": \"Labs\"})\n        assert await r.ts().info(1)\n\n    @pytest.mark.onlynoncluster\n    async def test_client_list(self, r: redis.Redis):\n        clients = await r.client_list()\n        assert isinstance(clients[0], dict)\n        assert \"addr\" in clients[0]\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    async def test_client_list_type(self, r: redis.Redis):\n        with pytest.raises(exceptions.RedisError):\n            await r.client_list(_type=\"not a client type\")\n        for client_type in [\"normal\", \"master\", \"replica\", \"pubsub\"]:\n            clients = await r.client_list(_type=client_type)\n            assert isinstance(clients, list)\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    @pytest.mark.onlynoncluster\n    async def test_client_id(self, r: redis.Redis):\n        assert await r.client_id() > 0\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    @pytest.mark.onlynoncluster\n    async def test_client_unblock(self, r: redis.Redis):\n        myid = await r.client_id()\n        assert not await r.client_unblock(myid)\n        assert not await r.client_unblock(myid, error=True)\n        assert not await r.client_unblock(myid, error=False)\n\n    @skip_if_server_version_lt(\"2.6.9\")\n    @pytest.mark.onlynoncluster\n    async def test_client_getname(self, r: redis.Redis):\n        assert await r.client_getname() is None\n\n    @skip_if_server_version_lt(\"2.6.9\")\n    @pytest.mark.onlynoncluster\n    async def test_client_setname(self, r: redis.Redis):\n        assert await r.client_setname(\"redis_py_test\")\n        assert_resp_response(\n            r, await r.client_getname(), \"redis_py_test\", b\"redis_py_test\"\n        )\n\n    @skip_if_server_version_lt(\"7.2.0\")\n    async def test_client_setinfo(self, r: redis.Redis):\n        from redis.utils import get_lib_version\n\n        await r.ping()\n        info = await r.client_info()\n        assert info[\"lib-name\"] == \"redis-py\"\n        assert info[\"lib-ver\"] == get_lib_version()\n        assert await r.client_setinfo(\"lib-name\", \"test\")\n        assert await r.client_setinfo(\"lib-ver\", \"123\")\n        info = await r.client_info()\n        assert info[\"lib-name\"] == \"test\"\n        assert info[\"lib-ver\"] == \"123\"\n\n        # Test deprecated lib_name/lib_version parameters\n        with pytest.warns(DeprecationWarning):\n            r2 = redis.Redis(lib_name=\"test2\", lib_version=\"1234\")\n        info = await r2.client_info()\n        assert info[\"lib-name\"] == \"test2\"\n        assert info[\"lib-ver\"] == \"1234\"\n        await r2.aclose()\n\n    @skip_if_server_version_lt(\"7.2.0\")\n    async def test_client_setinfo_with_driver_info(self, r: redis.Redis):\n        from redis import DriverInfo\n        from redis.utils import get_lib_version\n\n        info = DriverInfo().add_upstream_driver(\"celery\", \"5.4.1\")\n        r2 = redis.Redis(driver_info=info)\n        await r2.ping()\n        client_info = await r2.client_info()\n        assert client_info[\"lib-name\"] == \"redis-py(celery_v5.4.1)\"\n        assert client_info[\"lib-ver\"] == get_lib_version()\n        await r2.aclose()\n\n    @skip_if_server_version_lt(\"2.6.9\")\n    @pytest.mark.onlynoncluster\n    async def test_client_kill(self, r: redis.Redis, r2):\n        await r.client_setname(\"redis-py-c1\")\n        await r2.client_setname(\"redis-py-c2\")\n        clients = [\n            client\n            for client in await r.client_list()\n            if client.get(\"name\") in [\"redis-py-c1\", \"redis-py-c2\"]\n        ]\n        assert len(clients) == 2\n\n        clients_by_name = {client.get(\"name\"): client for client in clients}\n\n        client_addr = clients_by_name[\"redis-py-c2\"].get(\"addr\")\n        assert await r.client_kill(client_addr) is True\n\n        clients = [\n            client\n            for client in await r.client_list()\n            if client.get(\"name\") in [\"redis-py-c1\", \"redis-py-c2\"]\n        ]\n        assert len(clients) == 1\n        assert clients[0].get(\"name\") == \"redis-py-c1\"\n\n    @skip_if_server_version_lt(\"2.8.12\")\n    async def test_client_kill_filter_invalid_params(self, r: redis.Redis):\n        # empty\n        with pytest.raises(exceptions.DataError):\n            await r.client_kill_filter()\n\n        # invalid skipme\n        with pytest.raises(exceptions.DataError):\n            await r.client_kill_filter(skipme=\"yeah\")  # type: ignore\n\n        # invalid type\n        with pytest.raises(exceptions.DataError):\n            await r.client_kill_filter(_type=\"caster\")  # type: ignore\n\n    @skip_if_server_version_lt(\"2.8.12\")\n    @pytest.mark.onlynoncluster\n    async def test_client_kill_filter_by_id(self, r: redis.Redis, r2):\n        await r.client_setname(\"redis-py-c1\")\n        await r2.client_setname(\"redis-py-c2\")\n        clients = [\n            client\n            for client in await r.client_list()\n            if client.get(\"name\") in [\"redis-py-c1\", \"redis-py-c2\"]\n        ]\n        assert len(clients) == 2\n\n        clients_by_name = {client.get(\"name\"): client for client in clients}\n\n        client_2_id = clients_by_name[\"redis-py-c2\"].get(\"id\")\n        resp = await r.client_kill_filter(_id=client_2_id)\n        assert resp == 1\n\n        clients = [\n            client\n            for client in await r.client_list()\n            if client.get(\"name\") in [\"redis-py-c1\", \"redis-py-c2\"]\n        ]\n        assert len(clients) == 1\n        assert clients[0].get(\"name\") == \"redis-py-c1\"\n\n    @skip_if_server_version_lt(\"2.8.12\")\n    @pytest.mark.onlynoncluster\n    async def test_client_kill_filter_by_addr(self, r: redis.Redis, r2):\n        await r.client_setname(\"redis-py-c1\")\n        await r2.client_setname(\"redis-py-c2\")\n        clients = [\n            client\n            for client in await r.client_list()\n            if client.get(\"name\") in [\"redis-py-c1\", \"redis-py-c2\"]\n        ]\n        assert len(clients) == 2\n\n        clients_by_name = {client.get(\"name\"): client for client in clients}\n\n        client_2_addr = clients_by_name[\"redis-py-c2\"].get(\"addr\")\n        resp = await r.client_kill_filter(addr=client_2_addr)\n        assert resp == 1\n\n        clients = [\n            client\n            for client in await r.client_list()\n            if client.get(\"name\") in [\"redis-py-c1\", \"redis-py-c2\"]\n        ]\n        assert len(clients) == 1\n        assert clients[0].get(\"name\") == \"redis-py-c1\"\n\n    @skip_if_server_version_lt(\"2.6.9\")\n    async def test_client_list_after_client_setname(self, r: redis.Redis):\n        await r.client_setname(\"redis_py_test\")\n        clients = await r.client_list()\n        # we don't know which client ours will be\n        assert \"redis_py_test\" in [c[\"name\"] for c in clients]\n\n    @skip_if_server_version_lt(\"2.9.50\")\n    @pytest.mark.onlynoncluster\n    async def test_client_pause(self, r: redis.Redis):\n        assert await r.client_pause(1)\n        assert await r.client_pause(timeout=1)\n        with pytest.raises(exceptions.RedisError):\n            await r.client_pause(timeout=\"not an integer\")\n\n    @skip_if_server_version_lt(\"7.2.0\")\n    @pytest.mark.onlynoncluster\n    async def test_client_no_touch(self, r: redis.Redis):\n        assert await r.client_no_touch(\"ON\") == b\"OK\"\n        assert await r.client_no_touch(\"OFF\") == b\"OK\"\n        with pytest.raises(TypeError):\n            await r.client_no_touch()\n\n    async def test_config_get(self, r: redis.Redis):\n        data = await r.config_get()\n        assert \"maxmemory\" in data\n        assert data[\"maxmemory\"].isdigit()\n\n    @pytest.mark.onlynoncluster\n    async def test_config_resetstat(self, r: redis.Redis):\n        await r.ping()\n        prior_commands_processed = int((await r.info())[\"total_commands_processed\"])\n        assert prior_commands_processed >= 1\n        await r.config_resetstat()\n        reset_commands_processed = int((await r.info())[\"total_commands_processed\"])\n        assert reset_commands_processed < prior_commands_processed\n\n    async def test_config_set(self, r: redis.Redis):\n        await r.config_set(\"timeout\", 70)\n        assert (await r.config_get())[\"timeout\"] == \"70\"\n        assert await r.config_set(\"timeout\", 0)\n        assert (await r.config_get())[\"timeout\"] == \"0\"\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"7.9.0\")\n    async def test_config_get_for_modules(self, r: redis.Redis):\n        search_module_configs = await r.config_get(\"search-*\")\n        assert \"search-timeout\" in search_module_configs\n\n        ts_module_configs = await r.config_get(\"ts-*\")\n        assert \"ts-retention-policy\" in ts_module_configs\n\n        bf_module_configs = await r.config_get(\"bf-*\")\n        assert \"bf-error-rate\" in bf_module_configs\n\n        cf_module_configs = await r.config_get(\"cf-*\")\n        assert \"cf-initial-size\" in cf_module_configs\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"7.9.0\")\n    async def test_config_set_for_search_module(self, r: redis.Redis):\n        config = await r.config_get(\"*\")\n        initial_default_search_dialect = config[\"search-default-dialect\"]\n        try:\n            default_dialect_new = \"3\"\n            assert await r.config_set(\"search-default-dialect\", default_dialect_new)\n            assert (await r.config_get(\"*\"))[\n                \"search-default-dialect\"\n            ] == default_dialect_new\n            assert (\n                ((await r.ft().config_get(\"*\"))[b\"DEFAULT_DIALECT\"]).decode()\n                == default_dialect_new\n            )\n        except AssertionError as ex:\n            raise ex\n        finally:\n            assert await r.config_set(\n                \"search-default-dialect\", initial_default_search_dialect\n            )\n        with pytest.raises(exceptions.ResponseError):\n            await r.config_set(\"search-max-doctablesize\", 2000000)\n\n    @pytest.mark.onlynoncluster\n    async def test_dbsize(self, r: redis.Redis):\n        await r.set(\"a\", \"foo\")\n        await r.set(\"b\", \"bar\")\n        assert await r.dbsize() == 2\n\n    @pytest.mark.onlynoncluster\n    async def test_echo(self, r: redis.Redis):\n        assert await r.echo(\"foo bar\") == b\"foo bar\"\n\n    @pytest.mark.onlynoncluster\n    async def test_info(self, r: redis.Redis):\n        await r.set(\"a\", \"foo\")\n        await r.set(\"b\", \"bar\")\n        info = await r.info()\n        assert isinstance(info, dict)\n        assert \"arch_bits\" in info.keys()\n        assert \"redis_version\" in info.keys()\n\n    @pytest.mark.onlynoncluster\n    async def test_lastsave(self, r: redis.Redis):\n        assert isinstance(await r.lastsave(), datetime.datetime)\n\n    async def test_object(self, r: redis.Redis):\n        await r.set(\"a\", \"foo\")\n        assert isinstance(await r.object(\"refcount\", \"a\"), int)\n        assert isinstance(await r.object(\"idletime\", \"a\"), int)\n        assert await r.object(\"encoding\", \"a\") in (b\"raw\", b\"embstr\")\n        assert await r.object(\"idletime\", \"invalid-key\") is None\n\n    async def test_ping(self, r: redis.Redis):\n        assert await r.ping()\n\n    @pytest.mark.onlynoncluster\n    async def test_slowlog_get(self, r: redis.Redis, slowlog):\n        assert await r.slowlog_reset()\n        unicode_string = chr(3456) + \"abcd\" + chr(3421)\n        await r.get(unicode_string)\n        slowlog = await r.slowlog_get()\n        assert isinstance(slowlog, list)\n        commands = [log[\"command\"] for log in slowlog]\n\n        get_command = b\" \".join((b\"GET\", unicode_string.encode(\"utf-8\")))\n        assert get_command in commands\n        assert b\"SLOWLOG RESET\" in commands\n        # the order should be ['GET <uni string>', 'SLOWLOG RESET'],\n        # but if other clients are executing commands at the same time, there\n        # could be commands, before, between, or after, so just check that\n        # the two we care about are in the appropriate order.\n        assert commands.index(get_command) < commands.index(b\"SLOWLOG RESET\")\n\n        # make sure other attributes are typed correctly\n        assert isinstance(slowlog[0][\"start_time\"], int)\n        assert isinstance(slowlog[0][\"duration\"], int)\n\n    @pytest.mark.onlynoncluster\n    async def test_slowlog_get_limit(self, r: redis.Redis, slowlog):\n        assert await r.slowlog_reset()\n        await r.get(\"foo\")\n        slowlog = await r.slowlog_get(1)\n        assert isinstance(slowlog, list)\n        # only one command, based on the number we passed to slowlog_get()\n        assert len(slowlog) == 1\n\n    @pytest.mark.onlynoncluster\n    async def test_slowlog_length(self, r: redis.Redis, slowlog):\n        await r.get(\"foo\")\n        assert isinstance(await r.slowlog_len(), int)\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    async def test_time(self, r: redis.Redis):\n        t = await r.time()\n        assert len(t) == 2\n        assert isinstance(t[0], int)\n        assert isinstance(t[1], int)\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.5.240\")\n    async def test_hotkeys_start_basic(self, r: redis.Redis):\n        \"\"\"Test basic HOTKEYS START command with CPU metric\"\"\"\n        # Reset any previous session\n        try:\n            await r.hotkeys_stop()\n        except Exception:\n            pass\n\n        # Start collection with CPU metric\n        result = await r.hotkeys_start(count=10, metrics=[HotkeysMetricsTypes.CPU])\n        assert result == b\"OK\"\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.5.240\")\n    async def test_hotkeys_start_with_all_metrics(self, r: redis.Redis):\n        \"\"\"Test HOTKEYS START with both CPU and NET metrics\"\"\"\n        try:\n            await r.hotkeys_stop()\n        except Exception:\n            pass\n\n        result = await r.hotkeys_start(\n            count=5, metrics=[HotkeysMetricsTypes.CPU, HotkeysMetricsTypes.NET]\n        )\n        assert result == b\"OK\"\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.5.240\")\n    async def test_hotkeys_start_with_duration(self, r: redis.Redis):\n        \"\"\"Test HOTKEYS START with duration parameter\"\"\"\n        try:\n            await r.hotkeys_stop()\n        except Exception:\n            pass\n\n        result = await r.hotkeys_start(\n            count=10, metrics=[HotkeysMetricsTypes.CPU], duration=60\n        )\n        assert result == b\"OK\"\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.5.240\")\n    async def test_hotkeys_start_with_sample_ratio(self, r: redis.Redis):\n        \"\"\"Test HOTKEYS START with sample ratio\"\"\"\n        try:\n            await r.hotkeys_stop()\n        except Exception:\n            pass\n\n        result = await r.hotkeys_start(\n            count=10, metrics=[HotkeysMetricsTypes.CPU], sample_ratio=10\n        )\n        assert result == b\"OK\"\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.5.240\")\n    async def test_hotkeys_start_with_slots_fail_on_non_cluster_setup(\n        self, r: redis.Redis\n    ):\n        \"\"\"Test HOTKEYS START with specific hash slots\"\"\"\n        try:\n            await r.hotkeys_stop()\n        except Exception:\n            pass\n        with pytest.raises(Exception):\n            await r.hotkeys_start(\n                count=10, metrics=[HotkeysMetricsTypes.CPU], slots=[0, 100, 200]\n            )\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.5.240\")\n    async def test_hotkeys_start_with_all_parameters(self, r: redis.Redis):\n        \"\"\"Test HOTKEYS START with all optional parameters\"\"\"\n        try:\n            await r.hotkeys_stop()\n        except Exception:\n            pass\n\n        result = await r.hotkeys_start(\n            count=5,\n            metrics=[HotkeysMetricsTypes.CPU, HotkeysMetricsTypes.NET],\n            duration=30,\n            sample_ratio=5,\n        )\n        assert result == b\"OK\"\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.5.240\")\n    async def test_hotkeys_stop(self, r: redis.Redis):\n        \"\"\"Test HOTKEYS STOP command\"\"\"\n        try:\n            await r.hotkeys_stop()\n        except Exception:\n            pass\n\n        # Start a collection session\n        await r.hotkeys_start(count=10, metrics=[HotkeysMetricsTypes.CPU])\n\n        # Stop the session\n        result = await r.hotkeys_stop()\n        assert result == b\"OK\"\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.5.240\")\n    async def test_hotkeys_reset(self, r: redis.Redis):\n        \"\"\"Test HOTKEYS RESET command\"\"\"\n        try:\n            await r.hotkeys_stop()\n        except Exception:\n            pass\n\n        # Start a session and generate some data\n        await r.hotkeys_start(count=10, metrics=[HotkeysMetricsTypes.CPU])\n\n        # Perform some operations to generate hotkeys data\n        for i in range(5):\n            await r.set(f\"resetkey{i}\", f\"value{i}\")\n            await r.get(f\"resetkey{i}\")\n\n        # Stop the session\n        await r.hotkeys_stop()\n\n        # Get results before reset - should have data\n        result_before = await r.hotkeys_get()\n        assert isinstance(result_before, list)\n        for res_elem in result_before:\n            assert isinstance(res_elem, dict)\n            assert b\"tracking-active\" in res_elem\n\n        # Reset the results\n        result = await r.hotkeys_reset()\n        assert result == b\"OK\"\n\n        # Try to get results after reset - should fail or return empty\n        try:\n            result_after = await r.hotkeys_get()\n            # If it doesn't fail, verify the data is cleared\n            # The response might indicate no session exists\n            assert result_after != result_before\n        except Exception:\n            # Expected - no session exists after reset\n            pass\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.5.240\")\n    async def test_hotkeys_get_ongoing_session(self, r: redis.Redis):\n        \"\"\"Test HOTKEYS GET during an ongoing collection session\"\"\"\n        try:\n            await r.hotkeys_stop()\n        except Exception:\n            pass\n\n        # Start a collection session\n        await r.hotkeys_start(\n            count=10, metrics=[HotkeysMetricsTypes.CPU, HotkeysMetricsTypes.NET]\n        )\n\n        # Perform some operations to generate hotkeys data\n        for i in range(10):\n            await r.set(f\"key{i}\", f\"value{i}\")\n            await r.get(f\"key{i}\")\n\n        # Get the results\n        result = await r.hotkeys_get()\n\n        # Verify the response structure\n        assert isinstance(result, list)\n        for res_elem in result:\n            assert isinstance(res_elem, dict)\n            # Check tracking-active is 1 (ongoing session)\n            assert res_elem[b\"tracking-active\"] == 1\n\n        # Stop the session\n        await r.hotkeys_stop()\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.5.240\")\n    async def test_hotkeys_get_terminated_session(self, r: redis.Redis):\n        \"\"\"Test HOTKEYS GET after stopping a collection session\"\"\"\n        try:\n            await r.hotkeys_stop()\n        except Exception:\n            pass\n\n        # Start a collection session\n        await r.hotkeys_start(count=10, metrics=[HotkeysMetricsTypes.CPU])\n\n        # Perform some operations\n        for i in range(5):\n            await r.set(f\"testkey{i}\", f\"testvalue{i}\")\n\n        # Stop the session\n        await r.hotkeys_stop()\n\n        # Get the results\n        result = await r.hotkeys_get()\n\n        # Verify the response structure\n        assert isinstance(result, list)\n        for res_elem in result:\n            assert isinstance(res_elem, dict)\n            # Check tracking-active is 0 (terminated session)\n            assert res_elem[b\"tracking-active\"] == 0\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.5.240\")\n    async def test_hotkeys_get_all_fields(self, r: redis.Redis):\n        \"\"\"Test HOTKEYS GET returns all documented fields\"\"\"\n        try:\n            await r.hotkeys_stop()\n        except Exception:\n            pass\n\n        # Start a collection session with all parameters\n        await r.hotkeys_start(\n            count=5,\n            metrics=[HotkeysMetricsTypes.CPU, HotkeysMetricsTypes.NET],\n            sample_ratio=1,\n        )\n\n        # Perform operations to generate data\n        for i in range(20):\n            await r.set(\"anyprefix:{3}:key\", f\"value{i}\")\n            await r.get(\"anyprefix:{3}:key\")\n            await r.set(\"anyprefix:{1}:key\", f\"value{i}\")\n            await r.get(\"anyprefix:{1}:key\")\n\n        # Stop the session\n        await r.hotkeys_stop()\n\n        # Get the results\n        result = await r.hotkeys_get()\n\n        # Verify all documented fields are present\n        expected_fields = [\n            b\"tracking-active\",\n            b\"sample-ratio\",\n            b\"selected-slots\",\n            b\"net-bytes-all-commands-all-slots\",\n            b\"collection-start-time-unix-ms\",\n            b\"collection-duration-ms\",\n            b\"total-cpu-time-user-ms\",\n            b\"total-cpu-time-sys-ms\",\n            b\"total-net-bytes\",\n            b\"by-cpu-time-us\",\n            b\"by-net-bytes\",\n        ]\n\n        for elem in result:\n            for field in expected_fields:\n                assert field in elem, (\n                    f\"Field '{field}' is missing from HOTKEYS GET response\"\n                )\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.5.240\")\n    async def test_hotkeys_get_all_fields_decoded(self, decoded_r: redis.Redis):\n        \"\"\"Test HOTKEYS GET returns all documented fields\"\"\"\n        try:\n            await decoded_r.hotkeys_stop()\n        except Exception:\n            pass\n\n        # Start a collection session with all parameters\n        await decoded_r.hotkeys_start(\n            count=5,\n            metrics=[HotkeysMetricsTypes.CPU, HotkeysMetricsTypes.NET],\n            sample_ratio=1,\n        )\n\n        # Perform operations to generate data\n        for i in range(20):\n            await decoded_r.set(\"anyprefix:{3}:key\", f\"value{i}\")\n            await decoded_r.get(f\"anyprefix:{3}:key\")\n            await decoded_r.set(\"anyprefix:{1}:key\", f\"value{i}\")\n            await decoded_r.get(f\"anyprefix:{1}:key\")\n\n        # Stop the session\n        await decoded_r.hotkeys_stop()\n\n        # Get the results\n        result = await decoded_r.hotkeys_get()\n\n        # Verify all documented fields are present\n        expected_fields = [\n            \"tracking-active\",\n            \"sample-ratio\",\n            \"selected-slots\",\n            \"net-bytes-all-commands-all-slots\",\n            \"collection-start-time-unix-ms\",\n            \"collection-duration-ms\",\n            \"total-cpu-time-user-ms\",\n            \"total-cpu-time-sys-ms\",\n            \"total-net-bytes\",\n            \"by-cpu-time-us\",\n            \"by-net-bytes\",\n        ]\n\n        for elem in result:\n            for field in expected_fields:\n                assert field in elem, (\n                    f\"Field '{field}' is missing from HOTKEYS GET response\"\n                )\n\n    async def test_never_decode_option(self, r: redis.Redis):\n        opts = {NEVER_DECODE: []}\n        await r.delete(\"a\")\n        assert await r.execute_command(\"EXISTS\", \"a\", **opts) == 0\n\n    async def test_empty_response_option(self, r: redis.Redis):\n        opts = {EMPTY_RESPONSE: []}\n        await r.delete(\"a\")\n        assert await r.execute_command(\"EXISTS\", \"a\", **opts) == 0\n\n    # BASIC KEY COMMANDS\n    async def test_append(self, r: redis.Redis):\n        assert await r.append(\"a\", \"a1\") == 2\n        assert await r.get(\"a\") == b\"a1\"\n        assert await r.append(\"a\", \"a2\") == 4\n        assert await r.get(\"a\") == b\"a1a2\"\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    async def test_bitcount(self, r: redis.Redis):\n        await r.setbit(\"a\", 5, True)\n        assert await r.bitcount(\"a\") == 1\n        await r.setbit(\"a\", 6, True)\n        assert await r.bitcount(\"a\") == 2\n        await r.setbit(\"a\", 5, False)\n        assert await r.bitcount(\"a\") == 1\n        await r.setbit(\"a\", 9, True)\n        await r.setbit(\"a\", 17, True)\n        await r.setbit(\"a\", 25, True)\n        await r.setbit(\"a\", 33, True)\n        assert await r.bitcount(\"a\") == 5\n        assert await r.bitcount(\"a\", 0, -1) == 5\n        assert await r.bitcount(\"a\", 2, 3) == 2\n        assert await r.bitcount(\"a\", 2, -1) == 3\n        assert await r.bitcount(\"a\", -2, -1) == 2\n        assert await r.bitcount(\"a\", 1, 1) == 1\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    @pytest.mark.onlynoncluster\n    async def test_bitop_not_empty_string(self, r: redis.Redis):\n        await r.set(\"a\", \"\")\n        await r.bitop(\"not\", \"r\", \"a\")\n        assert await r.get(\"r\") is None\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    @pytest.mark.onlynoncluster\n    async def test_bitop_not(self, r: redis.Redis):\n        test_str = b\"\\xaa\\x00\\xff\\x55\"\n        correct = ~0xAA00FF55 & 0xFFFFFFFF\n        await r.set(\"a\", test_str)\n        await r.bitop(\"not\", \"r\", \"a\")\n        assert int(binascii.hexlify(await r.get(\"r\")), 16) == correct\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    @pytest.mark.onlynoncluster\n    async def test_bitop_not_in_place(self, r: redis.Redis):\n        test_str = b\"\\xaa\\x00\\xff\\x55\"\n        correct = ~0xAA00FF55 & 0xFFFFFFFF\n        await r.set(\"a\", test_str)\n        await r.bitop(\"not\", \"a\", \"a\")\n        assert int(binascii.hexlify(await r.get(\"a\")), 16) == correct\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    @pytest.mark.onlynoncluster\n    async def test_bitop_single_string(self, r: redis.Redis):\n        test_str = b\"\\x01\\x02\\xff\"\n        await r.set(\"a\", test_str)\n        await r.bitop(\"and\", \"res1\", \"a\")\n        await r.bitop(\"or\", \"res2\", \"a\")\n        await r.bitop(\"xor\", \"res3\", \"a\")\n        assert await r.get(\"res1\") == test_str\n        assert await r.get(\"res2\") == test_str\n        assert await r.get(\"res3\") == test_str\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    @pytest.mark.onlynoncluster\n    async def test_bitop_string_operands(self, r: redis.Redis):\n        await r.set(\"a\", b\"\\x01\\x02\\xff\\xff\")\n        await r.set(\"b\", b\"\\x01\\x02\\xff\")\n        await r.bitop(\"and\", \"res1\", \"a\", \"b\")\n        await r.bitop(\"or\", \"res2\", \"a\", \"b\")\n        await r.bitop(\"xor\", \"res3\", \"a\", \"b\")\n        assert int(binascii.hexlify(await r.get(\"res1\")), 16) == 0x0102FF00\n        assert int(binascii.hexlify(await r.get(\"res2\")), 16) == 0x0102FFFF\n        assert int(binascii.hexlify(await r.get(\"res3\")), 16) == 0x000000FF\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.1.224\")\n    async def test_bitop_diff(self, r: redis.Redis):\n        await r.set(\"a\", b\"\\xf0\")\n        await r.set(\"b\", b\"\\xc0\")\n        await r.set(\"c\", b\"\\x80\")\n\n        result = await r.bitop(\"DIFF\", \"result\", \"a\", \"b\", \"c\")\n        assert result == 1\n        assert await r.get(\"result\") == b\"\\x30\"\n\n        await r.bitop(\"DIFF\", \"result2\", \"a\", \"nonexistent\")\n        assert await r.get(\"result2\") == b\"\\xf0\"\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.1.224\")\n    async def test_bitop_diff1(self, r: redis.Redis):\n        await r.set(\"a\", b\"\\xf0\")\n        await r.set(\"b\", b\"\\xc0\")\n        await r.set(\"c\", b\"\\x80\")\n\n        result = await r.bitop(\"DIFF1\", \"result\", \"a\", \"b\", \"c\")\n        assert result == 1\n        assert await r.get(\"result\") == b\"\\x00\"\n\n        await r.set(\"d\", b\"\\x0f\")\n        await r.set(\"e\", b\"\\x03\")\n        await r.bitop(\"DIFF1\", \"result2\", \"d\", \"e\")\n        assert await r.get(\"result2\") == b\"\\x00\"\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.1.224\")\n    async def test_bitop_andor(self, r: redis.Redis):\n        await r.set(\"a\", b\"\\xf0\")\n        await r.set(\"b\", b\"\\xc0\")\n        await r.set(\"c\", b\"\\x80\")\n\n        result = await r.bitop(\"ANDOR\", \"result\", \"a\", \"b\", \"c\")\n        assert result == 1\n        assert await r.get(\"result\") == b\"\\xc0\"\n\n        await r.set(\"x\", b\"\\xf0\")\n        await r.set(\"y\", b\"\\x0f\")\n        await r.bitop(\"ANDOR\", \"result2\", \"x\", \"y\")\n        assert await r.get(\"result2\") == b\"\\x00\"\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.1.224\")\n    async def test_bitop_one(self, r: redis.Redis):\n        await r.set(\"a\", b\"\\xf0\")\n        await r.set(\"b\", b\"\\xc0\")\n        await r.set(\"c\", b\"\\x80\")\n\n        result = await r.bitop(\"ONE\", \"result\", \"a\", \"b\", \"c\")\n        assert result == 1\n        assert await r.get(\"result\") == b\"\\x30\"\n\n        await r.set(\"x\", b\"\\xf0\")\n        await r.set(\"y\", b\"\\x0f\")\n        await r.bitop(\"ONE\", \"result2\", \"x\", \"y\")\n        assert await r.get(\"result2\") == b\"\\xff\"\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.1.224\")\n    async def test_bitop_new_operations_with_empty_keys(self, r: redis.Redis):\n        await r.set(\"a\", b\"\\xff\")\n\n        await r.bitop(\"DIFF\", \"empty_result\", \"nonexistent\", \"a\")\n        assert await r.get(\"empty_result\") == b\"\\x00\"\n\n        await r.bitop(\"DIFF1\", \"empty_result2\", \"a\", \"nonexistent\")\n        assert await r.get(\"empty_result2\") == b\"\\x00\"\n\n        await r.bitop(\"ANDOR\", \"empty_result3\", \"a\", \"nonexistent\")\n        assert await r.get(\"empty_result3\") == b\"\\x00\"\n\n        await r.bitop(\"ONE\", \"empty_result4\", \"nonexistent\")\n        assert await r.get(\"empty_result4\") is None\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.1.224\")\n    async def test_bitop_new_operations_return_values(self, r: redis.Redis):\n        await r.set(\"a\", b\"\\xff\\x00\\xff\")\n        await r.set(\"b\", b\"\\x00\\xff\")\n\n        result1 = await r.bitop(\"DIFF\", \"result1\", \"a\", \"b\")\n        assert result1 == 3\n\n        result2 = await r.bitop(\"DIFF1\", \"result2\", \"a\", \"b\")\n        assert result2 == 3\n\n        result3 = await r.bitop(\"ANDOR\", \"result3\", \"a\", \"b\")\n        assert result3 == 3\n\n        result4 = await r.bitop(\"ONE\", \"result4\", \"a\", \"b\")\n        assert result4 == 3\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"2.8.7\")\n    async def test_bitpos(self, r: redis.Redis):\n        key = \"key:bitpos\"\n        await r.set(key, b\"\\xff\\xf0\\x00\")\n        assert await r.bitpos(key, 0) == 12\n        assert await r.bitpos(key, 0, 2, -1) == 16\n        assert await r.bitpos(key, 0, -2, -1) == 12\n        await r.set(key, b\"\\x00\\xff\\xf0\")\n        assert await r.bitpos(key, 1, 0) == 8\n        assert await r.bitpos(key, 1, 1) == 8\n        await r.set(key, b\"\\x00\\x00\\x00\")\n        assert await r.bitpos(key, 1) == -1\n\n    @skip_if_server_version_lt(\"2.8.7\")\n    async def test_bitpos_wrong_arguments(self, r: redis.Redis):\n        key = \"key:bitpos:wrong:args\"\n        await r.set(key, b\"\\xff\\xf0\\x00\")\n        with pytest.raises(exceptions.RedisError):\n            await r.bitpos(key, 0, end=1) == 12\n        with pytest.raises(exceptions.RedisError):\n            await r.bitpos(key, 7) == 12\n\n    async def test_decr(self, r: redis.Redis):\n        assert await r.decr(\"a\") == -1\n        assert await r.get(\"a\") == b\"-1\"\n        assert await r.decr(\"a\") == -2\n        assert await r.get(\"a\") == b\"-2\"\n        assert await r.decr(\"a\", amount=5) == -7\n        assert await r.get(\"a\") == b\"-7\"\n\n    async def test_decrby(self, r: redis.Redis):\n        assert await r.decrby(\"a\", amount=2) == -2\n        assert await r.decrby(\"a\", amount=3) == -5\n        assert await r.get(\"a\") == b\"-5\"\n\n    async def test_delete(self, r: redis.Redis):\n        assert await r.delete(\"a\") == 0\n        await r.set(\"a\", \"foo\")\n        assert await r.delete(\"a\") == 1\n\n    async def test_delete_with_multiple_keys(self, r: redis.Redis):\n        await r.set(\"a\", \"foo\")\n        await r.set(\"b\", \"bar\")\n        assert await r.delete(\"a\", \"b\") == 2\n        assert await r.get(\"a\") is None\n        assert await r.get(\"b\") is None\n\n    async def test_delitem(self, r: redis.Redis):\n        await r.set(\"a\", \"foo\")\n        await r.delete(\"a\")\n        assert await r.get(\"a\") is None\n\n    def _ensure_str(self, x):\n        return x.decode(\"ascii\") if isinstance(x, (bytes, bytearray)) else x\n\n    async def _server_xxh3_digest(self, r, key):\n        \"\"\"\n        Get the server-computed XXH3 hex digest for the key's value.\n        Requires the DIGEST command implemented on the server.\n        \"\"\"\n        d = await r.execute_command(\"DIGEST\", key)\n        return None if d is None else self._ensure_str(d).lower()\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_delex_nonexistent(self, r):\n        await r.delete(\"nope\")\n        assert await r.delex(\"nope\") == 0\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_delex_unconditional_delete_string(self, r):\n        await r.set(\"k\", b\"v\")\n        assert await r.exists(\"k\") == 1\n        assert await r.delex(\"k\") == 1\n        assert await r.exists(\"k\") == 0\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_delex_unconditional_delete_nonstring_allowed(self, r):\n        # Spec: error happens only when a condition is specified on a non-string key.\n        await r.lpush(\"lst\", \"a\")\n        assert await r.delex(\"lst\") == 1\n        assert await r.exists(\"lst\") == 0\n\n        await r.lpush(\"lst\", \"a\")\n\n        with pytest.raises(redis.ResponseError):\n            await r.delex(\"lst\", ifeq=b\"a\")\n        assert await r.exists(\"lst\") == 1\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_delex_ifeq(self, r):\n        await r.set(\"k\", b\"abc\")\n        assert await r.delex(\"k\", ifeq=b\"abc\") == 1  # matches → deleted\n        assert await r.exists(\"k\") == 0\n\n        await r.set(\"k\", b\"abc\")\n        assert await r.delex(\"k\", ifeq=b\"zzz\") == 0  # not match → not deleted\n        assert await r.get(\"k\") == b\"abc\"  # still there\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_delex_ifne(self, r):\n        await r.set(\"k2\", b\"abc\")\n        assert await r.delex(\"k2\", ifne=b\"zzz\") == 1  # different → deleted\n        assert await r.exists(\"k2\") == 0\n\n        await r.set(\"k2\", b\"abc\")\n        assert await r.delex(\"k2\", ifne=b\"abc\") == 0  # equal → not deleted\n        assert await r.get(\"k2\") == b\"abc\"\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_delex_with_conditionon_nonstring_values(self, r):\n        await r.lpush(\"nk\", \"x\")\n        with pytest.raises(redis.ResponseError):\n            await r.delex(\"nk\", ifeq=b\"x\")\n        with pytest.raises(redis.ResponseError):\n            await r.delex(\"nk\", ifne=b\"x\")\n        with pytest.raises(redis.ResponseError):\n            await r.delex(\"nk\", ifdeq=\"deadbeef\")\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    @pytest.mark.parametrize(\"val\", [b\"\", b\"abc\", b\"The quick brown fox\"])\n    async def test_delex_ifdeq_and_ifdne(self, r, val):\n        await r.set(\"h\", val)\n        d = await self._server_xxh3_digest(r, \"h\")\n        assert d is not None\n\n        # IFDEQ should delete with exact digest\n        await r.set(\"h\", val)\n        assert await r.delex(\"h\", ifdeq=d) == 1\n        assert await r.exists(\"h\") == 0\n\n        # IFDNE should NOT delete when digest matches\n        await r.set(\"h\", val)\n        assert await r.delex(\"h\", ifdne=d) == 0\n        assert await r.get(\"h\") == val\n\n        # IFDNE should delete when digest doesn't match\n        await r.set(\"h\", val)\n        wrong = \"0\" * len(d)\n        if wrong == d:\n            wrong = \"f\" * len(d)\n        assert await r.delex(\"h\", ifdne=wrong) == 1\n        assert await r.exists(\"h\") == 0\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_delex_pipeline(self, r):\n        await r.mset({\"p1{45}\": b\"A\", \"p2{45}\": b\"B\"})\n        p = r.pipeline()\n        p.delex(\"p1{45}\", ifeq=b\"A\")\n        p.delex(\"p2{45}\", ifne=b\"B\")  # false → 0\n        p.delex(\"nope\")  # nonexistent → 0\n        out = await p.execute()\n        assert out == [1, 0, 0]\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_delex_mutual_exclusion_client_side(self, r):\n        with pytest.raises(ValueError):\n            await r.delex(\"k\", ifeq=b\"A\", ifne=b\"B\")\n        with pytest.raises(ValueError):\n            await r.delex(\"k\", ifdeq=\"aa\", ifdne=\"bb\")\n\n    @skip_if_server_version_lt(\"4.0.0\")\n    async def test_unlink(self, r: redis.Redis):\n        assert await r.unlink(\"a\") == 0\n        await r.set(\"a\", \"foo\")\n        assert await r.unlink(\"a\") == 1\n        assert await r.get(\"a\") is None\n\n    @skip_if_server_version_lt(\"4.0.0\")\n    async def test_unlink_with_multiple_keys(self, r: redis.Redis):\n        await r.set(\"a\", \"foo\")\n        await r.set(\"b\", \"bar\")\n        assert await r.unlink(\"a\", \"b\") == 2\n        assert await r.get(\"a\") is None\n        assert await r.get(\"b\") is None\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    async def test_dump_and_restore(self, r: redis.Redis):\n        await r.set(\"a\", \"foo\")\n        dumped = await r.dump(\"a\")\n        await r.delete(\"a\")\n        await r.restore(\"a\", 0, dumped)\n        assert await r.get(\"a\") == b\"foo\"\n\n    @skip_if_server_version_lt(\"3.0.0\")\n    async def test_dump_and_restore_and_replace(self, r: redis.Redis):\n        await r.set(\"a\", \"bar\")\n        dumped = await r.dump(\"a\")\n        with pytest.raises(redis.ResponseError):\n            await r.restore(\"a\", 0, dumped)\n\n        await r.restore(\"a\", 0, dumped, replace=True)\n        assert await r.get(\"a\") == b\"bar\"\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    async def test_dump_and_restore_absttl(self, r: redis.Redis):\n        await r.set(\"a\", \"foo\")\n        dumped = await r.dump(\"a\")\n        await r.delete(\"a\")\n        ttl = int(\n            (await redis_server_time(r) + datetime.timedelta(minutes=1)).timestamp()\n            * 1000\n        )\n        await r.restore(\"a\", ttl, dumped, absttl=True)\n        assert await r.get(\"a\") == b\"foo\"\n        assert 0 < await r.ttl(\"a\") <= 61\n\n    async def test_exists(self, r: redis.Redis):\n        assert await r.exists(\"a\") == 0\n        await r.set(\"a\", \"foo\")\n        await r.set(\"b\", \"bar\")\n        assert await r.exists(\"a\") == 1\n        assert await r.exists(\"a\", \"b\") == 2\n\n    async def test_exists_contains(self, r: redis.Redis):\n        assert not await r.exists(\"a\")\n        await r.set(\"a\", \"foo\")\n        assert await r.exists(\"a\")\n\n    async def test_expire(self, r: redis.Redis):\n        assert not await r.expire(\"a\", 10)\n        await r.set(\"a\", \"foo\")\n        assert await r.expire(\"a\", 10)\n        assert 0 < await r.ttl(\"a\") <= 10\n        assert await r.persist(\"a\")\n        assert await r.ttl(\"a\") == -1\n\n    async def test_expireat_datetime(self, r: redis.Redis):\n        expire_at = await redis_server_time(r) + datetime.timedelta(minutes=1)\n        await r.set(\"a\", \"foo\")\n        assert await r.expireat(\"a\", expire_at)\n        assert 0 < await r.ttl(\"a\") <= 61\n\n    async def test_expireat_no_key(self, r: redis.Redis):\n        expire_at = await redis_server_time(r) + datetime.timedelta(minutes=1)\n        assert not await r.expireat(\"a\", expire_at)\n\n    async def test_expireat_unixtime(self, r: redis.Redis):\n        expire_at = await redis_server_time(r) + datetime.timedelta(minutes=1)\n        await r.set(\"a\", \"foo\")\n        expire_at_seconds = int(expire_at.timestamp())\n        assert await r.expireat(\"a\", expire_at_seconds)\n        assert 0 < await r.ttl(\"a\") <= 61\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_digest_nonexistent_returns_none(self, r):\n        assert await r.digest(\"no:such:key\") is None\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_digest_wrong_type_raises(self, r):\n        await r.lpush(\"alist\", \"x\")\n        with pytest.raises(Exception):  # or redis.exceptions.ResponseError\n            await r.digest(\"alist\")\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    @pytest.mark.parametrize(\n        \"value\", [b\"\", b\"abc\", b\"The quick brown fox jumps over the lazy dog\"]\n    )\n    async def test_digest_response_when_available(self, r, value):\n        key = \"k:digest\"\n        await r.delete(key)\n        await r.set(key, value)\n\n        res = await r.digest(key)\n        # got is str if decode_responses=True; ensure bytes->str for comparison\n        if isinstance(res, bytes):\n            res = res.decode()\n        assert res is not None\n        assert all(c in \"0123456789abcdefABCDEF\" for c in res)\n\n        assert len(res) == 16\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    @pytest.mark.parametrize(\n        \"value\",\n        [\n            b\"\",\n            b\"abc\",\n            b\"The quick brown fox jumps over the lazy dog\",\n            \"\",\n            \"abc\",\n            \"The quick brown fox jumps over the lazy dog\",\n        ],\n    )\n    async def test_local_digest_matches_server(self, r, value):\n        key = \"k:digest\"\n        await r.delete(key)\n        await r.set(key, value)\n\n        res_server = await r.digest(key)\n\n        # Caution! This one is not executing execute_command and it is not async\n        res_local = r.digest_local(value)\n\n        # Verify type consistency between server and local digest\n        if isinstance(res_server, bytes):\n            assert isinstance(res_local, bytes)\n        else:\n            assert isinstance(res_local, str)\n\n        assert res_server is not None\n        assert len(res_server) == 16\n        assert res_local is not None\n        assert len(res_local) == 16\n        assert res_server == res_local\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_pipeline_digest(self, r):\n        k1, k2 = \"k:d1{42}\", \"k:d2{42}\"\n        await r.mset({k1: b\"A\", k2: b\"B\"})\n        p = r.pipeline()\n        p.digest(k1)\n        p.digest(k2)\n        out = await p.execute()\n        assert len(out) == 2\n        for d in out:\n            if isinstance(d, bytes):\n                d = d.decode()\n            assert d is None or len(d) == 16\n\n    async def test_get_and_set(self, r: redis.Redis):\n        # get and set can't be tested independently of each other\n        assert await r.get(\"a\") is None\n        byte_string = b\"value\"\n        integer = 5\n        unicode_string = chr(3456) + \"abcd\" + chr(3421)\n        assert await r.set(\"byte_string\", byte_string)\n        assert await r.set(\"integer\", 5)\n        assert await r.set(\"unicode_string\", unicode_string)\n        assert await r.get(\"byte_string\") == byte_string\n        assert await r.get(\"integer\") == str(integer).encode()\n        assert (await r.get(\"unicode_string\")).decode(\"utf-8\") == unicode_string\n\n    async def test_get_set_bit(self, r: redis.Redis):\n        # no value\n        assert not await r.getbit(\"a\", 5)\n        # set bit 5\n        assert not await r.setbit(\"a\", 5, True)\n        assert await r.getbit(\"a\", 5)\n        # unset bit 4\n        assert not await r.setbit(\"a\", 4, False)\n        assert not await r.getbit(\"a\", 4)\n        # set bit 4\n        assert not await r.setbit(\"a\", 4, True)\n        assert await r.getbit(\"a\", 4)\n        # set bit 5 again\n        assert await r.setbit(\"a\", 5, True)\n        assert await r.getbit(\"a\", 5)\n\n    async def test_getrange(self, r: redis.Redis):\n        await r.set(\"a\", \"foo\")\n        assert await r.getrange(\"a\", 0, 0) == b\"f\"\n        assert await r.getrange(\"a\", 0, 2) == b\"foo\"\n        assert await r.getrange(\"a\", 3, 4) == b\"\"\n\n    async def test_getset(self, r: redis.Redis):\n        assert await r.getset(\"a\", \"foo\") is None\n        assert await r.getset(\"a\", \"bar\") == b\"foo\"\n        assert await r.get(\"a\") == b\"bar\"\n\n    async def test_incr(self, r: redis.Redis):\n        assert await r.incr(\"a\") == 1\n        assert await r.get(\"a\") == b\"1\"\n        assert await r.incr(\"a\") == 2\n        assert await r.get(\"a\") == b\"2\"\n        assert await r.incr(\"a\", amount=5) == 7\n        assert await r.get(\"a\") == b\"7\"\n\n    async def test_incrby(self, r: redis.Redis):\n        assert await r.incrby(\"a\") == 1\n        assert await r.incrby(\"a\", 4) == 5\n        assert await r.get(\"a\") == b\"5\"\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    async def test_incrbyfloat(self, r: redis.Redis):\n        assert await r.incrbyfloat(\"a\") == 1.0\n        assert await r.get(\"a\") == b\"1\"\n        assert await r.incrbyfloat(\"a\", 1.1) == 2.1\n        assert float(await r.get(\"a\")) == float(2.1)\n\n    @pytest.mark.onlynoncluster\n    async def test_keys(self, r: redis.Redis):\n        assert await r.keys() == []\n        keys_with_underscores = {b\"test_a\", b\"test_b\"}\n        keys = keys_with_underscores.union({b\"testc\"})\n        for key in keys:\n            await r.set(key, 1)\n        assert set(await r.keys(pattern=\"test_*\")) == keys_with_underscores\n        assert set(await r.keys(pattern=\"test*\")) == keys\n\n    @pytest.mark.onlynoncluster\n    async def test_mget(self, r: redis.Redis):\n        assert await r.mget([]) == []\n        assert await r.mget([\"a\", \"b\"]) == [None, None]\n        await r.set(\"a\", \"1\")\n        await r.set(\"b\", \"2\")\n        await r.set(\"c\", \"3\")\n        assert await r.mget(\"a\", \"other\", \"b\", \"c\") == [b\"1\", None, b\"2\", b\"3\"]\n\n    @pytest.mark.onlynoncluster\n    async def test_mset(self, r: redis.Redis):\n        d = {\"a\": b\"1\", \"b\": b\"2\", \"c\": b\"3\"}\n        assert await r.mset(d)\n        for k, v in d.items():\n            assert await r.get(k) == v\n\n    @pytest.mark.onlycluster\n    async def test_mset_on_cluster(self, r):\n        # validate that mset command works in cluster client\n        # when the keys are in the same slot\n        d = {\"a:{test:1}\": b\"1\", \"b:{test:1}\": b\"2\", \"c:{test:1}\": b\"3\"}\n        assert await r.mset(d)\n        for k, v in d.items():\n            assert await r.get(k) == v\n\n    @pytest.mark.onlycluster\n    async def test_mset_on_cluster_multiple_slots(self, r):\n        d = {\"a\": b\"1\", \"b\": b\"2\", \"c\": b\"3\"}\n        with pytest.raises(RedisClusterException):\n            assert await r.mset(d)\n\n    @pytest.mark.onlycluster\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_msetex_no_expiration_with_cluster_client(self, r):\n        all_test_keys = [\"1:{test:1}\", \"2:{test:1}\"]\n        for key in all_test_keys:\n            await r.delete(key)\n\n        # set items from mapping without expiration\n        assert await r.msetex(mapping={\"1:{test:1}\": 1, \"2:{test:1}\": b\"four\"}) == 1\n        assert await r.mget(\"1:{test:1}\", \"2:{test:1}\") == [b\"1\", b\"four\"]\n        assert await r.ttl(\"1:{test:1}\") == -1\n        assert await r.ttl(\"2:{test:1}\") == -1\n\n    @pytest.mark.onlycluster\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_msetex_expiration_ex_and_keepttl_with_cluster_client(self, r):\n        all_test_keys = [\"1:{test:1}\", \"2:{test:1}\"]\n        for key in all_test_keys:\n            await r.delete(key)\n\n        # set items from mapping with expiration - testing ex field\n        assert (\n            await r.msetex(\n                mapping={\"1:{test:1}\": 1, \"2:{test:1}\": \"2\"},\n                ex=10,\n            )\n            == 1\n        )\n        ttls = await asyncio.gather(*[r.ttl(key) for key in all_test_keys])\n        for ttl in ttls:\n            assert pytest.approx(ttl) == 10\n\n        assert await r.mget(*all_test_keys) == [b\"1\", b\"2\"]\n        await asyncio.sleep(1.1)\n        # validate keepttl\n        assert await r.msetex(mapping={\"1:{test:1}\": 11}, keepttl=True) == 1\n        assert await r.ttl(\"1:{test:1}\") < 10\n\n    @pytest.mark.onlycluster\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_msetex_expiration_px_with_cluster_client(self, r):\n        all_test_keys = [\"1:{test:1}\", \"2:{test:1}\"]\n        for key in all_test_keys:\n            await r.delete(key)\n\n        mapping = {\"1:{test:1}\": 1, \"2:{test:1}\": \"2\"}\n        # set key/value pairs provided in mapping\n        # with expiration - testing px field\n        assert await r.msetex(mapping=mapping, px=60000) == 1\n\n        ttls = await asyncio.gather(*[r.ttl(key) for key in mapping.keys()])\n        for ttl in ttls:\n            assert pytest.approx(ttl) == 60\n        assert await r.mget(*mapping.keys()) == [b\"1\", b\"2\"]\n\n    @pytest.mark.onlycluster\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_msetex_expiration_pxat_and_nx_with_cluster_client(self, r):\n        all_test_keys = [\n            \"1:{test:1}\",\n            \"2:{test:1}\",\n            \"3:{test:1}\",\n            \"new:{test:1}\",\n            \"new_2:{test:1}\",\n        ]\n        for key in all_test_keys:\n            await r.delete(key)\n\n        mapping = {\"1:{test:1}\": 1, \"2:{test:1}\": \"2\", \"3:{test:1}\": \"three\"}\n        assert await r.msetex(mapping=mapping, ex=30) == 1\n\n        # NX is set with existing keys - nothing should be saved or updated\n        expire_at = await redis_server_time(r) + datetime.timedelta(seconds=10)\n        assert (\n            await r.msetex(\n                mapping={\"1:{test:1}\": \"new_value\", \"new:{test:1}\": \"ok\"},\n                pxat=expire_at,\n                data_persist_option=DataPersistOptions.NX,\n            )\n            == 0\n        )\n        ttls = await asyncio.gather(*[r.ttl(key) for key in mapping.keys()])\n        for ttl in ttls:\n            assert 10 < ttl <= 30\n        assert await r.mget(*mapping.keys(), \"new:{test:1}\") == [\n            b\"1\",\n            b\"2\",\n            b\"three\",\n            None,\n        ]\n\n        # NX is set with non existing keys - values should be set\n        assert (\n            await r.msetex(\n                mapping={\"new:{test:1}\": \"ok\", \"new_2:{test:1}\": \"ok_2\"},\n                pxat=expire_at,\n                data_persist_option=DataPersistOptions.NX,\n            )\n            == 1\n        )\n        old_ttls = await asyncio.gather(*[r.ttl(key) for key in mapping.keys()])\n        new_ttls = await asyncio.gather(*(r.ttl(key) for key in [\"new\", \"new_2\"]))\n        for ttl in old_ttls:\n            assert 10 < ttl <= 30\n        for ttl in new_ttls:\n            assert ttl <= 11\n        assert await r.mget(*mapping.keys(), \"new:{test:1}\", \"new_2:{test:1}\") == [\n            b\"1\",\n            b\"2\",\n            b\"three\",\n            b\"ok\",\n            b\"ok_2\",\n        ]\n\n    @pytest.mark.onlycluster\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_msetex_expiration_exat_and_xx_with_cluster_client(self, r):\n        all_test_keys = [\"1:{test:1}\", \"2:{test:1}\", \"3:{test:1}\", \"new:{test:1}\"]\n        for key in all_test_keys:\n            await r.delete(key)\n\n        mapping = {\"1:{test:1}\": 1, \"2:{test:1}\": \"2\", \"3:{test:1}\": \"three\"}\n        assert await r.msetex(mapping, ex=30) == 1\n\n        expire_at = await redis_server_time(r) + datetime.timedelta(seconds=10)\n        ## XX is set with unexisting key - nothing should be saved or updated\n        assert (\n            await r.msetex(\n                mapping={\"1:{test:1}\": \"new_value\", \"new:{test:1}\": \"ok\"},\n                exat=expire_at,\n                data_persist_option=DataPersistOptions.XX,\n            )\n            == 0\n        )\n        ttls = await asyncio.gather(*[r.ttl(key) for key in mapping.keys()])\n        for ttl in ttls:\n            assert 10 < ttl <= 30\n        assert await r.mget(*mapping.keys(), \"new:{test:1}\") == [\n            b\"1\",\n            b\"2\",\n            b\"three\",\n            None,\n        ]\n\n        # XX is set with existing keys - values should be updated\n        assert (\n            await r.msetex(\n                mapping={\"1:{test:1}\": \"new_value\", \"2:{test:1}\": \"new_value_2\"},\n                exat=expire_at,\n                data_persist_option=DataPersistOptions.XX,\n            )\n            == 1\n        )\n        ttls = await asyncio.gather(*[r.ttl(key) for key in mapping.keys()])\n        assert ttls[0] <= 11\n        assert ttls[1] <= 11\n        assert 10 < ttls[2] <= 30\n        assert await r.mget(\n            \"1:{test:1}\", \"2:{test:1}\", \"3:{test:1}\", \"new:{test:1}\"\n        ) == [\n            b\"new_value\",\n            b\"new_value_2\",\n            b\"three\",\n            None,\n        ]\n\n    @pytest.mark.onlycluster\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_msetex_invalid_inputs_with_cluster_client(self, r):\n        mapping = {\"1\": 1, \"2\": \"2\", \"3\": \"three\"}\n        with pytest.raises(exceptions.RedisClusterException):\n            await r.msetex(mapping)\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_msetex_no_expiration(self, r):\n        all_test_keys = [\"1\", \"2\"]\n        for key in all_test_keys:\n            await r.delete(key)\n\n        # # set items from mapping without expiration\n        assert await r.msetex(mapping={\"1\": 1, \"2\": b\"four\"}) == 1\n        assert await r.mget(\"1\", \"2\") == [b\"1\", b\"four\"]\n        assert await r.ttl(\"1\") == -1\n        assert await r.ttl(\"2\") == -1\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_msetex_expiration_ex_and_keepttl(self, r):\n        all_test_keys = [\"1\", \"2\"]\n        for key in all_test_keys:\n            await r.delete(key)\n\n        # set items from mapping with expiration - testing ex field\n        assert (\n            await r.msetex(\n                mapping={\"1\": 1, \"2\": \"2\"},\n                ex=10,\n            )\n            == 1\n        )\n        ttls = await asyncio.gather(*[r.ttl(key) for key in all_test_keys])\n        for ttl in ttls:\n            assert pytest.approx(ttl) == 10\n\n        assert await r.mget(*all_test_keys) == [b\"1\", b\"2\"]\n        await asyncio.sleep(1.1)\n        # validate keepttl\n        assert await r.msetex(mapping={\"1\": 11}, keepttl=True) == 1\n        assert await r.ttl(\"1\") < 10\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_msetex_expiration_px(self, r):\n        all_test_keys = [\"1\", \"2\"]\n        for key in all_test_keys:\n            await r.delete(key)\n\n        mapping = {\"1\": 1, \"2\": \"2\"}\n        # set key/value pairs provided in mapping\n        # with expiration - testing px field\n        assert await r.msetex(mapping=mapping, px=60000) == 1\n\n        ttls = await asyncio.gather(*[r.ttl(key) for key in mapping.keys()])\n        for ttl in ttls:\n            assert pytest.approx(ttl) == 60\n        assert await r.mget(*mapping.keys()) == [b\"1\", b\"2\"]\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_msetex_expiration_pxat_and_nx(self, r):\n        all_test_keys = [\"1\", \"2\", \"3\", \"new\", \"new_2\"]\n        for key in all_test_keys:\n            await r.delete(key)\n\n        mapping = {\"1\": 1, \"2\": \"2\", \"3\": \"three\"}\n        assert await r.msetex(mapping=mapping, ex=30) == 1\n\n        # NX is set with existing keys - nothing should be saved or updated\n        expire_at = await redis_server_time(r) + datetime.timedelta(seconds=10)\n        assert (\n            await r.msetex(\n                mapping={\"1\": \"new_value\", \"new\": \"ok\"},\n                pxat=expire_at,\n                data_persist_option=DataPersistOptions.NX,\n            )\n            == 0\n        )\n        ttls = await asyncio.gather(*[r.ttl(key) for key in mapping.keys()])\n        for ttl in ttls:\n            assert 10 < ttl <= 30\n        assert await r.mget(*mapping.keys(), \"new\") == [b\"1\", b\"2\", b\"three\", None]\n\n        # NX is set with non existing keys - values should be set\n        assert (\n            await r.msetex(\n                mapping={\"new\": \"ok\", \"new_2\": \"ok_2\"},\n                pxat=expire_at,\n                data_persist_option=DataPersistOptions.NX,\n            )\n            == 1\n        )\n        old_ttls = await asyncio.gather(*[r.ttl(key) for key in mapping.keys()])\n        new_ttls = await asyncio.gather(*(r.ttl(key) for key in [\"new\", \"new_2\"]))\n        for ttl in old_ttls:\n            assert 10 < ttl <= 30\n        for ttl in new_ttls:\n            assert ttl <= 11\n        assert await r.mget(*mapping.keys(), \"new\", \"new_2\") == [\n            b\"1\",\n            b\"2\",\n            b\"three\",\n            b\"ok\",\n            b\"ok_2\",\n        ]\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_msetex_expiration_exat_and_xx(self, r):\n        all_test_keys = [\"1\", \"2\", \"3\", \"new\"]\n        for key in all_test_keys:\n            await r.delete(key)\n\n        mapping = {\"1\": 1, \"2\": \"2\", \"3\": \"three\"}\n        assert await r.msetex(mapping, ex=30) == 1\n\n        expire_at = await redis_server_time(r) + datetime.timedelta(seconds=10)\n        ## XX is set with unexisting key - nothing should be saved or updated\n        assert (\n            await r.msetex(\n                mapping={\"1\": \"new_value\", \"new\": \"ok\"},\n                exat=expire_at,\n                data_persist_option=DataPersistOptions.XX,\n            )\n            == 0\n        )\n        ttls = await asyncio.gather(*[r.ttl(key) for key in mapping.keys()])\n        for ttl in ttls:\n            assert 10 < ttl <= 30\n        assert await r.mget(*mapping.keys(), \"new\") == [b\"1\", b\"2\", b\"three\", None]\n\n        # XX is set with existing keys - values should be updated\n        assert (\n            await r.msetex(\n                mapping={\"1\": \"new_value\", \"2\": \"new_value_2\"},\n                exat=expire_at,\n                data_persist_option=DataPersistOptions.XX,\n            )\n            == 1\n        )\n        ttls = await asyncio.gather(*[r.ttl(key) for key in mapping.keys()])\n        assert ttls[0] <= 11\n        assert ttls[1] <= 11\n        assert 10 < ttls[2] <= 30\n        assert await r.mget(\"1\", \"2\", \"3\", \"new\") == [\n            b\"new_value\",\n            b\"new_value_2\",\n            b\"three\",\n            None,\n        ]\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_msetex_invalid_inputs(self, r):\n        mapping = {\"1\": 1, \"2\": \"2\"}\n        with pytest.raises(exceptions.DataError):\n            await r.msetex(mapping, ex=10, keepttl=True)\n\n    @pytest.mark.onlynoncluster\n    async def test_msetnx(self, r: redis.Redis):\n        d = {\"a\": b\"1\", \"b\": b\"2\", \"c\": b\"3\"}\n        assert await r.msetnx(d)\n        d2 = {\"a\": b\"x\", \"d\": b\"4\"}\n        assert not await r.msetnx(d2)\n        for k, v in d.items():\n            assert await r.get(k) == v\n        assert await r.get(\"d\") is None\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    async def test_pexpire(self, r: redis.Redis):\n        assert not await r.pexpire(\"a\", 60000)\n        await r.set(\"a\", \"foo\")\n        assert await r.pexpire(\"a\", 60000)\n        assert 0 < await r.pttl(\"a\") <= 60000\n        assert await r.persist(\"a\")\n        assert await r.pttl(\"a\") == -1\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    async def test_pexpireat_datetime(self, r: redis.Redis):\n        expire_at = await redis_server_time(r) + datetime.timedelta(minutes=1)\n        await r.set(\"a\", \"foo\")\n        assert await r.pexpireat(\"a\", expire_at)\n        assert 0 < await r.pttl(\"a\") <= 61000\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    async def test_pexpireat_no_key(self, r: redis.Redis):\n        expire_at = await redis_server_time(r) + datetime.timedelta(minutes=1)\n        assert not await r.pexpireat(\"a\", expire_at)\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    async def test_pexpireat_unixtime(self, r: redis.Redis):\n        expire_at = await redis_server_time(r) + datetime.timedelta(minutes=1)\n        await r.set(\"a\", \"foo\")\n        expire_at_milliseconds = int(expire_at.timestamp() * 1000)\n        assert await r.pexpireat(\"a\", expire_at_milliseconds)\n        assert 0 < await r.pttl(\"a\") <= 61000\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    async def test_psetex(self, r: redis.Redis):\n        assert await r.psetex(\"a\", 1000, \"value\")\n        assert await r.get(\"a\") == b\"value\"\n        assert 0 < await r.pttl(\"a\") <= 1000\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    async def test_psetex_timedelta(self, r: redis.Redis):\n        expire_at = datetime.timedelta(milliseconds=1000)\n        assert await r.psetex(\"a\", expire_at, \"value\")\n        assert await r.get(\"a\") == b\"value\"\n        assert 0 < await r.pttl(\"a\") <= 1000\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    async def test_pttl(self, r: redis.Redis):\n        assert not await r.pexpire(\"a\", 10000)\n        await r.set(\"a\", \"1\")\n        assert await r.pexpire(\"a\", 10000)\n        assert 0 < await r.pttl(\"a\") <= 10000\n        assert await r.persist(\"a\")\n        assert await r.pttl(\"a\") == -1\n\n    @skip_if_server_version_lt(\"2.8.0\")\n    async def test_pttl_no_key(self, r: redis.Redis):\n        \"\"\"PTTL on servers 2.8 and after return -2 when the key doesn't exist\"\"\"\n        assert await r.pttl(\"a\") == -2\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    async def test_hrandfield(self, r):\n        assert await r.hrandfield(\"key\") is None\n        await r.hset(\"key\", mapping={\"a\": 1, \"b\": 2, \"c\": 3, \"d\": 4, \"e\": 5})\n        assert await r.hrandfield(\"key\") is not None\n        assert len(await r.hrandfield(\"key\", 2)) == 2\n        # with values\n        assert_resp_response(r, len(await r.hrandfield(\"key\", 2, True)), 4, 2)\n        # without duplications\n        assert len(await r.hrandfield(\"key\", 10)) == 5\n        # with duplications\n        assert len(await r.hrandfield(\"key\", -10)) == 10\n\n    @pytest.mark.onlynoncluster\n    async def test_randomkey(self, r: redis.Redis):\n        assert await r.randomkey() is None\n        for key in (\"a\", \"b\", \"c\"):\n            await r.set(key, 1)\n        assert await r.randomkey() in (b\"a\", b\"b\", b\"c\")\n\n    @pytest.mark.onlynoncluster\n    async def test_rename(self, r: redis.Redis):\n        await r.set(\"a\", \"1\")\n        assert await r.rename(\"a\", \"b\")\n        assert await r.get(\"a\") is None\n        assert await r.get(\"b\") == b\"1\"\n\n    @pytest.mark.onlynoncluster\n    async def test_renamenx(self, r: redis.Redis):\n        await r.set(\"a\", \"1\")\n        await r.set(\"b\", \"2\")\n        assert not await r.renamenx(\"a\", \"b\")\n        assert await r.get(\"a\") == b\"1\"\n        assert await r.get(\"b\") == b\"2\"\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    async def test_set_nx(self, r: redis.Redis):\n        assert await r.set(\"a\", \"1\", nx=True)\n        assert not await r.set(\"a\", \"2\", nx=True)\n        assert await r.get(\"a\") == b\"1\"\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    async def test_set_xx(self, r: redis.Redis):\n        assert not await r.set(\"a\", \"1\", xx=True)\n        assert await r.get(\"a\") is None\n        await r.set(\"a\", \"bar\")\n        assert await r.set(\"a\", \"2\", xx=True)\n        assert await r.get(\"a\") == b\"2\"\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    async def test_set_px(self, r: redis.Redis):\n        assert await r.set(\"a\", \"1\", px=10000)\n        assert await r.get(\"a\") == b\"1\"\n        assert 0 < await r.pttl(\"a\") <= 10000\n        assert 0 < await r.ttl(\"a\") <= 10\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    async def test_set_px_timedelta(self, r: redis.Redis):\n        expire_at = datetime.timedelta(milliseconds=1000)\n        assert await r.set(\"a\", \"1\", px=expire_at)\n        assert 0 < await r.pttl(\"a\") <= 1000\n        assert 0 < await r.ttl(\"a\") <= 1\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    async def test_set_ex(self, r: redis.Redis):\n        assert await r.set(\"a\", \"1\", ex=10)\n        assert 0 < await r.ttl(\"a\") <= 10\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    async def test_set_ex_timedelta(self, r: redis.Redis):\n        expire_at = datetime.timedelta(seconds=60)\n        assert await r.set(\"a\", \"1\", ex=expire_at)\n        assert 0 < await r.ttl(\"a\") <= 60\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    async def test_set_multipleoptions(self, r: redis.Redis):\n        await r.set(\"a\", \"val\")\n        assert await r.set(\"a\", \"1\", xx=True, px=10000)\n        assert 0 < await r.ttl(\"a\") <= 10\n\n    @skip_if_server_version_lt(REDIS_6_VERSION)\n    async def test_set_keepttl(self, r: redis.Redis):\n        await r.set(\"a\", \"val\")\n        assert await r.set(\"a\", \"1\", xx=True, px=10000)\n        assert 0 < await r.ttl(\"a\") <= 10\n        await r.set(\"a\", \"2\", keepttl=True)\n        assert await r.get(\"a\") == b\"2\"\n        assert 0 < await r.ttl(\"a\") <= 10\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_set_ifeq_true_sets_and_returns_true(self, r):\n        await r.delete(\"k\")\n        await r.set(\"k\", b\"foo\")\n        assert await r.set(\"k\", b\"bar\", ifeq=b\"foo\") is True\n        assert await r.get(\"k\") == b\"bar\"\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_set_ifeq_false_does_not_set_returns_none(self, r):\n        await r.delete(\"k\")\n        await r.set(\"k\", b\"foo\")\n        assert await r.set(\"k\", b\"bar\", ifeq=b\"nope\") is None\n        assert await r.get(\"k\") == b\"foo\"\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_set_ifne_true_sets(self, r):\n        await r.delete(\"k\")\n        await r.set(\"k\", b\"foo\")\n        assert await r.set(\"k\", b\"bar\", ifne=b\"zzz\") is True\n        assert await r.get(\"k\") == b\"bar\"\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_set_ifne_false_does_not_set(self, r):\n        await r.delete(\"k\")\n        await r.set(\"k\", b\"foo\")\n        assert await r.set(\"k\", b\"bar\", ifne=b\"foo\") is None\n        assert await r.get(\"k\") == b\"foo\"\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_set_ifeq_when_key_missing_does_not_create(self, r):\n        await r.delete(\"k\")\n        assert await r.set(\"k\", b\"bar\", ifeq=b\"foo\") is None\n        assert await r.exists(\"k\") == 0\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_set_ifne_when_key_missing_creates(self, r):\n        await r.delete(\"k\")\n        assert await r.set(\"k\", b\"bar\", ifne=b\"foo\") is True\n        assert await r.get(\"k\") == b\"bar\"\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    @pytest.mark.parametrize(\"val\", [b\"\", b\"abc\", b\"The quick brown fox\"])\n    async def test_set_ifdeq_and_ifdne(self, r, val):\n        await r.delete(\"k\")\n        await r.set(\"k\", val)\n        d = await self._server_xxh3_digest(r, \"k\")\n        assert d is not None\n\n        # IFDEQ must match to set; if key missing => won't create\n        assert await r.set(\"k\", b\"X\", ifdeq=d) is True\n        assert await r.get(\"k\") == b\"X\"\n\n        await r.delete(\"k\")\n        # key missing + IFDEQ => not created\n        assert await r.set(\"k\", b\"Y\", ifdeq=d) is None\n        assert await r.exists(\"k\") == 0\n\n        # IFDNE: create when missing, and set when digest differs\n        assert await r.set(\"k\", b\"bar\", ifdne=d) is True\n        prev_d = await self._server_xxh3_digest(r, \"k\")\n        assert prev_d is not None\n        # If digest equal → do not set\n        assert await r.set(\"k\", b\"zzz\", ifdne=prev_d) is None\n        assert await r.get(\"k\") == b\"bar\"\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_set_with_get_returns_previous_value(self, r):\n        await r.delete(\"k\")\n        # when key didn’t exist → returns None, and key is created if condition allows it\n        prev = await r.set(\"k\", b\"v1\", get=True, ifne=b\"any\")  # IFNE on missing creates\n        assert prev is None\n        # subsequent GET returns previous value, regardless of whether set occurs\n        prev2 = await r.set(\n            \"k\", b\"v2\", get=True, ifeq=b\"v1\"\n        )  # matches → set; returns \"v1\"\n        assert prev2 == b\"v1\"\n        prev3 = await r.set(\n            \"k\", b\"v3\", get=True, ifeq=b\"no\"\n        )  # no set; returns previous \"v2\"\n        assert prev3 == b\"v2\"\n        assert await r.get(\"k\") == b\"v2\"\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_set_mutual_exclusion_client_side(self, r):\n        await r.delete(\"k\")\n        with pytest.raises(DataError):\n            await r.set(\"k\", b\"v\", nx=True, ifeq=b\"x\")\n        with pytest.raises(DataError):\n            await r.set(\"k\", b\"v\", ifdeq=\"aa\", ifdne=\"bb\")\n        with pytest.raises(DataError):\n            await r.set(\"k\", b\"v\", ex=1, px=1)\n        with pytest.raises(DataError):\n            await r.set(\"k\", b\"v\", exat=1, pxat=1)\n        with pytest.raises(DataError):\n            await r.set(\"k\", b\"v\", ex=1, exat=1)\n\n    async def test_setex(self, r: redis.Redis):\n        assert await r.setex(\"a\", 60, \"1\")\n        assert await r.get(\"a\") == b\"1\"\n        assert 0 < await r.ttl(\"a\") <= 60\n\n    async def test_setnx(self, r: redis.Redis):\n        assert await r.setnx(\"a\", \"1\")\n        assert await r.get(\"a\") == b\"1\"\n        assert not await r.setnx(\"a\", \"2\")\n        assert await r.get(\"a\") == b\"1\"\n\n    async def test_setrange(self, r: redis.Redis):\n        assert await r.setrange(\"a\", 5, \"foo\") == 8\n        assert await r.get(\"a\") == b\"\\0\\0\\0\\0\\0foo\"\n        await r.set(\"a\", \"abcdefghijh\")\n        assert await r.setrange(\"a\", 6, \"12345\") == 11\n        assert await r.get(\"a\") == b\"abcdef12345\"\n\n    async def test_strlen(self, r: redis.Redis):\n        await r.set(\"a\", \"foo\")\n        assert await r.strlen(\"a\") == 3\n\n    async def test_substr(self, r: redis.Redis):\n        await r.set(\"a\", \"0123456789\")\n        assert await r.substr(\"a\", 0) == b\"0123456789\"\n        assert await r.substr(\"a\", 2) == b\"23456789\"\n        assert await r.substr(\"a\", 3, 5) == b\"345\"\n        assert await r.substr(\"a\", 3, -2) == b\"345678\"\n\n    async def test_ttl(self, r: redis.Redis):\n        await r.set(\"a\", \"1\")\n        assert await r.expire(\"a\", 10)\n        assert 0 < await r.ttl(\"a\") <= 10\n        assert await r.persist(\"a\")\n        assert await r.ttl(\"a\") == -1\n\n    @skip_if_server_version_lt(\"2.8.0\")\n    async def test_ttl_nokey(self, r: redis.Redis):\n        \"\"\"TTL on servers 2.8 and after return -2 when the key doesn't exist\"\"\"\n        assert await r.ttl(\"a\") == -2\n\n    async def test_type(self, r: redis.Redis):\n        assert await r.type(\"a\") == b\"none\"\n        await r.set(\"a\", \"1\")\n        assert await r.type(\"a\") == b\"string\"\n        await r.delete(\"a\")\n        await r.lpush(\"a\", \"1\")\n        assert await r.type(\"a\") == b\"list\"\n        await r.delete(\"a\")\n        await r.sadd(\"a\", \"1\")\n        assert await r.type(\"a\") == b\"set\"\n        await r.delete(\"a\")\n        await r.zadd(\"a\", {\"1\": 1})\n        assert await r.type(\"a\") == b\"zset\"\n\n    # LIST COMMANDS\n    @pytest.mark.onlynoncluster\n    async def test_blpop(self, r: redis.Redis):\n        await r.rpush(\"a\", \"1\", \"2\")\n        await r.rpush(\"b\", \"3\", \"4\")\n        assert_resp_response(\n            r, await r.blpop([\"b\", \"a\"], timeout=1), (b\"b\", b\"3\"), [b\"b\", b\"3\"]\n        )\n        assert_resp_response(\n            r, await r.blpop([\"b\", \"a\"], timeout=1), (b\"b\", b\"4\"), [b\"b\", b\"4\"]\n        )\n        assert_resp_response(\n            r, await r.blpop([\"b\", \"a\"], timeout=1), (b\"a\", b\"1\"), [b\"a\", b\"1\"]\n        )\n        assert_resp_response(\n            r, await r.blpop([\"b\", \"a\"], timeout=1), (b\"a\", b\"2\"), [b\"a\", b\"2\"]\n        )\n        assert await r.blpop([\"b\", \"a\"], timeout=1) is None\n        await r.rpush(\"c\", \"1\")\n        assert_resp_response(\n            r, await r.blpop(\"c\", timeout=1), (b\"c\", b\"1\"), [b\"c\", b\"1\"]\n        )\n\n    @pytest.mark.onlynoncluster\n    async def test_brpop(self, r: redis.Redis):\n        await r.rpush(\"a\", \"1\", \"2\")\n        await r.rpush(\"b\", \"3\", \"4\")\n        assert_resp_response(\n            r, await r.brpop([\"b\", \"a\"], timeout=1), (b\"b\", b\"4\"), [b\"b\", b\"4\"]\n        )\n        assert_resp_response(\n            r, await r.brpop([\"b\", \"a\"], timeout=1), (b\"b\", b\"3\"), [b\"b\", b\"3\"]\n        )\n        assert_resp_response(\n            r, await r.brpop([\"b\", \"a\"], timeout=1), (b\"a\", b\"2\"), [b\"a\", b\"2\"]\n        )\n        assert_resp_response(\n            r, await r.brpop([\"b\", \"a\"], timeout=1), (b\"a\", b\"1\"), [b\"a\", b\"1\"]\n        )\n        assert await r.brpop([\"b\", \"a\"], timeout=1) is None\n        await r.rpush(\"c\", \"1\")\n        assert_resp_response(\n            r, await r.brpop(\"c\", timeout=1), (b\"c\", b\"1\"), [b\"c\", b\"1\"]\n        )\n\n    @pytest.mark.onlynoncluster\n    async def test_brpoplpush(self, r: redis.Redis):\n        await r.rpush(\"a\", \"1\", \"2\")\n        await r.rpush(\"b\", \"3\", \"4\")\n        assert await r.brpoplpush(\"a\", \"b\") == b\"2\"\n        assert await r.brpoplpush(\"a\", \"b\") == b\"1\"\n        assert await r.brpoplpush(\"a\", \"b\", timeout=1) is None\n        assert await r.lrange(\"a\", 0, -1) == []\n        assert await r.lrange(\"b\", 0, -1) == [b\"1\", b\"2\", b\"3\", b\"4\"]\n\n    @pytest.mark.onlynoncluster\n    async def test_brpoplpush_empty_string(self, r: redis.Redis):\n        await r.rpush(\"a\", \"\")\n        assert await r.brpoplpush(\"a\", \"b\") == b\"\"\n\n    async def test_lindex(self, r: redis.Redis):\n        await r.rpush(\"a\", \"1\", \"2\", \"3\")\n        assert await r.lindex(\"a\", \"0\") == b\"1\"\n        assert await r.lindex(\"a\", \"1\") == b\"2\"\n        assert await r.lindex(\"a\", \"2\") == b\"3\"\n\n    async def test_linsert(self, r: redis.Redis):\n        await r.rpush(\"a\", \"1\", \"2\", \"3\")\n        assert await r.linsert(\"a\", \"after\", \"2\", \"2.5\") == 4\n        assert await r.lrange(\"a\", 0, -1) == [b\"1\", b\"2\", b\"2.5\", b\"3\"]\n        assert await r.linsert(\"a\", \"before\", \"2\", \"1.5\") == 5\n        assert await r.lrange(\"a\", 0, -1) == [b\"1\", b\"1.5\", b\"2\", b\"2.5\", b\"3\"]\n\n    async def test_llen(self, r: redis.Redis):\n        await r.rpush(\"a\", \"1\", \"2\", \"3\")\n        assert await r.llen(\"a\") == 3\n\n    async def test_lpop(self, r: redis.Redis):\n        await r.rpush(\"a\", \"1\", \"2\", \"3\")\n        assert await r.lpop(\"a\") == b\"1\"\n        assert await r.lpop(\"a\") == b\"2\"\n        assert await r.lpop(\"a\") == b\"3\"\n        assert await r.lpop(\"a\") is None\n\n    async def test_lpush(self, r: redis.Redis):\n        assert await r.lpush(\"a\", \"1\") == 1\n        assert await r.lpush(\"a\", \"2\") == 2\n        assert await r.lpush(\"a\", \"3\", \"4\") == 4\n        assert await r.lrange(\"a\", 0, -1) == [b\"4\", b\"3\", b\"2\", b\"1\"]\n\n    async def test_lpushx(self, r: redis.Redis):\n        assert await r.lpushx(\"a\", \"1\") == 0\n        assert await r.lrange(\"a\", 0, -1) == []\n        await r.rpush(\"a\", \"1\", \"2\", \"3\")\n        assert await r.lpushx(\"a\", \"4\") == 4\n        assert await r.lrange(\"a\", 0, -1) == [b\"4\", b\"1\", b\"2\", b\"3\"]\n\n    async def test_lrange(self, r: redis.Redis):\n        await r.rpush(\"a\", \"1\", \"2\", \"3\", \"4\", \"5\")\n        assert await r.lrange(\"a\", 0, 2) == [b\"1\", b\"2\", b\"3\"]\n        assert await r.lrange(\"a\", 2, 10) == [b\"3\", b\"4\", b\"5\"]\n        assert await r.lrange(\"a\", 0, -1) == [b\"1\", b\"2\", b\"3\", b\"4\", b\"5\"]\n\n    async def test_lrem(self, r: redis.Redis):\n        await r.rpush(\"a\", \"Z\", \"b\", \"Z\", \"Z\", \"c\", \"Z\", \"Z\")\n        # remove the first 'Z'  item\n        assert await r.lrem(\"a\", 1, \"Z\") == 1\n        assert await r.lrange(\"a\", 0, -1) == [b\"b\", b\"Z\", b\"Z\", b\"c\", b\"Z\", b\"Z\"]\n        # remove the last 2 'Z' items\n        assert await r.lrem(\"a\", -2, \"Z\") == 2\n        assert await r.lrange(\"a\", 0, -1) == [b\"b\", b\"Z\", b\"Z\", b\"c\"]\n        # remove all 'Z' items\n        assert await r.lrem(\"a\", 0, \"Z\") == 2\n        assert await r.lrange(\"a\", 0, -1) == [b\"b\", b\"c\"]\n\n    async def test_lset(self, r: redis.Redis):\n        await r.rpush(\"a\", \"1\", \"2\", \"3\")\n        assert await r.lrange(\"a\", 0, -1) == [b\"1\", b\"2\", b\"3\"]\n        assert await r.lset(\"a\", 1, \"4\")\n        assert await r.lrange(\"a\", 0, 2) == [b\"1\", b\"4\", b\"3\"]\n\n    async def test_ltrim(self, r: redis.Redis):\n        await r.rpush(\"a\", \"1\", \"2\", \"3\")\n        assert await r.ltrim(\"a\", 0, 1)\n        assert await r.lrange(\"a\", 0, -1) == [b\"1\", b\"2\"]\n\n    async def test_rpop(self, r: redis.Redis):\n        await r.rpush(\"a\", \"1\", \"2\", \"3\")\n        assert await r.rpop(\"a\") == b\"3\"\n        assert await r.rpop(\"a\") == b\"2\"\n        assert await r.rpop(\"a\") == b\"1\"\n        assert await r.rpop(\"a\") is None\n\n    @pytest.mark.onlynoncluster\n    async def test_rpoplpush(self, r: redis.Redis):\n        await r.rpush(\"a\", \"a1\", \"a2\", \"a3\")\n        await r.rpush(\"b\", \"b1\", \"b2\", \"b3\")\n        assert await r.rpoplpush(\"a\", \"b\") == b\"a3\"\n        assert await r.lrange(\"a\", 0, -1) == [b\"a1\", b\"a2\"]\n        assert await r.lrange(\"b\", 0, -1) == [b\"a3\", b\"b1\", b\"b2\", b\"b3\"]\n\n    async def test_rpush(self, r: redis.Redis):\n        assert await r.rpush(\"a\", \"1\") == 1\n        assert await r.rpush(\"a\", \"2\") == 2\n        assert await r.rpush(\"a\", \"3\", \"4\") == 4\n        assert await r.lrange(\"a\", 0, -1) == [b\"1\", b\"2\", b\"3\", b\"4\"]\n\n    @skip_if_server_version_lt(\"6.0.6\")\n    async def test_lpos(self, r: redis.Redis):\n        assert await r.rpush(\"a\", \"a\", \"b\", \"c\", \"1\", \"2\", \"3\", \"c\", \"c\") == 8\n        assert await r.lpos(\"a\", \"a\") == 0\n        assert await r.lpos(\"a\", \"c\") == 2\n\n        assert await r.lpos(\"a\", \"c\", rank=1) == 2\n        assert await r.lpos(\"a\", \"c\", rank=2) == 6\n        assert await r.lpos(\"a\", \"c\", rank=4) is None\n        assert await r.lpos(\"a\", \"c\", rank=-1) == 7\n        assert await r.lpos(\"a\", \"c\", rank=-2) == 6\n\n        assert await r.lpos(\"a\", \"c\", count=0) == [2, 6, 7]\n        assert await r.lpos(\"a\", \"c\", count=1) == [2]\n        assert await r.lpos(\"a\", \"c\", count=2) == [2, 6]\n        assert await r.lpos(\"a\", \"c\", count=100) == [2, 6, 7]\n\n        assert await r.lpos(\"a\", \"c\", count=0, rank=2) == [6, 7]\n        assert await r.lpos(\"a\", \"c\", count=2, rank=-1) == [7, 6]\n\n        assert await r.lpos(\"axxx\", \"c\", count=0, rank=2) == []\n        assert await r.lpos(\"axxx\", \"c\") is None\n\n        assert await r.lpos(\"a\", \"x\", count=2) == []\n        assert await r.lpos(\"a\", \"x\") is None\n\n        assert await r.lpos(\"a\", \"a\", count=0, maxlen=1) == [0]\n        assert await r.lpos(\"a\", \"c\", count=0, maxlen=1) == []\n        assert await r.lpos(\"a\", \"c\", count=0, maxlen=3) == [2]\n        assert await r.lpos(\"a\", \"c\", count=0, maxlen=3, rank=-1) == [7, 6]\n        assert await r.lpos(\"a\", \"c\", count=0, maxlen=7, rank=2) == [6]\n\n    async def test_rpushx(self, r: redis.Redis):\n        assert await r.rpushx(\"a\", \"b\") == 0\n        assert await r.lrange(\"a\", 0, -1) == []\n        await r.rpush(\"a\", \"1\", \"2\", \"3\")\n        assert await r.rpushx(\"a\", \"4\") == 4\n        assert await r.lrange(\"a\", 0, -1) == [b\"1\", b\"2\", b\"3\", b\"4\"]\n\n    # SCAN COMMANDS\n    @skip_if_server_version_lt(\"2.8.0\")\n    @pytest.mark.onlynoncluster\n    async def test_scan(self, r: redis.Redis):\n        await r.set(\"a\", 1)\n        await r.set(\"b\", 2)\n        await r.set(\"c\", 3)\n        cursor, keys = await r.scan()\n        assert cursor == 0\n        assert set(keys) == {b\"a\", b\"b\", b\"c\"}\n        _, keys = await r.scan(match=\"a\")\n        assert set(keys) == {b\"a\"}\n\n    @skip_if_server_version_lt(REDIS_6_VERSION)\n    @pytest.mark.onlynoncluster\n    async def test_scan_type(self, r: redis.Redis):\n        await r.sadd(\"a-set\", 1)\n        await r.hset(\"a-hash\", \"foo\", 2)\n        await r.lpush(\"a-list\", \"aux\", 3)\n        _, keys = await r.scan(match=\"a*\", _type=\"SET\")\n        assert set(keys) == {b\"a-set\"}\n\n    @skip_if_server_version_lt(\"2.8.0\")\n    @pytest.mark.onlynoncluster\n    async def test_scan_iter(self, r: redis.Redis):\n        await r.set(\"a\", 1)\n        await r.set(\"b\", 2)\n        await r.set(\"c\", 3)\n        keys = [k async for k in r.scan_iter()]\n        assert set(keys) == {b\"a\", b\"b\", b\"c\"}\n        keys = [k async for k in r.scan_iter(match=\"a\")]\n        assert set(keys) == {b\"a\"}\n\n    @skip_if_server_version_lt(\"2.8.0\")\n    async def test_sscan(self, r: redis.Redis):\n        await r.sadd(\"a\", 1, 2, 3)\n        cursor, members = await r.sscan(\"a\")\n        assert cursor == 0\n        assert set(members) == {b\"1\", b\"2\", b\"3\"}\n        _, members = await r.sscan(\"a\", match=b\"1\")\n        assert set(members) == {b\"1\"}\n\n    @skip_if_server_version_lt(\"2.8.0\")\n    async def test_sscan_iter(self, r: redis.Redis):\n        await r.sadd(\"a\", 1, 2, 3)\n        members = [k async for k in r.sscan_iter(\"a\")]\n        assert set(members) == {b\"1\", b\"2\", b\"3\"}\n        members = [k async for k in r.sscan_iter(\"a\", match=b\"1\")]\n        assert set(members) == {b\"1\"}\n\n    @skip_if_server_version_lt(\"2.8.0\")\n    async def test_hscan(self, r: redis.Redis):\n        await r.hset(\"a\", mapping={\"a\": 1, \"b\": 2, \"c\": 3})\n        cursor, dic = await r.hscan(\"a\")\n        assert cursor == 0\n        assert dic == {b\"a\": b\"1\", b\"b\": b\"2\", b\"c\": b\"3\"}\n        _, dic = await r.hscan(\"a\", match=\"a\")\n        assert dic == {b\"a\": b\"1\"}\n        _, dic = await r.hscan(\"a_notset\", match=\"a\")\n        assert dic == {}\n\n    @skip_if_server_version_lt(\"7.3.240\")\n    async def test_hscan_novalues(self, r: redis.Redis):\n        await r.hset(\"a\", mapping={\"a\": 1, \"b\": 2, \"c\": 3})\n        cursor, keys = await r.hscan(\"a\", no_values=True)\n        assert cursor == 0\n        assert sorted(keys) == [b\"a\", b\"b\", b\"c\"]\n        _, keys = await r.hscan(\"a\", match=\"a\", no_values=True)\n        assert keys == [b\"a\"]\n        _, keys = await r.hscan(\"a_notset\", match=\"a\", no_values=True)\n        assert keys == []\n\n    @skip_if_server_version_lt(\"2.8.0\")\n    async def test_hscan_iter(self, r: redis.Redis):\n        await r.hset(\"a\", mapping={\"a\": 1, \"b\": 2, \"c\": 3})\n        dic = {k: v async for k, v in r.hscan_iter(\"a\")}\n        assert dic == {b\"a\": b\"1\", b\"b\": b\"2\", b\"c\": b\"3\"}\n        dic = {k: v async for k, v in r.hscan_iter(\"a\", match=\"a\")}\n        assert dic == {b\"a\": b\"1\"}\n        dic = {k: v async for k, v in r.hscan_iter(\"a_notset\", match=\"a\")}\n        assert dic == {}\n\n    @skip_if_server_version_lt(\"7.3.240\")\n    async def test_hscan_iter_novalues(self, r: redis.Redis):\n        await r.hset(\"a\", mapping={\"a\": 1, \"b\": 2, \"c\": 3})\n        keys = list([k async for k in r.hscan_iter(\"a\", no_values=True)])\n        assert sorted(keys) == [b\"a\", b\"b\", b\"c\"]\n        keys = list([k async for k in r.hscan_iter(\"a\", match=\"a\", no_values=True)])\n        assert keys == [b\"a\"]\n        keys = list(\n            [k async for k in r.hscan_iter(\"a\", match=\"a_notset\", no_values=True)]\n        )\n        assert keys == []\n\n    @skip_if_server_version_lt(\"2.8.0\")\n    async def test_zscan(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a\": 1, \"b\": 2, \"c\": 3})\n        cursor, pairs = await r.zscan(\"a\")\n        assert cursor == 0\n        assert set(pairs) == {(b\"a\", 1), (b\"b\", 2), (b\"c\", 3)}\n        _, pairs = await r.zscan(\"a\", match=\"a\")\n        assert set(pairs) == {(b\"a\", 1)}\n\n    @skip_if_server_version_lt(\"2.8.0\")\n    async def test_zscan_iter(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a\": 1, \"b\": 2, \"c\": 3})\n        pairs = [k async for k in r.zscan_iter(\"a\")]\n        assert set(pairs) == {(b\"a\", 1), (b\"b\", 2), (b\"c\", 3)}\n        pairs = [k async for k in r.zscan_iter(\"a\", match=\"a\")]\n        assert set(pairs) == {(b\"a\", 1)}\n\n    # SET COMMANDS\n    async def test_sadd(self, r: redis.Redis):\n        members = {b\"1\", b\"2\", b\"3\"}\n        await r.sadd(\"a\", *members)\n        assert set(await r.smembers(\"a\")) == members\n\n    async def test_scard(self, r: redis.Redis):\n        await r.sadd(\"a\", \"1\", \"2\", \"3\")\n        assert await r.scard(\"a\") == 3\n\n    @pytest.mark.onlynoncluster\n    async def test_sdiff(self, r: redis.Redis):\n        await r.sadd(\"a\", \"1\", \"2\", \"3\")\n        assert await r.sdiff(\"a\", \"b\") == {b\"1\", b\"2\", b\"3\"}\n        await r.sadd(\"b\", \"2\", \"3\")\n        assert await r.sdiff(\"a\", \"b\") == {b\"1\"}\n\n    @pytest.mark.onlynoncluster\n    async def test_sdiffstore(self, r: redis.Redis):\n        await r.sadd(\"a\", \"1\", \"2\", \"3\")\n        assert await r.sdiffstore(\"c\", \"a\", \"b\") == 3\n        assert await r.smembers(\"c\") == {b\"1\", b\"2\", b\"3\"}\n        await r.sadd(\"b\", \"2\", \"3\")\n        assert await r.sdiffstore(\"c\", \"a\", \"b\") == 1\n        assert await r.smembers(\"c\") == {b\"1\"}\n\n    @pytest.mark.onlynoncluster\n    async def test_sinter(self, r: redis.Redis):\n        await r.sadd(\"a\", \"1\", \"2\", \"3\")\n        assert await r.sinter(\"a\", \"b\") == set()\n        await r.sadd(\"b\", \"2\", \"3\")\n        assert await r.sinter(\"a\", \"b\") == {b\"2\", b\"3\"}\n\n    @pytest.mark.onlynoncluster\n    async def test_sinterstore(self, r: redis.Redis):\n        await r.sadd(\"a\", \"1\", \"2\", \"3\")\n        assert await r.sinterstore(\"c\", \"a\", \"b\") == 0\n        assert await r.smembers(\"c\") == set()\n        await r.sadd(\"b\", \"2\", \"3\")\n        assert await r.sinterstore(\"c\", \"a\", \"b\") == 2\n        assert await r.smembers(\"c\") == {b\"2\", b\"3\"}\n\n    async def test_sismember(self, r: redis.Redis):\n        await r.sadd(\"a\", \"1\", \"2\", \"3\")\n        assert await r.sismember(\"a\", \"1\")\n        assert await r.sismember(\"a\", \"2\")\n        assert await r.sismember(\"a\", \"3\")\n        assert not await r.sismember(\"a\", \"4\")\n\n    async def test_smembers(self, r: redis.Redis):\n        await r.sadd(\"a\", \"1\", \"2\", \"3\")\n        assert set(await r.smembers(\"a\")) == {b\"1\", b\"2\", b\"3\"}\n\n    @pytest.mark.onlynoncluster\n    async def test_smove(self, r: redis.Redis):\n        await r.sadd(\"a\", \"a1\", \"a2\")\n        await r.sadd(\"b\", \"b1\", \"b2\")\n        assert await r.smove(\"a\", \"b\", \"a1\")\n        assert await r.smembers(\"a\") == {b\"a2\"}\n        assert await r.smembers(\"b\") == {b\"b1\", b\"b2\", b\"a1\"}\n\n    async def test_spop(self, r: redis.Redis):\n        s = [b\"1\", b\"2\", b\"3\"]\n        await r.sadd(\"a\", *s)\n        value = await r.spop(\"a\")\n        assert value in s\n        assert set(await r.smembers(\"a\")) == set(s) - {value}\n\n    @skip_if_server_version_lt(\"3.2.0\")\n    async def test_spop_multi_value(self, r: redis.Redis):\n        s = [b\"1\", b\"2\", b\"3\"]\n        await r.sadd(\"a\", *s)\n        values = await r.spop(\"a\", 2)\n        assert len(values) == 2\n\n        for value in values:\n            assert value in s\n\n        response = await r.spop(\"a\", 1)\n        assert set(response) == set(s) - set(values)\n\n    async def test_srandmember(self, r: redis.Redis):\n        s = [b\"1\", b\"2\", b\"3\"]\n        await r.sadd(\"a\", *s)\n        assert await r.srandmember(\"a\") in s\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    async def test_srandmember_multi_value(self, r: redis.Redis):\n        s = [b\"1\", b\"2\", b\"3\"]\n        await r.sadd(\"a\", *s)\n        randoms = await r.srandmember(\"a\", number=2)\n        assert len(randoms) == 2\n        assert set(randoms).intersection(s) == set(randoms)\n\n    async def test_srem(self, r: redis.Redis):\n        await r.sadd(\"a\", \"1\", \"2\", \"3\", \"4\")\n        assert await r.srem(\"a\", \"5\") == 0\n        assert await r.srem(\"a\", \"2\", \"4\") == 2\n        assert set(await r.smembers(\"a\")) == {b\"1\", b\"3\"}\n\n    @pytest.mark.onlynoncluster\n    async def test_sunion(self, r: redis.Redis):\n        await r.sadd(\"a\", \"1\", \"2\")\n        await r.sadd(\"b\", \"2\", \"3\")\n        assert set(await r.sunion(\"a\", \"b\")) == {b\"1\", b\"2\", b\"3\"}\n\n    @pytest.mark.onlynoncluster\n    async def test_sunionstore(self, r: redis.Redis):\n        await r.sadd(\"a\", \"1\", \"2\")\n        await r.sadd(\"b\", \"2\", \"3\")\n        assert await r.sunionstore(\"c\", \"a\", \"b\") == 3\n        assert set(await r.smembers(\"c\")) == {b\"1\", b\"2\", b\"3\"}\n\n    # SORTED SET COMMANDS\n    async def test_zadd(self, r: redis.Redis):\n        mapping = {\"a1\": 1.0, \"a2\": 2.0, \"a3\": 3.0}\n        await r.zadd(\"a\", mapping)\n        response = await r.zrange(\"a\", 0, -1, withscores=True)\n        assert_resp_response(\n            r,\n            response,\n            [(b\"a1\", 1.0), (b\"a2\", 2.0), (b\"a3\", 3.0)],\n            [[b\"a1\", 1.0], [b\"a2\", 2.0], [b\"a3\", 3.0]],\n        )\n\n        # error cases\n        with pytest.raises(exceptions.DataError):\n            await r.zadd(\"a\", {})\n\n        # cannot use both nx and xx options\n        with pytest.raises(exceptions.DataError):\n            await r.zadd(\"a\", mapping, nx=True, xx=True)\n\n        # cannot use the incr options with more than one value\n        with pytest.raises(exceptions.DataError):\n            await r.zadd(\"a\", mapping, incr=True)\n\n    async def test_zadd_nx(self, r: redis.Redis):\n        assert await r.zadd(\"a\", {\"a1\": 1}) == 1\n        assert await r.zadd(\"a\", {\"a1\": 99, \"a2\": 2}, nx=True) == 1\n        response = await r.zrange(\"a\", 0, -1, withscores=True)\n        assert_resp_response(\n            r, response, [(b\"a1\", 1.0), (b\"a2\", 2.0)], [[b\"a1\", 1.0], [b\"a2\", 2.0]]\n        )\n\n    async def test_zadd_xx(self, r: redis.Redis):\n        assert await r.zadd(\"a\", {\"a1\": 1}) == 1\n        assert await r.zadd(\"a\", {\"a1\": 99, \"a2\": 2}, xx=True) == 0\n        response = await r.zrange(\"a\", 0, -1, withscores=True)\n        assert_resp_response(r, response, [(b\"a1\", 99.0)], [[b\"a1\", 99.0]])\n\n    async def test_zadd_ch(self, r: redis.Redis):\n        assert await r.zadd(\"a\", {\"a1\": 1}) == 1\n        assert await r.zadd(\"a\", {\"a1\": 99, \"a2\": 2}, ch=True) == 2\n        response = await r.zrange(\"a\", 0, -1, withscores=True)\n        assert_resp_response(\n            r, response, [(b\"a2\", 2.0), (b\"a1\", 99.0)], [[b\"a2\", 2.0], [b\"a1\", 99.0]]\n        )\n\n    async def test_zadd_incr(self, r: redis.Redis):\n        assert await r.zadd(\"a\", {\"a1\": 1}) == 1\n        assert await r.zadd(\"a\", {\"a1\": 4.5}, incr=True) == 5.5\n\n    async def test_zadd_incr_with_xx(self, r: redis.Redis):\n        # this asks zadd to incr 'a1' only if it exists, but it clearly\n        # doesn't. Redis returns a null value in this case and so should\n        # redis-py\n        assert await r.zadd(\"a\", {\"a1\": 1}, xx=True, incr=True) is None\n\n    async def test_zcard(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        assert await r.zcard(\"a\") == 3\n\n    async def test_zcount(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        assert await r.zcount(\"a\", \"-inf\", \"+inf\") == 3\n        assert await r.zcount(\"a\", 1, 2) == 2\n        assert await r.zcount(\"a\", \"(\" + str(1), 2) == 1\n        assert await r.zcount(\"a\", 1, \"(\" + str(2)) == 1\n        assert await r.zcount(\"a\", 10, 20) == 0\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"6.2.0\")\n    async def test_zdiff(self, r):\n        await r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        await r.zadd(\"b\", {\"a1\": 1, \"a2\": 2})\n        assert await r.zdiff([\"a\", \"b\"]) == [b\"a3\"]\n        response = await r.zdiff([\"a\", \"b\"], withscores=True)\n        assert_resp_response(r, response, [b\"a3\", b\"3\"], [[b\"a3\", 3.0]])\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"6.2.0\")\n    async def test_zdiffstore(self, r):\n        await r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        await r.zadd(\"b\", {\"a1\": 1, \"a2\": 2})\n        assert await r.zdiffstore(\"out\", [\"a\", \"b\"])\n        assert await r.zrange(\"out\", 0, -1) == [b\"a3\"]\n        response = await r.zrange(\"out\", 0, -1, withscores=True)\n        assert_resp_response(r, response, [(b\"a3\", 3.0)], [[b\"a3\", 3.0]])\n\n    async def test_zincrby(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        assert await r.zincrby(\"a\", 1, \"a2\") == 3.0\n        assert await r.zincrby(\"a\", 5, \"a3\") == 8.0\n        assert await r.zscore(\"a\", \"a2\") == 3.0\n        assert await r.zscore(\"a\", \"a3\") == 8.0\n\n    @skip_if_server_version_lt(\"2.8.9\")\n    async def test_zlexcount(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a\": 0, \"b\": 0, \"c\": 0, \"d\": 0, \"e\": 0, \"f\": 0, \"g\": 0})\n        assert await r.zlexcount(\"a\", \"-\", \"+\") == 7\n        assert await r.zlexcount(\"a\", \"[b\", \"[f\") == 5\n\n    @pytest.mark.onlynoncluster\n    async def test_zinterstore_sum(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a1\": 1, \"a2\": 1, \"a3\": 1})\n        await r.zadd(\"b\", {\"a1\": 2, \"a2\": 2, \"a3\": 2})\n        await r.zadd(\"c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert await r.zinterstore(\"d\", [\"a\", \"b\", \"c\"]) == 2\n        response = await r.zrange(\"d\", 0, -1, withscores=True)\n        assert_resp_response(\n            r, response, [(b\"a3\", 8), (b\"a1\", 9)], [[b\"a3\", 8.0], [b\"a1\", 9.0]]\n        )\n\n    @pytest.mark.onlynoncluster\n    async def test_zinterstore_max(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a1\": 1, \"a2\": 1, \"a3\": 1})\n        await r.zadd(\"b\", {\"a1\": 2, \"a2\": 2, \"a3\": 2})\n        await r.zadd(\"c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert await r.zinterstore(\"d\", [\"a\", \"b\", \"c\"], aggregate=\"MAX\") == 2\n        response = await r.zrange(\"d\", 0, -1, withscores=True)\n        assert_resp_response(\n            r, response, [(b\"a3\", 5), (b\"a1\", 6)], [[b\"a3\", 5], [b\"a1\", 6]]\n        )\n\n    @pytest.mark.onlynoncluster\n    async def test_zinterstore_min(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        await r.zadd(\"b\", {\"a1\": 2, \"a2\": 3, \"a3\": 5})\n        await r.zadd(\"c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert await r.zinterstore(\"d\", [\"a\", \"b\", \"c\"], aggregate=\"MIN\") == 2\n        response = await r.zrange(\"d\", 0, -1, withscores=True)\n        assert_resp_response(\n            r, response, [(b\"a1\", 1), (b\"a3\", 3)], [[b\"a1\", 1], [b\"a3\", 3]]\n        )\n\n    @pytest.mark.onlynoncluster\n    async def test_zinterstore_with_weight(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a1\": 1, \"a2\": 1, \"a3\": 1})\n        await r.zadd(\"b\", {\"a1\": 2, \"a2\": 2, \"a3\": 2})\n        await r.zadd(\"c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert await r.zinterstore(\"d\", {\"a\": 1, \"b\": 2, \"c\": 3}) == 2\n        response = await r.zrange(\"d\", 0, -1, withscores=True)\n        assert_resp_response(\n            r, response, [(b\"a3\", 20), (b\"a1\", 23)], [[b\"a3\", 20], [b\"a1\", 23]]\n        )\n\n    @skip_if_server_version_lt(\"4.9.0\")\n    async def test_zpopmax(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        response = await r.zpopmax(\"a\")\n        assert_resp_response(r, response, [(b\"a3\", 3)], [b\"a3\", 3.0])\n\n        # with count\n        response = await r.zpopmax(\"a\", count=2)\n        assert_resp_response(\n            r, response, [(b\"a2\", 2), (b\"a1\", 1)], [[b\"a2\", 2], [b\"a1\", 1]]\n        )\n\n    @skip_if_server_version_lt(\"4.9.0\")\n    async def test_zpopmin(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        response = await r.zpopmin(\"a\")\n        assert_resp_response(r, response, [(b\"a1\", 1)], [b\"a1\", 1.0])\n\n        # with count\n        response = await r.zpopmin(\"a\", count=2)\n        assert_resp_response(\n            r, response, [(b\"a2\", 2), (b\"a3\", 3)], [[b\"a2\", 2], [b\"a3\", 3]]\n        )\n\n    @skip_if_server_version_lt(\"4.9.0\")\n    @pytest.mark.onlynoncluster\n    async def test_bzpopmax(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a1\": 1, \"a2\": 2})\n        await r.zadd(\"b\", {\"b1\": 10, \"b2\": 20})\n        assert_resp_response(\n            r,\n            await r.bzpopmax([\"b\", \"a\"], timeout=1),\n            (b\"b\", b\"b2\", 20),\n            [b\"b\", b\"b2\", 20],\n        )\n        assert_resp_response(\n            r,\n            await r.bzpopmax([\"b\", \"a\"], timeout=1),\n            (b\"b\", b\"b1\", 10),\n            [b\"b\", b\"b1\", 10],\n        )\n        assert_resp_response(\n            r,\n            await r.bzpopmax([\"b\", \"a\"], timeout=1),\n            (b\"a\", b\"a2\", 2),\n            [b\"a\", b\"a2\", 2],\n        )\n        assert_resp_response(\n            r,\n            await r.bzpopmax([\"b\", \"a\"], timeout=1),\n            (b\"a\", b\"a1\", 1),\n            [b\"a\", b\"a1\", 1],\n        )\n        assert await r.bzpopmax([\"b\", \"a\"], timeout=1) is None\n        await r.zadd(\"c\", {\"c1\": 100})\n        assert_resp_response(\n            r, await r.bzpopmax(\"c\", timeout=1), (b\"c\", b\"c1\", 100), [b\"c\", b\"c1\", 100]\n        )\n\n    @skip_if_server_version_lt(\"4.9.0\")\n    @pytest.mark.onlynoncluster\n    async def test_bzpopmin(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a1\": 1, \"a2\": 2})\n        await r.zadd(\"b\", {\"b1\": 10, \"b2\": 20})\n        assert_resp_response(\n            r,\n            await r.bzpopmin([\"b\", \"a\"], timeout=1),\n            (b\"b\", b\"b1\", 10),\n            [b\"b\", b\"b1\", 10],\n        )\n        assert_resp_response(\n            r,\n            await r.bzpopmin([\"b\", \"a\"], timeout=1),\n            (b\"b\", b\"b2\", 20),\n            [b\"b\", b\"b2\", 20],\n        )\n        assert_resp_response(\n            r,\n            await r.bzpopmin([\"b\", \"a\"], timeout=1),\n            (b\"a\", b\"a1\", 1),\n            [b\"a\", b\"a1\", 1],\n        )\n        assert_resp_response(\n            r,\n            await r.bzpopmin([\"b\", \"a\"], timeout=1),\n            (b\"a\", b\"a2\", 2),\n            [b\"a\", b\"a2\", 2],\n        )\n        assert await r.bzpopmin([\"b\", \"a\"], timeout=1) is None\n        await r.zadd(\"c\", {\"c1\": 100})\n        assert_resp_response(\n            r, await r.bzpopmin(\"c\", timeout=1), (b\"c\", b\"c1\", 100), [b\"c\", b\"c1\", 100]\n        )\n\n    async def test_zrange(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        assert await r.zrange(\"a\", 0, 1) == [b\"a1\", b\"a2\"]\n        assert await r.zrange(\"a\", 1, 2) == [b\"a2\", b\"a3\"]\n\n        # withscores\n        response = await r.zrange(\"a\", 0, 1, withscores=True)\n        assert_resp_response(\n            r, response, [(b\"a1\", 1.0), (b\"a2\", 2.0)], [[b\"a1\", 1.0], [b\"a2\", 2.0]]\n        )\n        response = await r.zrange(\"a\", 1, 2, withscores=True)\n        assert_resp_response(\n            r, response, [(b\"a2\", 2.0), (b\"a3\", 3.0)], [[b\"a2\", 2.0], [b\"a3\", 3.0]]\n        )\n\n        # custom score cast function\n        response = await r.zrange(\"a\", 0, 1, withscores=True, score_cast_func=safe_str)\n        assert_resp_response(\n            r,\n            response,\n            [(b\"a1\", \"1\"), (b\"a2\", \"2\")],\n            [[b\"a1\", \"1.0\"], [b\"a2\", \"2.0\"]],\n        )\n\n    @skip_if_server_version_lt(\"2.8.9\")\n    async def test_zrangebylex(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a\": 0, \"b\": 0, \"c\": 0, \"d\": 0, \"e\": 0, \"f\": 0, \"g\": 0})\n        assert await r.zrangebylex(\"a\", \"-\", \"[c\") == [b\"a\", b\"b\", b\"c\"]\n        assert await r.zrangebylex(\"a\", \"-\", \"(c\") == [b\"a\", b\"b\"]\n        assert await r.zrangebylex(\"a\", \"[aaa\", \"(g\") == [b\"b\", b\"c\", b\"d\", b\"e\", b\"f\"]\n        assert await r.zrangebylex(\"a\", \"[f\", \"+\") == [b\"f\", b\"g\"]\n        assert await r.zrangebylex(\"a\", \"-\", \"+\", start=3, num=2) == [b\"d\", b\"e\"]\n\n    @skip_if_server_version_lt(\"2.9.9\")\n    async def test_zrevrangebylex(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a\": 0, \"b\": 0, \"c\": 0, \"d\": 0, \"e\": 0, \"f\": 0, \"g\": 0})\n        assert await r.zrevrangebylex(\"a\", \"[c\", \"-\") == [b\"c\", b\"b\", b\"a\"]\n        assert await r.zrevrangebylex(\"a\", \"(c\", \"-\") == [b\"b\", b\"a\"]\n        assert await r.zrevrangebylex(\"a\", \"(g\", \"[aaa\") == [\n            b\"f\",\n            b\"e\",\n            b\"d\",\n            b\"c\",\n            b\"b\",\n        ]\n        assert await r.zrevrangebylex(\"a\", \"+\", \"[f\") == [b\"g\", b\"f\"]\n        assert await r.zrevrangebylex(\"a\", \"+\", \"-\", start=3, num=2) == [b\"d\", b\"c\"]\n\n    async def test_zrangebyscore(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3, \"a4\": 4, \"a5\": 5})\n        assert await r.zrangebyscore(\"a\", 2, 4) == [b\"a2\", b\"a3\", b\"a4\"]\n\n        # slicing with start/num\n        assert await r.zrangebyscore(\"a\", 2, 4, start=1, num=2) == [b\"a3\", b\"a4\"]\n\n        # withscores\n        response = await r.zrangebyscore(\"a\", 2, 4, withscores=True)\n        assert_resp_response(\n            r,\n            response,\n            [(b\"a2\", 2.0), (b\"a3\", 3.0), (b\"a4\", 4.0)],\n            [[b\"a2\", 2.0], [b\"a3\", 3.0], [b\"a4\", 4.0]],\n        )\n\n        # custom score function\n        response = await r.zrangebyscore(\n            \"a\", 2, 4, withscores=True, score_cast_func=int\n        )\n        assert_resp_response(\n            r,\n            response,\n            [(b\"a2\", 2), (b\"a3\", 3), (b\"a4\", 4)],\n            [[b\"a2\", 2], [b\"a3\", 3], [b\"a4\", 4]],\n        )\n        response = await r.zrangebyscore(\n            \"a\", 2, 4, withscores=True, score_cast_func=safe_str\n        )\n        assert_resp_response(\n            r,\n            response,\n            [(b\"a2\", \"2\"), (b\"a3\", \"3\"), (b\"a4\", \"4\")],\n            [[b\"a2\", \"2.0\"], [b\"a3\", \"3.0\"], [b\"a4\", \"4.0\"]],\n        )\n\n    async def test_zrank(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3, \"a4\": 4, \"a5\": 5})\n        assert await r.zrank(\"a\", \"a1\") == 0\n        assert await r.zrank(\"a\", \"a2\") == 1\n        assert await r.zrank(\"a\", \"a6\") is None\n\n    @skip_if_server_version_lt(\"7.2.0\")\n    async def test_zrank_withscore(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3, \"a4\": 4, \"a5\": 5})\n        assert await r.zrank(\"a\", \"a1\") == 0\n        assert await r.zrank(\"a\", \"a2\") == 1\n        assert await r.zrank(\"a\", \"a6\") is None\n        assert_resp_response(\n            r, await r.zrank(\"a\", \"a3\", withscore=True), [2, 3.0], [2, 3.0]\n        )\n        assert await r.zrank(\"a\", \"a6\", withscore=True) is None\n\n        # custom score cast function\n        response = await r.zrank(\"a\", \"a3\", withscore=True, score_cast_func=safe_str)\n        assert_resp_response(r, response, [2, \"3\"], [2, \"3.0\"])\n\n    async def test_zrem(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        assert await r.zrem(\"a\", \"a2\") == 1\n        assert await r.zrange(\"a\", 0, -1) == [b\"a1\", b\"a3\"]\n        assert await r.zrem(\"a\", \"b\") == 0\n        assert await r.zrange(\"a\", 0, -1) == [b\"a1\", b\"a3\"]\n\n    async def test_zrem_multiple_keys(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        assert await r.zrem(\"a\", \"a1\", \"a2\") == 2\n        assert await r.zrange(\"a\", 0, 5) == [b\"a3\"]\n\n    @skip_if_server_version_lt(\"2.8.9\")\n    async def test_zremrangebylex(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a\": 0, \"b\": 0, \"c\": 0, \"d\": 0, \"e\": 0, \"f\": 0, \"g\": 0})\n        assert await r.zremrangebylex(\"a\", \"-\", \"[c\") == 3\n        assert await r.zrange(\"a\", 0, -1) == [b\"d\", b\"e\", b\"f\", b\"g\"]\n        assert await r.zremrangebylex(\"a\", \"[f\", \"+\") == 2\n        assert await r.zrange(\"a\", 0, -1) == [b\"d\", b\"e\"]\n        assert await r.zremrangebylex(\"a\", \"[h\", \"+\") == 0\n        assert await r.zrange(\"a\", 0, -1) == [b\"d\", b\"e\"]\n\n    async def test_zremrangebyrank(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3, \"a4\": 4, \"a5\": 5})\n        assert await r.zremrangebyrank(\"a\", 1, 3) == 3\n        assert await r.zrange(\"a\", 0, 5) == [b\"a1\", b\"a5\"]\n\n    async def test_zremrangebyscore(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3, \"a4\": 4, \"a5\": 5})\n        assert await r.zremrangebyscore(\"a\", 2, 4) == 3\n        assert await r.zrange(\"a\", 0, -1) == [b\"a1\", b\"a5\"]\n        assert await r.zremrangebyscore(\"a\", 2, 4) == 0\n        assert await r.zrange(\"a\", 0, -1) == [b\"a1\", b\"a5\"]\n\n    async def test_zrevrange(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        assert await r.zrevrange(\"a\", 0, 1) == [b\"a3\", b\"a2\"]\n        assert await r.zrevrange(\"a\", 1, 2) == [b\"a2\", b\"a1\"]\n\n        # withscores\n        response = await r.zrevrange(\"a\", 0, 1, withscores=True)\n        assert_resp_response(\n            r, response, [(b\"a3\", 3.0), (b\"a2\", 2.0)], [[b\"a3\", 3.0], [b\"a2\", 2.0]]\n        )\n        response = await r.zrevrange(\"a\", 1, 2, withscores=True)\n        assert_resp_response(\n            r, response, [(b\"a2\", 2.0), (b\"a1\", 1.0)], [[b\"a2\", 2.0], [b\"a1\", 1.0]]\n        )\n\n        # custom score function\n        response = await r.zrevrange(\"a\", 0, 1, withscores=True, score_cast_func=int)\n        assert_resp_response(\n            r, response, [(b\"a3\", 3), (b\"a2\", 2)], [[b\"a3\", 3], [b\"a2\", 2]]\n        )\n\n        # custom score cast function\n        # should be applied to resp2 and resp3\n        # responses\n        response = await r.zrevrange(\n            \"a\", 0, 1, withscores=True, score_cast_func=safe_str\n        )\n        assert_resp_response(\n            r,\n            response,\n            [(b\"a3\", \"3\"), (b\"a2\", \"2\")],\n            [[b\"a3\", \"3.0\"], [b\"a2\", \"2.0\"]],\n        )\n\n    async def test_zrevrangebyscore(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3, \"a4\": 4, \"a5\": 5})\n        assert await r.zrevrangebyscore(\"a\", 4, 2) == [b\"a4\", b\"a3\", b\"a2\"]\n\n        # slicing with start/num\n        assert await r.zrevrangebyscore(\"a\", 4, 2, start=1, num=2) == [b\"a3\", b\"a2\"]\n\n        # withscores\n        response = await r.zrevrangebyscore(\"a\", 4, 2, withscores=True)\n        assert_resp_response(\n            r,\n            response,\n            [(b\"a4\", 4.0), (b\"a3\", 3.0), (b\"a2\", 2.0)],\n            [[b\"a4\", 4.0], [b\"a3\", 3.0], [b\"a2\", 2.0]],\n        )\n\n        # custom score function\n        response = await r.zrevrangebyscore(\n            \"a\", 4, 2, withscores=True, score_cast_func=int\n        )\n        assert_resp_response(\n            r,\n            response,\n            [(b\"a4\", 4), (b\"a3\", 3), (b\"a2\", 2)],\n            [[b\"a4\", 4], [b\"a3\", 3], [b\"a2\", 2]],\n        )\n\n    async def test_zrevrank(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3, \"a4\": 4, \"a5\": 5})\n        assert await r.zrevrank(\"a\", \"a1\") == 4\n        assert await r.zrevrank(\"a\", \"a2\") == 3\n        assert await r.zrevrank(\"a\", \"a6\") is None\n\n    @skip_if_server_version_lt(\"7.2.0\")\n    async def test_zrevrank_withscore(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3, \"a4\": 4, \"a5\": 5})\n        assert await r.zrevrank(\"a\", \"a1\") == 4\n        assert await r.zrevrank(\"a\", \"a2\") == 3\n        assert await r.zrevrank(\"a\", \"a6\") is None\n        assert_resp_response(\n            r, await r.zrevrank(\"a\", \"a3\", withscore=True), [2, 3.0], [2, 3.0]\n        )\n        assert await r.zrevrank(\"a\", \"a6\", withscore=True) is None\n\n    async def test_zscore(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        assert await r.zscore(\"a\", \"a1\") == 1.0\n        assert await r.zscore(\"a\", \"a2\") == 2.0\n        assert await r.zscore(\"a\", \"a4\") is None\n\n    @pytest.mark.onlynoncluster\n    async def test_zunionstore_sum(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a1\": 1, \"a2\": 1, \"a3\": 1})\n        await r.zadd(\"b\", {\"a1\": 2, \"a2\": 2, \"a3\": 2})\n        await r.zadd(\"c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert await r.zunionstore(\"d\", [\"a\", \"b\", \"c\"]) == 4\n        response = await r.zrange(\"d\", 0, -1, withscores=True)\n        assert_resp_response(\n            r,\n            response,\n            [(b\"a2\", 3.0), (b\"a4\", 4.0), (b\"a3\", 8.0), (b\"a1\", 9.0)],\n            [[b\"a2\", 3.0], [b\"a4\", 4.0], [b\"a3\", 8.0], [b\"a1\", 9.0]],\n        )\n\n    @pytest.mark.onlynoncluster\n    async def test_zunionstore_max(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a1\": 1, \"a2\": 1, \"a3\": 1})\n        await r.zadd(\"b\", {\"a1\": 2, \"a2\": 2, \"a3\": 2})\n        await r.zadd(\"c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert await r.zunionstore(\"d\", [\"a\", \"b\", \"c\"], aggregate=\"MAX\") == 4\n        respponse = await r.zrange(\"d\", 0, -1, withscores=True)\n        assert_resp_response(\n            r,\n            respponse,\n            [(b\"a2\", 2.0), (b\"a4\", 4.0), (b\"a3\", 5.0), (b\"a1\", 6.0)],\n            [[b\"a2\", 2.0], [b\"a4\", 4.0], [b\"a3\", 5.0], [b\"a1\", 6.0]],\n        )\n\n    @pytest.mark.onlynoncluster\n    async def test_zunionstore_min(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        await r.zadd(\"b\", {\"a1\": 2, \"a2\": 2, \"a3\": 4})\n        await r.zadd(\"c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert await r.zunionstore(\"d\", [\"a\", \"b\", \"c\"], aggregate=\"MIN\") == 4\n        response = await r.zrange(\"d\", 0, -1, withscores=True)\n        assert_resp_response(\n            r,\n            response,\n            [(b\"a1\", 1.0), (b\"a2\", 2.0), (b\"a3\", 3.0), (b\"a4\", 4.0)],\n            [[b\"a1\", 1.0], [b\"a2\", 2.0], [b\"a3\", 3.0], [b\"a4\", 4.0]],\n        )\n\n    @pytest.mark.onlynoncluster\n    async def test_zunionstore_with_weight(self, r: redis.Redis):\n        await r.zadd(\"a\", {\"a1\": 1, \"a2\": 1, \"a3\": 1})\n        await r.zadd(\"b\", {\"a1\": 2, \"a2\": 2, \"a3\": 2})\n        await r.zadd(\"c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert await r.zunionstore(\"d\", {\"a\": 1, \"b\": 2, \"c\": 3}) == 4\n        response = await r.zrange(\"d\", 0, -1, withscores=True)\n        assert_resp_response(\n            r,\n            response,\n            [(b\"a2\", 5.0), (b\"a4\", 12.0), (b\"a3\", 20.0), (b\"a1\", 23.0)],\n            [[b\"a2\", 5.0], [b\"a4\", 12.0], [b\"a3\", 20.0], [b\"a1\", 23.0]],\n        )\n\n    # HYPERLOGLOG TESTS\n    @skip_if_server_version_lt(\"2.8.9\")\n    async def test_pfadd(self, r: redis.Redis):\n        members = {b\"1\", b\"2\", b\"3\"}\n        assert await r.pfadd(\"a\", *members) == 1\n        assert await r.pfadd(\"a\", *members) == 0\n        assert await r.pfcount(\"a\") == len(members)\n\n    @skip_if_server_version_lt(\"2.8.9\")\n    @pytest.mark.onlynoncluster\n    async def test_pfcount(self, r: redis.Redis):\n        members = {b\"1\", b\"2\", b\"3\"}\n        await r.pfadd(\"a\", *members)\n        assert await r.pfcount(\"a\") == len(members)\n        members_b = {b\"2\", b\"3\", b\"4\"}\n        await r.pfadd(\"b\", *members_b)\n        assert await r.pfcount(\"b\") == len(members_b)\n        assert await r.pfcount(\"a\", \"b\") == len(members_b.union(members))\n\n    @skip_if_server_version_lt(\"2.8.9\")\n    @pytest.mark.onlynoncluster\n    async def test_pfmerge(self, r: redis.Redis):\n        mema = {b\"1\", b\"2\", b\"3\"}\n        memb = {b\"2\", b\"3\", b\"4\"}\n        memc = {b\"5\", b\"6\", b\"7\"}\n        await r.pfadd(\"a\", *mema)\n        await r.pfadd(\"b\", *memb)\n        await r.pfadd(\"c\", *memc)\n        await r.pfmerge(\"d\", \"c\", \"a\")\n        assert await r.pfcount(\"d\") == 6\n        await r.pfmerge(\"d\", \"b\")\n        assert await r.pfcount(\"d\") == 7\n\n    # HASH COMMANDS\n    async def test_hget_and_hset(self, r: redis.Redis):\n        await r.hset(\"a\", mapping={\"1\": 1, \"2\": 2, \"3\": 3})\n        assert await r.hget(\"a\", \"1\") == b\"1\"\n        assert await r.hget(\"a\", \"2\") == b\"2\"\n        assert await r.hget(\"a\", \"3\") == b\"3\"\n\n        # field was updated, redis returns 0\n        assert await r.hset(\"a\", \"2\", 5) == 0\n        assert await r.hget(\"a\", \"2\") == b\"5\"\n\n        # field is new, redis returns 1\n        assert await r.hset(\"a\", \"4\", 4) == 1\n        assert await r.hget(\"a\", \"4\") == b\"4\"\n\n        # key inside of hash that doesn't exist returns null value\n        assert await r.hget(\"a\", \"b\") is None\n\n        # keys with bool(key) == False\n        assert await r.hset(\"a\", 0, 10) == 1\n        assert await r.hset(\"a\", \"\", 10) == 1\n\n    async def test_hset_with_multi_key_values(self, r: redis.Redis):\n        await r.hset(\"a\", mapping={\"1\": 1, \"2\": 2, \"3\": 3})\n        assert await r.hget(\"a\", \"1\") == b\"1\"\n        assert await r.hget(\"a\", \"2\") == b\"2\"\n        assert await r.hget(\"a\", \"3\") == b\"3\"\n\n        await r.hset(\"b\", \"foo\", \"bar\", mapping={\"1\": 1, \"2\": 2})\n        assert await r.hget(\"b\", \"1\") == b\"1\"\n        assert await r.hget(\"b\", \"2\") == b\"2\"\n        assert await r.hget(\"b\", \"foo\") == b\"bar\"\n\n    async def test_hset_without_data(self, r: redis.Redis):\n        with pytest.raises(exceptions.DataError):\n            await r.hset(\"x\")\n\n    async def test_hdel(self, r: redis.Redis):\n        await r.hset(\"a\", mapping={\"1\": 1, \"2\": 2, \"3\": 3})\n        assert await r.hdel(\"a\", \"2\") == 1\n        assert await r.hget(\"a\", \"2\") is None\n        assert await r.hdel(\"a\", \"1\", \"3\") == 2\n        assert await r.hlen(\"a\") == 0\n\n    async def test_hexists(self, r: redis.Redis):\n        await r.hset(\"a\", mapping={\"1\": 1, \"2\": 2, \"3\": 3})\n        assert await r.hexists(\"a\", \"1\")\n        assert not await r.hexists(\"a\", \"4\")\n\n    async def test_hgetall(self, r: redis.Redis):\n        h = {b\"a1\": b\"1\", b\"a2\": b\"2\", b\"a3\": b\"3\"}\n        await r.hset(\"a\", mapping=h)\n        assert await r.hgetall(\"a\") == h\n\n    async def test_hincrby(self, r: redis.Redis):\n        assert await r.hincrby(\"a\", \"1\") == 1\n        assert await r.hincrby(\"a\", \"1\", amount=2) == 3\n        assert await r.hincrby(\"a\", \"1\", amount=-2) == 1\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    async def test_hincrbyfloat(self, r: redis.Redis):\n        assert await r.hincrbyfloat(\"a\", \"1\") == 1.0\n        assert await r.hincrbyfloat(\"a\", \"1\") == 2.0\n        assert await r.hincrbyfloat(\"a\", \"1\", 1.2) == 3.2\n\n    async def test_hkeys(self, r: redis.Redis):\n        h = {b\"a1\": b\"1\", b\"a2\": b\"2\", b\"a3\": b\"3\"}\n        await r.hset(\"a\", mapping=h)\n        local_keys = list(h.keys())\n        remote_keys = await r.hkeys(\"a\")\n        assert sorted(local_keys) == sorted(remote_keys)\n\n    async def test_hlen(self, r: redis.Redis):\n        await r.hset(\"a\", mapping={\"1\": 1, \"2\": 2, \"3\": 3})\n        assert await r.hlen(\"a\") == 3\n\n    async def test_hmget(self, r: redis.Redis):\n        assert await r.hset(\"a\", mapping={\"a\": 1, \"b\": 2, \"c\": 3})\n        assert await r.hmget(\"a\", \"a\", \"b\", \"c\") == [b\"1\", b\"2\", b\"3\"]\n\n    async def test_hmset(self, r: redis.Redis):\n        h = {b\"a\": b\"1\", b\"b\": b\"2\", b\"c\": b\"3\"}\n        with pytest.warns(DeprecationWarning):\n            assert await r.hmset(\"a\", h)\n        assert await r.hgetall(\"a\") == h\n\n    async def test_hsetnx(self, r: redis.Redis):\n        # Initially set the hash field\n        assert await r.hsetnx(\"a\", \"1\", 1)\n        assert await r.hget(\"a\", \"1\") == b\"1\"\n        assert not await r.hsetnx(\"a\", \"1\", 2)\n        assert await r.hget(\"a\", \"1\") == b\"1\"\n\n    async def test_hvals(self, r: redis.Redis):\n        h = {b\"a1\": b\"1\", b\"a2\": b\"2\", b\"a3\": b\"3\"}\n        await r.hset(\"a\", mapping=h)\n        local_vals = list(h.values())\n        remote_vals = await r.hvals(\"a\")\n        assert sorted(local_vals) == sorted(remote_vals)\n\n    @skip_if_server_version_lt(\"3.2.0\")\n    async def test_hstrlen(self, r: redis.Redis):\n        await r.hset(\"a\", mapping={\"1\": \"22\", \"2\": \"333\"})\n        assert await r.hstrlen(\"a\", \"1\") == 2\n        assert await r.hstrlen(\"a\", \"2\") == 3\n\n    # SORT\n    async def test_sort_basic(self, r: redis.Redis):\n        await r.rpush(\"a\", \"3\", \"2\", \"1\", \"4\")\n        assert await r.sort(\"a\") == [b\"1\", b\"2\", b\"3\", b\"4\"]\n\n    async def test_sort_limited(self, r: redis.Redis):\n        await r.rpush(\"a\", \"3\", \"2\", \"1\", \"4\")\n        assert await r.sort(\"a\", start=1, num=2) == [b\"2\", b\"3\"]\n\n    @pytest.mark.onlynoncluster\n    async def test_sort_by(self, r: redis.Redis):\n        await r.set(\"score:1\", 8)\n        await r.set(\"score:2\", 3)\n        await r.set(\"score:3\", 5)\n        await r.rpush(\"a\", \"3\", \"2\", \"1\")\n        assert await r.sort(\"a\", by=\"score:*\") == [b\"2\", b\"3\", b\"1\"]\n\n    @pytest.mark.onlynoncluster\n    async def test_sort_get(self, r: redis.Redis):\n        await r.set(\"user:1\", \"u1\")\n        await r.set(\"user:2\", \"u2\")\n        await r.set(\"user:3\", \"u3\")\n        await r.rpush(\"a\", \"2\", \"3\", \"1\")\n        assert await r.sort(\"a\", get=\"user:*\") == [b\"u1\", b\"u2\", b\"u3\"]\n\n    @pytest.mark.onlynoncluster\n    async def test_sort_get_multi(self, r: redis.Redis):\n        await r.set(\"user:1\", \"u1\")\n        await r.set(\"user:2\", \"u2\")\n        await r.set(\"user:3\", \"u3\")\n        await r.rpush(\"a\", \"2\", \"3\", \"1\")\n        assert await r.sort(\"a\", get=(\"user:*\", \"#\")) == [\n            b\"u1\",\n            b\"1\",\n            b\"u2\",\n            b\"2\",\n            b\"u3\",\n            b\"3\",\n        ]\n\n    @pytest.mark.onlynoncluster\n    async def test_sort_get_groups_two(self, r: redis.Redis):\n        await r.set(\"user:1\", \"u1\")\n        await r.set(\"user:2\", \"u2\")\n        await r.set(\"user:3\", \"u3\")\n        await r.rpush(\"a\", \"2\", \"3\", \"1\")\n        assert await r.sort(\"a\", get=(\"user:*\", \"#\"), groups=True) == [\n            (b\"u1\", b\"1\"),\n            (b\"u2\", b\"2\"),\n            (b\"u3\", b\"3\"),\n        ]\n\n    @pytest.mark.onlynoncluster\n    async def test_sort_groups_string_get(self, r: redis.Redis):\n        await r.set(\"user:1\", \"u1\")\n        await r.set(\"user:2\", \"u2\")\n        await r.set(\"user:3\", \"u3\")\n        await r.rpush(\"a\", \"2\", \"3\", \"1\")\n        with pytest.raises(exceptions.DataError):\n            await r.sort(\"a\", get=\"user:*\", groups=True)\n\n    @pytest.mark.onlynoncluster\n    async def test_sort_groups_just_one_get(self, r: redis.Redis):\n        await r.set(\"user:1\", \"u1\")\n        await r.set(\"user:2\", \"u2\")\n        await r.set(\"user:3\", \"u3\")\n        await r.rpush(\"a\", \"2\", \"3\", \"1\")\n        with pytest.raises(exceptions.DataError):\n            await r.sort(\"a\", get=[\"user:*\"], groups=True)\n\n    async def test_sort_groups_no_get(self, r: redis.Redis):\n        await r.set(\"user:1\", \"u1\")\n        await r.set(\"user:2\", \"u2\")\n        await r.set(\"user:3\", \"u3\")\n        await r.rpush(\"a\", \"2\", \"3\", \"1\")\n        with pytest.raises(exceptions.DataError):\n            await r.sort(\"a\", groups=True)\n\n    @pytest.mark.onlynoncluster\n    async def test_sort_groups_three_gets(self, r: redis.Redis):\n        await r.set(\"user:1\", \"u1\")\n        await r.set(\"user:2\", \"u2\")\n        await r.set(\"user:3\", \"u3\")\n        await r.set(\"door:1\", \"d1\")\n        await r.set(\"door:2\", \"d2\")\n        await r.set(\"door:3\", \"d3\")\n        await r.rpush(\"a\", \"2\", \"3\", \"1\")\n        assert await r.sort(\"a\", get=(\"user:*\", \"door:*\", \"#\"), groups=True) == [\n            (b\"u1\", b\"d1\", b\"1\"),\n            (b\"u2\", b\"d2\", b\"2\"),\n            (b\"u3\", b\"d3\", b\"3\"),\n        ]\n\n    async def test_sort_desc(self, r: redis.Redis):\n        await r.rpush(\"a\", \"2\", \"3\", \"1\")\n        assert await r.sort(\"a\", desc=True) == [b\"3\", b\"2\", b\"1\"]\n\n    async def test_sort_alpha(self, r: redis.Redis):\n        await r.rpush(\"a\", \"e\", \"c\", \"b\", \"d\", \"a\")\n        assert await r.sort(\"a\", alpha=True) == [b\"a\", b\"b\", b\"c\", b\"d\", b\"e\"]\n\n    @pytest.mark.onlynoncluster\n    async def test_sort_store(self, r: redis.Redis):\n        await r.rpush(\"a\", \"2\", \"3\", \"1\")\n        assert await r.sort(\"a\", store=\"sorted_values\") == 3\n        assert await r.lrange(\"sorted_values\", 0, -1) == [b\"1\", b\"2\", b\"3\"]\n\n    @pytest.mark.onlynoncluster\n    async def test_sort_all_options(self, r: redis.Redis):\n        await r.set(\"user:1:username\", \"zeus\")\n        await r.set(\"user:2:username\", \"titan\")\n        await r.set(\"user:3:username\", \"hermes\")\n        await r.set(\"user:4:username\", \"hercules\")\n        await r.set(\"user:5:username\", \"apollo\")\n        await r.set(\"user:6:username\", \"athena\")\n        await r.set(\"user:7:username\", \"hades\")\n        await r.set(\"user:8:username\", \"dionysus\")\n\n        await r.set(\"user:1:favorite_drink\", \"yuengling\")\n        await r.set(\"user:2:favorite_drink\", \"rum\")\n        await r.set(\"user:3:favorite_drink\", \"vodka\")\n        await r.set(\"user:4:favorite_drink\", \"milk\")\n        await r.set(\"user:5:favorite_drink\", \"pinot noir\")\n        await r.set(\"user:6:favorite_drink\", \"water\")\n        await r.set(\"user:7:favorite_drink\", \"gin\")\n        await r.set(\"user:8:favorite_drink\", \"apple juice\")\n\n        await r.rpush(\"gods\", \"5\", \"8\", \"3\", \"1\", \"2\", \"7\", \"6\", \"4\")\n        num = await r.sort(\n            \"gods\",\n            start=2,\n            num=4,\n            by=\"user:*:username\",\n            get=\"user:*:favorite_drink\",\n            desc=True,\n            alpha=True,\n            store=\"sorted\",\n        )\n        assert num == 4\n        assert await r.lrange(\"sorted\", 0, 10) == [\n            b\"vodka\",\n            b\"milk\",\n            b\"gin\",\n            b\"apple juice\",\n        ]\n\n    async def test_sort_issue_924(self, r: redis.Redis):\n        # Tests for issue https://github.com/andymccurdy/redis-py/issues/924\n        await r.execute_command(\"SADD\", \"issue#924\", 1)\n        await r.execute_command(\"SORT\", \"issue#924\")\n\n    @pytest.mark.onlynoncluster\n    async def test_cluster_addslots(self, mock_cluster_resp_ok):\n        assert await mock_cluster_resp_ok.cluster(\"ADDSLOTS\", 1) is True\n\n    @pytest.mark.onlynoncluster\n    async def test_cluster_count_failure_reports(self, mock_cluster_resp_int):\n        assert isinstance(\n            await mock_cluster_resp_int.cluster(\"COUNT-FAILURE-REPORTS\", \"node\"), int\n        )\n\n    @pytest.mark.onlynoncluster\n    async def test_cluster_countkeysinslot(self, mock_cluster_resp_int):\n        assert isinstance(\n            await mock_cluster_resp_int.cluster(\"COUNTKEYSINSLOT\", 2), int\n        )\n\n    @pytest.mark.onlynoncluster\n    async def test_cluster_delslots(self, mock_cluster_resp_ok):\n        assert await mock_cluster_resp_ok.cluster(\"DELSLOTS\", 1) is True\n\n    @pytest.mark.onlynoncluster\n    async def test_cluster_failover(self, mock_cluster_resp_ok):\n        assert await mock_cluster_resp_ok.cluster(\"FAILOVER\", 1) is True\n\n    @pytest.mark.onlynoncluster\n    async def test_cluster_forget(self, mock_cluster_resp_ok):\n        assert await mock_cluster_resp_ok.cluster(\"FORGET\", 1) is True\n\n    @pytest.mark.onlynoncluster\n    async def test_cluster_info(self, mock_cluster_resp_info):\n        assert isinstance(await mock_cluster_resp_info.cluster(\"info\"), dict)\n\n    @pytest.mark.onlynoncluster\n    async def test_cluster_keyslot(self, mock_cluster_resp_int):\n        assert isinstance(await mock_cluster_resp_int.cluster(\"keyslot\", \"asdf\"), int)\n\n    @pytest.mark.onlynoncluster\n    async def test_cluster_meet(self, mock_cluster_resp_ok):\n        assert await mock_cluster_resp_ok.cluster(\"meet\", \"ip\", \"port\", 1) is True\n\n    @pytest.mark.onlynoncluster\n    async def test_cluster_nodes(self, mock_cluster_resp_nodes):\n        assert isinstance(await mock_cluster_resp_nodes.cluster(\"nodes\"), dict)\n\n    @pytest.mark.onlynoncluster\n    async def test_cluster_replicate(self, mock_cluster_resp_ok):\n        assert await mock_cluster_resp_ok.cluster(\"replicate\", \"nodeid\") is True\n\n    @pytest.mark.onlynoncluster\n    async def test_cluster_reset(self, mock_cluster_resp_ok):\n        assert await mock_cluster_resp_ok.cluster(\"reset\", \"hard\") is True\n\n    @pytest.mark.onlynoncluster\n    async def test_cluster_saveconfig(self, mock_cluster_resp_ok):\n        assert await mock_cluster_resp_ok.cluster(\"saveconfig\") is True\n\n    @pytest.mark.onlynoncluster\n    async def test_cluster_setslot(self, mock_cluster_resp_ok):\n        assert (\n            await mock_cluster_resp_ok.cluster(\"setslot\", 1, \"IMPORTING\", \"nodeid\")\n            is True\n        )\n\n    @pytest.mark.onlynoncluster\n    async def test_cluster_slaves(self, mock_cluster_resp_slaves):\n        assert isinstance(\n            await mock_cluster_resp_slaves.cluster(\"slaves\", \"nodeid\"), dict\n        )\n\n    @skip_if_server_version_lt(\"3.0.0\")\n    @skip_if_server_version_gte(\"7.0.0\")\n    @pytest.mark.onlynoncluster\n    async def test_readwrite(self, r: redis.Redis):\n        assert await r.readwrite()\n\n    @skip_if_server_version_lt(\"3.0.0\")\n    @pytest.mark.onlynoncluster\n    async def test_readonly_invalid_cluster_state(self, r: redis.Redis):\n        with pytest.raises(exceptions.RedisError):\n            await r.readonly()\n\n    @skip_if_server_version_lt(\"3.0.0\")\n    @pytest.mark.onlynoncluster\n    async def test_readonly(self, mock_cluster_resp_ok):\n        assert await mock_cluster_resp_ok.readonly() is True\n\n    # GEO COMMANDS\n    @skip_if_server_version_lt(\"3.2.0\")\n    async def test_geoadd(self, r: redis.Redis):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n\n        assert await r.geoadd(\"barcelona\", values) == 2\n        assert await r.zcard(\"barcelona\") == 2\n\n    @skip_if_server_version_lt(\"3.2.0\")\n    async def test_geoadd_invalid_params(self, r: redis.Redis):\n        with pytest.raises(exceptions.RedisError):\n            await r.geoadd(\"barcelona\", (1, 2))\n\n    @skip_if_server_version_lt(\"3.2.0\")\n    async def test_geodist(self, r: redis.Redis):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n\n        assert await r.geoadd(\"barcelona\", values) == 2\n        assert await r.geodist(\"barcelona\", \"place1\", \"place2\") == 3067.4157\n\n    @skip_if_server_version_lt(\"3.2.0\")\n    async def test_geodist_units(self, r: redis.Redis):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n\n        await r.geoadd(\"barcelona\", values)\n        assert await r.geodist(\"barcelona\", \"place1\", \"place2\", \"km\") == 3.0674\n\n    @skip_if_server_version_lt(\"3.2.0\")\n    async def test_geodist_missing_one_member(self, r: redis.Redis):\n        values = (2.1909389952632, 41.433791470673, \"place1\")\n        await r.geoadd(\"barcelona\", values)\n        assert await r.geodist(\"barcelona\", \"place1\", \"missing_member\", \"km\") is None\n\n    @skip_if_server_version_lt(\"3.2.0\")\n    async def test_geodist_invalid_units(self, r: redis.Redis):\n        with pytest.raises(exceptions.RedisError):\n            assert await r.geodist(\"x\", \"y\", \"z\", \"inches\")\n\n    @skip_if_server_version_lt(\"3.2.0\")\n    async def test_geohash(self, r: redis.Redis):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n\n        await r.geoadd(\"barcelona\", values)\n        assert_resp_response(\n            r,\n            await r.geohash(\"barcelona\", \"place1\", \"place2\", \"place3\"),\n            [\"sp3e9yg3kd0\", \"sp3e9cbc3t0\", None],\n            [b\"sp3e9yg3kd0\", b\"sp3e9cbc3t0\", None],\n        )\n\n    @skip_if_server_version_lt(\"3.2.0\")\n    async def test_geopos(self, r: redis.Redis):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n\n        await r.geoadd(\"barcelona\", values)\n        # redis uses 52 bits precision, hereby small errors may be introduced.\n        assert_resp_response(\n            r,\n            await r.geopos(\"barcelona\", \"place1\", \"place2\"),\n            [\n                (2.19093829393386841, 41.43379028184083523),\n                (2.18737632036209106, 41.40634178640635099),\n            ],\n            [\n                [2.19093829393386841, 41.43379028184083523],\n                [2.18737632036209106, 41.40634178640635099],\n            ],\n        )\n\n    @skip_if_server_version_lt(\"4.0.0\")\n    async def test_geopos_no_value(self, r: redis.Redis):\n        assert await r.geopos(\"barcelona\", \"place1\", \"place2\") == [None, None]\n\n    @skip_if_server_version_lt(\"3.2.0\")\n    @skip_if_server_version_gte(\"4.0.0\")\n    async def test_old_geopos_no_value(self, r: redis.Redis):\n        assert await r.geopos(\"barcelona\", \"place1\", \"place2\") == []\n\n    @skip_if_server_version_lt(\"3.2.0\")\n    async def test_georadius(self, r: redis.Redis):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            b\"\\x80place2\",\n        )\n\n        await r.geoadd(\"barcelona\", values)\n        assert await r.georadius(\"barcelona\", 2.191, 41.433, 1000) == [b\"place1\"]\n        assert await r.georadius(\"barcelona\", 2.187, 41.406, 1000) == [b\"\\x80place2\"]\n\n    @skip_if_server_version_lt(\"3.2.0\")\n    async def test_georadius_no_values(self, r: redis.Redis):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n\n        await r.geoadd(\"barcelona\", values)\n        assert await r.georadius(\"barcelona\", 1, 2, 1000) == []\n\n    @skip_if_server_version_lt(\"3.2.0\")\n    async def test_georadius_units(self, r: redis.Redis):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n\n        await r.geoadd(\"barcelona\", values)\n        assert await r.georadius(\"barcelona\", 2.191, 41.433, 1, unit=\"km\") == [\n            b\"place1\"\n        ]\n\n    @skip_unless_arch_bits(64)\n    @skip_if_server_version_lt(\"3.2.0\")\n    async def test_georadius_with(self, r: redis.Redis):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n\n        await r.geoadd(\"barcelona\", values)\n\n        # test a bunch of combinations to test the parse response\n        # function.\n        assert await r.georadius(\n            \"barcelona\",\n            2.191,\n            41.433,\n            1,\n            unit=\"km\",\n            withdist=True,\n            withcoord=True,\n            withhash=True,\n        ) == [\n            [\n                b\"place1\",\n                0.0881,\n                3471609698139488,\n                (2.19093829393386841, 41.43379028184083523),\n            ]\n        ]\n\n        assert await r.georadius(\n            \"barcelona\", 2.191, 41.433, 1, unit=\"km\", withdist=True, withcoord=True\n        ) == [[b\"place1\", 0.0881, (2.19093829393386841, 41.43379028184083523)]]\n\n        assert await r.georadius(\n            \"barcelona\", 2.191, 41.433, 1, unit=\"km\", withhash=True, withcoord=True\n        ) == [\n            [b\"place1\", 3471609698139488, (2.19093829393386841, 41.43379028184083523)]\n        ]\n\n        # test no values.\n        assert (\n            await r.georadius(\n                \"barcelona\",\n                2,\n                1,\n                1,\n                unit=\"km\",\n                withdist=True,\n                withcoord=True,\n                withhash=True,\n            )\n            == []\n        )\n\n    @skip_if_server_version_lt(\"3.2.0\")\n    async def test_georadius_count(self, r: redis.Redis):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n\n        await r.geoadd(\"barcelona\", values)\n        assert await r.georadius(\"barcelona\", 2.191, 41.433, 3000, count=1) == [\n            b\"place1\"\n        ]\n\n    @skip_if_server_version_lt(\"3.2.0\")\n    async def test_georadius_sort(self, r: redis.Redis):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n\n        await r.geoadd(\"barcelona\", values)\n        assert await r.georadius(\"barcelona\", 2.191, 41.433, 3000, sort=\"ASC\") == [\n            b\"place1\",\n            b\"place2\",\n        ]\n        assert await r.georadius(\"barcelona\", 2.191, 41.433, 3000, sort=\"DESC\") == [\n            b\"place2\",\n            b\"place1\",\n        ]\n\n    @skip_if_server_version_lt(\"3.2.0\")\n    @pytest.mark.onlynoncluster\n    async def test_georadius_store(self, r: redis.Redis):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n\n        await r.geoadd(\"barcelona\", values)\n        await r.georadius(\"barcelona\", 2.191, 41.433, 1000, store=\"places_barcelona\")\n        assert await r.zrange(\"places_barcelona\", 0, -1) == [b\"place1\"]\n\n    @skip_unless_arch_bits(64)\n    @skip_if_server_version_lt(\"3.2.0\")\n    @pytest.mark.onlynoncluster\n    async def test_georadius_store_dist(self, r: redis.Redis):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n\n        await r.geoadd(\"barcelona\", values)\n        await r.georadius(\n            \"barcelona\", 2.191, 41.433, 1000, store_dist=\"places_barcelona\"\n        )\n        # instead of save the geo score, the distance is saved.\n        assert await r.zscore(\"places_barcelona\", \"place1\") == 88.05060698409301\n\n    @skip_unless_arch_bits(64)\n    @skip_if_server_version_lt(\"3.2.0\")\n    async def test_georadiusmember(self, r: redis.Redis):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            b\"\\x80place2\",\n        )\n\n        await r.geoadd(\"barcelona\", values)\n        assert await r.georadiusbymember(\"barcelona\", \"place1\", 4000) == [\n            b\"\\x80place2\",\n            b\"place1\",\n        ]\n        assert await r.georadiusbymember(\"barcelona\", \"place1\", 10) == [b\"place1\"]\n\n        assert await r.georadiusbymember(\n            \"barcelona\", \"place1\", 4000, withdist=True, withcoord=True, withhash=True\n        ) == [\n            [\n                b\"\\x80place2\",\n                3067.4157,\n                3471609625421029,\n                (2.187376320362091, 41.40634178640635),\n            ],\n            [\n                b\"place1\",\n                0.0,\n                3471609698139488,\n                (2.1909382939338684, 41.433790281840835),\n            ],\n        ]\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    async def test_xack(self, r: redis.Redis):\n        stream = \"stream\"\n        group = \"group\"\n        consumer = \"consumer\"\n        # xack on a stream that doesn't exist\n        assert await r.xack(stream, group, \"0-0\") == 0\n\n        m1 = await r.xadd(stream, {\"one\": \"one\"})\n        m2 = await r.xadd(stream, {\"two\": \"two\"})\n        m3 = await r.xadd(stream, {\"three\": \"three\"})\n\n        # xack on a group that doesn't exist\n        assert await r.xack(stream, group, m1) == 0\n\n        await r.xgroup_create(stream, group, 0)\n        await r.xreadgroup(group, consumer, streams={stream: \">\"})\n        # xack returns the number of ack'd elements\n        assert await r.xack(stream, group, m1) == 1\n        assert await r.xack(stream, group, m2, m3) == 2\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    async def test_xadd(self, r: redis.Redis):\n        stream = \"stream\"\n        message_id = await r.xadd(stream, {\"foo\": \"bar\"})\n        assert re.match(rb\"[0-9]+\\-[0-9]+\", message_id)\n\n        # explicit message id\n        message_id = b\"9999999999999999999-0\"\n        assert message_id == await r.xadd(stream, {\"foo\": \"bar\"}, id=message_id)\n\n        # with maxlen, the list evicts the first message\n        await r.xadd(stream, {\"foo\": \"bar\"}, maxlen=2, approximate=False)\n        assert await r.xlen(stream) == 2\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    async def test_xclaim(self, r: redis.Redis):\n        stream = \"stream\"\n        group = \"group\"\n        consumer1 = \"consumer1\"\n        consumer2 = \"consumer2\"\n\n        message_id = await r.xadd(stream, {\"john\": \"wick\"})\n        message = await get_stream_message(r, stream, message_id)\n        await r.xgroup_create(stream, group, 0)\n\n        # trying to claim a message that isn't already pending doesn't\n        # do anything\n        response = await r.xclaim(\n            stream, group, consumer2, min_idle_time=0, message_ids=(message_id,)\n        )\n        assert response == []\n\n        # read the group as consumer1 to initially claim the messages\n        await r.xreadgroup(group, consumer1, streams={stream: \">\"})\n\n        # claim the message as consumer2\n        response = await r.xclaim(\n            stream, group, consumer2, min_idle_time=0, message_ids=(message_id,)\n        )\n        assert response[0] == message\n\n        # reclaim the message as consumer1, but use the justid argument\n        # which only returns message ids\n        assert await r.xclaim(\n            stream,\n            group,\n            consumer1,\n            min_idle_time=0,\n            message_ids=(message_id,),\n            justid=True,\n        ) == [message_id]\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    async def test_xclaim_trimmed(self, r: redis.Redis):\n        # xclaim should not raise an exception if the item is not there\n        stream = \"stream\"\n        group = \"group\"\n\n        await r.xgroup_create(stream, group, id=\"$\", mkstream=True)\n\n        # add a couple of new items\n        sid1 = await r.xadd(stream, {\"item\": 0})\n        sid2 = await r.xadd(stream, {\"item\": 0})\n\n        # read them from consumer1\n        await r.xreadgroup(group, \"consumer1\", {stream: \">\"})\n\n        # add a 3rd and trim the stream down to 2 items\n        await r.xadd(stream, {\"item\": 3}, maxlen=2, approximate=False)\n\n        # xclaim them from consumer2\n        # the item that is still in the stream should be returned\n        item = await r.xclaim(stream, group, \"consumer2\", 0, [sid1, sid2])\n        assert len(item) == 1\n        assert item[0][0] == sid2\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    async def test_xdel(self, r: redis.Redis):\n        stream = \"stream\"\n\n        # deleting from an empty stream doesn't do anything\n        assert await r.xdel(stream, 1) == 0\n\n        m1 = await r.xadd(stream, {\"foo\": \"bar\"})\n        m2 = await r.xadd(stream, {\"foo\": \"bar\"})\n        m3 = await r.xadd(stream, {\"foo\": \"bar\"})\n\n        # xdel returns the number of deleted elements\n        assert await r.xdel(stream, m1) == 1\n        assert await r.xdel(stream, m2, m3) == 2\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    async def test_xgroup_create(self, r: redis.Redis):\n        # tests xgroup_create and xinfo_groups\n        stream = \"stream\"\n        group = \"group\"\n        await r.xadd(stream, {\"foo\": \"bar\"})\n\n        # no group is setup yet, no info to obtain\n        assert await r.xinfo_groups(stream) == []\n\n        assert await r.xgroup_create(stream, group, 0)\n        expected = [\n            {\n                \"name\": group.encode(),\n                \"consumers\": 0,\n                \"pending\": 0,\n                \"last-delivered-id\": b\"0-0\",\n                \"entries-read\": None,\n                \"lag\": 1,\n            }\n        ]\n        assert await r.xinfo_groups(stream) == expected\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    async def test_xgroup_create_mkstream(self, r: redis.Redis):\n        # tests xgroup_create and xinfo_groups\n        stream = \"stream\"\n        group = \"group\"\n\n        # an error is raised if a group is created on a stream that\n        # doesn't already exist\n        with pytest.raises(exceptions.ResponseError):\n            await r.xgroup_create(stream, group, 0)\n\n        # however, with mkstream=True, the underlying stream is created\n        # automatically\n        assert await r.xgroup_create(stream, group, 0, mkstream=True)\n        expected = [\n            {\n                \"name\": group.encode(),\n                \"consumers\": 0,\n                \"pending\": 0,\n                \"last-delivered-id\": b\"0-0\",\n                \"entries-read\": None,\n                \"lag\": 0,\n            }\n        ]\n        assert await r.xinfo_groups(stream) == expected\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    async def test_xgroup_delconsumer(self, r: redis.Redis):\n        stream = \"stream\"\n        group = \"group\"\n        consumer = \"consumer\"\n        await r.xadd(stream, {\"foo\": \"bar\"})\n        await r.xadd(stream, {\"foo\": \"bar\"})\n        await r.xgroup_create(stream, group, 0)\n\n        # a consumer that hasn't yet read any messages doesn't do anything\n        assert await r.xgroup_delconsumer(stream, group, consumer) == 0\n\n        # read all messages from the group\n        await r.xreadgroup(group, consumer, streams={stream: \">\"})\n\n        # deleting the consumer should return 2 pending messages\n        assert await r.xgroup_delconsumer(stream, group, consumer) == 2\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    async def test_xgroup_destroy(self, r: redis.Redis):\n        stream = \"stream\"\n        group = \"group\"\n        await r.xadd(stream, {\"foo\": \"bar\"})\n\n        # destroying a nonexistent group returns False\n        assert not await r.xgroup_destroy(stream, group)\n\n        await r.xgroup_create(stream, group, 0)\n        assert await r.xgroup_destroy(stream, group)\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    @skip_if_server_version_gte(\"8.2.2\")\n    async def test_xgroup_setid(self, r: redis.Redis):\n        stream = \"stream\"\n        group = \"group\"\n        message_id = await r.xadd(stream, {\"foo\": \"bar\"})\n\n        await r.xgroup_create(stream, group, 0)\n        # advance the last_delivered_id to the message_id\n        await r.xgroup_setid(stream, group, message_id, entries_read=2)\n        expected = [\n            {\n                \"name\": group.encode(),\n                \"consumers\": 0,\n                \"pending\": 0,\n                \"last-delivered-id\": message_id,\n                \"entries-read\": 2,\n                \"lag\": -1,\n            }\n        ]\n        assert await r.xinfo_groups(stream) == expected\n\n    @skip_if_server_version_lt(\"8.2.2\")\n    async def test_xgroup_setid_fixed_max_entries_read(self, r):\n        stream = \"stream\"\n        group = \"group\"\n        message_id = await r.xadd(stream, {\"foo\": \"bar\"})\n        await r.xadd(stream, {\"foo1\": \"bar1\"})\n\n        await r.xgroup_create(stream, group, 0)\n        # advance the last_delivered_id to the message_id\n        await r.xgroup_setid(stream, group, message_id, entries_read=2)\n        expected = [\n            {\n                \"name\": group.encode(),\n                \"consumers\": 0,\n                \"pending\": 0,\n                \"last-delivered-id\": message_id,\n                \"entries-read\": 2,\n                \"lag\": 0,\n            }\n        ]\n        assert await r.xinfo_groups(stream) == expected\n\n    @skip_if_server_version_lt(\"7.2.0\")\n    async def test_xinfo_consumers(self, r: redis.Redis):\n        stream = \"stream\"\n        group = \"group\"\n        consumer1 = \"consumer1\"\n        consumer2 = \"consumer2\"\n        await r.xadd(stream, {\"foo\": \"bar\"})\n        await r.xadd(stream, {\"foo\": \"bar\"})\n        await r.xadd(stream, {\"foo\": \"bar\"})\n\n        await r.xgroup_create(stream, group, 0)\n        await r.xreadgroup(group, consumer1, streams={stream: \">\"}, count=1)\n        await r.xreadgroup(group, consumer2, streams={stream: \">\"})\n        info = await r.xinfo_consumers(stream, group)\n        assert len(info) == 2\n        expected = [\n            {\"name\": consumer1.encode(), \"pending\": 1},\n            {\"name\": consumer2.encode(), \"pending\": 2},\n        ]\n\n        # we can't determine the idle and inactive time, so just make sure it's an int\n        assert isinstance(info[0].pop(\"idle\"), int)\n        assert isinstance(info[1].pop(\"idle\"), int)\n        assert isinstance(info[0].pop(\"inactive\"), int)\n        assert isinstance(info[1].pop(\"inactive\"), int)\n        assert info == expected\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    async def test_xinfo_stream(self, r: redis.Redis):\n        stream = \"stream\"\n        m1 = await r.xadd(stream, {\"foo\": \"bar\"})\n        m2 = await r.xadd(stream, {\"foo\": \"bar\"})\n        info = await r.xinfo_stream(stream)\n\n        assert info[\"length\"] == 2\n        assert info[\"first-entry\"] == await get_stream_message(r, stream, m1)\n        assert info[\"last-entry\"] == await get_stream_message(r, stream, m2)\n\n        await r.xtrim(stream, 0)\n        info = await r.xinfo_stream(stream)\n        assert info[\"length\"] == 0\n        assert info[\"first-entry\"] is None\n        assert info[\"last-entry\"] is None\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    async def test_xinfo_stream_full(self, r: redis.Redis):\n        stream = \"stream\"\n        group = \"group\"\n\n        await r.xadd(stream, {\"foo\": \"bar\"})\n        info = await r.xinfo_stream(stream, full=True)\n        assert info[\"length\"] == 1\n        assert len(info[\"groups\"]) == 0\n\n        await r.xgroup_create(stream, group, 0)\n        info = await r.xinfo_stream(stream, full=True)\n        assert info[\"length\"] == 1\n\n        await r.xreadgroup(group, \"consumer\", streams={stream: \">\"})\n        info = await r.xinfo_stream(stream, full=True)\n        consumer = info[\"groups\"][0][\"consumers\"][0]\n        assert isinstance(consumer, dict)\n\n    @skip_if_server_version_lt(\"8.5.0\")\n    async def test_xinfo_stream_idempotent_fields(self, r: redis.Redis):\n        stream = \"stream\"\n\n        # Create stream with regular entry\n        await r.xadd(stream, {\"foo\": \"bar\"})\n        info = await r.xinfo_stream(stream)\n\n        # Verify new idempotent producer fields are present with default values\n        assert \"idmp-duration\" in info\n        assert \"idmp-maxsize\" in info\n        assert \"pids-tracked\" in info\n        assert \"iids-tracked\" in info\n        assert \"iids-added\" in info\n        assert \"iids-duplicates\" in info\n\n        # Default values (before any idempotent entries)\n        assert info[\"pids-tracked\"] == 0\n        assert info[\"iids-tracked\"] == 0\n        assert info[\"iids-added\"] == 0\n        assert info[\"iids-duplicates\"] == 0\n\n        # Add idempotent entry\n        await r.xadd(stream, {\"field1\": \"value1\"}, idmpauto=\"producer1\")\n        info = await r.xinfo_stream(stream)\n\n        # After adding idempotent entry\n        assert info[\"pids-tracked\"] == 1  # One producer tracked\n        assert info[\"iids-tracked\"] == 1  # One iid tracked\n        assert info[\"iids-added\"] == 1  # One idempotent entry added\n        assert info[\"iids-duplicates\"] == 0  # No duplicates yet\n\n        # Add duplicate entry\n        await r.xadd(stream, {\"field1\": \"value1\"}, idmpauto=\"producer1\")\n        info = await r.xinfo_stream(stream)\n\n        # After duplicate\n        assert info[\"pids-tracked\"] == 1  # Still one producer\n        assert info[\"iids-tracked\"] == 1  # Still one iid (duplicate doesn't add new)\n        assert info[\"iids-added\"] == 1  # Still one unique entry\n        assert info[\"iids-duplicates\"] == 1  # One duplicate detected\n\n        # Add entry from different producer\n        await r.xadd(stream, {\"field2\": \"value2\"}, idmpauto=\"producer2\")\n        info = await r.xinfo_stream(stream)\n\n        # After second producer\n        assert info[\"pids-tracked\"] == 2  # Two producers tracked\n        assert info[\"iids-tracked\"] == 2  # Two iids tracked\n        assert info[\"iids-added\"] == 2  # Two unique entries\n        assert info[\"iids-duplicates\"] == 1  # Still one duplicate\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    async def test_xlen(self, r: redis.Redis):\n        stream = \"stream\"\n        assert await r.xlen(stream) == 0\n        await r.xadd(stream, {\"foo\": \"bar\"})\n        await r.xadd(stream, {\"foo\": \"bar\"})\n        assert await r.xlen(stream) == 2\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    async def test_xpending(self, r: redis.Redis):\n        stream = \"stream\"\n        group = \"group\"\n        consumer1 = \"consumer1\"\n        consumer2 = \"consumer2\"\n        m1 = await r.xadd(stream, {\"foo\": \"bar\"})\n        m2 = await r.xadd(stream, {\"foo\": \"bar\"})\n        await r.xgroup_create(stream, group, 0)\n\n        # xpending on a group that has no consumers yet\n        expected = {\"pending\": 0, \"min\": None, \"max\": None, \"consumers\": []}\n        assert await r.xpending(stream, group) == expected\n\n        # read 1 message from the group with each consumer\n        await r.xreadgroup(group, consumer1, streams={stream: \">\"}, count=1)\n        await r.xreadgroup(group, consumer2, streams={stream: \">\"}, count=1)\n\n        expected = {\n            \"pending\": 2,\n            \"min\": m1,\n            \"max\": m2,\n            \"consumers\": [\n                {\"name\": consumer1.encode(), \"pending\": 1},\n                {\"name\": consumer2.encode(), \"pending\": 1},\n            ],\n        }\n        assert await r.xpending(stream, group) == expected\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    async def test_xpending_range(self, r: redis.Redis):\n        stream = \"stream\"\n        group = \"group\"\n        consumer1 = \"consumer1\"\n        consumer2 = \"consumer2\"\n        m1 = await r.xadd(stream, {\"foo\": \"bar\"})\n        m2 = await r.xadd(stream, {\"foo\": \"bar\"})\n        await r.xgroup_create(stream, group, 0)\n\n        # xpending range on a group that has no consumers yet\n        assert await r.xpending_range(stream, group, min=\"-\", max=\"+\", count=5) == []\n\n        # read 1 message from the group with each consumer\n        await r.xreadgroup(group, consumer1, streams={stream: \">\"}, count=1)\n        await r.xreadgroup(group, consumer2, streams={stream: \">\"}, count=1)\n\n        response = await r.xpending_range(stream, group, min=\"-\", max=\"+\", count=5)\n        assert len(response) == 2\n        assert response[0][\"message_id\"] == m1\n        assert response[0][\"consumer\"] == consumer1.encode()\n        assert response[1][\"message_id\"] == m2\n        assert response[1][\"consumer\"] == consumer2.encode()\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    async def test_xrange(self, r: redis.Redis):\n        stream = \"stream\"\n        m1 = await r.xadd(stream, {\"foo\": \"bar\"})\n        m2 = await r.xadd(stream, {\"foo\": \"bar\"})\n        m3 = await r.xadd(stream, {\"foo\": \"bar\"})\n        m4 = await r.xadd(stream, {\"foo\": \"bar\"})\n\n        def get_ids(results):\n            return [result[0] for result in results]\n\n        results = await r.xrange(stream, min=m1)\n        assert get_ids(results) == [m1, m2, m3, m4]\n\n        results = await r.xrange(stream, min=m2, max=m3)\n        assert get_ids(results) == [m2, m3]\n\n        results = await r.xrange(stream, max=m3)\n        assert get_ids(results) == [m1, m2, m3]\n\n        results = await r.xrange(stream, max=m2, count=1)\n        assert get_ids(results) == [m1]\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    async def test_xread(self, r: redis.Redis):\n        stream = \"stream\"\n        m1 = await r.xadd(stream, {\"foo\": \"bar\"})\n        m2 = await r.xadd(stream, {\"bing\": \"baz\"})\n\n        strem_name = stream.encode()\n        expected_entries = [\n            await get_stream_message(r, stream, m1),\n            await get_stream_message(r, stream, m2),\n        ]\n        # xread starting at 0 returns both messages\n        res = await r.xread(streams={stream: 0})\n        assert_resp_response(\n            r, res, [[strem_name, expected_entries]], {strem_name: [expected_entries]}\n        )\n\n        expected_entries = [await get_stream_message(r, stream, m1)]\n        # xread starting at 0 and count=1 returns only the first message\n        res = await r.xread(streams={stream: 0}, count=1)\n        assert_resp_response(\n            r, res, [[strem_name, expected_entries]], {strem_name: [expected_entries]}\n        )\n\n        expected_entries = [await get_stream_message(r, stream, m2)]\n        # xread starting at m1 returns only the second message\n        res = await r.xread(streams={stream: m1})\n        assert_resp_response(\n            r, res, [[strem_name, expected_entries]], {strem_name: [expected_entries]}\n        )\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    async def test_xreadgroup(self, r: redis.Redis):\n        stream = \"stream\"\n        group = \"group\"\n        consumer = \"consumer\"\n        m1 = await r.xadd(stream, {\"foo\": \"bar\"})\n        m2 = await r.xadd(stream, {\"bing\": \"baz\"})\n        await r.xgroup_create(stream, group, 0)\n\n        strem_name = stream.encode()\n        expected_entries = [\n            await get_stream_message(r, stream, m1),\n            await get_stream_message(r, stream, m2),\n        ]\n\n        # xread starting at 0 returns both messages\n        res = await r.xreadgroup(group, consumer, streams={stream: \">\"})\n        assert_resp_response(\n            r, res, [[strem_name, expected_entries]], {strem_name: [expected_entries]}\n        )\n\n        await r.xgroup_destroy(stream, group)\n        await r.xgroup_create(stream, group, 0)\n\n        expected_entries = [await get_stream_message(r, stream, m1)]\n\n        # xread with count=1 returns only the first message\n        res = await r.xreadgroup(group, consumer, streams={stream: \">\"}, count=1)\n        assert_resp_response(\n            r, res, [[strem_name, expected_entries]], {strem_name: [expected_entries]}\n        )\n\n        await r.xgroup_destroy(stream, group)\n\n        # create the group using $ as the last id meaning subsequent reads\n        # will only find messages added after this\n        await r.xgroup_create(stream, group, \"$\")\n\n        # xread starting after the last message returns an empty message list\n        res = await r.xreadgroup(group, consumer, streams={stream: \">\"})\n        assert_resp_response(r, res, [], {})\n\n        # xreadgroup with noack does not have any items in the PEL\n        await r.xgroup_destroy(stream, group)\n        await r.xgroup_create(stream, group, \"0\")\n        res = await r.xreadgroup(group, consumer, streams={stream: \">\"}, noack=True)\n        empty_res = await r.xreadgroup(group, consumer, streams={stream: \"0\"})\n        if is_resp2_connection(r):\n            assert len(res[0][1]) == 2\n            # now there should be nothing pending\n            assert len(empty_res[0][1]) == 0\n        else:\n            assert len(res[strem_name][0]) == 2\n            # now there should be nothing pending\n            assert len(empty_res[strem_name][0]) == 0\n\n        await r.xgroup_destroy(stream, group)\n        await r.xgroup_create(stream, group, \"0\")\n        # delete all the messages in the stream\n        expected_entries = [(m1, {}), (m2, {})]\n        await r.xreadgroup(group, consumer, streams={stream: \">\"})\n        await r.xtrim(stream, 0)\n        res = await r.xreadgroup(group, consumer, streams={stream: \"0\"})\n        assert_resp_response(\n            r, res, [[strem_name, expected_entries]], {strem_name: [expected_entries]}\n        )\n\n    def _validate_xreadgroup_with_claim_min_idle_time_response(\n        self, r, response, expected_entries\n    ):\n        # validate the number of streams\n        assert len(response) == len(expected_entries)\n\n        expected_streams = expected_entries.keys()\n        for str_index, expected_stream in enumerate(expected_streams):\n            expected_entries_per_stream = expected_entries[expected_stream]\n\n            if is_resp2_connection(r):\n                actual_entries_per_stream = response[str_index][1]\n                actual_stream = response[str_index][0]\n                assert actual_stream == expected_stream\n            else:\n                actual_entries_per_stream = response[expected_stream][0]\n\n            # validate the number of entries\n            assert len(actual_entries_per_stream) == len(expected_entries_per_stream)\n\n            for i, entry in enumerate(actual_entries_per_stream):\n                message = (entry[0], entry[1])\n                message_idle = int(entry[2])\n                message_delivered_count = int(entry[3])\n\n                expected_idle = expected_entries_per_stream[i][\"min_idle_time\"]\n\n                assert message == expected_entries_per_stream[i][\"msg\"]\n                if expected_idle == 0:\n                    assert message_idle == 0\n                    assert message_delivered_count == 0\n                else:\n                    assert message_idle >= expected_idle\n                    assert message_delivered_count > 0\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_xreadgroup_with_claim_min_idle_time(self, r):\n        stream = \"stream\"\n        group = \"group\"\n        consumer_1 = \"c1\"\n        stream_name = stream.encode()\n\n        try:\n            # before test cleanup\n            await r.xgroup_destroy(stream, group)\n        except ResponseError:\n            pass\n\n        m1 = await r.xadd(stream, {\"key_m1\": \"val_m1\"})\n        m2 = await r.xadd(stream, {\"key_m2\": \"val_m2\"})\n\n        await r.xgroup_create(stream, group, 0)\n\n        # read all the messages - this will save the msgs in PEL\n        await r.xreadgroup(group, consumer_1, streams={stream: \">\"})\n\n        # wait for 100ms - so that the messages would have been in the PEL for long enough\n        await asyncio.sleep(0.1)\n\n        m3 = await r.xadd(stream, {\"key_m3\": \"val_m3\"})\n        m4 = await r.xadd(stream, {\"key_m4\": \"val_m4\"})\n\n        expected_entries = {\n            stream_name: [\n                {\"msg\": await get_stream_message(r, stream, m1), \"min_idle_time\": 100},\n                {\"msg\": await get_stream_message(r, stream, m2), \"min_idle_time\": 100},\n                {\"msg\": await get_stream_message(r, stream, m3), \"min_idle_time\": 0},\n                {\"msg\": await get_stream_message(r, stream, m4), \"min_idle_time\": 0},\n            ]\n        }\n\n        res = await r.xreadgroup(\n            group, consumer_1, streams={stream: \">\"}, claim_min_idle_time=100\n        )\n\n        self._validate_xreadgroup_with_claim_min_idle_time_response(\n            r, res, expected_entries\n        )\n\n        # validate PEL still holds the messages until they are acknowledged\n        response = await r.xpending_range(stream, group, min=\"-\", max=\"+\", count=5)\n        assert len(response) == 4\n\n        # add 2 more messages\n        m5 = await r.xadd(stream, {\"key_m5\": \"val_m5\"})\n        m6 = await r.xadd(stream, {\"key_m6\": \"val_m6\"})\n\n        expected_entries = [\n            await get_stream_message(r, stream, m5),\n            await get_stream_message(r, stream, m6),\n        ]\n        # read all the messages - this will save the msgs in PEL\n        res = await r.xreadgroup(group, consumer_1, streams={stream: \">\"})\n        assert_resp_response(\n            r,\n            res,\n            [[stream_name, expected_entries]],\n            {stream_name: [expected_entries]},\n        )\n\n        # add 2 more messages\n        m7 = await r.xadd(stream, {\"key_m7\": \"val_m7\"})\n        m8 = await r.xadd(stream, {\"key_m8\": \"val_m8\"})\n        # read the messages with claim_min_idle_time=1000\n        # only m7 and m8 should be returned\n        # because the other messages have not been in the PEL for long enough\n        expected_entries = {\n            stream_name: [\n                {\"msg\": await get_stream_message(r, stream, m7), \"min_idle_time\": 0},\n                {\"msg\": await get_stream_message(r, stream, m8), \"min_idle_time\": 0},\n            ]\n        }\n        res = await r.xreadgroup(\n            group, consumer_1, streams={stream: \">\"}, claim_min_idle_time=100\n        )\n        self._validate_xreadgroup_with_claim_min_idle_time_response(\n            r, res, expected_entries\n        )\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_xreadgroup_with_claim_min_idle_time_multiple_streams_same_slots(\n        self, r\n    ):\n        stream_1 = \"stream1:{same_slot:42}\"\n        stream_2 = \"stream2:{same_slot:42}\"\n        group = \"group\"\n        consumer_1 = \"c1\"\n\n        stream_1_name = stream_1.encode()\n        stream_2_name = stream_2.encode()\n\n        # before test cleanup\n        try:\n            await r.xgroup_destroy(stream_1, group)\n        except ResponseError:\n            pass\n        try:\n            await r.xgroup_destroy(stream_2, group)\n        except ResponseError:\n            pass\n\n        m1_s1 = await r.xadd(stream_1, {\"key_m1_s1\": \"val_m1\"})\n        m2_s1 = await r.xadd(stream_1, {\"key_m2_s1\": \"val_m2\"})\n        await r.xgroup_create(stream_1, group, 0)\n\n        m1_s2 = await r.xadd(stream_2, {\"key_m1_s2\": \"val_m1\"})\n        m2_s2 = await r.xadd(stream_2, {\"key_m2_s2\": \"val_m2\"})\n        await r.xgroup_create(stream_2, group, 0)\n\n        # read all the messages - this will save the msgs in PEL\n        await r.xreadgroup(group, consumer_1, streams={stream_1: \">\", stream_2: \">\"})\n\n        # wait for 100ms - so that the messages would have been in the PEL for long enough\n        await asyncio.sleep(0.1)\n\n        m3_s1 = await r.xadd(stream_1, {\"key_m3_s1\": \"val_m3\"})\n        m4_s2 = await r.xadd(stream_2, {\"key_m4_s2\": \"val_m4\"})\n\n        expected_entries = {\n            stream_1_name: [\n                {\n                    \"msg\": await get_stream_message(r, stream_1, m1_s1),\n                    \"min_idle_time\": 100,\n                },\n                {\n                    \"msg\": await get_stream_message(r, stream_1, m2_s1),\n                    \"min_idle_time\": 100,\n                },\n                {\n                    \"msg\": await get_stream_message(r, stream_1, m3_s1),\n                    \"min_idle_time\": 0,\n                },\n            ],\n            stream_2_name: [\n                {\n                    \"msg\": await get_stream_message(r, stream_2, m1_s2),\n                    \"min_idle_time\": 100,\n                },\n                {\n                    \"msg\": await get_stream_message(r, stream_2, m2_s2),\n                    \"min_idle_time\": 100,\n                },\n                {\n                    \"msg\": await get_stream_message(r, stream_2, m4_s2),\n                    \"min_idle_time\": 0,\n                },\n            ],\n        }\n\n        res = await r.xreadgroup(\n            group,\n            consumer_1,\n            streams={stream_1: \">\", stream_2: \">\"},\n            claim_min_idle_time=100,\n        )\n\n        self._validate_xreadgroup_with_claim_min_idle_time_response(\n            r, res, expected_entries\n        )\n\n        # validate PEL still holds the messages until they are acknowledged\n        response = await r.xpending_range(stream_1, group, min=\"-\", max=\"+\", count=5)\n        assert len(response) == 3\n        response = await r.xpending_range(stream_2, group, min=\"-\", max=\"+\", count=5)\n        assert len(response) == 3\n\n        # add 2 more messages\n        await r.xadd(stream_1, {\"key_m5\": \"val_m5\"})\n        await r.xadd(stream_2, {\"key_m6\": \"val_m6\"})\n\n        # read all the messages - this will save the msgs in PEL\n        await r.xreadgroup(group, consumer_1, streams={stream_1: \">\", stream_2: \">\"})\n\n        # add 2 more messages\n        m7 = await r.xadd(stream_1, {\"key_m7\": \"val_m7\"})\n        m8 = await r.xadd(stream_2, {\"key_m8\": \"val_m8\"})\n        # read the messages with claim_min_idle_time=1000\n        # only m7 and m8 should be returned\n        # because the other messages have not been in the PEL for long enough\n        expected_entries = {\n            stream_1_name: [\n                {\"msg\": await get_stream_message(r, stream_1, m7), \"min_idle_time\": 0}\n            ],\n            stream_2_name: [\n                {\"msg\": await get_stream_message(r, stream_2, m8), \"min_idle_time\": 0}\n            ],\n        }\n        res = await r.xreadgroup(\n            group,\n            consumer_1,\n            streams={stream_1: \">\", stream_2: \">\"},\n            claim_min_idle_time=100,\n        )\n        self._validate_xreadgroup_with_claim_min_idle_time_response(\n            r, res, expected_entries\n        )\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_xreadgroup_with_claim_min_idle_time_multiple_streams(self, r):\n        stream_1 = \"stream1\"\n        stream_2 = \"stream2\"\n        group = \"group\"\n        consumer_1 = \"c1\"\n\n        stream_1_name = stream_1.encode()\n        stream_2_name = stream_2.encode()\n\n        # before test cleanup\n        try:\n            await r.xgroup_destroy(stream_1, group)\n        except ResponseError:\n            pass\n        try:\n            await r.xgroup_destroy(stream_2, group)\n        except ResponseError:\n            pass\n\n        m1_s1 = await r.xadd(stream_1, {\"key_m1_s1\": \"val_m1\"})\n        m2_s1 = await r.xadd(stream_1, {\"key_m2_s1\": \"val_m2\"})\n        await r.xgroup_create(stream_1, group, 0)\n\n        m1_s2 = await r.xadd(stream_2, {\"key_m1_s2\": \"val_m1\"})\n        m2_s2 = await r.xadd(stream_2, {\"key_m2_s2\": \"val_m2\"})\n        await r.xgroup_create(stream_2, group, 0)\n\n        # read all the messages - this will save the msgs in PEL\n        await r.xreadgroup(group, consumer_1, streams={stream_1: \">\", stream_2: \">\"})\n\n        # wait for 100ms - so that the messages would have been in the PEL for long enough\n        await asyncio.sleep(0.1)\n\n        m3_s1 = await r.xadd(stream_1, {\"key_m3_s1\": \"val_m3\"})\n        m4_s2 = await r.xadd(stream_2, {\"key_m4_s2\": \"val_m4\"})\n\n        expected_entries = {\n            stream_1_name: [\n                {\n                    \"msg\": await get_stream_message(r, stream_1, m1_s1),\n                    \"min_idle_time\": 100,\n                },\n                {\n                    \"msg\": await get_stream_message(r, stream_1, m2_s1),\n                    \"min_idle_time\": 100,\n                },\n                {\n                    \"msg\": await get_stream_message(r, stream_1, m3_s1),\n                    \"min_idle_time\": 0,\n                },\n            ],\n            stream_2_name: [\n                {\n                    \"msg\": await get_stream_message(r, stream_2, m1_s2),\n                    \"min_idle_time\": 100,\n                },\n                {\n                    \"msg\": await get_stream_message(r, stream_2, m2_s2),\n                    \"min_idle_time\": 100,\n                },\n                {\n                    \"msg\": await get_stream_message(r, stream_2, m4_s2),\n                    \"min_idle_time\": 0,\n                },\n            ],\n        }\n\n        res = await r.xreadgroup(\n            group,\n            consumer_1,\n            streams={stream_1: \">\", stream_2: \">\"},\n            claim_min_idle_time=100,\n        )\n\n        self._validate_xreadgroup_with_claim_min_idle_time_response(\n            r, res, expected_entries\n        )\n\n        # validate PEL still holds the messages until they are acknowledged\n        response = await r.xpending_range(stream_1, group, min=\"-\", max=\"+\", count=5)\n        assert len(response) == 3\n        response = await r.xpending_range(stream_2, group, min=\"-\", max=\"+\", count=5)\n        assert len(response) == 3\n\n        # add 2 more messages\n        await r.xadd(stream_1, {\"key_m5\": \"val_m5\"})\n        await r.xadd(stream_2, {\"key_m6\": \"val_m6\"})\n\n        # read all the messages - this will save the msgs in PEL\n        await r.xreadgroup(group, consumer_1, streams={stream_1: \">\", stream_2: \">\"})\n\n        # add 2 more messages\n        m7 = await r.xadd(stream_1, {\"key_m7\": \"val_m7\"})\n        m8 = await r.xadd(stream_2, {\"key_m8\": \"val_m8\"})\n        # read the messages with claim_min_idle_time=1000\n        # only m7 and m8 should be returned\n        # because the other messages have not been in the PEL for long enough\n        expected_entries = {\n            stream_1_name: [\n                {\"msg\": await get_stream_message(r, stream_1, m7), \"min_idle_time\": 0}\n            ],\n            stream_2_name: [\n                {\"msg\": await get_stream_message(r, stream_2, m8), \"min_idle_time\": 0}\n            ],\n        }\n        res = await r.xreadgroup(\n            group,\n            consumer_1,\n            streams={stream_1: \">\", stream_2: \">\"},\n            claim_min_idle_time=100,\n        )\n        self._validate_xreadgroup_with_claim_min_idle_time_response(\n            r, res, expected_entries\n        )\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    async def test_xrevrange(self, r: redis.Redis):\n        stream = \"stream\"\n        m1 = await r.xadd(stream, {\"foo\": \"bar\"})\n        m2 = await r.xadd(stream, {\"foo\": \"bar\"})\n        m3 = await r.xadd(stream, {\"foo\": \"bar\"})\n        m4 = await r.xadd(stream, {\"foo\": \"bar\"})\n\n        def get_ids(results):\n            return [result[0] for result in results]\n\n        results = await r.xrevrange(stream, max=m4)\n        assert get_ids(results) == [m4, m3, m2, m1]\n\n        results = await r.xrevrange(stream, max=m3, min=m2)\n        assert get_ids(results) == [m3, m2]\n\n        results = await r.xrevrange(stream, min=m3)\n        assert get_ids(results) == [m4, m3]\n\n        results = await r.xrevrange(stream, min=m2, count=1)\n        assert get_ids(results) == [m4]\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    async def test_xtrim(self, r: redis.Redis):\n        stream = \"stream\"\n\n        # trimming an empty key doesn't do anything\n        assert await r.xtrim(stream, 1000) == 0\n\n        await r.xadd(stream, {\"foo\": \"bar\"})\n        await r.xadd(stream, {\"foo\": \"bar\"})\n        await r.xadd(stream, {\"foo\": \"bar\"})\n        await r.xadd(stream, {\"foo\": \"bar\"})\n\n        # trimming an amount large than the number of messages\n        # doesn't do anything\n        assert await r.xtrim(stream, 5, approximate=False) == 0\n\n        # 1 message is trimmed\n        assert await r.xtrim(stream, 3, approximate=False) == 1\n\n    @skip_if_server_version_lt(\"8.1.224\")\n    async def test_xdelex(self, r: redis.Redis):\n        stream = \"stream\"\n\n        m1 = await r.xadd(stream, {\"foo\": \"bar\"})\n        m2 = await r.xadd(stream, {\"foo\": \"bar\"})\n        m3 = await r.xadd(stream, {\"foo\": \"bar\"})\n        m4 = await r.xadd(stream, {\"foo\": \"bar\"})\n\n        # Test XDELEX with default ref_policy (KEEPREF)\n        result = await r.xdelex(stream, m1)\n        assert result == [1]\n\n        # Test XDELEX with explicit KEEPREF\n        result = await r.xdelex(stream, m2, ref_policy=\"KEEPREF\")\n        assert result == [1]\n\n        # Test XDELEX with DELREF\n        result = await r.xdelex(stream, m3, ref_policy=\"DELREF\")\n        assert result == [1]\n\n        # Test XDELEX with ACKED\n        result = await r.xdelex(stream, m4, ref_policy=\"ACKED\")\n        assert result == [1]\n\n        # Test with non-existent ID\n        result = await r.xdelex(stream, \"999999-0\", ref_policy=\"KEEPREF\")\n        assert result == [-1]\n\n        # Test with multiple IDs\n        m5 = await r.xadd(stream, {\"foo\": \"bar\"})\n        m6 = await r.xadd(stream, {\"foo\": \"bar\"})\n        result = await r.xdelex(stream, m5, m6, ref_policy=\"KEEPREF\")\n        assert result == [1, 1]\n\n        # Test error cases\n        with pytest.raises(redis.DataError):\n            await r.xdelex(stream, \"123-0\", ref_policy=\"INVALID\")\n\n        with pytest.raises(redis.DataError):\n            await r.xdelex(stream)  # No IDs provided\n\n    @skip_if_server_version_lt(\"8.1.224\")\n    async def test_xackdel(self, r: redis.Redis):\n        stream = \"stream\"\n        group = \"group\"\n        consumer = \"consumer\"\n\n        m1 = await r.xadd(stream, {\"foo\": \"bar\"})\n        m2 = await r.xadd(stream, {\"foo\": \"bar\"})\n        m3 = await r.xadd(stream, {\"foo\": \"bar\"})\n        m4 = await r.xadd(stream, {\"foo\": \"bar\"})\n        await r.xgroup_create(stream, group, 0)\n\n        await r.xreadgroup(group, consumer, streams={stream: \">\"})\n\n        # Test XACKDEL with default ref_policy (KEEPREF)\n        result = await r.xackdel(stream, group, m1)\n        assert result == [1]\n\n        # Test XACKDEL with explicit KEEPREF\n        result = await r.xackdel(stream, group, m2, ref_policy=\"KEEPREF\")\n        assert result == [1]\n\n        # Test XACKDEL with DELREF\n        result = await r.xackdel(stream, group, m3, ref_policy=\"DELREF\")\n        assert result == [1]\n\n        # Test XACKDEL with ACKED\n        result = await r.xackdel(stream, group, m4, ref_policy=\"ACKED\")\n        assert result == [1]\n\n        # Test with non-existent ID\n        result = await r.xackdel(stream, group, \"999999-0\", ref_policy=\"KEEPREF\")\n        assert result == [-1]\n\n        # Test error cases\n        with pytest.raises(redis.DataError):\n            await r.xackdel(stream, group, m1, ref_policy=\"INVALID\")\n\n        with pytest.raises(redis.DataError):\n            await r.xackdel(stream, group)  # No IDs provided\n\n    @skip_if_server_version_lt(\"8.1.224\")\n    async def test_xtrim_with_options(self, r: redis.Redis):\n        stream = \"stream\"\n\n        await r.xadd(stream, {\"foo\": \"bar\"})\n        await r.xadd(stream, {\"foo\": \"bar\"})\n        await r.xadd(stream, {\"foo\": \"bar\"})\n        await r.xadd(stream, {\"foo\": \"bar\"})\n\n        # Test XTRIM with KEEPREF ref_policy\n        assert (\n            await r.xtrim(stream, maxlen=2, approximate=False, ref_policy=\"KEEPREF\")\n            == 2\n        )\n\n        await r.xadd(stream, {\"foo\": \"bar\"})\n        await r.xadd(stream, {\"foo\": \"bar\"})\n\n        # Test XTRIM with DELREF ref_policy\n        assert (\n            await r.xtrim(stream, maxlen=2, approximate=False, ref_policy=\"DELREF\") == 2\n        )\n\n        await r.xadd(stream, {\"foo\": \"bar\"})\n        await r.xadd(stream, {\"foo\": \"bar\"})\n\n        # Test XTRIM with ACKED ref_policy\n        assert (\n            await r.xtrim(stream, maxlen=2, approximate=False, ref_policy=\"ACKED\") == 2\n        )\n\n        # Test error case\n        with pytest.raises(redis.DataError):\n            await r.xtrim(stream, maxlen=2, ref_policy=\"INVALID\")\n\n    @skip_if_server_version_lt(\"8.1.224\")\n    async def test_xadd_with_options(self, r: redis.Redis):\n        stream = \"stream\"\n\n        # Test XADD with KEEPREF ref_policy\n        await r.xadd(\n            stream, {\"foo\": \"bar\"}, maxlen=2, approximate=False, ref_policy=\"KEEPREF\"\n        )\n        await r.xadd(\n            stream, {\"foo\": \"bar\"}, maxlen=2, approximate=False, ref_policy=\"KEEPREF\"\n        )\n        await r.xadd(\n            stream, {\"foo\": \"bar\"}, maxlen=2, approximate=False, ref_policy=\"KEEPREF\"\n        )\n        assert await r.xlen(stream) == 2\n\n        # Test XADD with DELREF ref_policy\n        await r.xadd(\n            stream, {\"foo\": \"bar\"}, maxlen=2, approximate=False, ref_policy=\"DELREF\"\n        )\n        assert await r.xlen(stream) == 2\n\n        # Test XADD with ACKED ref_policy\n        await r.xadd(\n            stream, {\"foo\": \"bar\"}, maxlen=2, approximate=False, ref_policy=\"ACKED\"\n        )\n        assert await r.xlen(stream) == 2\n\n        # Test error case\n        with pytest.raises(redis.DataError):\n            await r.xadd(stream, {\"foo\": \"bar\"}, ref_policy=\"INVALID\")\n\n    @skip_if_server_version_lt(\"8.5.0\")\n    async def test_xadd_idmpauto(self, r: redis.Redis):\n        stream = \"stream\"\n\n        # XADD with IDMPAUTO - first write\n        message_id1 = await r.xadd(stream, {\"field1\": \"value1\"}, idmpauto=\"producer1\")\n\n        # Test XADD with IDMPAUTO - duplicate write returns same ID\n        message_id2 = await r.xadd(stream, {\"field1\": \"value1\"}, idmpauto=\"producer1\")\n        assert message_id1 == message_id2\n\n        # Test XADD with IDMPAUTO - different content creates new entry\n        message_id3 = await r.xadd(stream, {\"field1\": \"value2\"}, idmpauto=\"producer1\")\n        assert message_id3 != message_id1\n\n        # Test XADD with IDMPAUTO - different producer creates new entry\n        message_id4 = await r.xadd(stream, {\"field1\": \"value1\"}, idmpauto=\"producer2\")\n        assert message_id4 != message_id1\n\n        # Verify stream has 3 entries (2 unique from producer1, 1 from producer2)\n        assert await r.xlen(stream) == 3\n\n    @skip_if_server_version_lt(\"8.5.0\")\n    async def test_xadd_idmp(self, r: redis.Redis):\n        stream = \"stream\"\n\n        # Test XADD with IDMP - first write\n        message_id1 = await r.xadd(\n            stream, {\"field1\": \"value1\"}, idmp=(\"producer1\", b\"msg1\")\n        )\n\n        # Test XADD with IDMP - duplicate write returns same ID\n        message_id2 = await r.xadd(\n            stream, {\"field1\": \"value1\"}, idmp=(\"producer1\", b\"msg1\")\n        )\n        assert message_id1 == message_id2\n\n        # Test XADD with IDMP - different iid creates new entry\n        message_id3 = await r.xadd(\n            stream, {\"field1\": \"value1\"}, idmp=(\"producer1\", b\"msg2\")\n        )\n        assert message_id3 != message_id1\n\n        # Test XADD with IDMP - different producer creates new entry\n        message_id4 = await r.xadd(\n            stream, {\"field1\": \"value1\"}, idmp=(\"producer2\", b\"msg1\")\n        )\n        assert message_id4 != message_id1\n\n        # Test XADD with IDMP - shorter binary iid\n        await r.xadd(stream, {\"field1\": \"value1\"}, idmp=(\"producer1\", b\"\\x01\"))\n\n        # Verify stream has 4 entries\n        assert await r.xlen(stream) == 4\n\n    @skip_if_server_version_lt(\"8.5.0\")\n    async def test_xadd_idmp_validation(self, r: redis.Redis):\n        stream = \"stream\"\n\n        # Test error: both idmpauto and idmp specified\n        with pytest.raises(redis.DataError):\n            await r.xadd(\n                stream,\n                {\"foo\": \"bar\"},\n                idmpauto=\"producer1\",\n                idmp=(\"producer1\", b\"msg1\"),\n            )\n\n        # Test error: idmpauto with explicit id\n        with pytest.raises(redis.DataError):\n            await r.xadd(\n                stream, {\"foo\": \"bar\"}, id=\"1234567890-0\", idmpauto=\"producer1\"\n            )\n\n        # Test error: idmp with explicit id\n        with pytest.raises(redis.DataError):\n            await r.xadd(\n                stream, {\"foo\": \"bar\"}, id=\"1234567890-0\", idmp=(\"producer1\", b\"msg1\")\n            )\n\n        # Test error: idmp not a tuple\n        with pytest.raises(redis.DataError):\n            await r.xadd(stream, {\"foo\": \"bar\"}, idmp=\"invalid\")\n\n        # Test error: idmp tuple with wrong number of elements\n        with pytest.raises(redis.DataError):\n            await r.xadd(stream, {\"foo\": \"bar\"}, idmp=(\"producer1\",))\n\n        # Test error: idmp tuple with wrong number of elements\n        with pytest.raises(redis.DataError):\n            await r.xadd(stream, {\"foo\": \"bar\"}, idmp=(\"producer1\", b\"msg1\", \"extra\"))\n\n    @skip_if_server_version_lt(\"8.5.0\")\n    async def test_xcfgset_idmp_duration(self, r: redis.Redis):\n        stream = \"stream\"\n\n        # Create stream first\n        await r.xadd(stream, {\"foo\": \"bar\"})\n\n        # Test XCFGSET with IDMP-DURATION only\n        assert await r.xcfgset(stream, idmp_duration=120) == b\"OK\"\n\n        # Test with minimum value\n        assert await r.xcfgset(stream, idmp_duration=1) == b\"OK\"\n\n        # Test with maximum value\n        assert await r.xcfgset(stream, idmp_duration=300) == b\"OK\"\n\n    @skip_if_server_version_lt(\"8.5.0\")\n    async def test_xcfgset_idmp_maxsize(self, r: redis.Redis):\n        stream = \"stream\"\n\n        # Create stream first\n        await r.xadd(stream, {\"foo\": \"bar\"})\n\n        # Test XCFGSET with IDMP-MAXSIZE only\n        assert await r.xcfgset(stream, idmp_maxsize=5000) == b\"OK\"\n\n        # Test with minimum value\n        assert await r.xcfgset(stream, idmp_maxsize=1) == b\"OK\"\n\n        # Test with maximum value\n        assert await r.xcfgset(stream, idmp_maxsize=10000) == b\"OK\"\n\n    @skip_if_server_version_lt(\"8.5.0\")\n    async def test_xcfgset_both_parameters(self, r: redis.Redis):\n        stream = \"stream\"\n\n        # Create stream first\n        await r.xadd(stream, {\"foo\": \"bar\"})\n\n        # Test XCFGSET with both IDMP-DURATION and IDMP-MAXSIZE\n        assert await r.xcfgset(stream, idmp_duration=120, idmp_maxsize=5000) == b\"OK\"\n\n        # Test with different values\n        assert await r.xcfgset(stream, idmp_duration=60, idmp_maxsize=10000) == b\"OK\"\n\n    @skip_if_server_version_lt(\"8.5.0\")\n    async def test_xcfgset_validation(self, r: redis.Redis):\n        stream = \"stream\"\n\n        # Test error: no parameters provided\n        with pytest.raises(redis.DataError):\n            await r.xcfgset(stream)\n\n        # Test error: idmp_duration too small\n        with pytest.raises(redis.DataError):\n            await r.xcfgset(stream, idmp_duration=0)\n\n        # Test error: idmp_duration too large\n        with pytest.raises(redis.DataError):\n            await r.xcfgset(stream, idmp_duration=301)\n\n        # Test error: idmp_duration not an integer\n        with pytest.raises(redis.DataError):\n            await r.xcfgset(stream, idmp_duration=\"invalid\")\n\n        # Test error: idmp_maxsize too small\n        with pytest.raises(redis.DataError):\n            await r.xcfgset(stream, idmp_maxsize=0)\n\n        # Test error: idmp_maxsize too large\n        with pytest.raises(redis.DataError):\n            await r.xcfgset(stream, idmp_maxsize=1000001)\n\n        # Test error: idmp_maxsize not an integer\n        with pytest.raises(redis.DataError):\n            await r.xcfgset(stream, idmp_maxsize=\"invalid\")\n\n    @pytest.mark.onlynoncluster\n    async def test_bitfield_operations(self, r: redis.Redis):\n        # comments show affected bits\n        await r.execute_command(\"SELECT\", 10)\n        bf = r.bitfield(\"a\")\n        resp = await (\n            bf.set(\"u8\", 8, 255)  # 00000000 11111111\n            .get(\"u8\", 0)  # 00000000\n            .get(\"u4\", 8)  # 1111\n            .get(\"u4\", 12)  # 1111\n            .get(\"u4\", 13)  # 111 0\n            .execute()\n        )\n        assert resp == [0, 0, 15, 15, 14]\n\n        # .set() returns the previous value...\n        resp = await (\n            bf.set(\"u8\", 4, 1)  # 0000 0001\n            .get(\"u16\", 0)  # 00000000 00011111\n            .set(\"u16\", 0, 0)  # 00000000 00000000\n            .execute()\n        )\n        assert resp == [15, 31, 31]\n\n        # incrby adds to the value\n        resp = await (\n            bf.incrby(\"u8\", 8, 254)  # 00000000 11111110\n            .incrby(\"u8\", 8, 1)  # 00000000 11111111\n            .get(\"u16\", 0)  # 00000000 11111111\n            .execute()\n        )\n        assert resp == [254, 255, 255]\n\n        # Verify overflow protection works as a method:\n        await r.delete(\"a\")\n        resp = await (\n            bf.set(\"u8\", 8, 254)  # 00000000 11111110\n            .overflow(\"fail\")\n            .incrby(\"u8\", 8, 2)  # incrby 2 would overflow, None returned\n            .incrby(\"u8\", 8, 1)  # 00000000 11111111\n            .incrby(\"u8\", 8, 1)  # incrby 1 would overflow, None returned\n            .get(\"u16\", 0)  # 00000000 11111111\n            .execute()\n        )\n        assert resp == [0, None, 255, None, 255]\n\n        # Verify overflow protection works as arg to incrby:\n        await r.delete(\"a\")\n        resp = await (\n            bf.set(\"u8\", 8, 255)  # 00000000 11111111\n            .incrby(\"u8\", 8, 1)  # 00000000 00000000  wrap default\n            .set(\"u8\", 8, 255)  # 00000000 11111111\n            .incrby(\"u8\", 8, 1, \"FAIL\")  # 00000000 11111111  fail\n            .incrby(\"u8\", 8, 1)  # 00000000 11111111  still fail\n            .get(\"u16\", 0)  # 00000000 11111111\n            .execute()\n        )\n        assert resp == [0, 0, 0, None, None, 255]\n\n        # test default default_overflow\n        await r.delete(\"a\")\n        bf = r.bitfield(\"a\", default_overflow=\"FAIL\")\n        resp = await (\n            bf.set(\"u8\", 8, 255)  # 00000000 11111111\n            .incrby(\"u8\", 8, 1)  # 00000000 11111111  fail default\n            .get(\"u16\", 0)  # 00000000 11111111\n            .execute()\n        )\n        assert resp == [0, None, 255]\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    async def test_bitfield_ro(self, r: redis.Redis):\n        bf = r.bitfield(\"a\")\n        resp = await bf.set(\"u8\", 8, 255).execute()\n        assert resp == [0]\n\n        resp = await r.bitfield_ro(\"a\", \"u8\", 0)\n        assert resp == [0]\n\n        items = [(\"u4\", 8), (\"u4\", 12), (\"u4\", 13)]\n        resp = await r.bitfield_ro(\"a\", \"u8\", 0, items)\n        assert resp == [0, 15, 15, 14]\n\n    @skip_if_server_version_lt(\"4.0.0\")\n    async def test_memory_stats(self, r: redis.Redis):\n        # put a key into the current db to make sure that \"db.<current-db>\"\n        # has data\n        await r.set(\"foo\", \"bar\")\n        stats = await r.memory_stats()\n        assert isinstance(stats, dict)\n        for key, value in stats.items():\n            if key.startswith(\"db.\"):\n                assert not isinstance(value, list)\n\n    @skip_if_server_version_lt(\"4.0.0\")\n    async def test_memory_usage(self, r: redis.Redis):\n        await r.set(\"foo\", \"bar\")\n        assert isinstance(await r.memory_usage(\"foo\"), int)\n\n    @skip_if_server_version_lt(\"4.0.0\")\n    async def test_module_list(self, r: redis.Redis):\n        assert isinstance(await r.module_list(), list)\n        for x in await r.module_list():\n            assert isinstance(x, dict)\n\n    @pytest.mark.onlynoncluster\n    async def test_interrupted_command(self, r: redis.Redis):\n        \"\"\"\n        Regression test for issue #1128:  An Un-handled BaseException\n        will leave the socket with un-read response to a previous\n        command.\n        \"\"\"\n        ready = asyncio.Event()\n\n        async def helper():\n            with pytest.raises(asyncio.CancelledError):\n                # blocking pop\n                ready.set()\n                await r.brpop([\"nonexist\"])\n            # If the following is not done, further Timout operations will fail,\n            # because the timeout won't catch its Cancelled Error if the task\n            # has a pending cancel.  Python documentation probably should reflect this.\n            if sys.version_info >= (3, 11):\n                asyncio.current_task().uncancel()\n            # if all is well, we can continue.  The following should not hang.\n            await r.set(\"status\", \"down\")\n\n        task = asyncio.create_task(helper())\n        await ready.wait()\n        await asyncio.sleep(0.01)\n        # the task is now sleeping, lets send it an exception\n        task.cancel()\n        # If all is well, the task should finish right away, otherwise fail with Timeout\n        async with async_timeout(1.0):\n            await task\n\n\n@pytest.mark.onlynoncluster\nclass TestBinarySave:\n    async def test_binary_get_set(self, r: redis.Redis):\n        assert await r.set(\" foo bar \", \"123\")\n        assert await r.get(\" foo bar \") == b\"123\"\n\n        assert await r.set(\" foo\\r\\nbar\\r\\n \", \"456\")\n        assert await r.get(\" foo\\r\\nbar\\r\\n \") == b\"456\"\n\n        assert await r.set(\" \\r\\n\\t\\x07\\x13 \", \"789\")\n        assert await r.get(\" \\r\\n\\t\\x07\\x13 \") == b\"789\"\n\n        assert sorted(await r.keys(\"*\")) == [\n            b\" \\r\\n\\t\\x07\\x13 \",\n            b\" foo\\r\\nbar\\r\\n \",\n            b\" foo bar \",\n        ]\n\n        assert await r.delete(\" foo bar \")\n        assert await r.delete(\" foo\\r\\nbar\\r\\n \")\n        assert await r.delete(\" \\r\\n\\t\\x07\\x13 \")\n\n    async def test_binary_lists(self, r: redis.Redis):\n        mapping = {\n            b\"foo bar\": [b\"1\", b\"2\", b\"3\"],\n            b\"foo\\r\\nbar\\r\\n\": [b\"4\", b\"5\", b\"6\"],\n            b\"foo\\tbar\\x07\": [b\"7\", b\"8\", b\"9\"],\n        }\n        # fill in lists\n        for key, value in mapping.items():\n            await r.rpush(key, *value)\n\n        # check that KEYS returns all the keys as they are\n        assert sorted(await r.keys(\"*\")) == sorted(mapping.keys())\n\n        # check that it is possible to get list content by key name\n        for key, value in mapping.items():\n            assert await r.lrange(key, 0, -1) == value\n\n    async def test_22_info(self, r: redis.Redis):\n        \"\"\"\n        Older Redis versions contained 'allocation_stats' in INFO that\n        was the cause of a number of bugs when parsing.\n        \"\"\"\n        info = (\n            \"allocation_stats:6=1,7=1,8=7141,9=180,10=92,11=116,12=5330,\"\n            \"13=123,14=3091,15=11048,16=225842,17=1784,18=814,19=12020,\"\n            \"20=2530,21=645,22=15113,23=8695,24=142860,25=318,26=3303,\"\n            \"27=20561,28=54042,29=37390,30=1884,31=18071,32=31367,33=160,\"\n            \"34=169,35=201,36=10155,37=1045,38=15078,39=22985,40=12523,\"\n            \"41=15588,42=265,43=1287,44=142,45=382,46=945,47=426,48=171,\"\n            \"49=56,50=516,51=43,52=41,53=46,54=54,55=75,56=647,57=332,\"\n            \"58=32,59=39,60=48,61=35,62=62,63=32,64=221,65=26,66=30,\"\n            \"67=36,68=41,69=44,70=26,71=144,72=169,73=24,74=37,75=25,\"\n            \"76=42,77=21,78=126,79=374,80=27,81=40,82=43,83=47,84=46,\"\n            \"85=114,86=34,87=37,88=7240,89=34,90=38,91=18,92=99,93=20,\"\n            \"94=18,95=17,96=15,97=22,98=18,99=69,100=17,101=22,102=15,\"\n            \"103=29,104=39,105=30,106=70,107=22,108=21,109=26,110=52,\"\n            \"111=45,112=33,113=67,114=41,115=44,116=48,117=53,118=54,\"\n            \"119=51,120=75,121=44,122=57,123=44,124=66,125=56,126=52,\"\n            \"127=81,128=108,129=70,130=50,131=51,132=53,133=45,134=62,\"\n            \"135=12,136=13,137=7,138=15,139=21,140=11,141=20,142=6,143=7,\"\n            \"144=11,145=6,146=16,147=19,148=1112,149=1,151=83,154=1,\"\n            \"155=1,156=1,157=1,160=1,161=1,162=2,166=1,169=1,170=1,171=2,\"\n            \"172=1,174=1,176=2,177=9,178=34,179=73,180=30,181=1,185=3,\"\n            \"187=1,188=1,189=1,192=1,196=1,198=1,200=1,201=1,204=1,205=1,\"\n            \"207=1,208=1,209=1,214=2,215=31,216=78,217=28,218=5,219=2,\"\n            \"220=1,222=1,225=1,227=1,234=1,242=1,250=1,252=1,253=1,\"\n            \">=256=203\"\n        )\n        parsed = parse_info(info)\n        assert \"allocation_stats\" in parsed\n        assert \"6\" in parsed[\"allocation_stats\"]\n        assert \">=256\" in parsed[\"allocation_stats\"]\n\n    async def test_large_responses(self, r: redis.Redis):\n        \"\"\"The PythonParser has some special cases for return values > 1MB\"\"\"\n        # load up 5MB of data into a key\n        data = \"\".join([ascii_letters] * (5000000 // len(ascii_letters)))\n        await r.set(\"a\", data)\n        assert await r.get(\"a\") == data.encode()\n\n    async def test_floating_point_encoding(self, r: redis.Redis):\n        \"\"\"\n        High precision floating point values sent to the server should keep\n        precision.\n        \"\"\"\n        timestamp = 1349673917.939762\n        await r.zadd(\"a\", {\"a1\": timestamp})\n        assert await r.zscore(\"a\", \"a1\") == timestamp\n\n\n@pytest.mark.asyncio\nclass TestAsyncXreadXreadgroupMetricsExport:\n    \"\"\"Tests for xread/xreadgroup async commands to verify streaming lag metrics are exported.\"\"\"\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    async def test_async_xread_exports_streaming_lag_metric(self, r: redis.Redis):\n        \"\"\"Test that async xread exports streaming lag metric.\"\"\"\n        stream = \"test-stream-metrics\"\n\n        # Add a message to the stream\n        await r.xadd(stream, {\"foo\": \"bar\"})\n\n        with patch(\n            \"redis.commands.core.async_record_streaming_lag\",\n            new_callable=AsyncMock,\n        ) as mock_recorder:\n            # Read from the stream\n            result = await r.xread(streams={stream: \"0\"})\n\n            # Verify the async recorder was called\n            mock_recorder.assert_awaited_once()\n            call_args = mock_recorder.call_args\n            # Verify response was passed to the recorder\n            assert call_args[1][\"response\"] is not None\n            # Verify result is returned correctly\n            assert result is not None\n\n        # Cleanup\n        await r.delete(stream)\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    async def test_async_xreadgroup_exports_streaming_lag_metric_with_consumer_group(\n        self, r: redis.Redis\n    ):\n        \"\"\"Test that async xreadgroup exports streaming lag metric with consumer group.\"\"\"\n        stream = \"test-stream-metrics-group\"\n        group = \"test-group\"\n        consumer = \"test-consumer\"\n\n        # Add a message and create consumer group\n        await r.xadd(stream, {\"foo\": \"bar\"})\n        try:\n            await r.xgroup_create(stream, group, 0)\n        except ResponseError:\n            # Group may already exist\n            pass\n\n        with patch(\n            \"redis.commands.core.async_record_streaming_lag\",\n            new_callable=AsyncMock,\n        ) as mock_recorder:\n            # Read from the stream via consumer group\n            result = await r.xreadgroup(\n                groupname=group,\n                consumername=consumer,\n                streams={stream: \">\"},\n            )\n\n            # Verify the async recorder was called with consumer group\n            mock_recorder.assert_awaited_once()\n            call_args = mock_recorder.call_args\n            assert call_args[1][\"response\"] is not None\n            assert call_args[1][\"consumer_group\"] == group\n            # Verify result is returned correctly\n            assert result is not None\n\n        # Cleanup\n        await r.xgroup_destroy(stream, group)\n        await r.delete(stream)\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    async def test_async_xread_handles_empty_response(self, r: redis.Redis):\n        \"\"\"Test that async xread handles empty response gracefully.\"\"\"\n        stream = \"test-stream-empty\"\n\n        # Create an empty stream by adding and deleting a message\n        msg_id = await r.xadd(stream, {\"foo\": \"bar\"})\n        await r.xdel(stream, msg_id)\n\n        with patch(\n            \"redis.commands.core.async_record_streaming_lag\",\n            new_callable=AsyncMock,\n        ) as mock_recorder:\n            # Read from the stream starting after the deleted message\n            result = await r.xread(streams={stream: msg_id})\n\n            # Verify the async recorder was called (even with empty response)\n            mock_recorder.assert_awaited_once()\n            # Result should be None or empty ([] for RESP2, {} for RESP3)\n            assert result is None or result == [] or result == {}\n\n        # Cleanup\n        await r.delete(stream)\n"
  },
  {
    "path": "tests/test_asyncio/test_connect.py",
    "content": "import asyncio\nimport re\nimport socket\nimport ssl\n\nimport pytest\nfrom redis.asyncio.connection import (\n    Connection,\n    SSLConnection,\n    UnixDomainSocketConnection,\n)\nfrom redis.exceptions import ConnectionError\n\nfrom ..ssl_utils import CertificateType, get_tls_certificates\n\n_CLIENT_NAME = \"test-suite-client\"\n_CMD_SEP = b\"\\r\\n\"\n_SUCCESS_RESP = b\"+OK\" + _CMD_SEP\n_ERROR_RESP = b\"-ERR\" + _CMD_SEP\n_SUPPORTED_CMDS = {f\"CLIENT SETNAME {_CLIENT_NAME}\": _SUCCESS_RESP}\n\n\n@pytest.fixture\ndef tcp_address():\n    with socket.socket() as sock:\n        sock.bind((\"127.0.0.1\", 0))\n        return sock.getsockname()\n\n\n@pytest.fixture\ndef uds_address(tmpdir):\n    return tmpdir / \"uds.sock\"\n\n\nasync def test_tcp_connect(tcp_address):\n    host, port = tcp_address\n    conn = Connection(host=host, port=port, client_name=_CLIENT_NAME, socket_timeout=10)\n    await _assert_connect(conn, tcp_address)\n\n\nasync def test_uds_connect(uds_address):\n    path = str(uds_address)\n    conn = UnixDomainSocketConnection(\n        path=path, client_name=_CLIENT_NAME, socket_timeout=10\n    )\n    await _assert_connect(conn, path)\n\n\n@pytest.mark.ssl\n@pytest.mark.parametrize(\n    \"ssl_ciphers\",\n    [\n        \"AES256-SHA:DHE-RSA-AES256-SHA:AES128-SHA:DHE-RSA-AES128-SHA\",\n        \"ECDHE-ECDSA-AES256-GCM-SHA384\",\n        \"ECDHE-RSA-AES128-GCM-SHA256\",\n    ],\n)\nasync def test_tcp_ssl_tls12_custom_ciphers(tcp_address, ssl_ciphers):\n    host, port = tcp_address\n\n    # in order to have working hostname verification, we need to use \"localhost\"\n    # as redis host as the server certificate is self-signed and only valid for \"localhost\"\n    host = \"localhost\"\n\n    server_certs = get_tls_certificates(cert_type=CertificateType.server)\n\n    conn = SSLConnection(\n        host=host,\n        port=port,\n        client_name=_CLIENT_NAME,\n        ssl_ca_certs=server_certs.ca_certfile,\n        socket_timeout=10,\n        ssl_min_version=ssl.TLSVersion.TLSv1_2,\n        ssl_ciphers=ssl_ciphers,\n    )\n    await _assert_connect(\n        conn, tcp_address, certfile=server_certs.certfile, keyfile=server_certs.keyfile\n    )\n    await conn.disconnect()\n\n\n@pytest.mark.ssl\n@pytest.mark.parametrize(\n    \"ssl_min_version\",\n    [\n        ssl.TLSVersion.TLSv1_2,\n        pytest.param(\n            ssl.TLSVersion.TLSv1_3,\n            marks=pytest.mark.skipif(not ssl.HAS_TLSv1_3, reason=\"requires TLSv1.3\"),\n        ),\n    ],\n)\nasync def test_tcp_ssl_connect(tcp_address, ssl_min_version):\n    host, port = tcp_address\n\n    # in order to have working hostname verification, we need to use \"localhost\"\n    # as redis host as the server certificate is self-signed and only valid for \"localhost\"\n    host = \"localhost\"\n\n    server_certs = get_tls_certificates(cert_type=CertificateType.server)\n\n    conn = SSLConnection(\n        host=host,\n        port=port,\n        client_name=_CLIENT_NAME,\n        ssl_ca_certs=server_certs.ca_certfile,\n        socket_timeout=10,\n        ssl_min_version=ssl_min_version,\n    )\n    await _assert_connect(\n        conn,\n        tcp_address,\n        certfile=server_certs.certfile,\n        keyfile=server_certs.keyfile,\n    )\n    await conn.disconnect()\n\n\n@pytest.mark.ssl\n@pytest.mark.skipif(not ssl.HAS_TLSv1_3, reason=\"requires TLSv1.3\")\nasync def test_tcp_ssl_version_mismatch(tcp_address):\n    host, port = tcp_address\n    certfile, keyfile, _ = get_tls_certificates(cert_type=CertificateType.server)\n    conn = SSLConnection(\n        host=host,\n        port=port,\n        client_name=_CLIENT_NAME,\n        ssl_ca_certs=certfile,\n        socket_timeout=1,\n        ssl_min_version=ssl.TLSVersion.TLSv1_3,\n    )\n    with pytest.raises(ConnectionError):\n        await _assert_connect(\n            conn,\n            tcp_address,\n            certfile=certfile,\n            keyfile=keyfile,\n            maximum_ssl_version=ssl.TLSVersion.TLSv1_2,\n        )\n    await conn.disconnect()\n\n\nasync def _assert_connect(\n    conn,\n    server_address,\n    certfile=None,\n    keyfile=None,\n    minimum_ssl_version=ssl.TLSVersion.TLSv1_2,\n    maximum_ssl_version=ssl.TLSVersion.TLSv1_3,\n):\n    stop_event = asyncio.Event()\n    finished = asyncio.Event()\n\n    async def _handler(reader, writer):\n        try:\n            return await _redis_request_handler(reader, writer, stop_event)\n        finally:\n            writer.close()\n            await writer.wait_closed()\n            finished.set()\n\n    if isinstance(server_address, str):\n        server = await asyncio.start_unix_server(_handler, path=server_address)\n    elif certfile:\n        host, port = server_address\n        context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)\n        context.minimum_version = minimum_ssl_version\n        context.maximum_version = maximum_ssl_version\n        context.load_cert_chain(certfile=certfile, keyfile=keyfile)\n        server = await asyncio.start_server(_handler, host=host, port=port, ssl=context)\n    else:\n        host, port = server_address\n        server = await asyncio.start_server(_handler, host=host, port=port)\n\n    async with server as aserver:\n        await aserver.start_serving()\n        try:\n            await conn.connect()\n            await conn.disconnect()\n        except ConnectionError:\n            finished.set()\n            raise\n        finally:\n            stop_event.set()\n            aserver.close()\n            await aserver.wait_closed()\n            await finished.wait()\n\n\nasync def _redis_request_handler(reader, writer, stop_event):\n    command = None\n    command_ptr = None\n    fragment_length = None\n    while not stop_event.is_set():\n        buffer = await reader.read(1024)\n        if not buffer:\n            break\n        parts = re.split(_CMD_SEP, buffer)\n        for fragment in parts:\n            fragment = fragment.decode()\n            if not fragment:\n                continue\n\n            if fragment.startswith(\"*\") and command is None:\n                command = [None for _ in range(int(fragment[1:]))]\n                command_ptr = 0\n                fragment_length = None\n                continue\n\n            if fragment.startswith(\"$\") and command[command_ptr] is None:\n                fragment_length = int(fragment[1:])\n                continue\n\n            assert len(fragment) == fragment_length\n            command[command_ptr] = fragment\n            command_ptr += 1\n\n            if command_ptr < len(command):\n                continue\n\n            command = \" \".join(command)\n            resp = _SUPPORTED_CMDS.get(command, _ERROR_RESP)\n            writer.write(resp)\n            await writer.drain()\n            command = None\n"
  },
  {
    "path": "tests/test_asyncio/test_connection.py",
    "content": "import asyncio\nimport socket\nimport types\nfrom unittest import mock\nfrom errno import ECONNREFUSED\nfrom unittest.mock import patch\n\nimport pytest\nimport redis\nfrom redis._parsers import (\n    _AsyncHiredisParser,\n    _AsyncRESP2Parser,\n    _AsyncRESP3Parser,\n    _AsyncRESPBase,\n)\nfrom redis.asyncio import ConnectionPool, Redis\nfrom redis.asyncio.connection import (\n    Connection,\n    SSLConnection,\n    UnixDomainSocketConnection,\n    parse_url,\n)\nfrom redis.asyncio.retry import Retry\nfrom redis.backoff import NoBackoff\nfrom redis.exceptions import ConnectionError, InvalidResponse, TimeoutError\nfrom redis.utils import HIREDIS_AVAILABLE\nfrom tests.conftest import skip_if_server_version_lt\n\nfrom .mocks import MockStream\n\n\n@pytest.mark.onlynoncluster\nasync def test_invalid_response(create_redis):\n    r = await create_redis(single_connection_client=True)\n\n    raw = b\"x\"\n    fake_stream = MockStream(raw + b\"\\r\\n\")\n\n    parser: _AsyncRESPBase = r.connection._parser\n\n    if isinstance(parser, _AsyncRESPBase):\n        exp_err = f\"Protocol Error: {raw!r}\"\n    else:\n        exp_err = f'Protocol error, got \"{raw.decode()}\" as reply type byte'\n\n    with mock.patch.object(parser, \"_stream\", fake_stream):\n        with pytest.raises(InvalidResponse, match=exp_err):\n            await parser.read_response()\n\n    await r.connection.disconnect()\n\n\n@pytest.mark.onlynoncluster\nasync def test_single_connection():\n    \"\"\"Test that concurrent requests on a single client are synchronised.\"\"\"\n    r = Redis(single_connection_client=True)\n\n    init_call_count = 0\n    command_call_count = 0\n    in_use = False\n\n    class Retry_:\n        async def call_with_retry(self, _, __, with_failure_count=False):\n            # If we remove the single-client lock, this error gets raised as two\n            # coroutines will be vying for the `in_use` flag due to the two\n            # asymmetric sleep calls\n            nonlocal command_call_count\n            nonlocal in_use\n            if in_use is True:\n                raise ValueError(\"Commands should be executed one at a time.\")\n            in_use = True\n            await asyncio.sleep(0.01)\n            command_call_count += 1\n            await asyncio.sleep(0.03)\n            in_use = False\n            return \"foo\"\n\n    mock_conn = mock.AsyncMock(spec=Connection)\n    mock_conn.retry = Retry_()\n    mock_conn.host = \"localhost\"\n    mock_conn.port = 6379\n\n    async def get_conn():\n        # Validate only one client is created in single-client mode when\n        # concurrent requests are made\n        nonlocal init_call_count\n        await asyncio.sleep(0.01)\n        init_call_count += 1\n        return mock_conn\n\n    with mock.patch.object(r.connection_pool, \"get_connection\", get_conn):\n        with mock.patch.object(r.connection_pool, \"release\"):\n            await asyncio.gather(r.set(\"a\", \"b\"), r.set(\"c\", \"d\"))\n\n    assert init_call_count == 1\n    assert command_call_count == 2\n    r.connection = None  # it was a Mock\n    await r.aclose()\n\n\n@skip_if_server_version_lt(\"4.0.0\")\n@pytest.mark.redismod\n@pytest.mark.onlynoncluster\nasync def test_loading_external_modules(r):\n    def inner():\n        pass\n\n    r.load_external_module(\"myfuncname\", inner)\n    assert getattr(r, \"myfuncname\") == inner\n    assert isinstance(getattr(r, \"myfuncname\"), types.FunctionType)\n\n    # and call it\n    from redis.commands import RedisModuleCommands\n\n    j = RedisModuleCommands.json\n    r.load_external_module(\"sometestfuncname\", j)\n\n    # d = {'hello': 'world!'}\n    # mod = j(r)\n    # mod.set(\"fookey\", \".\", d)\n    # assert mod.get('fookey') == d\n\n\nasync def test_socket_param_regression(r):\n    \"\"\"A regression test for issue #1060\"\"\"\n    conn = UnixDomainSocketConnection()\n    _ = await conn.disconnect() is True\n\n\nasync def test_can_run_concurrent_commands(r):\n    if getattr(r, \"connection\", None) is not None:\n        # Concurrent commands are only supported on pooled or cluster connections\n        # since there is no synchronization on a single connection.\n        pytest.skip(\"pool only\")\n    assert await r.ping() is True\n    assert all(await asyncio.gather(*(r.ping() for _ in range(10))))\n\n\nasync def test_connect_retry_on_timeout_error(connect_args):\n    \"\"\"Test that the _connect function is retried in case of a timeout\"\"\"\n    conn = Connection(\n        retry_on_timeout=True, retry=Retry(NoBackoff(), 3), **connect_args\n    )\n    origin_connect = conn._connect\n    conn._connect = mock.AsyncMock()\n\n    async def mock_connect():\n        # connect only on the last retry\n        if conn._connect.call_count <= 2:\n            raise socket.timeout\n        else:\n            return await origin_connect()\n\n    conn._connect.side_effect = mock_connect\n    await conn.connect()\n    assert conn._connect.call_count == 3\n    await conn.disconnect()\n\n\nasync def test_connect_without_retry_on_non_retryable_error():\n    \"\"\"\n    Test that the _connect function is not being retried in case of a CancelledError -\n    error that is not in the list of retry-able errors\"\"\"\n    with patch.object(Connection, \"_connect\") as _connect:\n        _connect.side_effect = asyncio.CancelledError(\"\")\n        conn = Connection(retry_on_timeout=True, retry=Retry(NoBackoff(), 2))\n        with pytest.raises(asyncio.CancelledError):\n            await conn.connect()\n        assert _connect.call_count == 1\n\n\nasync def test_connect_with_retries():\n    \"\"\"\n    Test that retries occur for the entire connect+handshake flow when OSError happens during the handshake phase.\n    \"\"\"\n    with patch.object(asyncio.StreamWriter, \"writelines\") as writelines:\n        writelines.side_effect = OSError(ECONNREFUSED)\n        conn = Connection(retry_on_timeout=True, retry=Retry(NoBackoff(), 2))\n        with pytest.raises(ConnectionError):\n            await conn.connect()\n        # the handshake commands are the failing ones\n        # validate that we don't execute too many commands on each retry\n        # 3 retries --> 3 commands\n        assert writelines.call_count == 3\n\n\nasync def test_connect_timeout_error_without_retry():\n    \"\"\"Test that the _connect function is not being retried if retry_on_timeout is\n    set to False\"\"\"\n    conn = Connection(retry_on_timeout=False)\n    conn._connect = mock.AsyncMock()\n    conn._connect.side_effect = socket.timeout\n\n    with pytest.raises(TimeoutError, match=\"Timeout connecting to server\"):\n        await conn.connect()\n    assert conn._connect.call_count == 1\n\n\n@pytest.mark.onlynoncluster\nasync def test_connection_parse_response_resume(r: redis.Redis):\n    \"\"\"\n    This test verifies that the Connection parser,\n    be that PythonParser or HiredisParser,\n    can be interrupted at IO time and then resume parsing.\n    \"\"\"\n    conn = Connection(**r.connection_pool.connection_kwargs)\n    await conn.connect()\n    message = (\n        b\"*3\\r\\n$7\\r\\nmessage\\r\\n$8\\r\\nchannel1\\r\\n\"\n        b\"$25\\r\\nhi\\r\\nthere\\r\\n+how\\r\\nare\\r\\nyou\\r\\n\"\n    )\n\n    conn._parser._stream = MockStream(message, interrupt_every=2)\n    for i in range(100):\n        try:\n            response = await conn.read_response(disconnect_on_error=False)\n            break\n        except MockStream.TestError:\n            pass\n\n    else:\n        pytest.fail(\"didn't receive a response\")\n    assert response\n    assert i > 0\n    await conn.disconnect()\n\n\n@pytest.mark.onlynoncluster\n@pytest.mark.parametrize(\n    \"parser_class\",\n    [_AsyncRESP2Parser, _AsyncRESP3Parser, _AsyncHiredisParser],\n    ids=[\"AsyncRESP2Parser\", \"AsyncRESP3Parser\", \"AsyncHiredisParser\"],\n)\nasync def test_connection_disconect_race(parser_class, connect_args):\n    \"\"\"\n    This test reproduces the case in issue #2349\n    where a connection is closed while the parser is reading to feed the\n    internal buffer.The stream `read()` will succeed, but when it returns,\n    another task has already called `disconnect()` and is waiting for\n    close to finish.  When we attempts to feed the buffer, we will fail\n    since the buffer is no longer there.\n\n    This test verifies that a read in progress can finish even\n    if the `disconnect()` method is called.\n    \"\"\"\n    if parser_class == _AsyncHiredisParser and not HIREDIS_AVAILABLE:\n        pytest.skip(\"Hiredis not available\")\n\n    connect_args[\"parser_class\"] = parser_class\n\n    conn = Connection(**connect_args)\n\n    cond = asyncio.Condition()\n    # 0 == initial\n    # 1 == reader is reading\n    # 2 == closer has closed and is waiting for close to finish\n    state = 0\n\n    # Mock read function, which wait for a close to happen before returning\n    # Can either be invoked as two `read()` calls (HiredisParser)\n    # or as a `readline()` followed by `readexact()` (PythonParser)\n    chunks = [b\"$13\\r\\n\", b\"Hello, World!\\r\\n\"]\n\n    async def read(_=None):\n        nonlocal state\n        async with cond:\n            if state == 0:\n                state = 1  # we are reading\n                cond.notify()\n                # wait until the closing task has done\n                await cond.wait_for(lambda: state == 2)\n        return chunks.pop(0)\n\n    # function closes the connection while reader is still blocked reading\n    async def do_close():\n        nonlocal state\n        async with cond:\n            await cond.wait_for(lambda: state == 1)\n            state = 2\n            cond.notify()\n        await conn.disconnect()\n\n    async def do_read():\n        return await conn.read_response()\n\n    reader = mock.Mock(spec=asyncio.StreamReader)\n    writer = mock.Mock(spec=asyncio.StreamWriter)\n    writer.transport.get_extra_info.side_effect = None\n\n    # for HiredisParser\n    reader.read.side_effect = read\n    # for PythonParser\n    reader.readline.side_effect = read\n    reader.readexactly.side_effect = read\n\n    async def open_connection(*args, **kwargs):\n        return reader, writer\n\n    async def dummy_method(*args, **kwargs):\n        pass\n\n    # get dummy stream objects for the connection\n    with patch.object(asyncio, \"open_connection\", open_connection):\n        # disable the initial version handshake\n        with patch.multiple(\n            conn, send_command=dummy_method, read_response=dummy_method\n        ):\n            await conn.connect()\n\n    vals = await asyncio.gather(do_read(), do_close())\n    assert vals == [b\"Hello, World!\", None]\n\n\n@pytest.mark.onlynoncluster\ndef test_create_single_connection_client_from_url():\n    client = Redis.from_url(\"redis://localhost:6379/0?\", single_connection_client=True)\n    assert client.single_connection_client is True\n\n\n@pytest.mark.parametrize(\"from_url\", (True, False), ids=(\"from_url\", \"from_args\"))\nasync def test_pool_auto_close(request, from_url):\n    \"\"\"Verify that basic Redis instances have auto_close_connection_pool set to True\"\"\"\n\n    url: str = request.config.getoption(\"--redis-url\")\n    url_args = parse_url(url)\n\n    async def get_redis_connection():\n        if from_url:\n            return Redis.from_url(url)\n        return Redis(**url_args)\n\n    r1 = await get_redis_connection()\n    assert r1.auto_close_connection_pool is True\n    await r1.aclose()\n\n\nasync def test_close_is_aclose(request):\n    \"\"\"Verify close() calls aclose()\"\"\"\n    calls = 0\n\n    async def mock_aclose(self):\n        nonlocal calls\n        calls += 1\n\n    url: str = request.config.getoption(\"--redis-url\")\n    r1 = await Redis.from_url(url)\n    with patch.object(r1, \"aclose\", mock_aclose):\n        with pytest.deprecated_call():\n            await r1.close()\n        assert calls == 1\n\n    with pytest.deprecated_call():\n        await r1.close()\n\n\nasync def test_pool_from_url_deprecation(request):\n    url: str = request.config.getoption(\"--redis-url\")\n\n    with pytest.deprecated_call():\n        return Redis.from_url(url, auto_close_connection_pool=False)\n\n\nasync def test_pool_auto_close_disable(request):\n    \"\"\"Verify that auto_close_connection_pool can be disabled (deprecated)\"\"\"\n\n    url: str = request.config.getoption(\"--redis-url\")\n    url_args = parse_url(url)\n\n    async def get_redis_connection():\n        url_args[\"auto_close_connection_pool\"] = False\n        with pytest.deprecated_call():\n            return Redis(**url_args)\n\n    r1 = await get_redis_connection()\n    assert r1.auto_close_connection_pool is False\n    await r1.connection_pool.disconnect()\n    await r1.aclose()\n\n\n@pytest.mark.parametrize(\"from_url\", (True, False), ids=(\"from_url\", \"from_args\"))\nasync def test_redis_connection_pool(request, from_url):\n    \"\"\"Verify that basic Redis instances using `connection_pool`\n    have auto_close_connection_pool set to False\"\"\"\n\n    url: str = request.config.getoption(\"--redis-url\")\n    url_args = parse_url(url)\n\n    pool = None\n\n    async def get_redis_connection():\n        nonlocal pool\n        if from_url:\n            pool = ConnectionPool.from_url(url)\n        else:\n            pool = ConnectionPool(**url_args)\n        return Redis(connection_pool=pool)\n\n    called = 0\n\n    async def mock_disconnect(_):\n        nonlocal called\n        called += 1\n\n    with patch.object(ConnectionPool, \"disconnect\", mock_disconnect):\n        async with await get_redis_connection() as r1:\n            assert r1.auto_close_connection_pool is False\n\n    assert called == 0\n    await pool.disconnect()\n\n\n@pytest.mark.parametrize(\"from_url\", (True, False), ids=(\"from_url\", \"from_args\"))\nasync def test_redis_from_pool(request, from_url):\n    \"\"\"Verify that basic Redis instances created using `from_pool()`\n    have auto_close_connection_pool set to True\"\"\"\n\n    url: str = request.config.getoption(\"--redis-url\")\n    url_args = parse_url(url)\n\n    pool = None\n\n    async def get_redis_connection():\n        nonlocal pool\n        if from_url:\n            pool = ConnectionPool.from_url(url)\n        else:\n            pool = ConnectionPool(**url_args)\n        return Redis.from_pool(pool)\n\n    called = 0\n\n    async def mock_disconnect(_):\n        nonlocal called\n        called += 1\n\n    with patch.object(ConnectionPool, \"disconnect\", mock_disconnect):\n        async with await get_redis_connection() as r1:\n            assert r1.auto_close_connection_pool is True\n\n    assert called == 1\n    await pool.disconnect()\n\n\n@pytest.mark.parametrize(\"auto_close\", (True, False))\nasync def test_redis_pool_auto_close_arg(request, auto_close):\n    \"\"\"test that redis instance where pool is provided have\n    auto_close_connection_pool set to False, regardless of arg\"\"\"\n\n    url: str = request.config.getoption(\"--redis-url\")\n    pool = ConnectionPool.from_url(url)\n\n    async def get_redis_connection():\n        with pytest.deprecated_call():\n            client = Redis(connection_pool=pool, auto_close_connection_pool=auto_close)\n        return client\n\n    called = 0\n\n    async def mock_disconnect(_):\n        nonlocal called\n        called += 1\n\n    with patch.object(ConnectionPool, \"disconnect\", mock_disconnect):\n        async with await get_redis_connection() as r1:\n            assert r1.auto_close_connection_pool is False\n\n    assert called == 0\n    await pool.disconnect()\n\n\nasync def test_client_garbage_collection(request):\n    \"\"\"\n    Test that a Redis client will call _close() on any\n    connection that it holds at time of destruction\n    \"\"\"\n\n    url: str = request.config.getoption(\"--redis-url\")\n    pool = ConnectionPool.from_url(url)\n\n    # create a client with a connection from the pool\n    client = Redis(connection_pool=pool, single_connection_client=True)\n    await client.initialize()\n    with mock.patch.object(client, \"connection\") as a:\n        # we cannot, in unittests, or from asyncio, reliably trigger garbage collection\n        # so we must just invoke the handler\n        with pytest.warns(ResourceWarning):\n            client.__del__()\n            assert a._close.called\n\n    await client.aclose()\n    await pool.aclose()\n\n\nasync def test_connection_garbage_collection(request):\n    \"\"\"\n    Test that a Connection object will call close() on the\n    stream that it holds.\n    \"\"\"\n\n    url: str = request.config.getoption(\"--redis-url\")\n    pool = ConnectionPool.from_url(url)\n\n    # create a client with a connection from the pool\n    client = Redis(connection_pool=pool, single_connection_client=True)\n    await client.initialize()\n    conn = client.connection\n\n    with mock.patch.object(conn, \"_reader\"):\n        with mock.patch.object(conn, \"_writer\") as a:\n            # we cannot, in unittests, or from asyncio, reliably trigger\n            # garbage collection so we must just invoke the handler\n            with pytest.warns(ResourceWarning):\n                conn.__del__()\n                assert a.close.called\n\n    await client.aclose()\n    await pool.aclose()\n\n\n@pytest.mark.parametrize(\n    \"conn, error, expected_message\",\n    [\n        (SSLConnection(), OSError(), \"Error connecting to localhost:6379.\"),\n        (SSLConnection(), OSError(12), \"Error 12 connecting to localhost:6379.\"),\n        (\n            SSLConnection(),\n            OSError(12, \"Some Error\"),\n            \"Error 12 connecting to localhost:6379. Some Error.\",\n        ),\n        (\n            UnixDomainSocketConnection(path=\"unix:///tmp/redis.sock\"),\n            OSError(),\n            \"Error connecting to unix:///tmp/redis.sock.\",\n        ),\n        (\n            UnixDomainSocketConnection(path=\"unix:///tmp/redis.sock\"),\n            OSError(12),\n            \"Error 12 connecting to unix:///tmp/redis.sock.\",\n        ),\n        (\n            UnixDomainSocketConnection(path=\"unix:///tmp/redis.sock\"),\n            OSError(12, \"Some Error\"),\n            \"Error 12 connecting to unix:///tmp/redis.sock. Some Error.\",\n        ),\n    ],\n)\nasync def test_format_error_message(conn, error, expected_message):\n    \"\"\"Test that the _error_message function formats errors correctly\"\"\"\n    error_message = conn._error_message(error)\n    assert error_message == expected_message\n\n\nasync def test_network_connection_failure():\n    exp_err = rf\"^Error {ECONNREFUSED} connecting to 127.0.0.1:9999.(.+)$\"\n    with pytest.raises(ConnectionError, match=exp_err):\n        redis = Redis(host=\"127.0.0.1\", port=9999)\n        await redis.set(\"a\", \"b\")\n\n\nasync def test_unix_socket_connection_failure():\n    exp_err = \"Error 2 connecting to unix:///tmp/a.sock. No such file or directory.\"\n    with pytest.raises(ConnectionError, match=exp_err):\n        redis = Redis(unix_socket_path=\"unix:///tmp/a.sock\")\n        await redis.set(\"a\", \"b\")\n"
  },
  {
    "path": "tests/test_asyncio/test_connection_pool.py",
    "content": "import asyncio\nimport re\nfrom unittest import mock\nfrom unittest.mock import patch\n\nimport pytest\nimport pytest_asyncio\nimport redis.asyncio as redis\nfrom redis.asyncio.connection import (\n    BlockingConnectionPool,\n    Connection,\n    ConnectionPool,\n    to_bool,\n)\nfrom unittest.mock import AsyncMock, MagicMock\n\nfrom redis.observability.metrics import CloseReason\nfrom redis.auth.token import TokenInterface\nfrom tests.conftest import skip_if_redis_enterprise, skip_if_server_version_lt\n\nfrom .compat import aclosing\nfrom .conftest import asynccontextmanager\nfrom .test_pubsub import wait_for_message\n\n\n@pytest.mark.onlynoncluster\nclass TestRedisAutoReleaseConnectionPool:\n    @pytest_asyncio.fixture\n    async def r(self, create_redis) -> redis.Redis:\n        \"\"\"This is necessary since r and r2 create ConnectionPools behind the scenes\"\"\"\n        r = await create_redis()\n        r.auto_close_connection_pool = True\n        yield r\n\n    @staticmethod\n    def get_total_connected_connections(pool):\n        return len(pool._available_connections) + len(pool._in_use_connections)\n\n    @staticmethod\n    async def create_two_conn(r: redis.Redis):\n        if not r.single_connection_client:  # Single already initialized connection\n            r.connection = await r.connection_pool.get_connection()\n        return await r.connection_pool.get_connection()\n\n    @staticmethod\n    def has_no_connected_connections(pool: redis.ConnectionPool):\n        return not any(\n            x.is_connected\n            for x in pool._available_connections + list(pool._in_use_connections)\n        )\n\n    async def test_auto_disconnect_redis_created_pool(self, r: redis.Redis):\n        new_conn = await self.create_two_conn(r)\n        assert new_conn != r.connection\n        assert self.get_total_connected_connections(r.connection_pool) == 2\n        await r.aclose()\n        assert self.has_no_connected_connections(r.connection_pool)\n\n    async def test_do_not_auto_disconnect_redis_created_pool(self, r2: redis.Redis):\n        assert r2.auto_close_connection_pool is False, (\n            \"The connection pool should not be disconnected as a manually created \"\n            \"connection pool was passed in in conftest.py\"\n        )\n        new_conn = await self.create_two_conn(r2)\n        assert self.get_total_connected_connections(r2.connection_pool) == 2\n        await r2.aclose()\n        assert r2.connection_pool._in_use_connections == {new_conn}\n        assert new_conn.is_connected\n        assert len(r2.connection_pool._available_connections) == 1\n        assert r2.connection_pool._available_connections[0].is_connected\n\n    async def test_auto_release_override_true_manual_created_pool(self, r: redis.Redis):\n        assert r.auto_close_connection_pool is True, \"This is from the class fixture\"\n        await self.create_two_conn(r)\n        await r.aclose()\n        assert self.get_total_connected_connections(r.connection_pool) == 2, (\n            \"The connection pool should not be disconnected as a manually created \"\n            \"connection pool was passed in in conftest.py\"\n        )\n        assert self.has_no_connected_connections(r.connection_pool)\n\n    @pytest.mark.parametrize(\"auto_close_conn_pool\", [True, False])\n    async def test_close_override(self, r: redis.Redis, auto_close_conn_pool):\n        r.auto_close_connection_pool = auto_close_conn_pool\n        await self.create_two_conn(r)\n        await r.aclose(close_connection_pool=True)\n        assert self.has_no_connected_connections(r.connection_pool)\n\n    @pytest.mark.parametrize(\"auto_close_conn_pool\", [True, False])\n    async def test_negate_auto_close_client_pool(\n        self, r: redis.Redis, auto_close_conn_pool\n    ):\n        r.auto_close_connection_pool = auto_close_conn_pool\n        new_conn = await self.create_two_conn(r)\n        await r.aclose(close_connection_pool=False)\n        assert not self.has_no_connected_connections(r.connection_pool)\n        assert r.connection_pool._in_use_connections == {new_conn}\n        assert r.connection_pool._available_connections[0].is_connected\n        assert self.get_total_connected_connections(r.connection_pool) == 2\n\n\nclass DummyConnection(Connection):\n    description_format = \"DummyConnection<>\"\n\n    def __init__(self, **kwargs):\n        self.kwargs = kwargs\n        self._connected = False\n\n    def repr_pieces(self):\n        return [(\"id\", id(self)), (\"kwargs\", self.kwargs)]\n\n    async def connect(self):\n        self._connected = True\n\n    async def disconnect(self):\n        self._connected = False\n\n    @property\n    def is_connected(self):\n        return self._connected\n\n    async def can_read_destructive(self, timeout: float = 0):\n        return False\n\n    def set_re_auth_token(self, token: TokenInterface):\n        pass\n\n    async def re_auth(self):\n        pass\n\n    def should_reconnect(self):\n        return False\n\n\nclass TestConnectionPool:\n    @asynccontextmanager\n    async def get_pool(\n        self,\n        connection_kwargs=None,\n        max_connections=None,\n        connection_class=redis.Connection,\n    ):\n        connection_kwargs = connection_kwargs or {}\n        pool = redis.ConnectionPool(\n            connection_class=connection_class,\n            max_connections=max_connections,\n            **connection_kwargs,\n        )\n        try:\n            yield pool\n        finally:\n            await pool.disconnect(inuse_connections=True)\n\n    async def test_connection_creation(self):\n        connection_kwargs = {\"foo\": \"bar\", \"biz\": \"baz\"}\n        async with self.get_pool(\n            connection_kwargs=connection_kwargs, connection_class=DummyConnection\n        ) as pool:\n            connection = await pool.get_connection()\n            assert isinstance(connection, DummyConnection)\n            assert connection.kwargs == connection_kwargs\n\n    async def test_aclosing(self):\n        connection_kwargs = {\"foo\": \"bar\", \"biz\": \"baz\"}\n        pool = redis.ConnectionPool(\n            connection_class=DummyConnection,\n            max_connections=None,\n            **connection_kwargs,\n        )\n        async with aclosing(pool):\n            pass\n\n    async def test_multiple_connections(self, master_host):\n        connection_kwargs = {\"host\": master_host[0]}\n        async with self.get_pool(connection_kwargs=connection_kwargs) as pool:\n            c1 = await pool.get_connection()\n            c2 = await pool.get_connection()\n            assert c1 != c2\n\n    async def test_max_connections(self, master_host):\n        connection_kwargs = {\"host\": master_host[0]}\n        async with self.get_pool(\n            max_connections=2, connection_kwargs=connection_kwargs\n        ) as pool:\n            await pool.get_connection()\n            await pool.get_connection()\n            with pytest.raises(redis.ConnectionError):\n                await pool.get_connection()\n\n    async def test_reuse_previously_released_connection(self, master_host):\n        connection_kwargs = {\"host\": master_host[0]}\n        async with self.get_pool(connection_kwargs=connection_kwargs) as pool:\n            c1 = await pool.get_connection()\n            await pool.release(c1)\n            c2 = await pool.get_connection()\n            assert c1 == c2\n\n    async def test_repr_contains_db_info_tcp(self):\n        connection_kwargs = {\n            \"host\": \"localhost\",\n            \"port\": 6379,\n            \"db\": 1,\n            \"client_name\": \"test-client\",\n        }\n        async with self.get_pool(\n            connection_kwargs=connection_kwargs, connection_class=redis.Connection\n        ) as pool:\n            expected = \"host=localhost,port=6379,db=1,client_name=test-client\"\n            assert expected in repr(pool)\n\n    async def test_repr_contains_db_info_unix(self):\n        connection_kwargs = {\"path\": \"/abc\", \"db\": 1, \"client_name\": \"test-client\"}\n        async with self.get_pool(\n            connection_kwargs=connection_kwargs,\n            connection_class=redis.UnixDomainSocketConnection,\n        ) as pool:\n            expected = \"path=/abc,db=1,client_name=test-client\"\n            assert expected in repr(pool)\n\n    async def test_pool_disconnect(self, master_host):\n        connection_kwargs = {\n            \"host\": master_host[0],\n            \"port\": master_host[1],\n        }\n        async with self.get_pool(connection_kwargs=connection_kwargs) as pool:\n            conn = await pool.get_connection()\n            await pool.disconnect(inuse_connections=True)\n            assert not conn.is_connected\n\n            await conn.connect()\n            await pool.disconnect(inuse_connections=False)\n            assert conn.is_connected\n\n    async def test_lock_not_held_during_connection_establishment(self):\n        \"\"\"\n        Test that the connection pool lock is not held during the\n        ensure_connection call, which involves socket connection and handshake.\n        This is important for performance under high load.\n        \"\"\"\n        lock_states = []\n\n        class SlowConnectConnection(DummyConnection):\n            \"\"\"Connection that simulates slow connection establishment\"\"\"\n\n            async def connect(self):\n                # Check if the pool's lock is held during connection\n                # We access the pool through the outer scope\n                lock_states.append(pool._lock.locked())\n                # Simulate slow connection\n                await asyncio.sleep(0.01)\n                self._connected = True\n\n        async with self.get_pool(connection_class=SlowConnectConnection) as pool:\n            # Get a connection - this should call connect() outside the lock\n            connection = await pool.get_connection()\n\n            # Verify the lock was NOT held during connect\n            assert len(lock_states) > 0, \"connect() should have been called\"\n            assert lock_states[0] is False, (\n                \"Lock should not be held during connection establishment\"\n            )\n\n            await pool.release(connection)\n\n    async def test_concurrent_connection_acquisition_performance(self):\n        \"\"\"\n        Test that multiple concurrent connection acquisitions don't block\n        each other during connection establishment.\n        \"\"\"\n        connection_delay = 0.05\n        num_connections = 3\n\n        class SlowConnectConnection(DummyConnection):\n            \"\"\"Connection that simulates slow connection establishment\"\"\"\n\n            async def connect(self):\n                # Simulate slow connection (e.g., network latency, TLS handshake)\n                await asyncio.sleep(connection_delay)\n                self._connected = True\n\n        async with self.get_pool(\n            connection_class=SlowConnectConnection, max_connections=10\n        ) as pool:\n            # Start acquiring multiple connections concurrently\n            start_time = asyncio.get_running_loop().time()\n\n            # Try to get connections concurrently\n            connections = await asyncio.gather(\n                *[pool.get_connection() for _ in range(num_connections)]\n            )\n\n            elapsed_time = asyncio.get_running_loop().time() - start_time\n\n            # With proper lock handling, these should complete mostly in parallel\n            # If the lock was held during connect(), it would take num_connections * connection_delay\n            # With lock only during pop, it should take ~connection_delay (connections in parallel)\n            # We allow 2.5x overhead for system variance\n            max_allowed_time = connection_delay * 2.5\n            assert elapsed_time < max_allowed_time, (\n                f\"Concurrent connections took {elapsed_time:.3f}s, \"\n                f\"expected < {max_allowed_time:.3f}s. \"\n                f\"This suggests lock was held during connection establishment.\"\n            )\n\n            # Clean up\n            for conn in connections:\n                await pool.release(conn)\n\n\nclass TestBlockingConnectionPool:\n    @asynccontextmanager\n    async def get_pool(self, connection_kwargs=None, max_connections=10, timeout=20):\n        connection_kwargs = connection_kwargs or {}\n        pool = redis.BlockingConnectionPool(\n            connection_class=DummyConnection,\n            max_connections=max_connections,\n            timeout=timeout,\n            **connection_kwargs,\n        )\n        try:\n            yield pool\n        finally:\n            await pool.disconnect(inuse_connections=True)\n\n    async def test_connection_creation(self, master_host):\n        connection_kwargs = {\n            \"foo\": \"bar\",\n            \"biz\": \"baz\",\n            \"host\": master_host[0],\n            \"port\": master_host[1],\n        }\n        async with self.get_pool(connection_kwargs=connection_kwargs) as pool:\n            connection = await pool.get_connection()\n            assert isinstance(connection, DummyConnection)\n            assert connection.kwargs == connection_kwargs\n\n    async def test_pool_disconnect(self, master_host):\n        connection_kwargs = {\n            \"foo\": \"bar\",\n            \"biz\": \"baz\",\n            \"host\": master_host[0],\n            \"port\": master_host[1],\n        }\n        async with self.get_pool(connection_kwargs=connection_kwargs) as pool:\n            conn = await pool.get_connection()\n            await pool.disconnect()\n            assert not conn.is_connected\n\n            await conn.connect()\n            await pool.disconnect(inuse_connections=False)\n            assert conn.is_connected\n\n    async def test_multiple_connections(self, master_host):\n        connection_kwargs = {\"host\": master_host[0], \"port\": master_host[1]}\n        async with self.get_pool(connection_kwargs=connection_kwargs) as pool:\n            c1 = await pool.get_connection()\n            c2 = await pool.get_connection()\n            assert c1 != c2\n\n    async def test_connection_pool_blocks_until_timeout(self, master_host):\n        \"\"\"When out of connections, block for timeout seconds, then raise\"\"\"\n        connection_kwargs = {\"host\": master_host[0]}\n        async with self.get_pool(\n            max_connections=1, timeout=0.1, connection_kwargs=connection_kwargs\n        ) as pool:\n            c1 = await pool.get_connection()\n\n            start = asyncio.get_running_loop().time()\n            with pytest.raises(redis.ConnectionError):\n                await pool.get_connection()\n\n            # we should have waited at least some period of time\n            assert asyncio.get_running_loop().time() - start >= 0.05\n            await c1.disconnect()\n\n    async def test_connection_pool_blocks_until_conn_available(self, master_host):\n        \"\"\"\n        When out of connections, block until another connection is released\n        to the pool\n        \"\"\"\n        connection_kwargs = {\"host\": master_host[0], \"port\": master_host[1]}\n        async with self.get_pool(\n            max_connections=1, timeout=2, connection_kwargs=connection_kwargs\n        ) as pool:\n            c1 = await pool.get_connection()\n\n            async def target():\n                await asyncio.sleep(0.1)\n                await pool.release(c1)\n\n            start = asyncio.get_running_loop().time()\n            await asyncio.gather(target(), pool.get_connection())\n            stop = asyncio.get_running_loop().time()\n            assert (stop - start) <= 0.2\n\n    async def test_reuse_previously_released_connection(self, master_host):\n        connection_kwargs = {\"host\": master_host[0]}\n        async with self.get_pool(connection_kwargs=connection_kwargs) as pool:\n            c1 = await pool.get_connection()\n            await pool.release(c1)\n            c2 = await pool.get_connection()\n            assert c1 == c2\n\n    def test_repr_contains_db_info_tcp(self):\n        pool = redis.ConnectionPool(\n            host=\"localhost\", port=6379, client_name=\"test-client\"\n        )\n        expected = \"host=localhost,port=6379,client_name=test-client\"\n        assert expected in repr(pool)\n\n    def test_repr_contains_db_info_unix(self):\n        pool = redis.ConnectionPool(\n            connection_class=redis.UnixDomainSocketConnection,\n            path=\"abc\",\n            db=0,\n            client_name=\"test-client\",\n        )\n        expected = \"path=abc,db=0,client_name=test-client\"\n        assert expected in repr(pool)\n\n    def test_repr_redacts_sensitive_information(self):\n        \"\"\"Test that __repr__ redacts sensitive values like password and username.\"\"\"\n        pool = ConnectionPool(\n            host=\"localhost\",\n            port=6379,\n            password=\"secret_password_123\",\n            username=\"myuser\",\n            ssl_password=\"ssl_secret_456\",\n            db=0,\n        )\n        repr_output = repr(pool)\n\n        # Verify sensitive values are redacted\n        assert \"secret_password_123\" not in repr_output\n        assert \"myuser\" not in repr_output\n        assert \"ssl_secret_456\" not in repr_output\n\n        # Verify the REDACTED placeholder is present\n        assert \"<REDACTED>\" in repr_output\n\n        # Verify non-sensitive values are still visible\n        assert \"host=localhost\" in repr_output\n        assert \"port=6379\" in repr_output\n        assert \"db=0\" in repr_output\n\n\nclass TestConnectionPoolURLParsing:\n    def test_hostname(self):\n        pool = redis.ConnectionPool.from_url(\"redis://my.host\")\n        assert pool.connection_class == redis.Connection\n        assert pool.connection_kwargs == {\"host\": \"my.host\"}\n\n    def test_quoted_hostname(self):\n        pool = redis.ConnectionPool.from_url(\"redis://my %2F host %2B%3D+\")\n        assert pool.connection_class == redis.Connection\n        assert pool.connection_kwargs == {\"host\": \"my / host +=+\"}\n\n    def test_port(self):\n        pool = redis.ConnectionPool.from_url(\"redis://localhost:6380\")\n        assert pool.connection_class == redis.Connection\n        assert pool.connection_kwargs == {\"host\": \"localhost\", \"port\": 6380}\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    def test_username(self):\n        pool = redis.ConnectionPool.from_url(\"redis://myuser:@localhost\")\n        assert pool.connection_class == redis.Connection\n        assert pool.connection_kwargs == {\"host\": \"localhost\", \"username\": \"myuser\"}\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    def test_quoted_username(self):\n        pool = redis.ConnectionPool.from_url(\n            \"redis://%2Fmyuser%2F%2B name%3D%24+:@localhost\"\n        )\n        assert pool.connection_class == redis.Connection\n        assert pool.connection_kwargs == {\n            \"host\": \"localhost\",\n            \"username\": \"/myuser/+ name=$+\",\n        }\n\n    def test_password(self):\n        pool = redis.ConnectionPool.from_url(\"redis://:mypassword@localhost\")\n        assert pool.connection_class == redis.Connection\n        assert pool.connection_kwargs == {\"host\": \"localhost\", \"password\": \"mypassword\"}\n\n    def test_quoted_password(self):\n        pool = redis.ConnectionPool.from_url(\n            \"redis://:%2Fmypass%2F%2B word%3D%24+@localhost\"\n        )\n        assert pool.connection_class == redis.Connection\n        assert pool.connection_kwargs == {\n            \"host\": \"localhost\",\n            \"password\": \"/mypass/+ word=$+\",\n        }\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    def test_username_and_password(self):\n        pool = redis.ConnectionPool.from_url(\"redis://myuser:mypass@localhost\")\n        assert pool.connection_class == redis.Connection\n        assert pool.connection_kwargs == {\n            \"host\": \"localhost\",\n            \"username\": \"myuser\",\n            \"password\": \"mypass\",\n        }\n\n    def test_db_as_argument(self):\n        pool = redis.ConnectionPool.from_url(\"redis://localhost\", db=1)\n        assert pool.connection_class == redis.Connection\n        assert pool.connection_kwargs == {\"host\": \"localhost\", \"db\": 1}\n\n    def test_db_in_path(self):\n        pool = redis.ConnectionPool.from_url(\"redis://localhost/2\", db=1)\n        assert pool.connection_class == redis.Connection\n        assert pool.connection_kwargs == {\"host\": \"localhost\", \"db\": 2}\n\n    def test_db_in_querystring(self):\n        pool = redis.ConnectionPool.from_url(\"redis://localhost/2?db=3\", db=1)\n        assert pool.connection_class == redis.Connection\n        assert pool.connection_kwargs == {\"host\": \"localhost\", \"db\": 3}\n\n    def test_extra_typed_querystring_options(self):\n        pool = redis.ConnectionPool.from_url(\n            \"redis://localhost/2?socket_timeout=20&socket_connect_timeout=10\"\n            \"&socket_keepalive=&retry_on_timeout=Yes&max_connections=10\"\n        )\n\n        assert pool.connection_class == redis.Connection\n        assert pool.connection_kwargs == {\n            \"host\": \"localhost\",\n            \"db\": 2,\n            \"socket_timeout\": 20.0,\n            \"socket_connect_timeout\": 10.0,\n            \"retry_on_timeout\": True,\n        }\n        assert pool.max_connections == 10\n\n    def test_boolean_parsing(self):\n        for expected, value in (\n            (None, None),\n            (None, \"\"),\n            (False, 0),\n            (False, \"0\"),\n            (False, \"f\"),\n            (False, \"F\"),\n            (False, \"False\"),\n            (False, \"n\"),\n            (False, \"N\"),\n            (False, \"No\"),\n            (True, 1),\n            (True, \"1\"),\n            (True, \"y\"),\n            (True, \"Y\"),\n            (True, \"Yes\"),\n        ):\n            assert expected is to_bool(value)\n\n    def test_client_name_in_querystring(self):\n        pool = redis.ConnectionPool.from_url(\"redis://location?client_name=test-client\")\n        assert pool.connection_kwargs[\"client_name\"] == \"test-client\"\n\n    def test_invalid_extra_typed_querystring_options(self):\n        with pytest.raises(ValueError):\n            redis.ConnectionPool.from_url(\n                \"redis://localhost/2?socket_timeout=_&socket_connect_timeout=abc\"\n            )\n\n    def test_extra_querystring_options(self):\n        pool = redis.ConnectionPool.from_url(\"redis://localhost?a=1&b=2\")\n        assert pool.connection_class == redis.Connection\n        assert pool.connection_kwargs == {\"host\": \"localhost\", \"a\": \"1\", \"b\": \"2\"}\n\n    def test_calling_from_subclass_returns_correct_instance(self):\n        pool = redis.BlockingConnectionPool.from_url(\"redis://localhost\")\n        assert isinstance(pool, redis.BlockingConnectionPool)\n\n    def test_client_creates_connection_pool(self):\n        r = redis.Redis.from_url(\"redis://myhost\")\n        assert r.connection_pool.connection_class == redis.Connection\n        assert r.connection_pool.connection_kwargs == {\"host\": \"myhost\"}\n\n    def test_invalid_scheme_raises_error(self):\n        with pytest.raises(ValueError) as cm:\n            redis.ConnectionPool.from_url(\"localhost\")\n        assert str(cm.value) == (\n            \"Redis URL must specify one of the following schemes \"\n            \"(redis://, rediss://, unix://)\"\n        )\n\n\nclass TestBlockingConnectionPoolURLParsing:\n    def test_extra_typed_querystring_options(self):\n        pool = redis.BlockingConnectionPool.from_url(\n            \"redis://localhost/2?socket_timeout=20&socket_connect_timeout=10\"\n            \"&socket_keepalive=&retry_on_timeout=Yes&max_connections=10&timeout=13.37\"\n        )\n\n        assert pool.connection_class == redis.Connection\n        assert pool.connection_kwargs == {\n            \"host\": \"localhost\",\n            \"db\": 2,\n            \"socket_timeout\": 20.0,\n            \"socket_connect_timeout\": 10.0,\n            \"retry_on_timeout\": True,\n        }\n        assert pool.max_connections == 10\n        assert pool.timeout == 13.37\n\n    def test_invalid_extra_typed_querystring_options(self):\n        with pytest.raises(ValueError):\n            redis.BlockingConnectionPool.from_url(\n                \"redis://localhost/2?timeout=_not_a_float_\"\n            )\n\n\nclass TestConnectionPoolUnixSocketURLParsing:\n    def test_defaults(self):\n        pool = redis.ConnectionPool.from_url(\"unix:///socket\")\n        assert pool.connection_class == redis.UnixDomainSocketConnection\n        assert pool.connection_kwargs == {\"path\": \"/socket\"}\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    def test_username(self):\n        pool = redis.ConnectionPool.from_url(\"unix://myuser:@/socket\")\n        assert pool.connection_class == redis.UnixDomainSocketConnection\n        assert pool.connection_kwargs == {\"path\": \"/socket\", \"username\": \"myuser\"}\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    def test_quoted_username(self):\n        pool = redis.ConnectionPool.from_url(\n            \"unix://%2Fmyuser%2F%2B name%3D%24+:@/socket\"\n        )\n        assert pool.connection_class == redis.UnixDomainSocketConnection\n        assert pool.connection_kwargs == {\n            \"path\": \"/socket\",\n            \"username\": \"/myuser/+ name=$+\",\n        }\n\n    def test_password(self):\n        pool = redis.ConnectionPool.from_url(\"unix://:mypassword@/socket\")\n        assert pool.connection_class == redis.UnixDomainSocketConnection\n        assert pool.connection_kwargs == {\"path\": \"/socket\", \"password\": \"mypassword\"}\n\n    def test_quoted_password(self):\n        pool = redis.ConnectionPool.from_url(\n            \"unix://:%2Fmypass%2F%2B word%3D%24+@/socket\"\n        )\n        assert pool.connection_class == redis.UnixDomainSocketConnection\n        assert pool.connection_kwargs == {\n            \"path\": \"/socket\",\n            \"password\": \"/mypass/+ word=$+\",\n        }\n\n    def test_quoted_path(self):\n        pool = redis.ConnectionPool.from_url(\n            \"unix://:mypassword@/my%2Fpath%2Fto%2F..%2F+_%2B%3D%24ocket\"\n        )\n        assert pool.connection_class == redis.UnixDomainSocketConnection\n        assert pool.connection_kwargs == {\n            \"path\": \"/my/path/to/../+_+=$ocket\",\n            \"password\": \"mypassword\",\n        }\n\n    def test_db_as_argument(self):\n        pool = redis.ConnectionPool.from_url(\"unix:///socket\", db=1)\n        assert pool.connection_class == redis.UnixDomainSocketConnection\n        assert pool.connection_kwargs == {\"path\": \"/socket\", \"db\": 1}\n\n    def test_db_in_querystring(self):\n        pool = redis.ConnectionPool.from_url(\"unix:///socket?db=2\", db=1)\n        assert pool.connection_class == redis.UnixDomainSocketConnection\n        assert pool.connection_kwargs == {\"path\": \"/socket\", \"db\": 2}\n\n    def test_client_name_in_querystring(self):\n        pool = redis.ConnectionPool.from_url(\"redis://location?client_name=test-client\")\n        assert pool.connection_kwargs[\"client_name\"] == \"test-client\"\n\n    def test_extra_querystring_options(self):\n        pool = redis.ConnectionPool.from_url(\"unix:///socket?a=1&b=2\")\n        assert pool.connection_class == redis.UnixDomainSocketConnection\n        assert pool.connection_kwargs == {\"path\": \"/socket\", \"a\": \"1\", \"b\": \"2\"}\n\n\nclass TestSSLConnectionURLParsing:\n    def test_host(self):\n        pool = redis.ConnectionPool.from_url(\"rediss://my.host\")\n        assert pool.connection_class == redis.SSLConnection\n        assert pool.connection_kwargs == {\"host\": \"my.host\"}\n\n    def test_cert_reqs_options(self):\n        import ssl\n\n        class DummyConnectionPool(redis.ConnectionPool):\n            def get_connection(self):\n                return self.make_connection()\n\n        pool = DummyConnectionPool.from_url(\"rediss://?ssl_cert_reqs=none\")\n        assert pool.get_connection().cert_reqs == ssl.CERT_NONE\n\n        pool = DummyConnectionPool.from_url(\"rediss://?ssl_cert_reqs=optional\")\n        assert pool.get_connection().cert_reqs == ssl.CERT_OPTIONAL\n\n        pool = DummyConnectionPool.from_url(\"rediss://?ssl_cert_reqs=required\")\n        assert pool.get_connection().cert_reqs == ssl.CERT_REQUIRED\n\n        pool = DummyConnectionPool.from_url(\"rediss://?ssl_check_hostname=False\")\n        assert pool.get_connection().check_hostname is False\n\n        pool = DummyConnectionPool.from_url(\"rediss://?ssl_check_hostname=True\")\n        assert pool.get_connection().check_hostname is True\n\n\nclass TestConnection:\n    async def test_on_connect_error(self):\n        \"\"\"\n        An error in Connection.on_connect should disconnect from the server\n        see for details: https://github.com/andymccurdy/redis-py/issues/368\n        \"\"\"\n        # this assumes the Redis server being tested against doesn't have\n        # 9999 databases ;)\n        bad_connection = redis.Redis(db=9999)\n        # an error should be raised on connect\n        with pytest.raises(redis.RedisError):\n            await bad_connection.info()\n        pool = bad_connection.connection_pool\n        assert len(pool._available_connections) == 1\n        assert not pool._available_connections[0]._reader\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"2.8.8\")\n    @skip_if_redis_enterprise()\n    async def test_busy_loading_disconnects_socket(self, r):\n        \"\"\"\n        If Redis raises a LOADING error, the connection should be\n        disconnected and a BusyLoadingError raised\n        \"\"\"\n        with pytest.raises(redis.BusyLoadingError):\n            await r.execute_command(\"DEBUG\", \"ERROR\", \"LOADING fake message\")\n        if r.connection:\n            assert not r.connection._reader\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"2.8.8\")\n    @skip_if_redis_enterprise()\n    async def test_busy_loading_from_pipeline_immediate_command(self, r):\n        \"\"\"\n        BusyLoadingErrors should raise from Pipelines that execute a\n        command immediately, like WATCH does.\n        \"\"\"\n        pipe = r.pipeline()\n        with pytest.raises(redis.BusyLoadingError):\n            await pipe.immediate_execute_command(\n                \"DEBUG\", \"ERROR\", \"LOADING fake message\"\n            )\n        pool = r.connection_pool\n        assert pipe.connection\n        assert pipe.connection in pool._in_use_connections\n        assert not pipe.connection._reader\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"2.8.8\")\n    @skip_if_redis_enterprise()\n    async def test_busy_loading_from_pipeline(self, r):\n        \"\"\"\n        BusyLoadingErrors should be raised from a pipeline execution\n        regardless of the raise_on_error flag.\n        \"\"\"\n        pipe = r.pipeline()\n        pipe.execute_command(\"DEBUG\", \"ERROR\", \"LOADING fake message\")\n        with pytest.raises(redis.BusyLoadingError):\n            await pipe.execute()\n        pool = r.connection_pool\n        assert not pipe.connection\n        assert len(pool._available_connections) == 1\n        assert not pool._available_connections[0]._reader\n\n    @skip_if_server_version_lt(\"2.8.8\")\n    @skip_if_redis_enterprise()\n    async def test_read_only_error(self, r):\n        \"\"\"READONLY errors get turned into ReadOnlyError exceptions\"\"\"\n        with pytest.raises(redis.ReadOnlyError):\n            await r.execute_command(\"DEBUG\", \"ERROR\", \"READONLY blah blah\")\n\n    @skip_if_redis_enterprise()\n    async def test_oom_error(self, r):\n        \"\"\"OOM errors get turned into OutOfMemoryError exceptions\"\"\"\n        with pytest.raises(redis.OutOfMemoryError):\n            # note: don't use the DEBUG OOM command since it's not the same\n            # as the db being full\n            await r.execute_command(\"DEBUG\", \"ERROR\", \"OOM blah blah\")\n\n    def test_connect_from_url_tcp(self):\n        connection = redis.Redis.from_url(\"redis://localhost:6379?db=0\")\n        pool = connection.connection_pool\n\n        assert re.match(\n            r\"< .*?([^\\.]+) \\( < .*?([^\\.]+) \\( (.+) \\) > \\) >\", repr(pool), re.VERBOSE\n        ).groups() == (\n            \"ConnectionPool\",\n            \"Connection\",\n            \"db=0,host=localhost,port=6379\",\n        )\n\n    def test_connect_from_url_unix(self):\n        connection = redis.Redis.from_url(\"unix:///path/to/socket\")\n        pool = connection.connection_pool\n\n        assert re.match(\n            r\"< .*?([^\\.]+) \\( < .*?([^\\.]+) \\( (.+) \\) > \\) >\", repr(pool), re.VERBOSE\n        ).groups() == (\n            \"ConnectionPool\",\n            \"UnixDomainSocketConnection\",\n            \"path=/path/to/socket\",\n        )\n\n    @skip_if_redis_enterprise()\n    async def test_connect_no_auth_supplied_when_required(self, r):\n        \"\"\"\n        AuthenticationError should be raised when the server requires a\n        password but one isn't supplied.\n        \"\"\"\n        with pytest.raises(redis.AuthenticationError):\n            await r.execute_command(\n                \"DEBUG\", \"ERROR\", \"ERR Client sent AUTH, but no password is set\"\n            )\n\n    @skip_if_redis_enterprise()\n    async def test_connect_invalid_password_supplied(self, r):\n        \"\"\"AuthenticationError should be raised when sending the wrong password\"\"\"\n        with pytest.raises(redis.AuthenticationError):\n            await r.execute_command(\"DEBUG\", \"ERROR\", \"ERR invalid password\")\n\n\n@pytest.mark.onlynoncluster\nclass TestMultiConnectionClient:\n    @pytest_asyncio.fixture()\n    async def r(self, create_redis, server):\n        redis = await create_redis(single_connection_client=False)\n        yield redis\n        await redis.flushall()\n\n\n@pytest.mark.onlynoncluster\n@pytest.mark.xfail(strict=False)\nclass TestHealthCheck:\n    interval = 60\n\n    @pytest_asyncio.fixture()\n    async def r(self, create_redis):\n        redis = await create_redis(health_check_interval=self.interval)\n        yield redis\n        await redis.flushall()\n\n    def assert_interval_advanced(self, connection):\n        diff = connection.next_health_check - asyncio.get_running_loop().time()\n        assert self.interval >= diff > (self.interval - 1)\n\n    async def test_health_check_runs(self, r):\n        if r.connection:\n            r.connection.next_health_check = asyncio.get_running_loop().time() - 1\n            await r.connection.check_health()\n            self.assert_interval_advanced(r.connection)\n\n    async def test_arbitrary_command_invokes_health_check(self, r):\n        # invoke a command to make sure the connection is entirely setup\n        if r.connection:\n            await r.get(\"foo\")\n            r.connection.next_health_check = asyncio.get_running_loop().time()\n            with mock.patch.object(\n                r.connection, \"send_command\", wraps=r.connection.send_command\n            ) as m:\n                await r.get(\"foo\")\n                m.assert_called_with(\"PING\", check_health=False)\n\n            self.assert_interval_advanced(r.connection)\n\n    async def test_arbitrary_command_advances_next_health_check(self, r):\n        if r.connection:\n            await r.get(\"foo\")\n            next_health_check = r.connection.next_health_check\n            # ensure that the event loop's `time()` advances a bit\n            await asyncio.sleep(0.001)\n            await r.get(\"foo\")\n            assert next_health_check < r.connection.next_health_check\n\n    async def test_health_check_not_invoked_within_interval(self, r):\n        if r.connection:\n            await r.get(\"foo\")\n            with mock.patch.object(\n                r.connection, \"send_command\", wraps=r.connection.send_command\n            ) as m:\n                await r.get(\"foo\")\n                ping_call_spec = ((\"PING\",), {\"check_health\": False})\n                assert ping_call_spec not in m.call_args_list\n\n    async def test_health_check_in_pipeline(self, r):\n        async with r.pipeline(transaction=False) as pipe:\n            pipe.connection = await pipe.connection_pool.get_connection()\n            pipe.connection.next_health_check = 0\n            with mock.patch.object(\n                pipe.connection, \"send_command\", wraps=pipe.connection.send_command\n            ) as m:\n                responses = await pipe.set(\"foo\", \"bar\").get(\"foo\").execute()\n                m.assert_any_call(\"PING\", check_health=False)\n                assert responses == [True, b\"bar\"]\n\n    async def test_health_check_in_transaction(self, r):\n        async with r.pipeline(transaction=True) as pipe:\n            pipe.connection = await pipe.connection_pool.get_connection()\n            pipe.connection.next_health_check = 0\n            with mock.patch.object(\n                pipe.connection, \"send_command\", wraps=pipe.connection.send_command\n            ) as m:\n                responses = await pipe.set(\"foo\", \"bar\").get(\"foo\").execute()\n                m.assert_any_call(\"PING\", check_health=False)\n                assert responses == [True, b\"bar\"]\n\n    async def test_health_check_in_watched_pipeline(self, r):\n        await r.set(\"foo\", \"bar\")\n        async with r.pipeline(transaction=False) as pipe:\n            pipe.connection = await pipe.connection_pool.get_connection()\n            pipe.connection.next_health_check = 0\n            with mock.patch.object(\n                pipe.connection, \"send_command\", wraps=pipe.connection.send_command\n            ) as m:\n                await pipe.watch(\"foo\")\n                # the health check should be called when watching\n                m.assert_called_with(\"PING\", check_health=False)\n                self.assert_interval_advanced(pipe.connection)\n                assert await pipe.get(\"foo\") == b\"bar\"\n\n                # reset the mock to clear the call list and schedule another\n                # health check\n                m.reset_mock()\n                pipe.connection.next_health_check = 0\n\n                pipe.multi()\n                responses = await pipe.set(\"foo\", \"not-bar\").get(\"foo\").execute()\n                assert responses == [True, b\"not-bar\"]\n                m.assert_any_call(\"PING\", check_health=False)\n\n    async def test_health_check_in_pubsub_before_subscribe(self, r):\n        \"\"\"A health check happens before the first [p]subscribe\"\"\"\n        p = r.pubsub()\n        p.connection = await p.connection_pool.get_connection()\n        p.connection.next_health_check = 0\n        with mock.patch.object(\n            p.connection, \"send_command\", wraps=p.connection.send_command\n        ) as m:\n            assert not p.subscribed\n            await p.subscribe(\"foo\")\n            # the connection is not yet in pubsub mode, so the normal\n            # ping/pong within connection.send_command should check\n            # the health of the connection\n            m.assert_any_call(\"PING\", check_health=False)\n            self.assert_interval_advanced(p.connection)\n\n            subscribe_message = await wait_for_message(p)\n            assert subscribe_message[\"type\"] == \"subscribe\"\n\n    async def test_health_check_in_pubsub_after_subscribed(self, r):\n        \"\"\"\n        Pubsub can handle a new subscribe when it's time to check the\n        connection health\n        \"\"\"\n        p = r.pubsub()\n        p.connection = await p.connection_pool.get_connection()\n        p.connection.next_health_check = 0\n        with mock.patch.object(\n            p.connection, \"send_command\", wraps=p.connection.send_command\n        ) as m:\n            await p.subscribe(\"foo\")\n            subscribe_message = await wait_for_message(p)\n            assert subscribe_message[\"type\"] == \"subscribe\"\n            self.assert_interval_advanced(p.connection)\n            # because we weren't subscribed when sending the subscribe\n            # message to 'foo', the connection's standard check_health ran\n            # prior to subscribing.\n            m.assert_any_call(\"PING\", check_health=False)\n\n            p.connection.next_health_check = 0\n            m.reset_mock()\n\n            await p.subscribe(\"bar\")\n            # the second subscribe issues exactly only command (the subscribe)\n            # and the health check is not invoked\n            m.assert_called_once_with(\"SUBSCRIBE\", \"bar\", check_health=False)\n\n            # since no message has been read since the health check was\n            # reset, it should still be 0\n            assert p.connection.next_health_check == 0\n\n            subscribe_message = await wait_for_message(p)\n            assert subscribe_message[\"type\"] == \"subscribe\"\n            assert await wait_for_message(p) is None\n            # now that the connection is subscribed, the pubsub health\n            # check should have taken over and include the HEALTH_CHECK_MESSAGE\n            m.assert_any_call(\"PING\", p.HEALTH_CHECK_MESSAGE, check_health=False)\n            self.assert_interval_advanced(p.connection)\n\n    async def test_health_check_in_pubsub_poll(self, r):\n        \"\"\"\n        Polling a pubsub connection that's subscribed will regularly\n        check the connection's health.\n        \"\"\"\n        p = r.pubsub()\n        p.connection = await p.connection_pool.get_connection()\n        with mock.patch.object(\n            p.connection, \"send_command\", wraps=p.connection.send_command\n        ) as m:\n            await p.subscribe(\"foo\")\n            subscribe_message = await wait_for_message(p)\n            assert subscribe_message[\"type\"] == \"subscribe\"\n            self.assert_interval_advanced(p.connection)\n\n            # polling the connection before the health check interval\n            # doesn't result in another health check\n            m.reset_mock()\n            next_health_check = p.connection.next_health_check\n            assert await wait_for_message(p) is None\n            assert p.connection.next_health_check == next_health_check\n            m.assert_not_called()\n\n            # reset the health check and poll again\n            # we should not receive a pong message, but the next_health_check\n            # should be advanced\n            p.connection.next_health_check = 0\n            assert await wait_for_message(p) is None\n            m.assert_called_with(\"PING\", p.HEALTH_CHECK_MESSAGE, check_health=False)\n            self.assert_interval_advanced(p.connection)\n\n\n@pytest.mark.asyncio\nclass TestAsyncConnectionPoolMetricsRecording:\n    \"\"\"Tests for async ConnectionPool metrics recording (create time and wait time).\"\"\"\n\n    async def test_connection_pool_records_create_time_on_new_connection(self):\n        \"\"\"Test that ConnectionPool.get_connection() records create time when a new connection is created.\"\"\"\n        from unittest.mock import AsyncMock, MagicMock\n\n        pool = ConnectionPool(max_connections=10)\n\n        # Mock the ensure_connection to avoid actual connection\n        mock_connection = MagicMock()\n        mock_connection.is_connected = False\n        mock_connection.disconnect = AsyncMock()\n\n        # Patch where the functions are used (in redis.asyncio.connection module)\n        with patch.object(pool, \"make_connection\", return_value=mock_connection):\n            with patch.object(pool, \"ensure_connection\", new_callable=AsyncMock):\n                with patch(\n                    \"redis.asyncio.connection.record_connection_create_time\",\n                    new_callable=AsyncMock,\n                ) as mock_record:\n                    await pool.get_connection()\n\n                    # Verify record_connection_create_time was called\n                    mock_record.assert_called_once()\n                    call_kwargs = mock_record.call_args[1]\n                    assert call_kwargs[\"connection_pool\"] is pool\n                    assert call_kwargs[\"duration_seconds\"] > 0\n\n        await pool.disconnect()\n\n    async def test_connection_pool_does_not_record_create_time_for_existing_connection(\n        self,\n    ):\n        \"\"\"Test that ConnectionPool.get_connection() does not record create time when reusing an existing connection.\"\"\"\n        from unittest.mock import AsyncMock, MagicMock\n\n        pool = ConnectionPool(max_connections=10)\n\n        # Add a mock connection to available connections\n        mock_connection = MagicMock()\n        mock_connection.disconnect = AsyncMock()\n        pool._available_connections.append(mock_connection)\n\n        with patch.object(pool, \"ensure_connection\", new_callable=AsyncMock):\n            with patch(\n                \"redis.asyncio.connection.record_connection_create_time\",\n                new_callable=AsyncMock,\n            ) as mock_record:\n                await pool.get_connection()\n\n                # Verify record_connection_create_time was NOT called\n                mock_record.assert_not_called()\n\n        await pool.disconnect()\n\n    async def test_blocking_connection_pool_records_create_time_and_wait_time(self):\n        \"\"\"Test that BlockingConnectionPool.get_connection() records both create time and wait time.\"\"\"\n        from unittest.mock import AsyncMock, MagicMock\n\n        pool = BlockingConnectionPool(max_connections=10, timeout=5)\n\n        # Mock the ensure_connection to avoid actual connection\n        mock_connection = MagicMock()\n        mock_connection.is_connected = False\n        mock_connection.disconnect = AsyncMock()\n\n        # Patch where the functions are used (in redis.asyncio.connection module)\n        with patch.object(pool, \"make_connection\", return_value=mock_connection):\n            with patch.object(pool, \"ensure_connection\", new_callable=AsyncMock):\n                with patch(\n                    \"redis.asyncio.connection.record_connection_create_time\",\n                    new_callable=AsyncMock,\n                ) as mock_create_time:\n                    with patch(\n                        \"redis.asyncio.connection.record_connection_wait_time\",\n                        new_callable=AsyncMock,\n                    ) as mock_wait_time:\n                        await pool.get_connection()\n\n                        # Verify record_connection_create_time was called\n                        mock_create_time.assert_called_once()\n                        create_kwargs = mock_create_time.call_args[1]\n                        assert create_kwargs[\"connection_pool\"] is pool\n                        assert create_kwargs[\"duration_seconds\"] > 0\n\n                        # Verify record_connection_wait_time was called\n                        mock_wait_time.assert_called_once()\n                        wait_kwargs = mock_wait_time.call_args[1]\n                        assert \"pool_name\" in wait_kwargs\n                        assert wait_kwargs[\"duration_seconds\"] > 0\n\n        await pool.disconnect()\n\n    async def test_blocking_connection_pool_records_only_wait_time_for_existing_connection(\n        self,\n    ):\n        \"\"\"Test that BlockingConnectionPool.get_connection() records only wait time when reusing an existing connection.\"\"\"\n        from unittest.mock import AsyncMock, MagicMock\n\n        pool = BlockingConnectionPool(max_connections=10, timeout=5)\n\n        # Add a mock connection to available connections\n        mock_connection = MagicMock()\n        mock_connection.disconnect = AsyncMock()\n        pool._available_connections.append(mock_connection)\n\n        with patch.object(pool, \"ensure_connection\", new_callable=AsyncMock):\n            with patch(\n                \"redis.asyncio.connection.record_connection_create_time\",\n                new_callable=AsyncMock,\n            ) as mock_create_time:\n                with patch(\n                    \"redis.asyncio.connection.record_connection_wait_time\",\n                    new_callable=AsyncMock,\n                ) as mock_wait_time:\n                    await pool.get_connection()\n\n                    # Verify record_connection_create_time was NOT called\n                    mock_create_time.assert_not_called()\n\n                    # Verify record_connection_wait_time was still called\n                    mock_wait_time.assert_called_once()\n                    wait_kwargs = mock_wait_time.call_args[1]\n                    assert \"pool_name\" in wait_kwargs\n                    assert wait_kwargs[\"duration_seconds\"] > 0\n\n        await pool.disconnect()\n\n    async def test_connection_disconnect_records_connection_closed_on_error(self):\n        \"\"\"Test that Connection.disconnect() records connection closed with ERROR reason when error is provided.\"\"\"\n\n        conn = Connection()\n        conn._writer = MagicMock()\n        conn._writer.close = MagicMock()\n        conn._writer.wait_closed = AsyncMock()\n        conn._reader = MagicMock()\n\n        with patch(\n            \"redis.asyncio.connection.record_connection_closed\",\n            new_callable=AsyncMock,\n        ) as mock_record:\n            error = ConnectionError(\"Connection lost\")\n            await conn.disconnect(error=error)\n\n            # Verify record_connection_closed was called with ERROR reason\n            mock_record.assert_called_once()\n            call_kwargs = mock_record.call_args[1]\n            assert call_kwargs[\"close_reason\"] == CloseReason.ERROR\n            assert call_kwargs[\"error_type\"] is error\n\n    async def test_connection_disconnect_records_connection_closed_on_healthcheck_failed(\n        self,\n    ):\n        \"\"\"Test that Connection.disconnect() records connection closed with HEALTHCHECK_FAILED reason.\"\"\"\n\n        conn = Connection()\n        conn._writer = MagicMock()\n        conn._writer.close = MagicMock()\n        conn._writer.wait_closed = AsyncMock()\n        conn._reader = MagicMock()\n\n        with patch(\n            \"redis.asyncio.connection.record_connection_closed\",\n            new_callable=AsyncMock,\n        ) as mock_record:\n            error = ConnectionError(\"Health check failed\")\n            await conn.disconnect(error=error, health_check_failed=True)\n\n            # Verify record_connection_closed was called with HEALTHCHECK_FAILED reason\n            mock_record.assert_called_once()\n            call_kwargs = mock_record.call_args[1]\n            assert call_kwargs[\"close_reason\"] == CloseReason.HEALTHCHECK_FAILED\n            assert call_kwargs[\"error_type\"] is error\n\n    async def test_connection_disconnect_records_connection_closed_on_application_close(\n        self,\n    ):\n        \"\"\"Test that Connection.disconnect() records connection closed with APPLICATION_CLOSE reason for normal close.\"\"\"\n\n        conn = Connection()\n        conn._writer = MagicMock()\n        conn._writer.close = MagicMock()\n        conn._writer.wait_closed = AsyncMock()\n        conn._reader = MagicMock()\n\n        with patch(\n            \"redis.asyncio.connection.record_connection_closed\",\n            new_callable=AsyncMock,\n        ) as mock_record:\n            await conn.disconnect()\n\n            # Verify record_connection_closed was called with APPLICATION_CLOSE reason\n            mock_record.assert_called_once()\n            call_kwargs = mock_record.call_args[1]\n            assert call_kwargs[\"close_reason\"] == CloseReason.APPLICATION_CLOSE\n"
  },
  {
    "path": "tests/test_asyncio/test_credentials.py",
    "content": "import functools\nimport random\nimport string\nfrom asyncio import Lock as AsyncLock\nfrom asyncio import sleep as async_sleep\nfrom typing import Optional, Tuple, Union\nfrom unittest.mock import AsyncMock, call\n\nimport pytest\nimport pytest_asyncio\nimport redis\nfrom redis import AuthenticationError, DataError, RedisError, ResponseError\nfrom redis.asyncio import Connection, ConnectionPool, Redis\nfrom redis.asyncio.retry import Retry\nfrom redis.auth.err import RequestTokenErr\nfrom redis.backoff import NoBackoff\nfrom redis.credentials import CredentialProvider, UsernamePasswordCredentialProvider\nfrom redis.exceptions import ConnectionError\nfrom redis.utils import str_if_bytes\nfrom tests.conftest import get_endpoint, skip_if_redis_enterprise\nfrom tests.entraid_utils import AuthType\nfrom tests.test_asyncio.conftest import get_credential_provider\n\ntry:\n    from redis_entraid.cred_provider import EntraIdCredentialsProvider\nexcept ImportError:\n    EntraIdCredentialsProvider = None\n\n\n@pytest.fixture()\ndef endpoint(request):\n    endpoint_name = request.config.getoption(\"--endpoint-name\")\n\n    try:\n        return get_endpoint(endpoint_name)\n    except FileNotFoundError as e:\n        pytest.skip(\n            f\"Skipping scenario test because endpoints file is missing: {str(e)}\"\n        )\n\n\n@pytest_asyncio.fixture()\nasync def r_credential(request, create_redis, endpoint):\n    credential_provider = request.param.get(\"cred_provider_class\", None)\n\n    if credential_provider is not None:\n        credential_provider = get_credential_provider(request)\n\n    kwargs = {\n        \"credential_provider\": credential_provider,\n    }\n\n    return await create_redis(url=endpoint, **kwargs)\n\n\n@pytest_asyncio.fixture()\nasync def r_acl_teardown(r: redis.Redis):\n    \"\"\"\n    A special fixture which removes the provided names from the database after use\n    \"\"\"\n    usernames = []\n\n    def factory(username):\n        usernames.append(username)\n        return r\n\n    yield factory\n    for username in usernames:\n        await r.acl_deluser(username)\n\n\n@pytest_asyncio.fixture()\nasync def r_required_pass_teardown(r: redis.Redis):\n    \"\"\"\n    A special fixture which removes the provided password from the database after use\n    \"\"\"\n    passwords = []\n\n    def factory(username):\n        passwords.append(username)\n        return r\n\n    yield factory\n    for password in passwords:\n        try:\n            await r.auth(password)\n        except (ResponseError, AuthenticationError):\n            await r.auth(\"default\", \"\")\n        await r.config_set(\"requirepass\", \"\")\n\n\nclass NoPassCredProvider(CredentialProvider):\n    def get_credentials(self) -> Union[Tuple[str], Tuple[str, str]]:\n        return \"username\", \"\"\n\n\nclass AsyncRandomAuthCredProvider(CredentialProvider):\n    def __init__(self, user: Optional[str], endpoint: str):\n        self.user = user\n        self.endpoint = endpoint\n\n    @functools.lru_cache(maxsize=10)\n    def get_credentials(self) -> Union[Tuple[str, str], Tuple[str]]:\n        def get_random_string(length):\n            letters = string.ascii_lowercase\n            result_str = \"\".join(random.choice(letters) for i in range(length))\n            return result_str\n\n        if self.user:\n            auth_token: str = get_random_string(5) + self.user + \"_\" + self.endpoint\n            return self.user, auth_token\n        else:\n            auth_token: str = get_random_string(5) + self.endpoint\n            return (auth_token,)\n\n\nasync def init_acl_user(r, username, password):\n    # reset the user\n    await r.acl_deluser(username)\n    if password:\n        assert (\n            await r.acl_setuser(\n                username,\n                enabled=True,\n                passwords=[\"+\" + password],\n                keys=\"~*\",\n                commands=[\n                    \"+ping\",\n                    \"+command\",\n                    \"+info\",\n                    \"+select\",\n                    \"+flushdb\",\n                    \"+cluster\",\n                ],\n            )\n            is True\n        )\n    else:\n        assert (\n            await r.acl_setuser(\n                username,\n                enabled=True,\n                keys=\"~*\",\n                commands=[\n                    \"+ping\",\n                    \"+command\",\n                    \"+info\",\n                    \"+select\",\n                    \"+flushdb\",\n                    \"+cluster\",\n                ],\n                nopass=True,\n            )\n            is True\n        )\n\n\nasync def init_required_pass(r, password):\n    await r.config_set(\"requirepass\", password)\n\n\n@pytest.mark.asyncio\nclass TestCredentialsProvider:\n    @skip_if_redis_enterprise()\n    async def test_only_pass_without_creds_provider(\n        self, r_required_pass_teardown, create_redis\n    ):\n        # test for default user (`username` is supposed to be optional)\n        password = \"password\"\n        r = r_required_pass_teardown(password)\n        await init_required_pass(r, password)\n        assert await r.auth(password) is True\n\n        r2 = await create_redis(flushdb=False, password=password)\n\n        assert await r2.ping() is True\n\n    @skip_if_redis_enterprise()\n    async def test_user_and_pass_without_creds_provider(\n        self, r_acl_teardown, create_redis\n    ):\n        \"\"\"\n        Test backward compatibility with username and password\n        \"\"\"\n        # test for other users\n        username = \"username\"\n        password = \"password\"\n        r = r_acl_teardown(username)\n        await init_acl_user(r, username, password)\n        r2 = await create_redis(flushdb=False, username=username, password=password)\n\n        assert await r2.ping() is True\n\n    @pytest.mark.parametrize(\"username\", [\"username\", None])\n    @skip_if_redis_enterprise()\n    @pytest.mark.onlynoncluster\n    async def test_credential_provider_with_supplier(\n        self, r_acl_teardown, r_required_pass_teardown, create_redis, username\n    ):\n        creds_provider = AsyncRandomAuthCredProvider(\n            user=username,\n            endpoint=\"localhost\",\n        )\n\n        auth_args = creds_provider.get_credentials()\n        password = auth_args[-1]\n\n        if username:\n            r = r_acl_teardown(username)\n            await init_acl_user(r, username, password)\n        else:\n            r = r_required_pass_teardown(password)\n            await init_required_pass(r, password)\n\n        r2 = await create_redis(flushdb=False, credential_provider=creds_provider)\n\n        assert await r2.ping() is True\n\n    async def test_async_credential_provider_no_password_success(\n        self, r_acl_teardown, create_redis\n    ):\n        username = \"username\"\n        r = r_acl_teardown(username)\n        await init_acl_user(r, username, \"\")\n        r2 = await create_redis(\n            flushdb=False,\n            credential_provider=NoPassCredProvider(),\n        )\n        assert await r2.ping() is True\n\n    @pytest.mark.onlynoncluster\n    async def test_credential_provider_no_password_error(\n        self, r_acl_teardown, create_redis\n    ):\n        username = \"username\"\n        r = r_acl_teardown(username)\n        await init_acl_user(r, username, \"password\")\n        with pytest.raises(AuthenticationError) as e:\n            await create_redis(\n                flushdb=False,\n                credential_provider=NoPassCredProvider(),\n                single_connection_client=True,\n            )\n        assert e.match(\"invalid username-password\")\n        assert await r.acl_deluser(username)\n\n    @pytest.mark.onlynoncluster\n    async def test_password_and_username_together_with_cred_provider_raise_error(\n        self, r_acl_teardown, create_redis\n    ):\n        username = \"username\"\n        r = r_acl_teardown(username)\n        await init_acl_user(r, username, \"password\")\n        cred_provider = UsernamePasswordCredentialProvider(\n            username=\"username\", password=\"password\"\n        )\n        with pytest.raises(DataError) as e:\n            await create_redis(\n                flushdb=False,\n                username=\"username\",\n                password=\"password\",\n                credential_provider=cred_provider,\n                single_connection_client=True,\n            )\n        assert e.match(\n            \"'username' and 'password' cannot be passed along with \"\n            \"'credential_provider'.\"\n        )\n\n    @pytest.mark.onlynoncluster\n    async def test_change_username_password_on_existing_connection(\n        self, r_acl_teardown, create_redis\n    ):\n        username = \"origin_username\"\n        password = \"origin_password\"\n        new_username = \"new_username\"\n        new_password = \"new_password\"\n        r = r_acl_teardown(username)\n        await init_acl_user(r, username, password)\n        r2 = await create_redis(flushdb=False, username=username, password=password)\n        assert await r2.ping() is True\n        conn = await r2.connection_pool.get_connection()\n        await conn.send_command(\"PING\")\n        assert str_if_bytes(await conn.read_response()) == \"PONG\"\n        assert conn.username == username\n        assert conn.password == password\n        await init_acl_user(r, new_username, new_password)\n        conn.password = new_password\n        conn.username = new_username\n        await conn.send_command(\"PING\")\n        assert str_if_bytes(await conn.read_response()) == \"PONG\"\n\n\n@pytest.mark.asyncio\nclass TestUsernamePasswordCredentialProvider:\n    async def test_user_pass_credential_provider_acl_user_and_pass(\n        self, r_acl_teardown, create_redis\n    ):\n        username = \"username\"\n        password = \"password\"\n        r = r_acl_teardown(username)\n        provider = UsernamePasswordCredentialProvider(username, password)\n        assert provider.username == username\n        assert provider.password == password\n        assert provider.get_credentials() == (username, password)\n        await init_acl_user(r, provider.username, provider.password)\n        r2 = await create_redis(flushdb=False, credential_provider=provider)\n        assert await r2.ping() is True\n\n    async def test_user_pass_provider_only_password(\n        self, r_required_pass_teardown, create_redis\n    ):\n        password = \"password\"\n        provider = UsernamePasswordCredentialProvider(password=password)\n        r = r_required_pass_teardown(password)\n        assert provider.username == \"\"\n        assert provider.password == password\n        assert provider.get_credentials() == (password,)\n\n        await init_required_pass(r, password)\n\n        r2 = await create_redis(flushdb=False, credential_provider=provider)\n        assert await r2.auth(provider.password) is True\n        assert await r2.ping() is True\n\n\n@pytest.mark.asyncio\n@pytest.mark.onlynoncluster\n@pytest.mark.skipif(not EntraIdCredentialsProvider, reason=\"requires redis-entraid\")\nclass TestStreamingCredentialProvider:\n    @pytest.mark.parametrize(\n        \"credential_provider\",\n        [\n            {\n                \"cred_provider_class\": EntraIdCredentialsProvider,\n                \"cred_provider_kwargs\": {\"expiration_refresh_ratio\": 0.00005},\n                \"mock_idp\": True,\n            }\n        ],\n        indirect=True,\n    )\n    async def test_async_re_auth_all_connections(self, credential_provider):\n        mock_connection = AsyncMock(spec=Connection)\n        mock_connection.retry = Retry(NoBackoff(), 0)\n        mock_another_connection = AsyncMock(spec=Connection)\n        mock_pool = AsyncMock(spec=ConnectionPool)\n        mock_pool.connection_kwargs = {\n            \"credential_provider\": credential_provider,\n        }\n        mock_pool.get_connection.return_value = mock_connection\n        mock_pool._available_connections = [mock_connection, mock_another_connection]\n        mock_pool._lock = AsyncLock()\n        auth_token = None\n\n        async def re_auth_callback(token):\n            nonlocal auth_token\n            auth_token = token\n            async with mock_pool._lock:\n                for conn in mock_pool._available_connections:\n                    await conn.send_command(\n                        \"AUTH\", token.try_get(\"oid\"), token.get_value()\n                    )\n                    await conn.read_response()\n\n        mock_pool.re_auth_callback = re_auth_callback\n\n        await Redis(\n            connection_pool=mock_pool,\n            credential_provider=credential_provider,\n        )\n\n        await credential_provider.get_credentials_async()\n        await async_sleep(0.5)\n\n        mock_connection.send_command.assert_has_calls(\n            [call(\"AUTH\", auth_token.try_get(\"oid\"), auth_token.get_value())]\n        )\n        mock_another_connection.send_command.assert_has_calls(\n            [call(\"AUTH\", auth_token.try_get(\"oid\"), auth_token.get_value())]\n        )\n\n    @pytest.mark.parametrize(\n        \"credential_provider\",\n        [\n            {\n                \"cred_provider_class\": EntraIdCredentialsProvider,\n                \"cred_provider_kwargs\": {\"expiration_refresh_ratio\": 0.00005},\n                \"mock_idp\": True,\n            }\n        ],\n        indirect=True,\n    )\n    async def test_async_re_auth_partial_connections(self, credential_provider):\n        mock_connection = AsyncMock(spec=Connection)\n        mock_connection.retry = Retry(NoBackoff(), 3)\n        mock_another_connection = AsyncMock(spec=Connection)\n        mock_another_connection.retry = Retry(NoBackoff(), 3)\n        mock_failed_connection = AsyncMock(spec=Connection)\n        mock_failed_connection.read_response.side_effect = ConnectionError(\n            \"Failed auth\"\n        )\n        mock_failed_connection.retry = Retry(NoBackoff(), 3)\n        mock_pool = AsyncMock(spec=ConnectionPool)\n        mock_pool.connection_kwargs = {\n            \"credential_provider\": credential_provider,\n        }\n        mock_pool.get_connection.return_value = mock_connection\n        mock_pool._available_connections = [\n            mock_connection,\n            mock_another_connection,\n            mock_failed_connection,\n        ]\n        mock_pool._lock = AsyncLock()\n\n        async def _raise(error: RedisError):\n            pass\n\n        async def re_auth_callback(token):\n            async with mock_pool._lock:\n                for conn in mock_pool._available_connections:\n                    await conn.retry.call_with_retry(\n                        lambda: conn.send_command(\n                            \"AUTH\", token.try_get(\"oid\"), token.get_value()\n                        ),\n                        lambda error: _raise(error),\n                    )\n                    await conn.retry.call_with_retry(\n                        lambda: conn.read_response(), lambda error: _raise(error)\n                    )\n\n        mock_pool.re_auth_callback = re_auth_callback\n\n        await Redis(\n            connection_pool=mock_pool,\n            credential_provider=credential_provider,\n        )\n\n        await credential_provider.get_credentials_async()\n        await async_sleep(0.5)\n\n        mock_connection.read_response.assert_has_calls([call()])\n        mock_another_connection.read_response.assert_has_calls([call()])\n        mock_failed_connection.read_response.assert_has_calls([call(), call(), call()])\n\n    @pytest.mark.parametrize(\n        \"credential_provider\",\n        [\n            {\n                \"cred_provider_class\": EntraIdCredentialsProvider,\n                \"cred_provider_kwargs\": {\"expiration_refresh_ratio\": 0.00005},\n                \"mock_idp\": True,\n            }\n        ],\n        indirect=True,\n    )\n    async def test_re_auth_pub_sub_in_resp3(self, credential_provider):\n        mock_pubsub_connection = AsyncMock(spec=Connection)\n        mock_pubsub_connection.get_protocol.return_value = 3\n        mock_pubsub_connection.credential_provider = credential_provider\n        mock_pubsub_connection.retry = Retry(NoBackoff(), 3)\n        mock_pubsub_connection.host = \"localhost\"\n        mock_pubsub_connection.port = 6379\n        mock_another_connection = AsyncMock(spec=Connection)\n        mock_another_connection.retry = Retry(NoBackoff(), 3)\n        mock_another_connection.host = \"localhost\"\n        mock_another_connection.port = 6379\n\n        mock_pool = AsyncMock(spec=ConnectionPool)\n        mock_pool.connection_kwargs = {\n            \"credential_provider\": credential_provider,\n        }\n\n        async def get_connection_side_effect():\n            if not hasattr(get_connection_side_effect, \"call_count\"):\n                get_connection_side_effect.call_count = 0\n            result = [mock_pubsub_connection, mock_another_connection][\n                get_connection_side_effect.call_count\n            ]\n            get_connection_side_effect.call_count += 1\n            return result\n\n        mock_pool.get_connection = AsyncMock(side_effect=get_connection_side_effect)\n        mock_pool._available_connections = [mock_another_connection]\n        mock_pool._lock = AsyncLock()\n        auth_token = None\n\n        async def re_auth_callback(token):\n            nonlocal auth_token\n            auth_token = token\n            async with mock_pool._lock:\n                for conn in mock_pool._available_connections:\n                    await conn.send_command(\n                        \"AUTH\", token.try_get(\"oid\"), token.get_value()\n                    )\n                    await conn.read_response()\n\n        mock_pool.re_auth_callback = re_auth_callback\n\n        r = Redis(\n            connection_pool=mock_pool,\n            credential_provider=credential_provider,\n        )\n        p = r.pubsub()\n        await p.subscribe(\"test\")\n        await credential_provider.get_credentials_async()\n        await async_sleep(0.5)\n\n        mock_pubsub_connection.send_command.assert_has_calls(\n            [\n                call(\"SUBSCRIBE\", \"test\", check_health=True),\n                call(\"AUTH\", auth_token.try_get(\"oid\"), auth_token.get_value()),\n            ]\n        )\n        mock_another_connection.send_command.assert_has_calls(\n            [call(\"AUTH\", auth_token.try_get(\"oid\"), auth_token.get_value())]\n        )\n\n    @pytest.mark.parametrize(\n        \"credential_provider\",\n        [\n            {\n                \"cred_provider_class\": EntraIdCredentialsProvider,\n                \"cred_provider_kwargs\": {\"expiration_refresh_ratio\": 0.00005},\n                \"mock_idp\": True,\n            }\n        ],\n        indirect=True,\n    )\n    async def test_do_not_re_auth_pub_sub_in_resp2(self, credential_provider):\n        mock_pubsub_connection = AsyncMock(spec=Connection)\n        mock_pubsub_connection.get_protocol.return_value = 2\n        mock_pubsub_connection.credential_provider = credential_provider\n        mock_pubsub_connection.retry = Retry(NoBackoff(), 3)\n        mock_pubsub_connection.host = \"localhost\"\n        mock_pubsub_connection.port = 6379\n        mock_another_connection = AsyncMock(spec=Connection)\n        mock_another_connection.retry = Retry(NoBackoff(), 3)\n        mock_another_connection.host = \"localhost\"\n        mock_another_connection.port = 6379\n\n        mock_pool = AsyncMock(spec=ConnectionPool)\n        mock_pool.connection_kwargs = {\n            \"credential_provider\": credential_provider,\n        }\n\n        async def get_connection_side_effect():\n            if not hasattr(get_connection_side_effect, \"call_count\"):\n                get_connection_side_effect.call_count = 0\n            result = [mock_pubsub_connection, mock_another_connection][\n                get_connection_side_effect.call_count\n            ]\n            get_connection_side_effect.call_count += 1\n            return result\n\n        mock_pool.get_connection = AsyncMock(side_effect=get_connection_side_effect)\n        mock_pool._available_connections = [mock_another_connection]\n        mock_pool._lock = AsyncLock()\n        auth_token = None\n\n        async def re_auth_callback(token):\n            nonlocal auth_token\n            auth_token = token\n            async with mock_pool._lock:\n                for conn in mock_pool._available_connections:\n                    await conn.send_command(\n                        \"AUTH\", token.try_get(\"oid\"), token.get_value()\n                    )\n                    await conn.read_response()\n\n        mock_pool.re_auth_callback = re_auth_callback\n\n        r = Redis(\n            connection_pool=mock_pool,\n            credential_provider=credential_provider,\n        )\n        p = r.pubsub()\n        await p.subscribe(\"test\")\n        await credential_provider.get_credentials_async()\n        await async_sleep(0.5)\n\n        mock_pubsub_connection.send_command.assert_has_calls(\n            [\n                call(\"SUBSCRIBE\", \"test\", check_health=True),\n            ]\n        )\n        mock_another_connection.send_command.assert_has_calls(\n            [call(\"AUTH\", auth_token.try_get(\"oid\"), auth_token.get_value())]\n        )\n\n    @pytest.mark.parametrize(\n        \"credential_provider\",\n        [\n            {\n                \"cred_provider_class\": EntraIdCredentialsProvider,\n                \"cred_provider_kwargs\": {\"expiration_refresh_ratio\": 0.00005},\n                \"mock_idp\": True,\n            }\n        ],\n        indirect=True,\n    )\n    async def test_fails_on_token_renewal(self, credential_provider):\n        credential_provider._token_mgr._idp.request_token.side_effect = [\n            RequestTokenErr,\n            RequestTokenErr,\n            RequestTokenErr,\n            RequestTokenErr,\n        ]\n        mock_connection = AsyncMock(spec=Connection)\n        mock_connection.retry = Retry(NoBackoff(), 0)\n        mock_another_connection = AsyncMock(spec=Connection)\n        mock_pool = AsyncMock(spec=ConnectionPool)\n        mock_pool.connection_kwargs = {\n            \"credential_provider\": credential_provider,\n        }\n        mock_pool.get_connection.return_value = mock_connection\n        mock_pool._available_connections = [mock_connection, mock_another_connection]\n\n        await Redis(\n            connection_pool=mock_pool,\n            credential_provider=credential_provider,\n        )\n\n        with pytest.raises(RequestTokenErr):\n            await credential_provider.get_credentials()\n\n\n@pytest.mark.asyncio\n@pytest.mark.onlynoncluster\n@pytest.mark.cp_integration\n@pytest.mark.skipif(not EntraIdCredentialsProvider, reason=\"requires redis-entraid\")\nclass TestEntraIdCredentialsProvider:\n    @pytest.mark.parametrize(\n        \"r_credential\",\n        [\n            {\n                \"cred_provider_class\": EntraIdCredentialsProvider,\n            },\n            {\n                \"cred_provider_class\": EntraIdCredentialsProvider,\n                \"cred_provider_kwargs\": {\"block_for_initial\": True},\n            },\n            {\n                \"cred_provider_class\": EntraIdCredentialsProvider,\n                \"idp_kwargs\": {\"auth_type\": AuthType.DEFAULT_AZURE_CREDENTIAL},\n            },\n        ],\n        ids=[\"blocked\", \"non-blocked\", \"DefaultAzureCredential\"],\n        indirect=True,\n    )\n    @pytest.mark.asyncio\n    @pytest.mark.onlynoncluster\n    @pytest.mark.cp_integration\n    async def test_async_auth_pool_with_credential_provider(self, r_credential: Redis):\n        assert await r_credential.ping() is True\n\n    @pytest.mark.parametrize(\n        \"r_credential\",\n        [\n            {\n                \"cred_provider_class\": EntraIdCredentialsProvider,\n            },\n            {\n                \"cred_provider_class\": EntraIdCredentialsProvider,\n                \"cred_provider_kwargs\": {\"block_for_initial\": True},\n            },\n        ],\n        ids=[\"blocked\", \"non-blocked\"],\n        indirect=True,\n    )\n    @pytest.mark.asyncio\n    @pytest.mark.onlynoncluster\n    @pytest.mark.cp_integration\n    async def test_async_pipeline_with_credential_provider(self, r_credential: Redis):\n        pipe = r_credential.pipeline()\n\n        await pipe.set(\"key\", \"value\")\n        await pipe.get(\"key\")\n\n        assert await pipe.execute() == [True, b\"value\"]\n\n    @pytest.mark.parametrize(\n        \"r_credential\",\n        [\n            {\n                \"cred_provider_class\": EntraIdCredentialsProvider,\n            },\n        ],\n        indirect=True,\n    )\n    @pytest.mark.asyncio\n    @pytest.mark.onlynoncluster\n    @pytest.mark.cp_integration\n    async def test_async_auth_pubsub_with_credential_provider(\n        self, r_credential: Redis\n    ):\n        p = r_credential.pubsub()\n        await p.subscribe(\"entraid\")\n\n        await r_credential.publish(\"entraid\", \"test\")\n        await r_credential.publish(\"entraid\", \"test\")\n\n        msg1 = await p.get_message()\n\n        assert msg1[\"type\"] == \"subscribe\"\n\n\n@pytest.mark.asyncio\n@pytest.mark.onlycluster\n@pytest.mark.cp_integration\n@pytest.mark.skipif(not EntraIdCredentialsProvider, reason=\"requires redis-entraid\")\nclass TestClusterEntraIdCredentialsProvider:\n    @pytest.mark.parametrize(\n        \"r_credential\",\n        [\n            {\n                \"cred_provider_class\": EntraIdCredentialsProvider,\n            },\n            {\n                \"cred_provider_class\": EntraIdCredentialsProvider,\n                \"cred_provider_kwargs\": {\"block_for_initial\": True},\n            },\n            {\n                \"cred_provider_class\": EntraIdCredentialsProvider,\n                \"idp_kwargs\": {\"auth_type\": AuthType.DEFAULT_AZURE_CREDENTIAL},\n            },\n        ],\n        ids=[\"blocked\", \"non-blocked\", \"DefaultAzureCredential\"],\n        indirect=True,\n    )\n    @pytest.mark.asyncio\n    @pytest.mark.onlycluster\n    @pytest.mark.cp_integration\n    async def test_async_auth_pool_with_credential_provider(self, r_credential: Redis):\n        assert await r_credential.ping() is True\n"
  },
  {
    "path": "tests/test_asyncio/test_cwe_404.py",
    "content": "import asyncio\nimport contextlib\n\nimport pytest\nfrom redis.asyncio import Redis\nfrom redis.asyncio.cluster import RedisCluster\nfrom redis.asyncio.connection import async_timeout\n\n\nclass DelayProxy:\n    def __init__(self, addr, redis_addr, delay: float = 0.0):\n        self.addr = addr\n        self.redis_addr = redis_addr\n        self.delay = delay\n        self.send_event = asyncio.Event()\n        self.server = None\n        self.task = None\n        self.cond = asyncio.Condition()\n        self.running = 0\n\n    async def __aenter__(self):\n        await self.start()\n        return self\n\n    async def __aexit__(self, *args):\n        await self.stop()\n\n    async def start(self):\n        # test that we can connect to redis\n        async with async_timeout(2):\n            _, redis_writer = await asyncio.open_connection(*self.redis_addr)\n        redis_writer.close()\n        self.server = await asyncio.start_server(\n            self.handle, *self.addr, reuse_address=True\n        )\n        self.task = asyncio.create_task(self.server.serve_forever())\n\n    @contextlib.contextmanager\n    def set_delay(self, delay: float = 0.0):\n        \"\"\"\n        Allow to override the delay for parts of tests which aren't time dependent,\n        to speed up execution.\n        \"\"\"\n        old_delay = self.delay\n        self.delay = delay\n        try:\n            yield\n        finally:\n            self.delay = old_delay\n\n    async def handle(self, reader, writer):\n        # establish connection to redis\n        redis_reader, redis_writer = await asyncio.open_connection(*self.redis_addr)\n        pipe1 = asyncio.create_task(\n            self.pipe(reader, redis_writer, \"to redis:\", self.send_event)\n        )\n        pipe2 = asyncio.create_task(self.pipe(redis_reader, writer, \"from redis:\"))\n        await asyncio.gather(pipe1, pipe2)\n\n    async def stop(self):\n        # shutdown the server\n        self.task.cancel()\n        try:\n            await self.task\n        except asyncio.CancelledError:\n            pass\n        await self.server.wait_closed()\n        # Server does not wait for all spawned tasks.  We must do that also to ensure\n        # that all sockets are closed.\n        async with self.cond:\n            await self.cond.wait_for(lambda: self.running == 0)\n\n    async def pipe(\n        self,\n        reader: asyncio.StreamReader,\n        writer: asyncio.StreamWriter,\n        name=\"\",\n        event: asyncio.Event = None,\n    ):\n        self.running += 1\n        try:\n            while True:\n                data = await reader.read(1000)\n                if not data:\n                    break\n                # print(f\"{name} read {len(data)} delay {self.delay}\")\n                if event:\n                    event.set()\n                await asyncio.sleep(self.delay)\n                writer.write(data)\n                await writer.drain()\n        finally:\n            try:\n                writer.close()\n                await writer.wait_closed()\n            except RuntimeError:\n                # ignore errors on close pertaining to no event loop. Don't want\n                # to clutter the test output with errors if being garbage collected\n                pass\n            async with self.cond:\n                self.running -= 1\n                if self.running == 0:\n                    self.cond.notify_all()\n\n\n@pytest.mark.onlynoncluster\n@pytest.mark.parametrize(\"delay\", argvalues=[0.05, 0.5, 1, 2])\nasync def test_standalone(delay, master_host):\n    # create a tcp socket proxy that relays data to Redis and back,\n    # inserting 0.1 seconds of delay\n    async with DelayProxy(addr=(\"127.0.0.1\", 5380), redis_addr=master_host) as dp:\n        for b in [True, False]:\n            # note that we connect to proxy, rather than to Redis directly\n            async with Redis(\n                host=\"127.0.0.1\", port=5380, single_connection_client=b\n            ) as r:\n                await r.set(\"foo\", \"foo\")\n                await r.set(\"bar\", \"bar\")\n\n                async def op(r):\n                    with dp.set_delay(delay * 2):\n                        return await r.get(\n                            \"foo\"\n                        )  # <-- this is the operation we want to cancel\n\n                dp.send_event.clear()\n                t = asyncio.create_task(op(r))\n                # Wait until the task has sent, and then some, to make sure it has\n                # settled on the read.\n                await dp.send_event.wait()\n                await asyncio.sleep(0.01)  # a little extra time for prudence\n                t.cancel()\n                with pytest.raises(asyncio.CancelledError):\n                    await t\n\n                # make sure that our previous request, cancelled while waiting for\n                # a repsponse, didn't leave the connection open andin a bad state\n                assert await r.get(\"bar\") == b\"bar\"\n                assert await r.ping()\n                assert await r.get(\"foo\") == b\"foo\"\n\n\n@pytest.mark.onlynoncluster\n@pytest.mark.parametrize(\"delay\", argvalues=[0.05, 0.5, 1, 2])\nasync def test_standalone_pipeline(delay, master_host):\n    async with DelayProxy(addr=(\"127.0.0.1\", 5380), redis_addr=master_host) as dp:\n        for b in [True, False]:\n            async with Redis(\n                host=\"127.0.0.1\", port=5380, single_connection_client=b\n            ) as r:\n                await r.set(\"foo\", \"foo\")\n                await r.set(\"bar\", \"bar\")\n\n                pipe = r.pipeline()\n\n                pipe2 = r.pipeline()\n                pipe2.get(\"bar\")\n                pipe2.ping()\n                pipe2.get(\"foo\")\n\n                async def op(pipe):\n                    with dp.set_delay(delay * 2):\n                        return await pipe.get(\n                            \"foo\"\n                        ).execute()  # <-- this is the operation we want to cancel\n\n                dp.send_event.clear()\n                t = asyncio.create_task(op(pipe))\n                # wait until task has settled on the read\n                await dp.send_event.wait()\n                await asyncio.sleep(0.01)\n                t.cancel()\n                with pytest.raises(asyncio.CancelledError):\n                    await t\n\n                # we have now cancelled the pieline in the middle of a request,\n                # make sure that the connection is still usable\n                pipe.get(\"bar\")\n                pipe.ping()\n                pipe.get(\"foo\")\n                await pipe.reset()\n\n                # check that the pipeline is empty after reset\n                assert await pipe.execute() == []\n\n                # validating that the pipeline can be used as it could previously\n                pipe.get(\"bar\")\n                pipe.ping()\n                pipe.get(\"foo\")\n                assert await pipe.execute() == [b\"bar\", True, b\"foo\"]\n                assert await pipe2.execute() == [b\"bar\", True, b\"foo\"]\n\n\n@pytest.mark.onlycluster\nasync def test_cluster(master_host):\n    delay = 0.1\n    cluster_port = 16379\n    remap_base = 7372\n    n_nodes = 6\n    hostname, _ = master_host\n\n    def remap(address):\n        host, port = address\n        return host, remap_base + port - cluster_port\n\n    proxies = []\n    for i in range(n_nodes):\n        port = cluster_port + i\n        remapped = remap_base + i\n        forward_addr = hostname, port\n        proxy = DelayProxy(addr=(hostname, remapped), redis_addr=forward_addr)\n        proxies.append(proxy)\n\n    def all_clear():\n        for p in proxies:\n            p.send_event.clear()\n\n    async def wait_for_send():\n        await asyncio.wait(\n            [asyncio.Task(p.send_event.wait()) for p in proxies],\n            return_when=asyncio.FIRST_COMPLETED,\n        )\n\n    @contextlib.contextmanager\n    def set_delay(delay: float):\n        with contextlib.ExitStack() as stack:\n            for p in proxies:\n                stack.enter_context(p.set_delay(delay))\n            yield\n\n    async with contextlib.AsyncExitStack() as stack:\n        for p in proxies:\n            await stack.enter_async_context(p)\n\n        r = RedisCluster.from_url(\n            f\"redis://{hostname}:{remap_base}\", address_remap=remap\n        )\n        try:\n            await r.initialize()\n            await r.set(\"foo\", \"foo\")\n            await r.set(\"bar\", \"bar\")\n\n            async def op(r):\n                with set_delay(delay):\n                    return await r.get(\"foo\")\n\n            all_clear()\n            t = asyncio.create_task(op(r))\n            # Wait for whichever DelayProxy gets the request first\n            await wait_for_send()\n            await asyncio.sleep(0.01)\n            t.cancel()\n            with pytest.raises(asyncio.CancelledError):\n                await t\n\n            # try a number of requests to exercise all the connections\n            async def doit():\n                assert await r.get(\"bar\") == b\"bar\"\n                assert await r.ping()\n                assert await r.get(\"foo\") == b\"foo\"\n\n            await asyncio.gather(*[doit() for _ in range(10)])\n        finally:\n            await r.aclose()\n"
  },
  {
    "path": "tests/test_asyncio/test_encoding.py",
    "content": "import pytest\nimport pytest_asyncio\nimport redis.asyncio as redis\nfrom redis.exceptions import DataError\n\n\n@pytest.mark.onlynoncluster\nclass TestEncoding:\n    @pytest_asyncio.fixture()\n    async def r(self, create_redis):\n        redis = await create_redis(decode_responses=True)\n        yield redis\n        await redis.flushall()\n\n    @pytest_asyncio.fixture()\n    async def r_no_decode(self, create_redis):\n        redis = await create_redis(decode_responses=False)\n        yield redis\n        await redis.flushall()\n\n    async def test_simple_encoding(self, r_no_decode: redis.Redis):\n        unicode_string = chr(3456) + \"abcd\" + chr(3421)\n        await r_no_decode.set(\"unicode-string\", unicode_string.encode(\"utf-8\"))\n        cached_val = await r_no_decode.get(\"unicode-string\")\n        assert isinstance(cached_val, bytes)\n        assert unicode_string == cached_val.decode(\"utf-8\")\n\n    async def test_simple_encoding_and_decoding(self, r: redis.Redis):\n        unicode_string = chr(3456) + \"abcd\" + chr(3421)\n        await r.set(\"unicode-string\", unicode_string)\n        cached_val = await r.get(\"unicode-string\")\n        assert isinstance(cached_val, str)\n        assert unicode_string == cached_val\n\n    async def test_memoryview_encoding(self, r_no_decode: redis.Redis):\n        unicode_string = chr(3456) + \"abcd\" + chr(3421)\n        unicode_string_view = memoryview(unicode_string.encode(\"utf-8\"))\n        await r_no_decode.set(\"unicode-string-memoryview\", unicode_string_view)\n        cached_val = await r_no_decode.get(\"unicode-string-memoryview\")\n        # The cached value won't be a memoryview because it's a copy from Redis\n        assert isinstance(cached_val, bytes)\n        assert unicode_string == cached_val.decode(\"utf-8\")\n\n    async def test_memoryview_encoding_and_decoding(self, r: redis.Redis):\n        unicode_string = chr(3456) + \"abcd\" + chr(3421)\n        unicode_string_view = memoryview(unicode_string.encode(\"utf-8\"))\n        await r.set(\"unicode-string-memoryview\", unicode_string_view)\n        cached_val = await r.get(\"unicode-string-memoryview\")\n        assert isinstance(cached_val, str)\n        assert unicode_string == cached_val\n\n    async def test_list_encoding(self, r: redis.Redis):\n        unicode_string = chr(3456) + \"abcd\" + chr(3421)\n        result = [unicode_string, unicode_string, unicode_string]\n        await r.rpush(\"a\", *result)\n        assert await r.lrange(\"a\", 0, -1) == result\n\n\n@pytest.mark.onlynoncluster\nclass TestEncodingErrors:\n    async def test_ignore(self, create_redis):\n        r = await create_redis(decode_responses=True, encoding_errors=\"ignore\")\n        await r.set(\"a\", b\"foo\\xff\")\n        assert await r.get(\"a\") == \"foo\"\n\n    async def test_replace(self, create_redis):\n        r = await create_redis(decode_responses=True, encoding_errors=\"replace\")\n        await r.set(\"a\", b\"foo\\xff\")\n        assert await r.get(\"a\") == \"foo\\ufffd\"\n\n\n@pytest.mark.onlynoncluster\nclass TestMemoryviewsAreNotPacked:\n    async def test_memoryviews_are_not_packed(self, r):\n        arg = memoryview(b\"some_arg\")\n        arg_list = [\"SOME_COMMAND\", arg]\n        c = r.connection or await r.connection_pool.get_connection()\n        cmd = c.pack_command(*arg_list)\n        assert cmd[1] is arg\n        cmds = c.pack_commands([arg_list, arg_list])\n        assert cmds[1] is arg\n        assert cmds[3] is arg\n\n\nclass TestCommandsAreNotEncoded:\n    @pytest_asyncio.fixture()\n    async def r(self, create_redis):\n        redis = await create_redis(encoding=\"utf-16\")\n        yield redis\n        await redis.flushall()\n\n    @pytest.mark.xfail\n    async def test_basic_command(self, r: redis.Redis):\n        await r.set(\"hello\", \"world\")\n\n\nclass TestInvalidUserInput:\n    async def test_boolean_fails(self, r: redis.Redis):\n        with pytest.raises(DataError):\n            await r.set(\"a\", True)  # type: ignore\n\n    async def test_none_fails(self, r: redis.Redis):\n        with pytest.raises(DataError):\n            await r.set(\"a\", None)  # type: ignore\n\n    async def test_user_type_fails(self, r: redis.Redis):\n        class Foo:\n            def __str__(self):\n                return \"Foo\"\n\n        with pytest.raises(DataError):\n            await r.set(\"a\", Foo())  # type: ignore\n"
  },
  {
    "path": "tests/test_asyncio/test_hash.py",
    "content": "import asyncio\nimport math\nfrom datetime import datetime, timedelta\n\nimport pytest\n\nfrom redis import exceptions\nfrom redis.commands.core import HashDataPersistOptions\nfrom tests.conftest import skip_if_server_version_lt\nfrom tests.test_asyncio.test_utils import redis_server_time\n\n\n@skip_if_server_version_lt(\"7.3.240\")\nasync def test_hexpire_basic(r):\n    await r.delete(\"test:hash\")\n    await r.hset(\"test:hash\", mapping={\"field1\": \"value1\", \"field2\": \"value2\"})\n    assert await r.hexpire(\"test:hash\", 1, \"field1\") == [1]\n    await asyncio.sleep(1.1)\n    assert await r.hexists(\"test:hash\", \"field1\") is False\n    assert await r.hexists(\"test:hash\", \"field2\") is True\n\n\n@skip_if_server_version_lt(\"7.3.240\")\nasync def test_hexpire_with_timedelta(r):\n    await r.delete(\"test:hash\")\n    await r.hset(\"test:hash\", mapping={\"field1\": \"value1\", \"field2\": \"value2\"})\n    assert await r.hexpire(\"test:hash\", timedelta(seconds=1), \"field1\") == [1]\n    await asyncio.sleep(1.1)\n    assert await r.hexists(\"test:hash\", \"field1\") is False\n    assert await r.hexists(\"test:hash\", \"field2\") is True\n\n\n@skip_if_server_version_lt(\"7.3.240\")\nasync def test_hexpire_conditions(r):\n    await r.delete(\"test:hash\")\n    await r.hset(\"test:hash\", mapping={\"field1\": \"value1\"})\n    assert await r.hexpire(\"test:hash\", 2, \"field1\", xx=True) == [0]\n    assert await r.hexpire(\"test:hash\", 2, \"field1\", nx=True) == [1]\n    assert await r.hexpire(\"test:hash\", 1, \"field1\", xx=True) == [1]\n    assert await r.hexpire(\"test:hash\", 2, \"field1\", nx=True) == [0]\n    await asyncio.sleep(1.1)\n    assert await r.hexists(\"test:hash\", \"field1\") is False\n    await r.hset(\"test:hash\", \"field1\", \"value1\")\n    await r.hexpire(\"test:hash\", 2, \"field1\")\n    assert await r.hexpire(\"test:hash\", 1, \"field1\", gt=True) == [0]\n    assert await r.hexpire(\"test:hash\", 1, \"field1\", lt=True) == [1]\n    await asyncio.sleep(1.1)\n    assert await r.hexists(\"test:hash\", \"field1\") is False\n\n\n@skip_if_server_version_lt(\"7.3.240\")\nasync def test_hexpire_nonexistent_key_or_field(r):\n    await r.delete(\"test:hash\")\n    assert await r.hexpire(\"test:hash\", 1, \"field1\") == [-2]\n    await r.hset(\"test:hash\", \"field1\", \"value1\")\n    assert await r.hexpire(\"test:hash\", 1, \"nonexistent_field\") == [-2]\n\n\n@skip_if_server_version_lt(\"7.3.240\")\nasync def test_hexpire_multiple_fields(r):\n    await r.delete(\"test:hash\")\n    await r.hset(\n        \"test:hash\",\n        mapping={\"field1\": \"value1\", \"field2\": \"value2\", \"field3\": \"value3\"},\n    )\n    assert await r.hexpire(\"test:hash\", 1, \"field1\", \"field2\") == [1, 1]\n    await asyncio.sleep(1.1)\n    assert await r.hexists(\"test:hash\", \"field1\") is False\n    assert await r.hexists(\"test:hash\", \"field2\") is False\n    assert await r.hexists(\"test:hash\", \"field3\") is True\n\n\n@skip_if_server_version_lt(\"7.3.240\")\nasync def test_hpexpire_basic(r):\n    await r.delete(\"test:hash\")\n    await r.hset(\"test:hash\", mapping={\"field1\": \"value1\", \"field2\": \"value2\"})\n    assert await r.hpexpire(\"test:hash\", 500, \"field1\") == [1]\n    await asyncio.sleep(0.6)\n    assert await r.hexists(\"test:hash\", \"field1\") is False\n    assert await r.hexists(\"test:hash\", \"field2\") is True\n\n\n@skip_if_server_version_lt(\"7.3.240\")\nasync def test_hpexpire_with_timedelta(r):\n    await r.delete(\"test:hash\")\n    await r.hset(\"test:hash\", mapping={\"field1\": \"value1\", \"field2\": \"value2\"})\n    assert await r.hpexpire(\"test:hash\", timedelta(milliseconds=500), \"field1\") == [1]\n    await asyncio.sleep(0.6)\n    assert await r.hexists(\"test:hash\", \"field1\") is False\n    assert await r.hexists(\"test:hash\", \"field2\") is True\n\n\n@skip_if_server_version_lt(\"7.3.240\")\nasync def test_hpexpire_conditions(r):\n    await r.delete(\"test:hash\")\n    await r.hset(\"test:hash\", mapping={\"field1\": \"value1\"})\n    assert await r.hpexpire(\"test:hash\", 1500, \"field1\", xx=True) == [0]\n    assert await r.hpexpire(\"test:hash\", 1500, \"field1\", nx=True) == [1]\n    assert await r.hpexpire(\"test:hash\", 500, \"field1\", xx=True) == [1]\n    assert await r.hpexpire(\"test:hash\", 1500, \"field1\", nx=True) == [0]\n    await asyncio.sleep(0.6)\n    assert await r.hexists(\"test:hash\", \"field1\") is False\n    await r.hset(\"test:hash\", \"field1\", \"value1\")\n    await r.hpexpire(\"test:hash\", 1000, \"field1\")\n    assert await r.hpexpire(\"test:hash\", 500, \"field1\", gt=True) == [0]\n    assert await r.hpexpire(\"test:hash\", 500, \"field1\", lt=True) == [1]\n    await asyncio.sleep(0.6)\n    assert await r.hexists(\"test:hash\", \"field1\") is False\n\n\n@skip_if_server_version_lt(\"7.3.240\")\nasync def test_hpexpire_nonexistent_key_or_field(r):\n    await r.delete(\"test:hash\")\n    assert await r.hpexpire(\"test:hash\", 500, \"field1\") == [-2]\n    await r.hset(\"test:hash\", \"field1\", \"value1\")\n    assert await r.hpexpire(\"test:hash\", 500, \"nonexistent_field\") == [-2]\n\n\n@skip_if_server_version_lt(\"7.3.240\")\nasync def test_hpexpire_multiple_fields(r):\n    await r.delete(\"test:hash\")\n    await r.hset(\n        \"test:hash\",\n        mapping={\"field1\": \"value1\", \"field2\": \"value2\", \"field3\": \"value3\"},\n    )\n    assert await r.hpexpire(\"test:hash\", 500, \"field1\", \"field2\") == [1, 1]\n    await asyncio.sleep(0.6)\n    assert await r.hexists(\"test:hash\", \"field1\") is False\n    assert await r.hexists(\"test:hash\", \"field2\") is False\n    assert await r.hexists(\"test:hash\", \"field3\") is True\n\n\n@skip_if_server_version_lt(\"7.3.240\")\nasync def test_hexpireat_basic(r):\n    await r.delete(\"test:hash\")\n    await r.hset(\"test:hash\", mapping={\"field1\": \"value1\", \"field2\": \"value2\"})\n    exp_time = math.ceil((datetime.now() + timedelta(seconds=1)).timestamp())\n    assert await r.hexpireat(\"test:hash\", exp_time, \"field1\") == [1]\n    await asyncio.sleep(2.1)\n    assert await r.hexists(\"test:hash\", \"field1\") is False\n    assert await r.hexists(\"test:hash\", \"field2\") is True\n\n\n@skip_if_server_version_lt(\"7.3.240\")\nasync def test_hexpireat_with_datetime(r):\n    await r.delete(\"test:hash\")\n    await r.hset(\"test:hash\", mapping={\"field1\": \"value1\", \"field2\": \"value2\"})\n    exp_time = (datetime.now() + timedelta(seconds=2)).replace(microsecond=0)\n    assert await r.hexpireat(\"test:hash\", exp_time, \"field1\") == [1]\n    await asyncio.sleep(2.1)\n    assert await r.hexists(\"test:hash\", \"field1\") is False\n    assert await r.hexists(\"test:hash\", \"field2\") is True\n\n\n@skip_if_server_version_lt(\"7.3.240\")\nasync def test_hexpireat_conditions(r):\n    await r.delete(\"test:hash\")\n    await r.hset(\"test:hash\", mapping={\"field1\": \"value1\"})\n    future_exp_time = int((datetime.now() + timedelta(seconds=2)).timestamp())\n    past_exp_time = int((datetime.now() - timedelta(seconds=1)).timestamp())\n    assert await r.hexpireat(\"test:hash\", future_exp_time, \"field1\", xx=True) == [0]\n    assert await r.hexpireat(\"test:hash\", future_exp_time, \"field1\", nx=True) == [1]\n    assert await r.hexpireat(\"test:hash\", past_exp_time, \"field1\", gt=True) == [0]\n    assert await r.hexpireat(\"test:hash\", past_exp_time, \"field1\", lt=True) == [2]\n    assert await r.hexists(\"test:hash\", \"field1\") is False\n\n\n@skip_if_server_version_lt(\"7.3.240\")\nasync def test_hexpireat_nonexistent_key_or_field(r):\n    await r.delete(\"test:hash\")\n    future_exp_time = int((datetime.now() + timedelta(seconds=1)).timestamp())\n    assert await r.hexpireat(\"test:hash\", future_exp_time, \"field1\") == [-2]\n    await r.hset(\"test:hash\", \"field1\", \"value1\")\n    assert await r.hexpireat(\"test:hash\", future_exp_time, \"nonexistent_field\") == [-2]\n\n\n@skip_if_server_version_lt(\"7.3.240\")\nasync def test_hexpireat_multiple_fields(r):\n    await r.delete(\"test:hash\")\n    await r.hset(\n        \"test:hash\",\n        mapping={\"field1\": \"value1\", \"field2\": \"value2\", \"field3\": \"value3\"},\n    )\n    exp_time = math.ceil((datetime.now() + timedelta(seconds=1)).timestamp())\n    assert await r.hexpireat(\"test:hash\", exp_time, \"field1\", \"field2\") == [1, 1]\n    await asyncio.sleep(2.1)\n    assert await r.hexists(\"test:hash\", \"field1\") is False\n    assert await r.hexists(\"test:hash\", \"field2\") is False\n    assert await r.hexists(\"test:hash\", \"field3\") is True\n\n\n@skip_if_server_version_lt(\"7.3.240\")\nasync def test_hpexpireat_basic(r):\n    await r.delete(\"test:hash\")\n    await r.hset(\"test:hash\", mapping={\"field1\": \"value1\", \"field2\": \"value2\"})\n    exp_time = int((datetime.now() + timedelta(milliseconds=400)).timestamp() * 1000)\n    assert await r.hpexpireat(\"test:hash\", exp_time, \"field1\") == [1]\n    await asyncio.sleep(0.5)\n    assert await r.hexists(\"test:hash\", \"field1\") is False\n    assert await r.hexists(\"test:hash\", \"field2\") is True\n\n\n@skip_if_server_version_lt(\"7.3.240\")\nasync def test_hpexpireat_with_datetime(r):\n    await r.delete(\"test:hash\")\n    await r.hset(\"test:hash\", mapping={\"field1\": \"value1\", \"field2\": \"value2\"})\n    exp_time = datetime.now() + timedelta(milliseconds=400)\n    assert await r.hpexpireat(\"test:hash\", exp_time, \"field1\") == [1]\n    await asyncio.sleep(0.5)\n    assert await r.hexists(\"test:hash\", \"field1\") is False\n    assert await r.hexists(\"test:hash\", \"field2\") is True\n\n\n@skip_if_server_version_lt(\"7.3.240\")\nasync def test_hpexpireat_conditions(r):\n    await r.delete(\"test:hash\")\n    await r.hset(\"test:hash\", mapping={\"field1\": \"value1\"})\n    future_exp_time = int(\n        (datetime.now() + timedelta(milliseconds=500)).timestamp() * 1000\n    )\n    past_exp_time = int(\n        (datetime.now() - timedelta(milliseconds=500)).timestamp() * 1000\n    )\n    assert await r.hpexpireat(\"test:hash\", future_exp_time, \"field1\", xx=True) == [0]\n    assert await r.hpexpireat(\"test:hash\", future_exp_time, \"field1\", nx=True) == [1]\n    assert await r.hpexpireat(\"test:hash\", past_exp_time, \"field1\", gt=True) == [0]\n    assert await r.hpexpireat(\"test:hash\", past_exp_time, \"field1\", lt=True) == [2]\n    assert await r.hexists(\"test:hash\", \"field1\") is False\n\n\n@skip_if_server_version_lt(\"7.3.240\")\nasync def test_hpexpireat_nonexistent_key_or_field(r):\n    await r.delete(\"test:hash\")\n    future_exp_time = int(\n        (datetime.now() + timedelta(milliseconds=500)).timestamp() * 1000\n    )\n    assert await r.hpexpireat(\"test:hash\", future_exp_time, \"field1\") == [-2]\n    await r.hset(\"test:hash\", \"field1\", \"value1\")\n    assert await r.hpexpireat(\"test:hash\", future_exp_time, \"nonexistent_field\") == [-2]\n\n\n@skip_if_server_version_lt(\"7.3.240\")\nasync def test_hpexpireat_multiple_fields(r):\n    await r.delete(\"test:hash\")\n    await r.hset(\n        \"test:hash\",\n        mapping={\"field1\": \"value1\", \"field2\": \"value2\", \"field3\": \"value3\"},\n    )\n    exp_time = int((datetime.now() + timedelta(milliseconds=400)).timestamp() * 1000)\n    assert await r.hpexpireat(\"test:hash\", exp_time, \"field1\", \"field2\") == [1, 1]\n    await asyncio.sleep(0.5)\n    assert await r.hexists(\"test:hash\", \"field1\") is False\n    assert await r.hexists(\"test:hash\", \"field2\") is False\n    assert await r.hexists(\"test:hash\", \"field3\") is True\n\n\n@skip_if_server_version_lt(\"7.3.240\")\nasync def test_hpersist_multiple_fields_mixed_conditions(r):\n    await r.delete(\"test:hash\")\n    await r.hset(\"test:hash\", mapping={\"field1\": \"value1\", \"field2\": \"value2\"})\n    await r.hexpire(\"test:hash\", 5000, \"field1\")\n    assert await r.hpersist(\"test:hash\", \"field1\", \"field2\", \"field3\") == [1, -1, -2]\n\n\n@skip_if_server_version_lt(\"7.3.240\")\nasync def test_hexpiretime_multiple_fields_mixed_conditions(r):\n    await r.delete(\"test:hash\")\n    await r.hset(\"test:hash\", mapping={\"field1\": \"value1\", \"field2\": \"value2\"})\n    future_time = int((datetime.now() + timedelta(minutes=30)).timestamp())\n    await r.hexpireat(\"test:hash\", future_time, \"field1\")\n    result = await r.hexpiretime(\"test:hash\", \"field1\", \"field2\", \"field3\")\n    assert future_time - 10 < result[0] <= future_time\n    assert result[1:] == [-1, -2]\n\n\n@skip_if_server_version_lt(\"7.3.240\")\nasync def test_hpexpiretime_multiple_fields_mixed_conditions(r):\n    await r.delete(\"test:hash\")\n    await r.hset(\"test:hash\", mapping={\"field1\": \"value1\", \"field2\": \"value2\"})\n    future_time = int((datetime.now() + timedelta(minutes=30)).timestamp())\n    await r.hexpireat(\"test:hash\", future_time, \"field1\")\n    result = await r.hpexpiretime(\"test:hash\", \"field1\", \"field2\", \"field3\")\n    assert future_time * 1000 - 10000 < result[0] <= future_time * 1000\n    assert result[1:] == [-1, -2]\n\n\n@skip_if_server_version_lt(\"7.3.240\")\nasync def test_ttl_multiple_fields_mixed_conditions(r):\n    await r.delete(\"test:hash\")\n    await r.hset(\"test:hash\", mapping={\"field1\": \"value1\", \"field2\": \"value2\"})\n    future_time = int((datetime.now() + timedelta(minutes=30)).timestamp())\n    await r.hexpireat(\"test:hash\", future_time, \"field1\")\n    result = await r.httl(\"test:hash\", \"field1\", \"field2\", \"field3\")\n    assert 30 * 60 - 10 < result[0] <= 30 * 60\n    assert result[1:] == [-1, -2]\n\n\n@skip_if_server_version_lt(\"7.3.240\")\nasync def test_pttl_multiple_fields_mixed_conditions(r):\n    await r.delete(\"test:hash\")\n    await r.hset(\"test:hash\", mapping={\"field1\": \"value1\", \"field2\": \"value2\"})\n    future_time = int((datetime.now() + timedelta(minutes=30)).timestamp())\n    await r.hexpireat(\"test:hash\", future_time, \"field1\")\n    result = await r.hpttl(\"test:hash\", \"field1\", \"field2\", \"field3\")\n    assert 30 * 60000 - 10000 < result[0] <= 30 * 60000\n    assert result[1:] == [-1, -2]\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_hgetdel(r):\n    await r.delete(\"test:hash\")\n    await r.hset(\"test:hash\", \"foo\", \"bar\", mapping={\"1\": 1, \"2\": 2})\n    assert await r.hgetdel(\"test:hash\", \"foo\", \"1\") == [b\"bar\", b\"1\"]\n    assert await r.hget(\"test:hash\", \"foo\") is None\n    assert await r.hget(\"test:hash\", \"1\") is None\n    assert await r.hget(\"test:hash\", \"2\") == b\"2\"\n    assert await r.hgetdel(\"test:hash\", \"foo\", \"1\") == [None, None]\n    assert await r.hget(\"test:hash\", \"2\") == b\"2\"\n\n    with pytest.raises(exceptions.DataError):\n        await r.hgetdel(\"test:hash\")\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_hgetex_no_expiration(r):\n    await r.delete(\"test:hash\")\n    await r.hset(\n        \"b\", \"foo\", \"bar\", mapping={\"1\": 1, \"2\": 2, \"3\": \"three\", \"4\": b\"four\"}\n    )\n\n    assert await r.hgetex(\"b\", \"foo\", \"1\", \"4\") == [b\"bar\", b\"1\", b\"four\"]\n    assert await r.hgetex(\"b\", \"foo\") == [b\"bar\"]\n    assert await r.httl(\"b\", \"foo\", \"1\", \"4\") == [-1, -1, -1]\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_hgetex_expiration_configs(r):\n    await r.delete(\"test:hash\")\n    await r.hset(\n        \"test:hash\", \"foo\", \"bar\", mapping={\"1\": 1, \"3\": \"three\", \"4\": b\"four\"}\n    )\n\n    test_keys = [\"foo\", \"1\", \"4\"]\n    # test get with multiple fields with expiration set through 'ex'\n    assert await r.hgetex(\"test:hash\", *test_keys, ex=10) == [\n        b\"bar\",\n        b\"1\",\n        b\"four\",\n    ]\n    ttls = await r.httl(\"test:hash\", *test_keys)\n    for ttl in ttls:\n        assert pytest.approx(ttl) == 10\n\n    # test get with multiple fields removing expiration settings with 'persist'\n    assert await r.hgetex(\"test:hash\", *test_keys, persist=True) == [\n        b\"bar\",\n        b\"1\",\n        b\"four\",\n    ]\n    assert await r.httl(\"test:hash\", *test_keys) == [-1, -1, -1]\n\n    # test get with multiple fields with expiration set through 'px'\n    assert await r.hgetex(\"test:hash\", *test_keys, px=6000) == [\n        b\"bar\",\n        b\"1\",\n        b\"four\",\n    ]\n    ttls = await r.httl(\"test:hash\", *test_keys)\n    for ttl in ttls:\n        assert pytest.approx(ttl) == 6\n\n    # test get single field with expiration set through 'pxat'\n    expire_at = await redis_server_time(r) + timedelta(minutes=1)\n    assert await r.hgetex(\"test:hash\", \"foo\", pxat=expire_at) == [b\"bar\"]\n    assert (await r.httl(\"test:hash\", \"foo\"))[0] <= 61\n\n    # test get single field with expiration set through 'exat'\n    expire_at = await redis_server_time(r) + timedelta(seconds=10)\n    assert await r.hgetex(\"test:hash\", \"foo\", exat=expire_at) == [b\"bar\"]\n    assert (await r.httl(\"test:hash\", \"foo\"))[0] <= 10\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_hgetex_validate_expired_fields_removed(r):\n    await r.delete(\"test:hash\")\n    await r.hset(\n        \"test:hash\", \"foo\", \"bar\", mapping={\"1\": 1, \"3\": \"three\", \"4\": b\"four\"}\n    )\n\n    # test get multiple fields with expiration set\n    # validate that expired fields are removed\n    assert await r.hgetex(\"test:hash\", \"foo\", \"1\", \"3\", ex=1) == [\n        b\"bar\",\n        b\"1\",\n        b\"three\",\n    ]\n    await asyncio.sleep(1.1)\n    assert await r.hgetex(\"test:hash\", \"foo\", \"1\", \"3\") == [None, None, None]\n    assert await r.httl(\"test:hash\", \"foo\", \"1\", \"3\") == [-2, -2, -2]\n    assert await r.hgetex(\"test:hash\", \"4\") == [b\"four\"]\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_hgetex_invalid_inputs(r):\n    with pytest.raises(exceptions.DataError):\n        await r.hgetex(\"b\", \"foo\", ex=10, persist=True)\n\n    with pytest.raises(exceptions.DataError):\n        await r.hgetex(\"b\", \"foo\", ex=10.0, persist=True)\n\n    with pytest.raises(exceptions.DataError):\n        await r.hgetex(\"b\", \"foo\", ex=10, px=6000)\n\n    with pytest.raises(exceptions.DataError):\n        await r.hgetex(\"b\", ex=10)\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_hsetex_no_expiration(r):\n    await r.delete(\"test:hash\")\n\n    # # set items from mapping without expiration\n    assert await r.hsetex(\"test:hash\", None, None, mapping={\"1\": 1, \"4\": b\"four\"}) == 1\n    assert await r.httl(\"test:hash\", \"foo\", \"1\", \"4\") == [-2, -1, -1]\n    assert await r.hgetex(\"test:hash\", \"foo\", \"1\") == [None, b\"1\"]\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_hsetex_expiration_ex_and_keepttl(r):\n    await r.delete(\"test:hash\")\n\n    # set items from key/value provided\n    # combined with mapping and items with expiration - testing ex field\n    assert (\n        await r.hsetex(\n            \"test:hash\",\n            \"foo\",\n            \"bar\",\n            mapping={\"1\": 1, \"2\": \"2\"},\n            items=[\"i1\", 11, \"i2\", 22],\n            ex=10,\n        )\n        == 1\n    )\n    test_keys = [\"foo\", \"1\", \"2\", \"i1\", \"i2\"]\n    ttls = await r.httl(\"test:hash\", *test_keys)\n    for ttl in ttls:\n        assert pytest.approx(ttl) == 10\n\n    assert await r.hgetex(\"test:hash\", *test_keys) == [\n        b\"bar\",\n        b\"1\",\n        b\"2\",\n        b\"11\",\n        b\"22\",\n    ]\n    await asyncio.sleep(1.1)\n    # validate keepttl\n    assert await r.hsetex(\"test:hash\", \"foo\", \"bar1\", keepttl=True) == 1\n    assert 0 < (await r.httl(\"test:hash\", \"foo\"))[0] < 10\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_hsetex_expiration_px(r):\n    await r.delete(\"test:hash\")\n    # set items from key/value provided and mapping\n    # with expiration - testing px field\n    assert (\n        await r.hsetex(\"test:hash\", \"foo\", \"bar\", mapping={\"1\": 1, \"2\": \"2\"}, px=60000)\n        == 1\n    )\n    test_keys = [\"foo\", \"1\", \"2\"]\n    ttls = await r.httl(\"test:hash\", *test_keys)\n    for ttl in ttls:\n        assert pytest.approx(ttl) == 60\n\n    assert await r.hgetex(\"test:hash\", *test_keys) == [b\"bar\", b\"1\", b\"2\"]\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_hsetex_expiration_pxat_and_fnx(r):\n    await r.delete(\"test:hash\")\n    assert (\n        await r.hsetex(\"test:hash\", \"foo\", \"bar\", mapping={\"1\": 1, \"2\": \"2\"}, ex=30)\n        == 1\n    )\n\n    expire_at = await redis_server_time(r) + timedelta(minutes=1)\n    assert (\n        await r.hsetex(\n            \"test:hash\",\n            \"foo\",\n            \"bar1\",\n            mapping={\"new\": \"ok\"},\n            pxat=expire_at,\n            data_persist_option=HashDataPersistOptions.FNX,\n        )\n        == 0\n    )\n    ttls = await r.httl(\"test:hash\", \"foo\", \"new\")\n    assert ttls[0] <= 30\n    assert ttls[1] == -2\n\n    assert await r.hgetex(\"test:hash\", \"foo\", \"1\", \"new\") == [b\"bar\", b\"1\", None]\n    assert (\n        await r.hsetex(\n            \"test:hash\",\n            \"foo_new\",\n            \"bar1\",\n            mapping={\"new\": \"ok\"},\n            pxat=expire_at,\n            data_persist_option=HashDataPersistOptions.FNX,\n        )\n        == 1\n    )\n    ttls = await r.httl(\"test:hash\", \"foo\", \"new\")\n    for ttl in ttls:\n        assert ttl <= 61\n    assert await r.hgetex(\"test:hash\", \"foo\", \"foo_new\", \"new\") == [\n        b\"bar\",\n        b\"bar1\",\n        b\"ok\",\n    ]\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_hsetex_expiration_exat_and_fxx(r):\n    await r.delete(\"test:hash\")\n    assert (\n        await r.hsetex(\"test:hash\", \"foo\", \"bar\", mapping={\"1\": 1, \"2\": \"2\"}, ex=30)\n        == 1\n    )\n\n    expire_at = await redis_server_time(r) + timedelta(seconds=10)\n    assert (\n        await r.hsetex(\n            \"test:hash\",\n            \"foo\",\n            \"bar1\",\n            mapping={\"new\": \"ok\"},\n            exat=expire_at,\n            data_persist_option=HashDataPersistOptions.FXX,\n        )\n        == 0\n    )\n    ttls = await r.httl(\"test:hash\", \"foo\", \"new\")\n    assert 10 < ttls[0] <= 30\n    assert ttls[1] == -2\n\n    assert await r.hgetex(\"test:hash\", \"foo\", \"1\", \"new\") == [b\"bar\", b\"1\", None]\n    assert (\n        await r.hsetex(\n            \"test:hash\",\n            \"foo\",\n            \"bar1\",\n            mapping={\"1\": \"new_value\"},\n            exat=expire_at,\n            data_persist_option=HashDataPersistOptions.FXX,\n        )\n        == 1\n    )\n    assert await r.hgetex(\"test:hash\", \"foo\", \"1\") == [b\"bar1\", b\"new_value\"]\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_hsetex_invalid_inputs(r):\n    with pytest.raises(exceptions.DataError):\n        await r.hsetex(\"b\", \"foo\", \"bar\", ex=10.0)\n\n    with pytest.raises(exceptions.DataError):\n        await r.hsetex(\"b\", None, None)\n\n    with pytest.raises(exceptions.DataError):\n        await r.hsetex(\"b\", \"foo\", \"bar\", items=[\"i1\", 11, \"i2\"], px=6000)\n\n    with pytest.raises(exceptions.DataError):\n        await r.hsetex(\"b\", \"foo\", \"bar\", ex=10, keepttl=True)\n"
  },
  {
    "path": "tests/test_asyncio/test_json.py",
    "content": "import pytest\nimport pytest_asyncio\nimport redis.asyncio as redis\nfrom redis import exceptions\nfrom redis.commands.json.path import Path\nfrom tests.conftest import assert_resp_response, skip_ifmodversion_lt\n\n\n@pytest_asyncio.fixture()\nasync def decoded_r(create_redis, stack_url):\n    return await create_redis(decode_responses=True, url=stack_url)\n\n\n@pytest.mark.redismod\nasync def test_json_setbinarykey(decoded_r: redis.Redis):\n    d = {\"hello\": \"world\", b\"some\": \"value\"}\n    with pytest.raises(TypeError):\n        decoded_r.json().set(\"somekey\", Path.root_path(), d)\n    assert await decoded_r.json().set(\"somekey\", Path.root_path(), d, decode_keys=True)\n\n\n@pytest.mark.redismod\nasync def test_json_setgetdeleteforget(decoded_r: redis.Redis):\n    assert await decoded_r.json().set(\"foo\", Path.root_path(), \"bar\")\n    assert await decoded_r.json().get(\"foo\") == \"bar\"\n    assert await decoded_r.json().get(\"baz\") is None\n    assert await decoded_r.json().delete(\"foo\") == 1\n    assert await decoded_r.json().forget(\"foo\") == 0  # second delete\n    assert await decoded_r.exists(\"foo\") == 0\n\n\n@pytest.mark.redismod\nasync def test_jsonget(decoded_r: redis.Redis):\n    await decoded_r.json().set(\"foo\", Path.root_path(), \"bar\")\n    assert await decoded_r.json().get(\"foo\") == \"bar\"\n\n\n@pytest.mark.redismod\nasync def test_json_get_jset(decoded_r: redis.Redis):\n    assert await decoded_r.json().set(\"foo\", Path.root_path(), \"bar\")\n    assert await decoded_r.json().get(\"foo\") == \"bar\"\n    assert await decoded_r.json().get(\"baz\") is None\n    assert 1 == await decoded_r.json().delete(\"foo\")\n    assert await decoded_r.exists(\"foo\") == 0\n\n\n@pytest.mark.redismod\nasync def test_nonascii_setgetdelete(decoded_r: redis.Redis):\n    assert await decoded_r.json().set(\"notascii\", Path.root_path(), \"hyvää-élève\")\n    assert await decoded_r.json().get(\"notascii\", no_escape=True) == \"hyvää-élève\"\n    assert 1 == await decoded_r.json().delete(\"notascii\")\n    assert await decoded_r.exists(\"notascii\") == 0\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"2.6.0\", \"ReJSON\")\nasync def test_json_merge(decoded_r: redis.Redis):\n    # Test with root path $\n    assert await decoded_r.json().set(\n        \"person_data\",\n        \"$\",\n        {\"person1\": {\"personal_data\": {\"name\": \"John\"}}},\n    )\n    assert await decoded_r.json().merge(\n        \"person_data\", \"$\", {\"person1\": {\"personal_data\": {\"hobbies\": \"reading\"}}}\n    )\n    assert await decoded_r.json().get(\"person_data\") == {\n        \"person1\": {\"personal_data\": {\"name\": \"John\", \"hobbies\": \"reading\"}}\n    }\n\n    # Test with root path path $.person1.personal_data\n    assert await decoded_r.json().merge(\n        \"person_data\", \"$.person1.personal_data\", {\"country\": \"Israel\"}\n    )\n    assert await decoded_r.json().get(\"person_data\") == {\n        \"person1\": {\n            \"personal_data\": {\"name\": \"John\", \"hobbies\": \"reading\", \"country\": \"Israel\"}\n        }\n    }\n\n    # Test with null value to delete a value\n    assert await decoded_r.json().merge(\n        \"person_data\", \"$.person1.personal_data\", {\"name\": None}\n    )\n    assert await decoded_r.json().get(\"person_data\") == {\n        \"person1\": {\"personal_data\": {\"country\": \"Israel\", \"hobbies\": \"reading\"}}\n    }\n\n\n@pytest.mark.redismod\nasync def test_jsonsetexistentialmodifiersshouldsucceed(decoded_r: redis.Redis):\n    obj = {\"foo\": \"bar\"}\n    assert await decoded_r.json().set(\"obj\", Path.root_path(), obj)\n\n    # Test that flags prevent updates when conditions are unmet\n    assert await decoded_r.json().set(\"obj\", Path(\"foo\"), \"baz\", nx=True) is None\n    assert await decoded_r.json().set(\"obj\", Path(\"qaz\"), \"baz\", xx=True) is None\n\n    # Test that flags allow updates when conditions are met\n    assert await decoded_r.json().set(\"obj\", Path(\"foo\"), \"baz\", xx=True)\n    assert await decoded_r.json().set(\"obj\", Path(\"qaz\"), \"baz\", nx=True)\n\n    # Test that flags are mutually exclusive\n    with pytest.raises(Exception):\n        await decoded_r.json().set(\"obj\", Path(\"foo\"), \"baz\", nx=True, xx=True)\n\n\n@pytest.mark.redismod\nasync def test_mgetshouldsucceed(decoded_r: redis.Redis):\n    await decoded_r.json().set(\"1\", Path.root_path(), 1)\n    await decoded_r.json().set(\"2\", Path.root_path(), 2)\n    assert await decoded_r.json().mget([\"1\"], Path.root_path()) == [1]\n\n    assert await decoded_r.json().mget([1, 2], Path.root_path()) == [1, 2]\n\n\n@pytest.mark.onlynoncluster\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"2.6.0\", \"ReJSON\")\nasync def test_mset(decoded_r: redis.Redis):\n    await decoded_r.json().mset(\n        [(\"1\", Path.root_path(), 1), (\"2\", Path.root_path(), 2)]\n    )\n\n    assert await decoded_r.json().mget([\"1\"], Path.root_path()) == [1]\n    assert await decoded_r.json().mget([\"1\", \"2\"], Path.root_path()) == [1, 2]\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"99.99.99\", \"ReJSON\")  # todo: update after the release\nasync def test_clear(decoded_r: redis.Redis):\n    await decoded_r.json().set(\"arr\", Path.root_path(), [0, 1, 2, 3, 4])\n    assert 1 == await decoded_r.json().clear(\"arr\", Path.root_path())\n    assert_resp_response(decoded_r, await decoded_r.json().get(\"arr\"), [], [])\n\n\n@pytest.mark.redismod\nasync def test_type(decoded_r: redis.Redis):\n    await decoded_r.json().set(\"1\", Path.root_path(), 1)\n    assert_resp_response(\n        decoded_r,\n        await decoded_r.json().type(\"1\", Path.root_path()),\n        \"integer\",\n        [\"integer\"],\n    )\n    assert_resp_response(\n        decoded_r, await decoded_r.json().type(\"1\"), \"integer\", [\"integer\"]\n    )\n\n\n@pytest.mark.redismod\nasync def test_numincrby(decoded_r):\n    await decoded_r.json().set(\"num\", Path.root_path(), 1)\n    assert_resp_response(\n        decoded_r, await decoded_r.json().numincrby(\"num\", Path.root_path(), 1), 2, [2]\n    )\n    res = await decoded_r.json().numincrby(\"num\", Path.root_path(), 0.5)\n    assert_resp_response(decoded_r, res, 2.5, [2.5])\n    res = await decoded_r.json().numincrby(\"num\", Path.root_path(), -1.25)\n    assert_resp_response(decoded_r, res, 1.25, [1.25])\n\n\n@pytest.mark.redismod\nasync def test_nummultby(decoded_r: redis.Redis):\n    await decoded_r.json().set(\"num\", Path.root_path(), 1)\n\n    with pytest.deprecated_call():\n        res = await decoded_r.json().nummultby(\"num\", Path.root_path(), 2)\n        assert_resp_response(decoded_r, res, 2, [2])\n        res = await decoded_r.json().nummultby(\"num\", Path.root_path(), 2.5)\n        assert_resp_response(decoded_r, res, 5, [5])\n        res = await decoded_r.json().nummultby(\"num\", Path.root_path(), 0.5)\n        assert_resp_response(decoded_r, res, 2.5, [2.5])\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"99.99.99\", \"ReJSON\")  # todo: update after the release\nasync def test_toggle(decoded_r: redis.Redis):\n    await decoded_r.json().set(\"bool\", Path.root_path(), False)\n    assert await decoded_r.json().toggle(\"bool\", Path.root_path())\n    assert await decoded_r.json().toggle(\"bool\", Path.root_path()) is False\n    # check non-boolean value\n    await decoded_r.json().set(\"num\", Path.root_path(), 1)\n    with pytest.raises(exceptions.ResponseError):\n        await decoded_r.json().toggle(\"num\", Path.root_path())\n\n\n@pytest.mark.redismod\nasync def test_strappend(decoded_r: redis.Redis):\n    await decoded_r.json().set(\"jsonkey\", Path.root_path(), \"foo\")\n    assert 6 == await decoded_r.json().strappend(\"jsonkey\", \"bar\")\n    assert \"foobar\" == await decoded_r.json().get(\"jsonkey\", Path.root_path())\n\n\n@pytest.mark.redismod\nasync def test_strlen(decoded_r: redis.Redis):\n    await decoded_r.json().set(\"str\", Path.root_path(), \"foo\")\n    assert 3 == await decoded_r.json().strlen(\"str\", Path.root_path())\n    await decoded_r.json().strappend(\"str\", \"bar\", Path.root_path())\n    assert 6 == await decoded_r.json().strlen(\"str\", Path.root_path())\n    assert 6 == await decoded_r.json().strlen(\"str\")\n\n\n@pytest.mark.redismod\nasync def test_arrappend(decoded_r: redis.Redis):\n    await decoded_r.json().set(\"arr\", Path.root_path(), [1])\n    assert 2 == await decoded_r.json().arrappend(\"arr\", Path.root_path(), 2)\n    assert 4 == await decoded_r.json().arrappend(\"arr\", Path.root_path(), 3, 4)\n    assert 7 == await decoded_r.json().arrappend(\"arr\", Path.root_path(), *[5, 6, 7])\n\n\n@pytest.mark.redismod\nasync def test_arrindex(decoded_r: redis.Redis):\n    r_path = Path.root_path()\n    await decoded_r.json().set(\"arr\", r_path, [0, 1, 2, 3, 4])\n    assert 1 == await decoded_r.json().arrindex(\"arr\", r_path, 1)\n    assert -1 == await decoded_r.json().arrindex(\"arr\", r_path, 1, 2)\n    assert 4 == await decoded_r.json().arrindex(\"arr\", r_path, 4)\n    assert 4 == await decoded_r.json().arrindex(\"arr\", r_path, 4, start=0)\n    assert 4 == await decoded_r.json().arrindex(\"arr\", r_path, 4, start=0, stop=5000)\n    assert -1 == await decoded_r.json().arrindex(\"arr\", r_path, 4, start=0, stop=-1)\n    assert -1 == await decoded_r.json().arrindex(\"arr\", r_path, 4, start=1, stop=3)\n\n\n@pytest.mark.redismod\nasync def test_arrinsert(decoded_r: redis.Redis):\n    await decoded_r.json().set(\"arr\", Path.root_path(), [0, 4])\n    assert 5 == await decoded_r.json().arrinsert(\"arr\", Path.root_path(), 1, *[1, 2, 3])\n    assert await decoded_r.json().get(\"arr\") == [0, 1, 2, 3, 4]\n\n    # test prepends\n    await decoded_r.json().set(\"val2\", Path.root_path(), [5, 6, 7, 8, 9])\n    await decoded_r.json().arrinsert(\"val2\", Path.root_path(), 0, [\"some\", \"thing\"])\n    assert await decoded_r.json().get(\"val2\") == [[\"some\", \"thing\"], 5, 6, 7, 8, 9]\n\n\n@pytest.mark.redismod\nasync def test_arrlen(decoded_r: redis.Redis):\n    await decoded_r.json().set(\"arr\", Path.root_path(), [0, 1, 2, 3, 4])\n    assert 5 == await decoded_r.json().arrlen(\"arr\", Path.root_path())\n    assert 5 == await decoded_r.json().arrlen(\"arr\")\n    assert await decoded_r.json().arrlen(\"fakekey\") is None\n\n\n@pytest.mark.redismod\nasync def test_arrpop(decoded_r: redis.Redis):\n    await decoded_r.json().set(\"arr\", Path.root_path(), [0, 1, 2, 3, 4])\n    assert 4 == await decoded_r.json().arrpop(\"arr\", Path.root_path(), 4)\n    assert 3 == await decoded_r.json().arrpop(\"arr\", Path.root_path(), -1)\n    assert 2 == await decoded_r.json().arrpop(\"arr\", Path.root_path())\n    assert 0 == await decoded_r.json().arrpop(\"arr\", Path.root_path(), 0)\n    assert [1] == await decoded_r.json().get(\"arr\")\n\n    # test out of bounds\n    await decoded_r.json().set(\"arr\", Path.root_path(), [0, 1, 2, 3, 4])\n    assert 4 == await decoded_r.json().arrpop(\"arr\", Path.root_path(), 99)\n\n    # none test\n    await decoded_r.json().set(\"arr\", Path.root_path(), [])\n    assert await decoded_r.json().arrpop(\"arr\") is None\n\n\n@pytest.mark.redismod\nasync def test_arrtrim(decoded_r: redis.Redis):\n    await decoded_r.json().set(\"arr\", Path.root_path(), [0, 1, 2, 3, 4])\n    assert 3 == await decoded_r.json().arrtrim(\"arr\", Path.root_path(), 1, 3)\n    assert [1, 2, 3] == await decoded_r.json().get(\"arr\")\n\n    # <0 test, should be 0 equivalent\n    await decoded_r.json().set(\"arr\", Path.root_path(), [0, 1, 2, 3, 4])\n    assert 0 == await decoded_r.json().arrtrim(\"arr\", Path.root_path(), -1, 3)\n\n    # testing stop > end\n    await decoded_r.json().set(\"arr\", Path.root_path(), [0, 1, 2, 3, 4])\n    assert 2 == await decoded_r.json().arrtrim(\"arr\", Path.root_path(), 3, 99)\n\n    # start > array size and stop\n    await decoded_r.json().set(\"arr\", Path.root_path(), [0, 1, 2, 3, 4])\n    assert 0 == await decoded_r.json().arrtrim(\"arr\", Path.root_path(), 9, 1)\n\n    # all larger\n    await decoded_r.json().set(\"arr\", Path.root_path(), [0, 1, 2, 3, 4])\n    assert 0 == await decoded_r.json().arrtrim(\"arr\", Path.root_path(), 9, 11)\n\n\n@pytest.mark.redismod\nasync def test_resp(decoded_r: redis.Redis):\n    obj = {\"foo\": \"bar\", \"baz\": 1, \"qaz\": True}\n    await decoded_r.json().set(\"obj\", Path.root_path(), obj)\n    assert \"bar\" == await decoded_r.json().resp(\"obj\", Path(\"foo\"))\n    assert 1 == await decoded_r.json().resp(\"obj\", Path(\"baz\"))\n    assert await decoded_r.json().resp(\"obj\", Path(\"qaz\"))\n    assert isinstance(await decoded_r.json().resp(\"obj\"), list)\n\n\n@pytest.mark.redismod\nasync def test_objkeys(decoded_r: redis.Redis):\n    obj = {\"foo\": \"bar\", \"baz\": \"qaz\"}\n    await decoded_r.json().set(\"obj\", Path.root_path(), obj)\n    keys = await decoded_r.json().objkeys(\"obj\", Path.root_path())\n    keys.sort()\n    exp = list(obj.keys())\n    exp.sort()\n    assert exp == keys\n\n    await decoded_r.json().set(\"obj\", Path.root_path(), obj)\n    keys = await decoded_r.json().objkeys(\"obj\")\n    assert keys == list(obj.keys())\n\n    assert await decoded_r.json().objkeys(\"fakekey\") is None\n\n\n@pytest.mark.redismod\nasync def test_objlen(decoded_r: redis.Redis):\n    obj = {\"foo\": \"bar\", \"baz\": \"qaz\"}\n    await decoded_r.json().set(\"obj\", Path.root_path(), obj)\n    assert len(obj) == await decoded_r.json().objlen(\"obj\", Path.root_path())\n\n    await decoded_r.json().set(\"obj\", Path.root_path(), obj)\n    assert len(obj) == await decoded_r.json().objlen(\"obj\")\n\n\n# @pytest.mark.redismod\n# async def test_json_commands_in_pipeline(decoded_r: redis.Redis):\n#     async with decoded_r.json().pipeline() as p:\n#         p.set(\"foo\", Path.root_path(), \"bar\")\n#         p.get(\"foo\")\n#         p.delete(\"foo\")\n#         assert [True, \"bar\", 1] == await p.execute()\n#     assert await decoded_r.keys() == []\n#     assert await decoded_r.get(\"foo\") is None\n\n#     # now with a true, json object\n#     await decoded_r.flushdb()\n#     p = await decoded_r.json().pipeline()\n#     d = {\"hello\": \"world\", \"oh\": \"snap\"}\n#     with pytest.deprecated_call():\n#         p.jsonset(\"foo\", Path.root_path(), d)\n#         p.jsonget(\"foo\")\n#     p.exists(\"notarealkey\")\n#     p.delete(\"foo\")\n#     assert [True, d, 0, 1] == p.execute()\n#     assert await decoded_r.keys() == []\n#     assert await decoded_r.get(\"foo\") is None\n\n\n@pytest.mark.redismod\nasync def test_json_delete_with_dollar(decoded_r: redis.Redis):\n    doc1 = {\"a\": 1, \"nested\": {\"a\": 2, \"b\": 3}}\n    assert await decoded_r.json().set(\"doc1\", \"$\", doc1)\n    assert await decoded_r.json().delete(\"doc1\", \"$..a\") == 2\n    assert await decoded_r.json().get(\"doc1\", \"$\") == [{\"nested\": {\"b\": 3}}]\n\n    doc2 = {\"a\": {\"a\": 2, \"b\": 3}, \"b\": [\"a\", \"b\"], \"nested\": {\"b\": [True, \"a\", \"b\"]}}\n    assert await decoded_r.json().set(\"doc2\", \"$\", doc2)\n    assert await decoded_r.json().delete(\"doc2\", \"$..a\") == 1\n    assert await decoded_r.json().get(\"doc2\", \"$\") == [\n        {\"nested\": {\"b\": [True, \"a\", \"b\"]}, \"b\": [\"a\", \"b\"]}\n    ]\n\n    doc3 = [\n        {\n            \"ciao\": [\"non ancora\"],\n            \"nested\": [\n                {\"ciao\": [1, \"a\"]},\n                {\"ciao\": [2, \"a\"]},\n                {\"ciaoc\": [3, \"non\", \"ciao\"]},\n                {\"ciao\": [4, \"a\"]},\n                {\"e\": [5, \"non\", \"ciao\"]},\n            ],\n        }\n    ]\n    assert await decoded_r.json().set(\"doc3\", \"$\", doc3)\n    assert await decoded_r.json().delete(\"doc3\", '$.[0][\"nested\"]..ciao') == 3\n\n    doc3val = [\n        [\n            {\n                \"ciao\": [\"non ancora\"],\n                \"nested\": [\n                    {},\n                    {},\n                    {\"ciaoc\": [3, \"non\", \"ciao\"]},\n                    {},\n                    {\"e\": [5, \"non\", \"ciao\"]},\n                ],\n            }\n        ]\n    ]\n    assert await decoded_r.json().get(\"doc3\", \"$\") == doc3val\n\n    # Test async default path\n    assert await decoded_r.json().delete(\"doc3\") == 1\n    assert await decoded_r.json().get(\"doc3\", \"$\") is None\n\n    await decoded_r.json().delete(\"not_a_document\", \"..a\")\n\n\n@pytest.mark.redismod\nasync def test_json_forget_with_dollar(decoded_r: redis.Redis):\n    doc1 = {\"a\": 1, \"nested\": {\"a\": 2, \"b\": 3}}\n    assert await decoded_r.json().set(\"doc1\", \"$\", doc1)\n    assert await decoded_r.json().forget(\"doc1\", \"$..a\") == 2\n    assert await decoded_r.json().get(\"doc1\", \"$\") == [{\"nested\": {\"b\": 3}}]\n\n    doc2 = {\"a\": {\"a\": 2, \"b\": 3}, \"b\": [\"a\", \"b\"], \"nested\": {\"b\": [True, \"a\", \"b\"]}}\n    assert await decoded_r.json().set(\"doc2\", \"$\", doc2)\n    assert await decoded_r.json().forget(\"doc2\", \"$..a\") == 1\n    assert await decoded_r.json().get(\"doc2\", \"$\") == [\n        {\"nested\": {\"b\": [True, \"a\", \"b\"]}, \"b\": [\"a\", \"b\"]}\n    ]\n\n    doc3 = [\n        {\n            \"ciao\": [\"non ancora\"],\n            \"nested\": [\n                {\"ciao\": [1, \"a\"]},\n                {\"ciao\": [2, \"a\"]},\n                {\"ciaoc\": [3, \"non\", \"ciao\"]},\n                {\"ciao\": [4, \"a\"]},\n                {\"e\": [5, \"non\", \"ciao\"]},\n            ],\n        }\n    ]\n    assert await decoded_r.json().set(\"doc3\", \"$\", doc3)\n    assert await decoded_r.json().forget(\"doc3\", '$.[0][\"nested\"]..ciao') == 3\n\n    doc3val = [\n        [\n            {\n                \"ciao\": [\"non ancora\"],\n                \"nested\": [\n                    {},\n                    {},\n                    {\"ciaoc\": [3, \"non\", \"ciao\"]},\n                    {},\n                    {\"e\": [5, \"non\", \"ciao\"]},\n                ],\n            }\n        ]\n    ]\n    assert await decoded_r.json().get(\"doc3\", \"$\") == doc3val\n\n    # Test async default path\n    assert await decoded_r.json().forget(\"doc3\") == 1\n    assert await decoded_r.json().get(\"doc3\", \"$\") is None\n\n    await decoded_r.json().forget(\"not_a_document\", \"..a\")\n\n\n@pytest.mark.onlynoncluster\n@pytest.mark.redismod\nasync def test_json_mget_dollar(decoded_r: redis.Redis):\n    # Test mget with multi paths\n    await decoded_r.json().set(\n        \"doc1\",\n        \"$\",\n        {\"a\": 1, \"b\": 2, \"nested\": {\"a\": 3}, \"c\": None, \"nested2\": {\"a\": None}},\n    )\n    await decoded_r.json().set(\n        \"doc2\",\n        \"$\",\n        {\"a\": 4, \"b\": 5, \"nested\": {\"a\": 6}, \"c\": None, \"nested2\": {\"a\": [None]}},\n    )\n    # Compare also to single JSON.GET\n    assert await decoded_r.json().get(\"doc1\", \"$..a\") == [1, 3, None]\n    assert await decoded_r.json().get(\"doc2\", \"$..a\") == [4, 6, [None]]\n\n    # Test mget with single path\n    assert await decoded_r.json().mget([\"doc1\"], \"$..a\") == [[1, 3, None]]\n    # Test mget with multi path\n    res = await decoded_r.json().mget([\"doc1\", \"doc2\"], \"$..a\")\n    assert res == [[1, 3, None], [4, 6, [None]]]\n\n    # Test missing key\n    res = await decoded_r.json().mget([\"doc1\", \"missing_doc\"], \"$..a\")\n    assert res == [[1, 3, None], None]\n    res = await decoded_r.json().mget([\"missing_doc1\", \"missing_doc2\"], \"$..a\")\n    assert res == [None, None]\n\n\n@pytest.mark.redismod\nasync def test_numby_commands_dollar(decoded_r: redis.Redis):\n    # Test NUMINCRBY\n    await decoded_r.json().set(\n        \"doc1\", \"$\", {\"a\": \"b\", \"b\": [{\"a\": 2}, {\"a\": 5.0}, {\"a\": \"c\"}]}\n    )\n    # Test multi\n    assert await decoded_r.json().numincrby(\"doc1\", \"$..a\", 2) == [None, 4, 7.0, None]\n\n    res = await decoded_r.json().numincrby(\"doc1\", \"$..a\", 2.5)\n    assert res == [None, 6.5, 9.5, None]\n    # Test single\n    assert await decoded_r.json().numincrby(\"doc1\", \"$.b[1].a\", 2) == [11.5]\n\n    assert await decoded_r.json().numincrby(\"doc1\", \"$.b[2].a\", 2) == [None]\n    assert await decoded_r.json().numincrby(\"doc1\", \"$.b[1].a\", 3.5) == [15.0]\n\n    # Test NUMMULTBY\n    await decoded_r.json().set(\n        \"doc1\", \"$\", {\"a\": \"b\", \"b\": [{\"a\": 2}, {\"a\": 5.0}, {\"a\": \"c\"}]}\n    )\n\n    # test list\n    with pytest.deprecated_call():\n        res = await decoded_r.json().nummultby(\"doc1\", \"$..a\", 2)\n        assert res == [None, 4, 10, None]\n        res = await decoded_r.json().nummultby(\"doc1\", \"$..a\", 2.5)\n        assert res == [None, 10.0, 25.0, None]\n\n    # Test single\n    with pytest.deprecated_call():\n        assert await decoded_r.json().nummultby(\"doc1\", \"$.b[1].a\", 2) == [50.0]\n        assert await decoded_r.json().nummultby(\"doc1\", \"$.b[2].a\", 2) == [None]\n        assert await decoded_r.json().nummultby(\"doc1\", \"$.b[1].a\", 3) == [150.0]\n\n    # test missing keys\n    with pytest.raises(exceptions.ResponseError):\n        await decoded_r.json().numincrby(\"non_existing_doc\", \"$..a\", 2)\n        await decoded_r.json().nummultby(\"non_existing_doc\", \"$..a\", 2)\n\n    # Test legacy NUMINCRBY\n    await decoded_r.json().set(\n        \"doc1\", \"$\", {\"a\": \"b\", \"b\": [{\"a\": 2}, {\"a\": 5.0}, {\"a\": \"c\"}]}\n    )\n    assert_resp_response(\n        decoded_r, await decoded_r.json().numincrby(\"doc1\", \".b[0].a\", 3), 5, [5]\n    )\n\n    # Test legacy NUMMULTBY\n    await decoded_r.json().set(\n        \"doc1\", \"$\", {\"a\": \"b\", \"b\": [{\"a\": 2}, {\"a\": 5.0}, {\"a\": \"c\"}]}\n    )\n\n    with pytest.deprecated_call():\n        assert_resp_response(\n            decoded_r, await decoded_r.json().nummultby(\"doc1\", \".b[0].a\", 3), 6, [6]\n        )\n\n\n@pytest.mark.redismod\nasync def test_strappend_dollar(decoded_r: redis.Redis):\n    await decoded_r.json().set(\n        \"doc1\", \"$\", {\"a\": \"foo\", \"nested1\": {\"a\": \"hello\"}, \"nested2\": {\"a\": 31}}\n    )\n    # Test multi\n    assert await decoded_r.json().strappend(\"doc1\", \"bar\", \"$..a\") == [6, 8, None]\n\n    res = [{\"a\": \"foobar\", \"nested1\": {\"a\": \"hellobar\"}, \"nested2\": {\"a\": 31}}]\n    assert await decoded_r.json().get(\"doc1\", \"$\") == res\n\n    # Test single\n    assert await decoded_r.json().strappend(\"doc1\", \"baz\", \"$.nested1.a\") == [11]\n\n    res = [{\"a\": \"foobar\", \"nested1\": {\"a\": \"hellobarbaz\"}, \"nested2\": {\"a\": 31}}]\n    assert await decoded_r.json().get(\"doc1\", \"$\") == res\n\n    # Test missing key\n    with pytest.raises(exceptions.ResponseError):\n        await decoded_r.json().strappend(\"non_existing_doc\", \"$..a\", \"err\")\n\n    # Test multi\n    assert await decoded_r.json().strappend(\"doc1\", \"bar\", \".*.a\") == 14\n    res = [{\"a\": \"foobar\", \"nested1\": {\"a\": \"hellobarbazbar\"}, \"nested2\": {\"a\": 31}}]\n    assert await decoded_r.json().get(\"doc1\", \"$\") == res\n\n    # Test missing path\n    with pytest.raises(exceptions.ResponseError):\n        await decoded_r.json().strappend(\"doc1\", \"piu\")\n\n\n@pytest.mark.redismod\nasync def test_strlen_dollar(decoded_r: redis.Redis):\n    # Test multi\n    await decoded_r.json().set(\n        \"doc1\", \"$\", {\"a\": \"foo\", \"nested1\": {\"a\": \"hello\"}, \"nested2\": {\"a\": 31}}\n    )\n    assert await decoded_r.json().strlen(\"doc1\", \"$..a\") == [3, 5, None]\n\n    res2 = await decoded_r.json().strappend(\"doc1\", \"bar\", \"$..a\")\n    res1 = await decoded_r.json().strlen(\"doc1\", \"$..a\")\n    assert res1 == res2\n\n    # Test single\n    assert await decoded_r.json().strlen(\"doc1\", \"$.nested1.a\") == [8]\n    assert await decoded_r.json().strlen(\"doc1\", \"$.nested2.a\") == [None]\n\n    # Test missing key\n    with pytest.raises(exceptions.ResponseError):\n        await decoded_r.json().strlen(\"non_existing_doc\", \"$..a\")\n\n\n@pytest.mark.redismod\nasync def test_arrappend_dollar(decoded_r: redis.Redis):\n    await decoded_r.json().set(\n        \"doc1\",\n        \"$\",\n        {\n            \"a\": [\"foo\"],\n            \"nested1\": {\"a\": [\"hello\", None, \"world\"]},\n            \"nested2\": {\"a\": 31},\n        },\n    )\n    # Test multi\n    res = [3, 5, None]\n    assert await decoded_r.json().arrappend(\"doc1\", \"$..a\", \"bar\", \"racuda\") == res\n    res = [\n        {\n            \"a\": [\"foo\", \"bar\", \"racuda\"],\n            \"nested1\": {\"a\": [\"hello\", None, \"world\", \"bar\", \"racuda\"]},\n            \"nested2\": {\"a\": 31},\n        }\n    ]\n    assert await decoded_r.json().get(\"doc1\", \"$\") == res\n\n    # Test single\n    assert await decoded_r.json().arrappend(\"doc1\", \"$.nested1.a\", \"baz\") == [6]\n    res = [\n        {\n            \"a\": [\"foo\", \"bar\", \"racuda\"],\n            \"nested1\": {\"a\": [\"hello\", None, \"world\", \"bar\", \"racuda\", \"baz\"]},\n            \"nested2\": {\"a\": 31},\n        }\n    ]\n    assert await decoded_r.json().get(\"doc1\", \"$\") == res\n\n    # Test missing key\n    with pytest.raises(exceptions.ResponseError):\n        await decoded_r.json().arrappend(\"non_existing_doc\", \"$..a\")\n\n    # Test legacy\n    await decoded_r.json().set(\n        \"doc1\",\n        \"$\",\n        {\n            \"a\": [\"foo\"],\n            \"nested1\": {\"a\": [\"hello\", None, \"world\"]},\n            \"nested2\": {\"a\": 31},\n        },\n    )\n    # Test multi (all paths are updated, but return result of last path)\n    assert await decoded_r.json().arrappend(\"doc1\", \"..a\", \"bar\", \"racuda\") == 5\n\n    res = [\n        {\n            \"a\": [\"foo\", \"bar\", \"racuda\"],\n            \"nested1\": {\"a\": [\"hello\", None, \"world\", \"bar\", \"racuda\"]},\n            \"nested2\": {\"a\": 31},\n        }\n    ]\n    assert await decoded_r.json().get(\"doc1\", \"$\") == res\n    # Test single\n    assert await decoded_r.json().arrappend(\"doc1\", \".nested1.a\", \"baz\") == 6\n    res = [\n        {\n            \"a\": [\"foo\", \"bar\", \"racuda\"],\n            \"nested1\": {\"a\": [\"hello\", None, \"world\", \"bar\", \"racuda\", \"baz\"]},\n            \"nested2\": {\"a\": 31},\n        }\n    ]\n    assert await decoded_r.json().get(\"doc1\", \"$\") == res\n\n    # Test missing key\n    with pytest.raises(exceptions.ResponseError):\n        await decoded_r.json().arrappend(\"non_existing_doc\", \"$..a\")\n\n\n@pytest.mark.redismod\nasync def test_arrinsert_dollar(decoded_r: redis.Redis):\n    await decoded_r.json().set(\n        \"doc1\",\n        \"$\",\n        {\n            \"a\": [\"foo\"],\n            \"nested1\": {\"a\": [\"hello\", None, \"world\"]},\n            \"nested2\": {\"a\": 31},\n        },\n    )\n    # Test multi\n    res = await decoded_r.json().arrinsert(\"doc1\", \"$..a\", \"1\", \"bar\", \"racuda\")\n    assert res == [3, 5, None]\n\n    res = [\n        {\n            \"a\": [\"foo\", \"bar\", \"racuda\"],\n            \"nested1\": {\"a\": [\"hello\", \"bar\", \"racuda\", None, \"world\"]},\n            \"nested2\": {\"a\": 31},\n        }\n    ]\n    assert await decoded_r.json().get(\"doc1\", \"$\") == res\n    # Test single\n    assert await decoded_r.json().arrinsert(\"doc1\", \"$.nested1.a\", -2, \"baz\") == [6]\n    res = [\n        {\n            \"a\": [\"foo\", \"bar\", \"racuda\"],\n            \"nested1\": {\"a\": [\"hello\", \"bar\", \"racuda\", \"baz\", None, \"world\"]},\n            \"nested2\": {\"a\": 31},\n        }\n    ]\n    assert await decoded_r.json().get(\"doc1\", \"$\") == res\n\n    # Test missing key\n    with pytest.raises(exceptions.ResponseError):\n        await decoded_r.json().arrappend(\"non_existing_doc\", \"$..a\")\n\n\n@pytest.mark.redismod\nasync def test_arrlen_dollar(decoded_r: redis.Redis):\n    await decoded_r.json().set(\n        \"doc1\",\n        \"$\",\n        {\n            \"a\": [\"foo\"],\n            \"nested1\": {\"a\": [\"hello\", None, \"world\"]},\n            \"nested2\": {\"a\": 31},\n        },\n    )\n\n    # Test multi\n    assert await decoded_r.json().arrlen(\"doc1\", \"$..a\") == [1, 3, None]\n    res = await decoded_r.json().arrappend(\"doc1\", \"$..a\", \"non\", \"abba\", \"stanza\")\n    assert res == [4, 6, None]\n\n    await decoded_r.json().clear(\"doc1\", \"$.a\")\n    assert await decoded_r.json().arrlen(\"doc1\", \"$..a\") == [0, 6, None]\n    # Test single\n    assert await decoded_r.json().arrlen(\"doc1\", \"$.nested1.a\") == [6]\n\n    # Test missing key\n    with pytest.raises(exceptions.ResponseError):\n        await decoded_r.json().arrappend(\"non_existing_doc\", \"$..a\")\n\n    await decoded_r.json().set(\n        \"doc1\",\n        \"$\",\n        {\n            \"a\": [\"foo\"],\n            \"nested1\": {\"a\": [\"hello\", None, \"world\"]},\n            \"nested2\": {\"a\": 31},\n        },\n    )\n    # Test multi (return result of last path)\n    assert await decoded_r.json().arrlen(\"doc1\", \"$..a\") == [1, 3, None]\n    assert await decoded_r.json().arrappend(\"doc1\", \"..a\", \"non\", \"abba\", \"stanza\") == 6\n\n    # Test single\n    assert await decoded_r.json().arrlen(\"doc1\", \".nested1.a\") == 6\n\n    # Test missing key\n    assert await decoded_r.json().arrlen(\"non_existing_doc\", \"..a\") is None\n\n\n@pytest.mark.redismod\nasync def test_arrpop_dollar(decoded_r: redis.Redis):\n    await decoded_r.json().set(\n        \"doc1\",\n        \"$\",\n        {\n            \"a\": [\"foo\"],\n            \"nested1\": {\"a\": [\"hello\", None, \"world\"]},\n            \"nested2\": {\"a\": 31},\n        },\n    )\n\n    # Test multi\n    assert await decoded_r.json().arrpop(\"doc1\", \"$..a\", 1) == ['\"foo\"', None, None]\n\n    res = [{\"a\": [], \"nested1\": {\"a\": [\"hello\", \"world\"]}, \"nested2\": {\"a\": 31}}]\n    assert await decoded_r.json().get(\"doc1\", \"$\") == res\n\n    # Test missing key\n    with pytest.raises(exceptions.ResponseError):\n        await decoded_r.json().arrpop(\"non_existing_doc\", \"..a\")\n\n    # # Test legacy\n    await decoded_r.json().set(\n        \"doc1\",\n        \"$\",\n        {\n            \"a\": [\"foo\"],\n            \"nested1\": {\"a\": [\"hello\", None, \"world\"]},\n            \"nested2\": {\"a\": 31},\n        },\n    )\n    # Test multi (all paths are updated, but return result of last path)\n    assert await decoded_r.json().arrpop(\"doc1\", \"..a\", \"1\") == \"null\"\n    res = [{\"a\": [], \"nested1\": {\"a\": [\"hello\", \"world\"]}, \"nested2\": {\"a\": 31}}]\n    assert await decoded_r.json().get(\"doc1\", \"$\") == res\n\n    # # Test missing key\n    with pytest.raises(exceptions.ResponseError):\n        await decoded_r.json().arrpop(\"non_existing_doc\", \"..a\")\n\n\n@pytest.mark.redismod\nasync def test_arrtrim_dollar(decoded_r: redis.Redis):\n    await decoded_r.json().set(\n        \"doc1\",\n        \"$\",\n        {\n            \"a\": [\"foo\"],\n            \"nested1\": {\"a\": [\"hello\", None, \"world\"]},\n            \"nested2\": {\"a\": 31},\n        },\n    )\n    # Test multi\n    assert await decoded_r.json().arrtrim(\"doc1\", \"$..a\", \"1\", -1) == [0, 2, None]\n    res = [{\"a\": [], \"nested1\": {\"a\": [None, \"world\"]}, \"nested2\": {\"a\": 31}}]\n    assert await decoded_r.json().get(\"doc1\", \"$\") == res\n\n    assert await decoded_r.json().arrtrim(\"doc1\", \"$..a\", \"1\", \"1\") == [0, 1, None]\n    res = [{\"a\": [], \"nested1\": {\"a\": [\"world\"]}, \"nested2\": {\"a\": 31}}]\n    assert await decoded_r.json().get(\"doc1\", \"$\") == res\n    # Test single\n    assert await decoded_r.json().arrtrim(\"doc1\", \"$.nested1.a\", 1, 0) == [0]\n    res = [{\"a\": [], \"nested1\": {\"a\": []}, \"nested2\": {\"a\": 31}}]\n    assert await decoded_r.json().get(\"doc1\", \"$\") == res\n\n    # Test missing key\n    with pytest.raises(exceptions.ResponseError):\n        await decoded_r.json().arrtrim(\"non_existing_doc\", \"..a\", \"0\", 1)\n\n    # Test legacy\n    await decoded_r.json().set(\n        \"doc1\",\n        \"$\",\n        {\n            \"a\": [\"foo\"],\n            \"nested1\": {\"a\": [\"hello\", None, \"world\"]},\n            \"nested2\": {\"a\": 31},\n        },\n    )\n\n    # Test multi (all paths are updated, but return result of last path)\n    assert await decoded_r.json().arrtrim(\"doc1\", \"..a\", \"1\", \"-1\") == 2\n\n    # Test single\n    assert await decoded_r.json().arrtrim(\"doc1\", \".nested1.a\", \"1\", \"1\") == 1\n    res = [{\"a\": [], \"nested1\": {\"a\": [\"world\"]}, \"nested2\": {\"a\": 31}}]\n    assert await decoded_r.json().get(\"doc1\", \"$\") == res\n\n    # Test missing key\n    with pytest.raises(exceptions.ResponseError):\n        await decoded_r.json().arrtrim(\"non_existing_doc\", \"..a\", 1, 1)\n\n\n@pytest.mark.redismod\nasync def test_objkeys_dollar(decoded_r: redis.Redis):\n    await decoded_r.json().set(\n        \"doc1\",\n        \"$\",\n        {\n            \"nested1\": {\"a\": {\"foo\": 10, \"bar\": 20}},\n            \"a\": [\"foo\"],\n            \"nested2\": {\"a\": {\"baz\": 50}},\n        },\n    )\n\n    # Test single\n    assert await decoded_r.json().objkeys(\"doc1\", \"$.nested1.a\") == [[\"foo\", \"bar\"]]\n\n    # Test legacy\n    assert await decoded_r.json().objkeys(\"doc1\", \".*.a\") == [\"foo\", \"bar\"]\n    # Test single\n    assert await decoded_r.json().objkeys(\"doc1\", \".nested2.a\") == [\"baz\"]\n\n    # Test missing key\n    assert await decoded_r.json().objkeys(\"non_existing_doc\", \"..a\") is None\n\n    # Test non existing doc\n    with pytest.raises(exceptions.ResponseError):\n        assert await decoded_r.json().objkeys(\"non_existing_doc\", \"$..a\") == []\n\n    assert await decoded_r.json().objkeys(\"doc1\", \"$..nowhere\") == []\n\n\n@pytest.mark.redismod\nasync def test_objlen_dollar(decoded_r: redis.Redis):\n    await decoded_r.json().set(\n        \"doc1\",\n        \"$\",\n        {\n            \"nested1\": {\"a\": {\"foo\": 10, \"bar\": 20}},\n            \"a\": [\"foo\"],\n            \"nested2\": {\"a\": {\"baz\": 50}},\n        },\n    )\n    # Test multi\n    assert await decoded_r.json().objlen(\"doc1\", \"$..a\") == [None, 2, 1]\n    # Test single\n    assert await decoded_r.json().objlen(\"doc1\", \"$.nested1.a\") == [2]\n\n    # Test missing key, and path\n    with pytest.raises(exceptions.ResponseError):\n        await decoded_r.json().objlen(\"non_existing_doc\", \"$..a\")\n\n    assert await decoded_r.json().objlen(\"doc1\", \"$.nowhere\") == []\n\n    # Test legacy\n    assert await decoded_r.json().objlen(\"doc1\", \".*.a\") == 2\n\n    # Test single\n    assert await decoded_r.json().objlen(\"doc1\", \".nested2.a\") == 1\n\n    # Test missing key\n    assert await decoded_r.json().objlen(\"non_existing_doc\", \"..a\") is None\n\n    # Test missing path\n    # with pytest.raises(exceptions.ResponseError):\n    await decoded_r.json().objlen(\"doc1\", \".nowhere\")\n\n\ndef load_types_data(nested_key_name):\n    td = {\n        \"object\": {},\n        \"array\": [],\n        \"string\": \"str\",\n        \"integer\": 42,\n        \"number\": 1.2,\n        \"boolean\": False,\n        \"null\": None,\n    }\n    jdata = {}\n    types = []\n    for i, (k, v) in zip(range(1, len(td) + 1), iter(td.items())):\n        jdata[\"nested\" + str(i)] = {nested_key_name: v}\n        types.append(k)\n\n    return jdata, types\n\n\n@pytest.mark.redismod\nasync def test_type_dollar(decoded_r: redis.Redis):\n    jdata, jtypes = load_types_data(\"a\")\n    await decoded_r.json().set(\"doc1\", \"$\", jdata)\n    # Test multi\n    assert_resp_response(\n        decoded_r, await decoded_r.json().type(\"doc1\", \"$..a\"), jtypes, [jtypes]\n    )\n\n    # Test single\n    res = await decoded_r.json().type(\"doc1\", \"$.nested2.a\")\n    assert_resp_response(decoded_r, res, [jtypes[1]], [[jtypes[1]]])\n\n    # Test missing key\n    assert_resp_response(\n        decoded_r, await decoded_r.json().type(\"non_existing_doc\", \"..a\"), None, [None]\n    )\n\n\n@pytest.mark.redismod\nasync def test_clear_dollar(decoded_r: redis.Redis):\n    await decoded_r.json().set(\n        \"doc1\",\n        \"$\",\n        {\n            \"nested1\": {\"a\": {\"foo\": 10, \"bar\": 20}},\n            \"a\": [\"foo\"],\n            \"nested2\": {\"a\": \"claro\"},\n            \"nested3\": {\"a\": {\"baz\": 50}},\n        },\n    )\n\n    # Test multi\n    assert await decoded_r.json().clear(\"doc1\", \"$..a\") == 3\n\n    res = [\n        {\"nested1\": {\"a\": {}}, \"a\": [], \"nested2\": {\"a\": \"claro\"}, \"nested3\": {\"a\": {}}}\n    ]\n    assert await decoded_r.json().get(\"doc1\", \"$\") == res\n\n    # Test single\n    await decoded_r.json().set(\n        \"doc1\",\n        \"$\",\n        {\n            \"nested1\": {\"a\": {\"foo\": 10, \"bar\": 20}},\n            \"a\": [\"foo\"],\n            \"nested2\": {\"a\": \"claro\"},\n            \"nested3\": {\"a\": {\"baz\": 50}},\n        },\n    )\n    assert await decoded_r.json().clear(\"doc1\", \"$.nested1.a\") == 1\n    res = [\n        {\n            \"nested1\": {\"a\": {}},\n            \"a\": [\"foo\"],\n            \"nested2\": {\"a\": \"claro\"},\n            \"nested3\": {\"a\": {\"baz\": 50}},\n        }\n    ]\n    assert await decoded_r.json().get(\"doc1\", \"$\") == res\n\n    # Test missing path (async defaults to root)\n    assert await decoded_r.json().clear(\"doc1\") == 1\n    assert await decoded_r.json().get(\"doc1\", \"$\") == [{}]\n\n    # Test missing key\n    with pytest.raises(exceptions.ResponseError):\n        await decoded_r.json().clear(\"non_existing_doc\", \"$..a\")\n\n\n@pytest.mark.redismod\nasync def test_toggle_dollar(decoded_r: redis.Redis):\n    await decoded_r.json().set(\n        \"doc1\",\n        \"$\",\n        {\n            \"a\": [\"foo\"],\n            \"nested1\": {\"a\": False},\n            \"nested2\": {\"a\": 31},\n            \"nested3\": {\"a\": True},\n        },\n    )\n    # Test multi\n    assert await decoded_r.json().toggle(\"doc1\", \"$..a\") == [None, 1, None, 0]\n    res = [\n        {\n            \"a\": [\"foo\"],\n            \"nested1\": {\"a\": True},\n            \"nested2\": {\"a\": 31},\n            \"nested3\": {\"a\": False},\n        }\n    ]\n    assert await decoded_r.json().get(\"doc1\", \"$\") == res\n\n    # Test missing key\n    with pytest.raises(exceptions.ResponseError):\n        await decoded_r.json().toggle(\"non_existing_doc\", \"$..a\")\n"
  },
  {
    "path": "tests/test_asyncio/test_lock.py",
    "content": "import asyncio\n\nimport pytest\nimport pytest_asyncio\nfrom redis.asyncio.lock import Lock\nfrom redis.exceptions import LockError, LockNotOwnedError\n\n\nclass TestLock:\n    @pytest_asyncio.fixture()\n    async def r_decoded(self, create_redis):\n        redis = await create_redis(decode_responses=True)\n        yield redis\n        await redis.flushall()\n\n    def get_lock(self, redis, *args, **kwargs):\n        kwargs[\"lock_class\"] = Lock\n        return redis.lock(*args, **kwargs)\n\n    async def test_lock(self, r):\n        lock = self.get_lock(r, \"foo\")\n        assert await lock.acquire(blocking=False)\n        assert await r.get(\"foo\") == lock.local.token\n        assert await r.ttl(\"foo\") == -1\n        await lock.release()\n        assert await r.get(\"foo\") is None\n\n    async def test_lock_token(self, r):\n        lock = self.get_lock(r, \"foo\")\n        await self._test_lock_token(r, lock)\n\n    async def test_lock_token_thread_local_false(self, r):\n        lock = self.get_lock(r, \"foo\", thread_local=False)\n        await self._test_lock_token(r, lock)\n\n    async def _test_lock_token(self, r, lock):\n        assert await lock.acquire(blocking=False, token=\"test\")\n        assert await r.get(\"foo\") == b\"test\"\n        assert lock.local.token == b\"test\"\n        assert await r.ttl(\"foo\") == -1\n        await lock.release()\n        assert await r.get(\"foo\") is None\n        assert lock.local.token is None\n\n    async def test_locked(self, r):\n        lock = self.get_lock(r, \"foo\")\n        assert await lock.locked() is False\n        await lock.acquire(blocking=False)\n        assert await lock.locked() is True\n        await lock.release()\n        assert await lock.locked() is False\n\n    async def _test_owned(self, client):\n        lock = self.get_lock(client, \"foo\")\n        assert await lock.owned() is False\n        await lock.acquire(blocking=False)\n        assert await lock.owned() is True\n        await lock.release()\n        assert await lock.owned() is False\n\n        lock2 = self.get_lock(client, \"foo\")\n        assert await lock.owned() is False\n        assert await lock2.owned() is False\n        await lock2.acquire(blocking=False)\n        assert await lock.owned() is False\n        assert await lock2.owned() is True\n        await lock2.release()\n        assert await lock.owned() is False\n        assert await lock2.owned() is False\n\n    async def test_owned(self, r):\n        await self._test_owned(r)\n\n    async def test_owned_with_decoded_responses(self, r_decoded):\n        await self._test_owned(r_decoded)\n\n    async def test_competing_locks(self, r):\n        lock1 = self.get_lock(r, \"foo\")\n        lock2 = self.get_lock(r, \"foo\")\n        assert await lock1.acquire(blocking=False)\n        assert not await lock2.acquire(blocking=False)\n        await lock1.release()\n        assert await lock2.acquire(blocking=False)\n        assert not await lock1.acquire(blocking=False)\n        await lock2.release()\n\n    async def test_timeout(self, r):\n        lock = self.get_lock(r, \"foo\", timeout=10)\n        assert await lock.acquire(blocking=False)\n        assert 8 < (await r.ttl(\"foo\")) <= 10\n        await lock.release()\n\n    async def test_float_timeout(self, r):\n        lock = self.get_lock(r, \"foo\", timeout=9.5)\n        assert await lock.acquire(blocking=False)\n        assert 8 < (await r.pttl(\"foo\")) <= 9500\n        await lock.release()\n\n    async def test_blocking(self, r):\n        blocking = False\n        lock = self.get_lock(r, \"foo\", blocking=blocking)\n        assert not lock.blocking\n\n        lock_2 = self.get_lock(r, \"foo\")\n        assert lock_2.blocking\n\n    async def test_blocking_timeout(self, r):\n        lock1 = self.get_lock(r, \"foo\")\n        assert await lock1.acquire(blocking=False)\n        bt = 0.2\n        sleep = 0.05\n        lock2 = self.get_lock(r, \"foo\", sleep=sleep, blocking_timeout=bt)\n        start = asyncio.get_running_loop().time()\n        assert not await lock2.acquire()\n        # The elapsed duration should be less than the total blocking_timeout\n        assert bt >= (asyncio.get_running_loop().time() - start) > bt - sleep\n        await lock1.release()\n\n    async def test_context_manager(self, r):\n        # blocking_timeout prevents a deadlock if the lock can't be acquired\n        # for some reason\n        async with self.get_lock(r, \"foo\", blocking_timeout=0.2) as lock:\n            assert await r.get(\"foo\") == lock.local.token\n        assert await r.get(\"foo\") is None\n\n    async def test_context_manager_raises_when_locked_not_acquired(self, r):\n        await r.set(\"foo\", \"bar\")\n        with pytest.raises(LockError):\n            async with self.get_lock(r, \"foo\", blocking_timeout=0.1):\n                pass\n\n    async def test_context_manager_not_raise_on_release_lock_not_owned_error(self, r):\n        try:\n            async with self.get_lock(\n                r, \"foo\", timeout=0.1, raise_on_release_error=False\n            ):\n                await asyncio.sleep(0.15)\n        except LockNotOwnedError:\n            pytest.fail(\"LockNotOwnedError should not have been raised\")\n\n        with pytest.raises(LockNotOwnedError):\n            async with self.get_lock(\n                r, \"foo\", timeout=0.1, raise_on_release_error=True\n            ):\n                await asyncio.sleep(0.15)\n\n    async def test_context_manager_not_raise_on_release_lock_error(self, r):\n        try:\n            async with self.get_lock(\n                r, \"foo\", timeout=0.1, raise_on_release_error=False\n            ) as lock:\n                await lock.release()\n        except LockError:\n            pytest.fail(\"LockError should not have been raised\")\n\n        with pytest.raises(LockError):\n            async with self.get_lock(\n                r, \"foo\", timeout=0.1, raise_on_release_error=True\n            ) as lock:\n                await lock.release()\n\n    async def test_high_sleep_small_blocking_timeout(self, r):\n        lock1 = self.get_lock(r, \"foo\")\n        assert await lock1.acquire(blocking=False)\n        sleep = 60\n        bt = 1\n        lock2 = self.get_lock(r, \"foo\", sleep=sleep, blocking_timeout=bt)\n        start = asyncio.get_running_loop().time()\n        assert not await lock2.acquire()\n        # the elapsed timed is less than the blocking_timeout as the lock is\n        # unattainable given the sleep/blocking_timeout configuration\n        assert bt > (asyncio.get_running_loop().time() - start)\n        await lock1.release()\n\n    async def test_releasing_unlocked_lock_raises_error(self, r):\n        lock = self.get_lock(r, \"foo\")\n        with pytest.raises(LockError):\n            await lock.release()\n\n    async def test_releasing_lock_no_longer_owned_raises_error(self, r):\n        lock = self.get_lock(r, \"foo\")\n        await lock.acquire(blocking=False)\n        # manually change the token\n        await r.set(\"foo\", \"a\")\n        with pytest.raises(LockNotOwnedError):\n            await lock.release()\n        # even though we errored, the token is still cleared\n        assert lock.local.token is None\n\n    async def test_extend_lock(self, r):\n        lock = self.get_lock(r, \"foo\", timeout=10)\n        assert await lock.acquire(blocking=False)\n        assert 8000 < (await r.pttl(\"foo\")) <= 10000\n        assert await lock.extend(10)\n        assert 16000 < (await r.pttl(\"foo\")) <= 20000\n        await lock.release()\n\n    async def test_extend_lock_replace_ttl(self, r):\n        lock = self.get_lock(r, \"foo\", timeout=10)\n        assert await lock.acquire(blocking=False)\n        assert 8000 < (await r.pttl(\"foo\")) <= 10000\n        assert await lock.extend(10, replace_ttl=True)\n        assert 8000 < (await r.pttl(\"foo\")) <= 10000\n        await lock.release()\n\n    async def test_extend_lock_float(self, r):\n        lock = self.get_lock(r, \"foo\", timeout=10.5)\n        assert await lock.acquire(blocking=False)\n        assert 10400 < (await r.pttl(\"foo\")) <= 10500\n        old_ttl = await r.pttl(\"foo\")\n        assert await lock.extend(10.5)\n        assert old_ttl + 10400 < (await r.pttl(\"foo\")) <= old_ttl + 10500\n        await lock.release()\n\n    async def test_extending_unlocked_lock_raises_error(self, r):\n        lock = self.get_lock(r, \"foo\", timeout=10)\n        with pytest.raises(LockError):\n            await lock.extend(10)\n\n    async def test_extending_lock_with_no_timeout_raises_error(self, r):\n        lock = self.get_lock(r, \"foo\")\n        assert await lock.acquire(blocking=False)\n        with pytest.raises(LockError):\n            await lock.extend(10)\n        await lock.release()\n\n    async def test_extending_lock_no_longer_owned_raises_error(self, r):\n        lock = self.get_lock(r, \"foo\", timeout=10)\n        assert await lock.acquire(blocking=False)\n        await r.set(\"foo\", \"a\")\n        with pytest.raises(LockNotOwnedError):\n            await lock.extend(10)\n\n    async def test_reacquire_lock(self, r):\n        lock = self.get_lock(r, \"foo\", timeout=10)\n        assert await lock.acquire(blocking=False)\n        assert await r.pexpire(\"foo\", 5000)\n        assert await r.pttl(\"foo\") <= 5000\n        assert await lock.reacquire()\n        assert 8000 < (await r.pttl(\"foo\")) <= 10000\n        await lock.release()\n\n    async def test_reacquiring_unlocked_lock_raises_error(self, r):\n        lock = self.get_lock(r, \"foo\", timeout=10)\n        with pytest.raises(LockError):\n            await lock.reacquire()\n\n    async def test_reacquiring_lock_with_no_timeout_raises_error(self, r):\n        lock = self.get_lock(r, \"foo\")\n        assert await lock.acquire(blocking=False)\n        with pytest.raises(LockError):\n            await lock.reacquire()\n        await lock.release()\n\n    async def test_reacquiring_lock_no_longer_owned_raises_error(self, r):\n        lock = self.get_lock(r, \"foo\", timeout=10)\n        assert await lock.acquire(blocking=False)\n        await r.set(\"foo\", \"a\")\n        with pytest.raises(LockNotOwnedError):\n            await lock.reacquire()\n\n    async def test_release_cancellation_preserves_lock_state(self, r):\n        \"\"\"\n        Test that cancelling release() doesn't leave lock in inconsistent state.\n\n        Regression test for GitHub issue #3847. Before the fix, if release()\n        was cancelled during execution, the token would be cleared but the\n        Redis key would remain, causing a permanent deadlock.\n        \"\"\"\n        lock = self.get_lock(r, \"foo\")\n        await lock.acquire(blocking=False)\n\n        # Verify lock is owned\n        original_token = lock.local.token\n        assert original_token is not None\n        assert await lock.owned()\n\n        # Create release task and cancel it immediately\n        release_task = asyncio.create_task(lock.release())\n        release_task.cancel()\n\n        try:\n            await release_task\n        except asyncio.CancelledError:\n            # Expected: the release task was deliberately cancelled to test lock state.\n            pass\n\n        # Check the lock state after cancellation\n        if lock.local.token is not None:\n            # Release was cancelled before completion - token preserved\n            # This is the fix: we can now retry the release\n            assert lock.local.token == original_token\n            await lock.release()\n            assert await lock.locked() is False\n        else:\n            # Release completed before cancel took effect\n            assert await lock.locked() is False\n\n\n@pytest.mark.onlynoncluster\nclass TestLockClassSelection:\n    def test_lock_class_argument(self, r):\n        class MyLock:\n            def __init__(self, *args, **kwargs):\n                pass\n\n        lock = r.lock(\"foo\", lock_class=MyLock)\n        assert isinstance(lock, MyLock)\n"
  },
  {
    "path": "tests/test_asyncio/test_monitor.py",
    "content": "import pytest\nfrom tests.conftest import skip_if_redis_enterprise, skip_ifnot_redis_enterprise\n\nfrom .conftest import wait_for_command\n\n\n@pytest.mark.onlynoncluster\nclass TestMonitor:\n    async def test_wait_command_not_found(self, r):\n        \"\"\"Make sure the wait_for_command func works when command is not found\"\"\"\n        async with r.monitor() as m:\n            response = await wait_for_command(r, m, \"nothing\")\n            assert response is None\n\n    async def test_response_values(self, r):\n        db = r.connection_pool.connection_kwargs.get(\"db\", 0)\n        async with r.monitor() as m:\n            await r.ping()\n            response = await wait_for_command(r, m, \"PING\")\n            assert isinstance(response[\"time\"], float)\n            assert response[\"db\"] == db\n            assert response[\"client_type\"] in (\"tcp\", \"unix\")\n            assert isinstance(response[\"client_address\"], str)\n            assert isinstance(response[\"client_port\"], str)\n            assert response[\"command\"] == \"PING\"\n\n    async def test_command_with_quoted_key(self, r):\n        async with r.monitor() as m:\n            await r.get('foo\"bar')\n            response = await wait_for_command(r, m, 'GET foo\"bar')\n            assert response[\"command\"] == 'GET foo\"bar'\n\n    async def test_command_with_binary_data(self, r):\n        async with r.monitor() as m:\n            byte_string = b\"foo\\x92\"\n            await r.get(byte_string)\n            response = await wait_for_command(r, m, \"GET foo\\\\x92\")\n            assert response[\"command\"] == \"GET foo\\\\x92\"\n\n    async def test_command_with_escaped_data(self, r):\n        async with r.monitor() as m:\n            byte_string = b\"foo\\\\x92\"\n            await r.get(byte_string)\n            response = await wait_for_command(r, m, \"GET foo\\\\\\\\x92\")\n            assert response[\"command\"] == \"GET foo\\\\\\\\x92\"\n\n    @skip_if_redis_enterprise()\n    async def test_lua_script(self, r):\n        async with r.monitor() as m:\n            script = 'return redis.call(\"GET\", \"foo\")'\n            assert await r.eval(script, 0) is None\n            response = await wait_for_command(r, m, \"GET foo\")\n            assert response[\"command\"] == \"GET foo\"\n            assert response[\"client_type\"] == \"lua\"\n            assert response[\"client_address\"] == \"lua\"\n            assert response[\"client_port\"] == \"\"\n\n    @skip_ifnot_redis_enterprise()\n    async def test_lua_script_in_enterprise(self, r):\n        async with r.monitor() as m:\n            script = 'return redis.call(\"GET\", \"foo\")'\n            assert await r.eval(script, 0) is None\n            response = await wait_for_command(r, m, \"GET foo\")\n            assert response is None\n"
  },
  {
    "path": "tests/test_asyncio/test_multidb/__init__.py",
    "content": ""
  },
  {
    "path": "tests/test_asyncio/test_multidb/conftest.py",
    "content": "from unittest.mock import Mock, AsyncMock, patch\n\nimport pytest\n\nfrom redis.asyncio.multidb.config import (\n    MultiDbConfig,\n    DatabaseConfig,\n    DEFAULT_AUTO_FALLBACK_INTERVAL,\n    InitialHealthCheck,\n)\nfrom redis.asyncio.multidb.failover import AsyncFailoverStrategy\nfrom redis.asyncio.multidb.failure_detector import AsyncFailureDetector\nfrom redis.asyncio.multidb.healthcheck import (\n    HealthCheck,\n    AbstractHealthCheckPolicy,\n    DEFAULT_HEALTH_CHECK_PROBES,\n    DEFAULT_HEALTH_CHECK_INTERVAL,\n    DEFAULT_HEALTH_CHECK_POLICY,\n    DEFAULT_HEALTH_CHECK_TIMEOUT,\n)\nfrom redis.data_structure import WeightedList\nfrom redis.multidb.circuit import State as CBState, CircuitBreaker\nfrom redis.asyncio import Redis, ConnectionPool\nfrom redis.asyncio.multidb.database import Database, Databases\n\n\n@pytest.fixture(autouse=True)\ndef mock_health_check_client(request):\n    \"\"\"\n    Mock client for health check policies.\n    Uses real policy classes but mocks only the client layer.\n\n    Skip this fixture for tests marked with @pytest.mark.no_mock_connections\n    \"\"\"\n    # Check if the test is marked to skip connection mocking\n    if request.node.get_closest_marker(\"no_mock_connections\"):\n        yield\n        return\n\n    async def mock_get_client(self, database):\n        mock_client = AsyncMock()\n        mock_client.ping = AsyncMock(return_value=True)\n        mock_client.aclose = AsyncMock()\n        return mock_client\n\n    with patch.object(AbstractHealthCheckPolicy, \"get_client\", mock_get_client):\n        yield\n\n\n@pytest.fixture()\ndef mock_client() -> Redis:\n    return Mock(spec=Redis)\n\n\n@pytest.fixture()\ndef mock_cb() -> CircuitBreaker:\n    return Mock(spec=CircuitBreaker)\n\n\n@pytest.fixture()\ndef mock_fd() -> AsyncFailureDetector:\n    return Mock(spec=AsyncFailureDetector)\n\n\n@pytest.fixture()\ndef mock_fs() -> AsyncFailoverStrategy:\n    return Mock(spec=AsyncFailoverStrategy)\n\n\n@pytest.fixture()\ndef mock_hc() -> HealthCheck:\n    mock = Mock(spec=HealthCheck)\n    mock.health_check_probes = DEFAULT_HEALTH_CHECK_PROBES\n    # Use minimal delay for faster test execution\n    mock.health_check_delay = 0.01\n    mock.health_check_timeout = DEFAULT_HEALTH_CHECK_TIMEOUT\n    # check_health is async, so use AsyncMock\n    mock.check_health = AsyncMock(return_value=True)\n    return mock\n\n\ndef _create_mock_db(request) -> Database:\n    \"\"\"Helper to create a mock Database with proper client setup.\"\"\"\n    db = Mock(spec=Database)\n    db.weight = request.param.get(\"weight\", 1.0)\n    db.client = Mock(spec=Redis)\n    db.client.connection_pool = Mock(spec=ConnectionPool)\n\n    cb = request.param.get(\"circuit\", {})\n    mock_cb = Mock(spec=CircuitBreaker)\n    mock_cb.grace_period = cb.get(\"grace_period\", 1.0)\n    mock_cb.state = cb.get(\"state\", CBState.CLOSED)\n\n    db.circuit = mock_cb\n    return db\n\n\n@pytest.fixture()\ndef mock_db(request) -> Database:\n    return _create_mock_db(request)\n\n\n@pytest.fixture()\ndef mock_db1(request) -> Database:\n    return _create_mock_db(request)\n\n\n@pytest.fixture()\ndef mock_db2(request) -> Database:\n    return _create_mock_db(request)\n\n\n@pytest.fixture()\ndef mock_multi_db_config(request, mock_fd, mock_fs, mock_hc, mock_ed) -> MultiDbConfig:\n    hc_interval = request.param.get(\"hc_interval\", DEFAULT_HEALTH_CHECK_INTERVAL)\n    auto_fallback_interval = request.param.get(\n        \"auto_fallback_interval\", DEFAULT_AUTO_FALLBACK_INTERVAL\n    )\n    health_check_policy = request.param.get(\n        \"health_check_policy\", DEFAULT_HEALTH_CHECK_POLICY\n    )\n    health_check_probes = request.param.get(\n        \"health_check_probes\", DEFAULT_HEALTH_CHECK_PROBES\n    )\n    initial_health_check_policy = request.param.get(\n        \"initial_health_check_policy\", InitialHealthCheck.ALL_AVAILABLE\n    )\n\n    config = MultiDbConfig(\n        databases_config=[Mock(spec=DatabaseConfig)],\n        failure_detectors=[mock_fd],\n        health_check_interval=hc_interval,\n        health_check_delay=0.05,\n        health_check_policy=health_check_policy,\n        health_check_probes=health_check_probes,\n        failover_strategy=mock_fs,\n        auto_fallback_interval=auto_fallback_interval,\n        event_dispatcher=mock_ed,\n        initial_health_check_policy=initial_health_check_policy,\n    )\n\n    return config\n\n\ndef create_weighted_list(*databases) -> Databases:\n    dbs = WeightedList()\n\n    for db in databases:\n        dbs.add(db, db.weight)\n\n    return dbs\n"
  },
  {
    "path": "tests/test_asyncio/test_multidb/test_client.py",
    "content": "import asyncio\nfrom unittest.mock import patch, AsyncMock, Mock\n\nimport pybreaker\nimport pytest\n\nfrom redis.asyncio.multidb.client import MultiDBClient\nfrom redis.asyncio.multidb.config import InitialHealthCheck\nfrom redis.asyncio.multidb.database import AsyncDatabase\nfrom redis.asyncio.multidb.failover import WeightBasedFailoverStrategy\nfrom redis.asyncio.multidb.failure_detector import AsyncFailureDetector\nfrom redis.asyncio.multidb.healthcheck import HealthCheck\nfrom redis.event import EventDispatcher, AsyncOnCommandsFailEvent\nfrom redis.multidb.circuit import State as CBState, PBCircuitBreakerAdapter\nfrom redis.multidb.config import DatabaseConfig\nfrom redis.multidb.exception import (\n    NoValidDatabaseException,\n    UnhealthyDatabaseException,\n    InitialHealthCheckFailedError,\n)\nfrom tests.test_asyncio.helpers import wait_for_condition\nfrom tests.test_asyncio.test_multidb.conftest import create_weighted_list\n\n\n@pytest.mark.onlynoncluster\nclass TestMultiDbClient:\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_execute_command_against_correct_db_on_successful_initialization(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db1.client.execute_command = AsyncMock(return_value=\"OK1\")\n\n            mock_hc.check_health.return_value = True\n\n            async with MultiDBClient(mock_multi_db_config) as client:\n                assert (\n                    mock_multi_db_config.failover_strategy.set_databases.call_count == 1\n                )\n                assert await client.set(\"key\", \"value\") == \"OK1\"\n                # 3 databases × 3 probes = 9 calls minimum\n                assert len(mock_hc.check_health.call_args_list) >= 9\n\n                assert mock_db.circuit.state == CBState.CLOSED\n                assert mock_db1.circuit.state == CBState.CLOSED\n                assert mock_db2.circuit.state == CBState.CLOSED\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {\"initial_health_check_policy\": InitialHealthCheck.MAJORITY_AVAILABLE},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.OPEN}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_execute_command_against_correct_db_and_closed_circuit(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        \"\"\"\n        Validates that commands are executed against the correct\n        database when one database becomes unhealthy during initialization.\n        Ensures the client selects the highest-weighted\n        healthy database (mock_db1) and executes commands against it\n        with a CLOSED circuit.\n        \"\"\"\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db.client.execute_command = AsyncMock(\n                return_value=\"NOT_OK-->Response from unexpected db - mock_db\"\n            )\n            mock_db1.client.execute_command = AsyncMock(return_value=\"OK1\")\n            mock_db2.client.execute_command = AsyncMock(\n                return_value=\"NOT_OK-->Response from unexpected db - mock_db2\"\n            )\n\n            async def mock_check_health(database, connection=None):\n                if database == mock_db2:\n                    return False\n                else:\n                    return True\n\n            mock_hc.check_health.side_effect = mock_check_health\n\n            async with MultiDBClient(mock_multi_db_config) as client:\n                assert (\n                    mock_multi_db_config.failover_strategy.set_databases.call_count == 1\n                )\n                result = await client.set(\"key\", \"value\")\n                assert result == \"OK1\"\n                # 3 databases × 3 probes = 9 calls minimum (but one db fails, so >= 7)\n                assert len(mock_hc.check_health.call_args_list) >= 7\n\n                assert mock_db.circuit.state == CBState.CLOSED\n                assert mock_db1.circuit.state == CBState.CLOSED\n                assert mock_db2.circuit.state == CBState.OPEN\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_execute_command_against_correct_db_on_background_health_check_determine_active_db_unhealthy(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        cb = PBCircuitBreakerAdapter(pybreaker.CircuitBreaker(reset_timeout=0.1))\n        cb.database = mock_db\n        mock_db.circuit = cb\n\n        cb1 = PBCircuitBreakerAdapter(pybreaker.CircuitBreaker(reset_timeout=0.1))\n        cb1.database = mock_db1\n        mock_db1.circuit = cb1\n\n        cb2 = PBCircuitBreakerAdapter(pybreaker.CircuitBreaker(reset_timeout=0.1))\n        cb2.database = mock_db2\n        mock_db2.circuit = cb2\n\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n\n        # Track health check rounds per database\n        # A round is a complete health check cycle (initial or background)\n        db_rounds = {id(mock_db): 0, id(mock_db1): 0, id(mock_db2): 0}\n        db_probes_in_round = {id(mock_db): 0, id(mock_db1): 0, id(mock_db2): 0}\n\n        # Create events for each failover scenario\n        db1_became_unhealthy = asyncio.Event()\n        db2_became_unhealthy = asyncio.Event()\n        db_became_unhealthy = asyncio.Event()\n        counter_lock = asyncio.Lock()\n\n        async def mock_check_health(database, connection=None):\n            async with counter_lock:\n                db_probes_in_round[id(database)] += 1\n                # After 3 probes, increment the round counter\n                if db_probes_in_round[id(database)] > 3:\n                    db_rounds[id(database)] += 1\n                    db_probes_in_round[id(database)] = 1\n                current_round = db_rounds[id(database)]\n\n            # Round 0 (initial health check): All databases healthy\n            if current_round == 0:\n                return True\n\n            # Round 1: mock_db1 becomes unhealthy, others healthy\n            if current_round == 1:\n                if database == mock_db1:\n                    db1_became_unhealthy.set()\n                    return False\n                return True\n\n            # Round 2: mock_db2 also becomes unhealthy, mock_db healthy\n            if current_round == 2:\n                if database == mock_db1:\n                    return False\n                if database == mock_db2:\n                    db2_became_unhealthy.set()\n                    return False\n                return True\n\n            # Round 3: mock_db also becomes unhealthy, but mock_db1 recovers\n            if current_round == 3:\n                if database == mock_db:\n                    db_became_unhealthy.set()\n                    return False\n                if database == mock_db2:\n                    return False\n                return True\n\n            # Round 4+: mock_db1 is healthy, others unhealthy\n            if database == mock_db1:\n                return True\n            return False\n\n        mock_hc.check_health.side_effect = mock_check_health\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db.client.execute_command = AsyncMock(return_value=\"OK\")\n            mock_db1.client.execute_command = AsyncMock(return_value=\"OK1\")\n            mock_db2.client.execute_command = AsyncMock(return_value=\"OK2\")\n            mock_multi_db_config.health_check_interval = 0.05\n            mock_multi_db_config.failover_strategy = WeightBasedFailoverStrategy()\n\n            async with MultiDBClient(mock_multi_db_config) as client:\n                assert await client.set(\"key\", \"value\") == \"OK1\"\n\n                # Wait for mock_db1 to become unhealthy\n                # Use wait_for_condition to wait for the event with a timeout\n                # (instead of just sleeping)\n                await wait_for_condition(\n                    lambda: cb1.state == CBState.OPEN,\n                    timeout=0.5,\n                    error_message=\"Timeout waiting for cb1 to open\",\n                )\n\n                assert await client.set(\"key\", \"value\") == \"OK2\"\n\n                # Wait for mock_db2 to become unhealthy\n                # Use wait_for_condition to wait for the event with a timeout\n                # (instead of just sleeping)\n                await wait_for_condition(\n                    lambda: cb2.state == CBState.OPEN,\n                    timeout=0.5,\n                    error_message=\"Timeout waiting for cb2 to open\",\n                )\n\n                assert await client.set(\"key\", \"value\") == \"OK\"\n\n                # Wait for mock_db to become unhealthy\n                assert await asyncio.wait_for(\n                    db_became_unhealthy.wait(), timeout=1.0\n                ), \"Timeout waiting for mock_db to become unhealthy\"\n                await wait_for_condition(\n                    lambda: cb.state == CBState.OPEN,\n                    timeout=0.5,\n                    error_message=\"Timeout waiting for cb to open\",\n                )\n\n                # Wait for mock_db1 to recover (circuit breaker to close)\n                await wait_for_condition(\n                    lambda: cb1.state == CBState.CLOSED,\n                    timeout=1.0,\n                    error_message=\"Timeout waiting for cb1 to close (mock_db1 to recover)\",\n                )\n\n                assert await client.set(\"key\", \"value\") == \"OK1\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_execute_command_auto_fallback_to_highest_weight_db(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n\n        # Track health check rounds per database\n        db_rounds = {id(mock_db): 0, id(mock_db1): 0, id(mock_db2): 0}\n        db_probes_in_round = {id(mock_db): 0, id(mock_db1): 0, id(mock_db2): 0}\n        db1_became_unhealthy = asyncio.Event()\n        counter_lock = asyncio.Lock()\n\n        async def mock_check_health(database, connection=None):\n            async with counter_lock:\n                db_probes_in_round[id(database)] += 1\n                if db_probes_in_round[id(database)] > 3:\n                    db_rounds[id(database)] += 1\n                    db_probes_in_round[id(database)] = 1\n                current_round = db_rounds[id(database)]\n\n            # Round 0 (initial health check): All databases healthy\n            if current_round == 0:\n                return True\n\n            # Round 1: mock_db1 becomes unhealthy, others healthy\n            if current_round == 1:\n                if database == mock_db1:\n                    db1_became_unhealthy.set()\n                    return False\n                return True\n\n            # Round 2+: All databases healthy (mock_db1 recovers)\n            return True\n\n        mock_hc.check_health.side_effect = mock_check_health\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db.client.execute_command = AsyncMock(return_value=\"OK\")\n            mock_db1.client.execute_command = AsyncMock(return_value=\"OK1\")\n            mock_db2.client.execute_command = AsyncMock(return_value=\"OK2\")\n            mock_multi_db_config.health_check_interval = 0.05\n            mock_multi_db_config.auto_fallback_interval = 0.1\n            mock_multi_db_config.failover_strategy = WeightBasedFailoverStrategy()\n\n            async with MultiDBClient(mock_multi_db_config) as client:\n                assert await client.set(\"key\", \"value\") == \"OK1\"\n\n                # Wait for mock_db1 to become unhealthy\n                assert await asyncio.wait_for(\n                    db1_became_unhealthy.wait(), timeout=1.0\n                ), \"Timeout waiting for mock_db1 to become unhealthy\"\n\n                # Wait for circuit breaker to actually open\n                await wait_for_condition(\n                    lambda: mock_db1.circuit.state == CBState.OPEN,\n                    timeout=0.5,\n                    error_message=\"Timeout waiting for cb1 to open after error event.\",\n                )\n\n                # Now the failover strategy will select mock_db2\n                assert await client.set(\"key\", \"value\") == \"OK2\"\n\n                # Wait for auto fallback interval to pass (mock_db1 recovers in round 2+)\n                await wait_for_condition(\n                    lambda: mock_db1.circuit.state == CBState.CLOSED,\n                    timeout=1.0,\n                    error_message=\"Timeout waiting for mock_db1 to be healthy again.\",\n                )\n\n                # Wait for auto fallback time to pass - this way on next command execution\n                # the active database will be re-evaluated\n                await asyncio.sleep(0.1)\n\n                # Now the failover strategy will select mock_db1 again\n                assert await client.set(\"key\", \"value\") == \"OK1\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_execute_command_do_not_auto_fallback_to_highest_weight_db(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n\n        # Track health check rounds per database\n        db_rounds = {id(mock_db): 0, id(mock_db1): 0, id(mock_db2): 0}\n        db_probes_in_round = {id(mock_db): 0, id(mock_db1): 0, id(mock_db2): 0}\n        db1_became_unhealthy = asyncio.Event()\n        counter_lock = asyncio.Lock()\n\n        async def mock_check_health(database, connection=None):\n            async with counter_lock:\n                db_probes_in_round[id(database)] += 1\n                if db_probes_in_round[id(database)] > 3:\n                    db_rounds[id(database)] += 1\n                    db_probes_in_round[id(database)] = 1\n                current_round = db_rounds[id(database)]\n\n            # Round 0 (initial health check): All databases healthy\n            if current_round == 0:\n                return True\n\n            # Round 1+: mock_db1 stays unhealthy (no auto fallback)\n            if database == mock_db1:\n                db1_became_unhealthy.set()\n                return False\n\n            return True\n\n        mock_hc.check_health.side_effect = mock_check_health\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db.client.execute_command = AsyncMock(return_value=\"OK\")\n            mock_db1.client.execute_command = AsyncMock(return_value=\"OK1\")\n            mock_db2.client.execute_command = AsyncMock(return_value=\"OK2\")\n            mock_multi_db_config.health_check_interval = 0.05\n            mock_multi_db_config.auto_fallback_interval = -1\n            mock_multi_db_config.failover_strategy = WeightBasedFailoverStrategy()\n\n            async with MultiDBClient(mock_multi_db_config) as client:\n                assert await client.set(\"key\", \"value\") == \"OK1\"\n\n                # Wait for mock_db1 to become unhealthy\n                assert await asyncio.wait_for(\n                    db1_became_unhealthy.wait(), timeout=1.0\n                ), \"Timeout waiting for mock_db1 to become unhealthy\"\n\n                # Wait for circuit breaker state to actually reflect the unhealthy status\n                # (instead of just sleeping)\n                await wait_for_condition(\n                    lambda: mock_db1.circuit.state == CBState.OPEN,\n                    timeout=0.5,\n                    error_message=\"Timeout waiting for cb1 to open after error event.\",\n                )\n\n                assert await client.set(\"key\", \"value\") == \"OK2\"\n                await asyncio.sleep(0.5)\n                assert await client.set(\"key\", \"value\") == \"OK2\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_add_database_makes_new_database_active(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        \"\"\"\n        Test that add_database with a DatabaseConfig creates a new database\n        and makes it active if it has the highest weight.\n        \"\"\"\n        databases = create_weighted_list(mock_db, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        new_db_config = DatabaseConfig(\n            weight=0.8,  # Higher than mock_db2's 0.5\n            from_url=\"redis://localhost:6379\",\n        )\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db2.client.execute_command.return_value = \"OK2\"\n            mock_hc.check_health = AsyncMock(return_value=True)\n\n            async with MultiDBClient(mock_multi_db_config) as client:\n                assert (\n                    mock_multi_db_config.failover_strategy.set_databases.call_count == 1\n                )\n\n                # Initially mock_db2 is active (highest weight among initial databases)\n                assert await client.set(\"key\", \"value\") == \"OK2\"\n                initial_hc_count = len(mock_hc.check_health.call_args_list)\n\n                # Mock the client class to return a mock client for the new database\n                mock_new_client = AsyncMock()\n                mock_new_client.execute_command.return_value = \"OK_NEW\"\n                mock_new_client.connection_pool = Mock()\n\n                with patch.object(\n                    mock_multi_db_config.client_class,\n                    \"from_url\",\n                    return_value=mock_new_client,\n                ):\n                    await client.add_database(new_db_config)\n\n                # Health check should have been called for the new database\n                assert len(mock_hc.check_health.call_args_list) > initial_hc_count\n\n                # New database should be active since it has highest weight\n                assert await client.set(\"key\", \"value\") == \"OK_NEW\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_add_database_skip_unhealthy_false_raises_exception(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        \"\"\"\n        Test that add_database with skip_unhealthy=False raises an exception\n        when the new database fails health check due to an exception.\n        \"\"\"\n        databases = create_weighted_list(mock_db, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        new_db_config = DatabaseConfig(\n            weight=0.8,\n            from_url=\"redis://localhost:6379\",\n        )\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db2.client.execute_command.return_value = \"OK2\"\n\n            # Health check returns True for existing databases, raises exception for new one\n            async def mock_check_health(database, connection=None):\n                if database in [mock_db, mock_db2]:\n                    return True\n                # Raise an exception for the new database to trigger UnhealthyDatabaseException\n                raise ConnectionError(\"Connection refused\")\n\n            mock_hc.check_health = AsyncMock(side_effect=mock_check_health)\n\n            async with MultiDBClient(mock_multi_db_config) as client:\n                # Initially mock_db2 is active\n                assert await client.set(\"key\", \"value\") == \"OK2\"\n\n                mock_new_client = AsyncMock()\n                mock_new_client.execute_command.return_value = \"OK_NEW\"\n                mock_new_client.connection_pool = Mock()\n\n                with patch.object(\n                    mock_multi_db_config.client_class,\n                    \"from_url\",\n                    return_value=mock_new_client,\n                ):\n                    # With skip_unhealthy=False, should raise exception\n                    with pytest.raises(UnhealthyDatabaseException):\n                        await client.add_database(\n                            new_db_config, skip_initial_health_check=False\n                        )\n\n                # Database list should remain unchanged\n                assert len(client.get_databases()) == 2\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_remove_highest_weighted_database(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db1.client.execute_command.return_value = \"OK1\"\n            mock_db2.client.execute_command.return_value = \"OK2\"\n\n            mock_hc.check_health.return_value = True\n\n            async with MultiDBClient(mock_multi_db_config) as client:\n                assert (\n                    mock_multi_db_config.failover_strategy.set_databases.call_count == 1\n                )\n\n                assert await client.set(\"key\", \"value\") == \"OK1\"\n                assert len(mock_hc.check_health.call_args_list) == 9\n\n                await client.remove_database(mock_db1)\n                assert await client.set(\"key\", \"value\") == \"OK2\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_update_database_weight_to_be_highest(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db1.client.execute_command.return_value = \"OK1\"\n            mock_db2.client.execute_command.return_value = \"OK2\"\n\n            mock_hc.check_health.return_value = True\n\n            async with MultiDBClient(mock_multi_db_config) as client:\n                assert (\n                    mock_multi_db_config.failover_strategy.set_databases.call_count == 1\n                )\n\n                assert await client.set(\"key\", \"value\") == \"OK1\"\n                assert len(mock_hc.check_health.call_args_list) == 9\n\n                await client.update_database_weight(mock_db2, 0.8)\n                assert mock_db2.weight == 0.8\n\n                assert await client.set(\"key\", \"value\") == \"OK2\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_add_new_failure_detector(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db1.client.execute_command.return_value = \"OK1\"\n            mock_multi_db_config.event_dispatcher = EventDispatcher()\n            mock_fd = mock_multi_db_config.failure_detectors[0]\n\n            # Event fired if command against mock_db1 would fail\n            command_fail_event = AsyncOnCommandsFailEvent(\n                commands=(\"SET\", \"key\", \"value\"),\n                exception=Exception(),\n            )\n\n            mock_hc.check_health.return_value = True\n\n            async with MultiDBClient(mock_multi_db_config) as client:\n                assert (\n                    mock_multi_db_config.failover_strategy.set_databases.call_count == 1\n                )\n                assert await client.set(\"key\", \"value\") == \"OK1\"\n                assert len(mock_hc.check_health.call_args_list) == 9\n\n                # Simulate failing command events that lead to a failure detection\n                for _ in range(5):\n                    await mock_multi_db_config.event_dispatcher.dispatch_async(\n                        command_fail_event\n                    )\n\n                assert mock_fd.register_failure.call_count == 5\n\n                another_fd = Mock(spec=AsyncFailureDetector)\n                client.add_failure_detector(another_fd)\n\n                # Simulate failing command events that lead to a failure detection\n                for _ in range(5):\n                    await mock_multi_db_config.event_dispatcher.dispatch_async(\n                        command_fail_event\n                    )\n\n                assert mock_fd.register_failure.call_count == 10\n                assert another_fd.register_failure.call_count == 5\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_add_new_health_check(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db1.client.execute_command.return_value = \"OK1\"\n\n            mock_hc.check_health.return_value = True\n\n            async with MultiDBClient(mock_multi_db_config) as client:\n                assert (\n                    mock_multi_db_config.failover_strategy.set_databases.call_count == 1\n                )\n                assert await client.set(\"key\", \"value\") == \"OK1\"\n                assert len(mock_hc.check_health.call_args_list) >= 9\n\n                another_hc = Mock(spec=HealthCheck)\n                another_hc.health_check_probes = 3\n                another_hc.health_check_delay = 0.01\n                another_hc.health_check_timeout = 3.0\n                another_hc.check_health.return_value = True\n\n                await client.add_health_check(another_hc)\n                await client._check_db_health(mock_db1)\n\n                assert len(mock_hc.check_health.call_args_list) >= 12\n                assert len(another_hc.check_health.call_args_list) >= 3\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_set_active_database(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db1.client.execute_command.return_value = \"OK1\"\n            mock_db.client.execute_command.return_value = \"OK\"\n\n            mock_hc.check_health.return_value = True\n\n            async with MultiDBClient(mock_multi_db_config) as client:\n                assert (\n                    mock_multi_db_config.failover_strategy.set_databases.call_count == 1\n                )\n                assert await client.set(\"key\", \"value\") == \"OK1\"\n                assert len(mock_hc.check_health.call_args_list) >= 9\n\n                await client.set_active_database(mock_db)\n                assert await client.set(\"key\", \"value\") == \"OK\"\n\n                with pytest.raises(\n                    ValueError, match=\"Given database is not a member of database list\"\n                ):\n                    await client.set_active_database(Mock(spec=AsyncDatabase))\n\n                mock_hc.check_health.return_value = False\n\n                with pytest.raises(\n                    NoValidDatabaseException,\n                    match=\"Cannot set active database, database is unhealthy\",\n                ):\n                    await client.set_active_database(mock_db1)\n\n\n@pytest.mark.onlynoncluster\nclass TestGeoFailoverMetricRecording:\n    \"\"\"Tests for geo failover metric recording in async MultiDBClient.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_manual_failover_records_metric(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        \"\"\"\n        Test that set_active_database records geo failover metric with MANUAL reason.\n        \"\"\"\n        from redis.observability.attributes import GeoFailoverReason\n\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db1.client.execute_command = AsyncMock(return_value=\"OK1\")\n            mock_db.client.execute_command = AsyncMock(return_value=\"OK\")\n            mock_hc.check_health = AsyncMock(return_value=True)\n\n            async with MultiDBClient(mock_multi_db_config) as client:\n                # Initial active database should be mock_db1 (highest weight)\n                assert await client.set(\"key\", \"value\") == \"OK1\"\n\n                # Now manually switch to mock_db\n                # Patch at the module where it's imported\n                with patch(\n                    \"redis.asyncio.multidb.command_executor.record_geo_failover\"\n                ) as mock_record_geo_failover:\n                    mock_record_geo_failover.return_value = None\n                    await client.set_active_database(mock_db)\n\n                    # Verify record_geo_failover was called with correct arguments\n                    mock_record_geo_failover.assert_called_once()\n                    call_kwargs = mock_record_geo_failover.call_args[1]\n                    assert call_kwargs[\"fail_from\"] == mock_db1\n                    assert call_kwargs[\"fail_to\"] == mock_db\n                    assert call_kwargs[\"reason\"] == GeoFailoverReason.MANUAL\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_automatic_failover_records_metric(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        \"\"\"\n        Test that automatic failover records geo failover metric with AUTOMATIC reason.\n        \"\"\"\n        from redis.observability.attributes import GeoFailoverReason\n\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db1.client.execute_command = AsyncMock(return_value=\"OK1\")\n            mock_db2.client.execute_command = AsyncMock(return_value=\"OK2\")\n            mock_hc.check_health = AsyncMock(return_value=True)\n\n            async with MultiDBClient(mock_multi_db_config) as client:\n                # Initial active database should be mock_db1 (highest weight)\n                assert await client.set(\"key\", \"value\") == \"OK1\"\n\n                # Simulate mock_db1 becoming unhealthy (circuit open)\n                mock_db1.circuit.state = CBState.OPEN\n\n                # Configure the failover strategy to return mock_db2\n                mock_multi_db_config.failover_strategy.database.return_value = mock_db2\n\n                # Patch at the module where it's imported\n                with patch(\n                    \"redis.asyncio.multidb.command_executor.record_geo_failover\"\n                ) as mock_record_geo_failover:\n                    mock_record_geo_failover.return_value = None\n                    # Execute a command - this should trigger automatic failover\n                    assert await client.set(\"key\", \"value\") == \"OK2\"\n\n                    # Verify record_geo_failover was called with AUTOMATIC reason\n                    mock_record_geo_failover.assert_called_once()\n                    call_kwargs = mock_record_geo_failover.call_args[1]\n                    assert call_kwargs[\"fail_from\"] == mock_db1\n                    assert call_kwargs[\"fail_to\"] == mock_db2\n                    assert call_kwargs[\"reason\"] == GeoFailoverReason.AUTOMATIC\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_no_metric_recorded_when_same_database(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        \"\"\"\n        Test that no geo failover metric is recorded when active database doesn't change.\n        \"\"\"\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db1.client.execute_command = AsyncMock(return_value=\"OK1\")\n            mock_hc.check_health = AsyncMock(return_value=True)\n\n            async with MultiDBClient(mock_multi_db_config) as client:\n                # Initial active database should be mock_db1 (highest weight)\n                assert await client.set(\"key\", \"value\") == \"OK1\"\n\n                # Patch at the module where it's imported\n                with patch(\n                    \"redis.asyncio.multidb.command_executor.record_geo_failover\"\n                ) as mock_record_geo_failover:\n                    mock_record_geo_failover.return_value = None\n                    # Set active database to the same database\n                    await client.set_active_database(mock_db1)\n\n                    # Verify record_geo_failover was NOT called\n                    mock_record_geo_failover.assert_not_called()\n\n\n@pytest.mark.onlynoncluster\nclass TestInitialHealthCheckPolicy:\n    \"\"\"Tests for initial health check policy evaluation.\"\"\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_all_healthy_policy_succeeds_when_all_databases_healthy(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        \"\"\"\n        Test that ALL_HEALTHY policy succeeds when all databases pass health check.\n        \"\"\"\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db1.client.execute_command.return_value = \"OK1\"\n            mock_hc.check_health = AsyncMock(return_value=True)\n\n            async with MultiDBClient(mock_multi_db_config) as client:\n                # Should succeed without raising InitialHealthCheckFailedError\n                assert await client.set(\"key\", \"value\") == \"OK1\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_all_healthy_policy_fails_when_one_database_unhealthy(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        \"\"\"\n        Test that ALL_HEALTHY policy fails when any database fails health check.\n        \"\"\"\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n\n            async def mock_check_health(database, connection=None):\n                # mock_db2 is unhealthy\n                return database != mock_db2\n\n            mock_hc.check_health = AsyncMock(side_effect=mock_check_health)\n\n            with pytest.raises(\n                InitialHealthCheckFailedError,\n                match=\"Initial health check failed\",\n            ):\n                async with MultiDBClient(mock_multi_db_config) as client:\n                    await client.set(\"key\", \"value\")\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {\"initial_health_check_policy\": InitialHealthCheck.MAJORITY_AVAILABLE},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_majority_healthy_policy_succeeds_when_majority_healthy(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        \"\"\"\n        Test that MAJORITY_HEALTHY policy succeeds when more than half of databases are healthy.\n        With 3 databases, 2 healthy is a majority.\n        \"\"\"\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db1.client.execute_command.return_value = \"OK1\"\n\n            async def mock_check_health(database, connection=None):\n                # mock_db2 is unhealthy, but 2 out of 3 are healthy (majority)\n                return database != mock_db2\n\n            mock_hc.check_health = AsyncMock(side_effect=mock_check_health)\n\n            async with MultiDBClient(mock_multi_db_config) as client:\n                # Should succeed - 2 out of 3 healthy is a majority\n                assert await client.set(\"key\", \"value\") == \"OK1\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {\"initial_health_check_policy\": InitialHealthCheck.MAJORITY_AVAILABLE},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_majority_healthy_policy_fails_when_minority_healthy(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        \"\"\"\n        Test that MAJORITY_HEALTHY policy fails when less than half of databases are healthy.\n        With 3 databases, only 1 healthy is not a majority.\n        \"\"\"\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n\n            async def mock_check_health(database, connection=None):\n                # Only mock_db is healthy (1 out of 3 is not a majority)\n                return database == mock_db\n\n            mock_hc.check_health = AsyncMock(side_effect=mock_check_health)\n\n            with pytest.raises(\n                InitialHealthCheckFailedError,\n                match=\"Initial health check failed\",\n            ):\n                async with MultiDBClient(mock_multi_db_config) as client:\n                    await client.set(\"key\", \"value\")\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {\"initial_health_check_policy\": InitialHealthCheck.ONE_AVAILABLE},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_any_healthy_policy_succeeds_when_one_database_healthy(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        \"\"\"\n        Test that ANY_HEALTHY policy succeeds when at least one database is healthy.\n        \"\"\"\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db.client.execute_command.return_value = \"OK\"\n\n            async def mock_check_health(database, connection=None):\n                # Only mock_db is healthy\n                return database == mock_db\n\n            mock_hc.check_health = AsyncMock(side_effect=mock_check_health)\n\n            async with MultiDBClient(mock_multi_db_config) as client:\n                # Should succeed - at least one database is healthy\n                assert await client.set(\"key\", \"value\") == \"OK\"\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {\"initial_health_check_policy\": InitialHealthCheck.ONE_AVAILABLE},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_any_healthy_policy_fails_when_no_database_healthy(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        \"\"\"\n        Test that ANY_HEALTHY policy fails when no database is healthy.\n        \"\"\"\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_hc.check_health = AsyncMock(return_value=False)\n\n            with pytest.raises(\n                InitialHealthCheckFailedError,\n                match=\"Initial health check failed\",\n            ):\n                async with MultiDBClient(mock_multi_db_config) as client:\n                    await client.set(\"key\", \"value\")\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_custom_health_check_parameters_are_respected(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2\n    ):\n        \"\"\"\n        Test that custom health check parameters (probes, delay, timeout)\n        override the default values and are properly used during health checks.\n        \"\"\"\n        from redis.asyncio.multidb.healthcheck import AbstractHealthCheck\n        import asyncio\n\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n\n        # Track actual delays between probes\n        probe_timestamps = []\n        probe_lock = asyncio.Lock()\n\n        class CustomHealthCheck(AbstractHealthCheck):\n            \"\"\"Custom health check with non-default parameters.\"\"\"\n\n            def __init__(self):\n                # Use custom values: 5 probes, 0.02s delay, 2.0s timeout\n                super().__init__(\n                    health_check_probes=5,\n                    health_check_delay=0.02,\n                    health_check_timeout=2.0,\n                )\n\n            async def check_health(self, database, connection=None) -> bool:\n                import time\n\n                async with probe_lock:\n                    probe_timestamps.append(time.time())\n                return True\n\n        custom_hc = CustomHealthCheck()\n\n        # Verify custom parameters are set correctly\n        assert custom_hc.health_check_probes == 5\n        assert custom_hc.health_check_delay == 0.02\n        assert custom_hc.health_check_timeout == 2.0\n\n        mock_multi_db_config.health_checks = [custom_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db1.client.execute_command = AsyncMock(return_value=\"OK1\")\n\n            async with MultiDBClient(mock_multi_db_config) as client:\n                # Client should initialize successfully\n                assert await client.set(\"key\", \"value\") == \"OK1\"\n\n                # With 3 databases and 5 probes each, we should have 15 probes total\n                # (executed in parallel per database, but sequentially within each db)\n                assert len(probe_timestamps) == 15\n\n                # Verify delays between probes within each database\n                # Since probes run in parallel across databases, we need to check\n                # that the minimum delay between consecutive probes is approximately\n                # the configured delay (0.02s)\n                # Sort timestamps and check that there are gaps of ~0.02s\n                sorted_timestamps = sorted(probe_timestamps)\n\n                # With 3 databases running in parallel, each doing 5 probes with 0.02s delay,\n                # the total time should be approximately 4 * 0.02 = 0.08s per database\n                # (4 delays between 5 probes)\n                total_duration = sorted_timestamps[-1] - sorted_timestamps[0]\n                # Should be at least 4 delays worth (0.08s) but not too long\n                assert total_duration >= 0.04, (\n                    f\"Total duration {total_duration}s is too short, \"\n                    f\"expected at least 0.04s for 5 probes with 0.02s delay\"\n                )\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_custom_health_check_timeout_triggers_unhealthy(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2\n    ):\n        \"\"\"\n        Test that a custom health check timeout is respected and triggers\n        UnhealthyDatabaseException when exceeded.\n        \"\"\"\n        from redis.asyncio.multidb.healthcheck import AbstractHealthCheck\n        from redis.multidb.exception import InitialHealthCheckFailedError\n        import asyncio\n\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n\n        class SlowHealthCheck(AbstractHealthCheck):\n            \"\"\"Health check that takes longer than the configured timeout.\"\"\"\n\n            def __init__(self):\n                # Short timeout (0.1s) but health check will take longer (0.5s)\n                super().__init__(\n                    health_check_probes=1,\n                    health_check_delay=0.01,\n                    health_check_timeout=0.1,\n                )\n\n            async def check_health(self, database, connection=None) -> bool:\n                # Sleep longer than the timeout\n                await asyncio.sleep(0.5)\n                return True\n\n        slow_hc = SlowHealthCheck()\n\n        # Verify custom timeout is set\n        assert slow_hc.health_check_timeout == 0.1\n\n        mock_multi_db_config.health_checks = [slow_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db1.client.execute_command = AsyncMock(return_value=\"OK1\")\n\n            # Executing a command triggers initialize() which runs health checks\n            # The health check should timeout and raise InitialHealthCheckFailedError\n            with pytest.raises(InitialHealthCheckFailedError):\n                async with MultiDBClient(mock_multi_db_config) as client:\n                    await client.set(\"key\", \"value\")\n"
  },
  {
    "path": "tests/test_asyncio/test_multidb/test_command_executor.py",
    "content": "import asyncio\nfrom unittest.mock import AsyncMock\n\nimport pytest\n\nfrom redis.asyncio.multidb.failure_detector import FailureDetectorAsyncWrapper\nfrom redis.event import EventDispatcher\nfrom redis.exceptions import ConnectionError\nfrom redis.asyncio.multidb.command_executor import DefaultCommandExecutor\nfrom redis.asyncio.retry import Retry\nfrom redis.backoff import NoBackoff\nfrom redis.multidb.circuit import State as CBState\nfrom redis.multidb.failure_detector import CommandFailureDetector\nfrom redis.observability.attributes import GeoFailoverReason\nfrom tests.test_asyncio.test_multidb.conftest import create_weighted_list\n\n\n@pytest.mark.onlynoncluster\nclass TestDefaultCommandExecutor:\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_db,mock_db1,mock_db2\",\n        [\n            (\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_execute_command_on_active_database(\n        self, mock_db, mock_db1, mock_db2, mock_fd, mock_fs, mock_ed\n    ):\n        mock_db1.client.execute_command = AsyncMock(return_value=\"OK1\")\n        mock_db2.client.execute_command = AsyncMock(return_value=\"OK2\")\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n\n        executor = DefaultCommandExecutor(\n            failure_detectors=[mock_fd],\n            databases=databases,\n            failover_strategy=mock_fs,\n            event_dispatcher=mock_ed,\n            command_retry=Retry(NoBackoff(), 0),\n        )\n\n        await executor.set_active_database(mock_db1, GeoFailoverReason.MANUAL)\n        assert await executor.execute_command(\"SET\", \"key\", \"value\") == \"OK1\"\n\n        await executor.set_active_database(mock_db2, GeoFailoverReason.MANUAL)\n        assert await executor.execute_command(\"SET\", \"key\", \"value\") == \"OK2\"\n        assert mock_ed.register_listeners.call_count == 1\n        assert mock_fd.register_command_execution.call_count == 2\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_db,mock_db1,mock_db2\",\n        [\n            (\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_execute_command_automatically_select_active_database(\n        self, mock_db, mock_db1, mock_db2, mock_fd, mock_fs, mock_ed\n    ):\n        mock_db1.client.execute_command = AsyncMock(return_value=\"OK1\")\n        mock_db2.client.execute_command = AsyncMock(return_value=\"OK2\")\n        mock_selector = AsyncMock(side_effect=[mock_db1, mock_db2])\n        type(mock_fs).database = mock_selector\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n\n        executor = DefaultCommandExecutor(\n            failure_detectors=[mock_fd],\n            databases=databases,\n            failover_strategy=mock_fs,\n            event_dispatcher=mock_ed,\n            command_retry=Retry(NoBackoff(), 0),\n        )\n\n        assert await executor.execute_command(\"SET\", \"key\", \"value\") == \"OK1\"\n        mock_db1.circuit.state = CBState.OPEN\n\n        assert await executor.execute_command(\"SET\", \"key\", \"value\") == \"OK2\"\n        assert mock_ed.register_listeners.call_count == 1\n        assert mock_selector.call_count == 2\n        assert mock_fd.register_command_execution.call_count == 2\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_db,mock_db1,mock_db2\",\n        [\n            (\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_execute_command_fallback_to_another_db_after_fallback_interval(\n        self, mock_db, mock_db1, mock_db2, mock_fd, mock_fs, mock_ed\n    ):\n        mock_db1.client.execute_command = AsyncMock(return_value=\"OK1\")\n        mock_db2.client.execute_command = AsyncMock(return_value=\"OK2\")\n        mock_selector = AsyncMock(side_effect=[mock_db1, mock_db2, mock_db1])\n        type(mock_fs).database = mock_selector\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n\n        executor = DefaultCommandExecutor(\n            failure_detectors=[mock_fd],\n            databases=databases,\n            failover_strategy=mock_fs,\n            event_dispatcher=mock_ed,\n            auto_fallback_interval=0.1,\n            command_retry=Retry(NoBackoff(), 0),\n        )\n\n        assert await executor.execute_command(\"SET\", \"key\", \"value\") == \"OK1\"\n        mock_db1.weight = 0.1\n        await asyncio.sleep(0.15)\n\n        assert await executor.execute_command(\"SET\", \"key\", \"value\") == \"OK2\"\n        mock_db1.weight = 0.7\n        await asyncio.sleep(0.15)\n\n        assert await executor.execute_command(\"SET\", \"key\", \"value\") == \"OK1\"\n        assert mock_ed.register_listeners.call_count == 1\n        assert mock_selector.call_count == 3\n        assert mock_fd.register_command_execution.call_count == 3\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_db,mock_db1,mock_db2\",\n        [\n            (\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_execute_command_fallback_to_another_db_after_failure_detection(\n        self, mock_db, mock_db1, mock_db2, mock_fs\n    ):\n        mock_db1.client.execute_command = AsyncMock(\n            side_effect=[\n                \"OK1\",\n                ConnectionError,\n                ConnectionError,\n                ConnectionError,\n                \"OK1\",\n            ]\n        )\n        mock_db2.client.execute_command = AsyncMock(\n            side_effect=[\"OK2\", ConnectionError, ConnectionError, ConnectionError]\n        )\n        mock_selector = AsyncMock(side_effect=[mock_db1, mock_db2, mock_db1])\n        type(mock_fs).database = mock_selector\n        threshold = 3\n        fd = FailureDetectorAsyncWrapper(CommandFailureDetector(threshold, 1))\n        ed = EventDispatcher()\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n\n        executor = DefaultCommandExecutor(\n            failure_detectors=[fd],\n            databases=databases,\n            failover_strategy=mock_fs,\n            event_dispatcher=ed,\n            auto_fallback_interval=0.1,\n            command_retry=Retry(NoBackoff(), threshold),\n        )\n        fd.set_command_executor(command_executor=executor)\n\n        assert await executor.execute_command(\"SET\", \"key\", \"value\") == \"OK1\"\n        assert await executor.execute_command(\"SET\", \"key\", \"value\") == \"OK2\"\n        assert await executor.execute_command(\"SET\", \"key\", \"value\") == \"OK1\"\n        assert mock_selector.call_count == 3\n"
  },
  {
    "path": "tests/test_asyncio/test_multidb/test_config.py",
    "content": "from unittest.mock import Mock\n\nimport pytest\n\nfrom redis.asyncio import ConnectionPool\nfrom redis.asyncio.multidb.config import (\n    DatabaseConfig,\n    MultiDbConfig,\n    DEFAULT_GRACE_PERIOD,\n    DEFAULT_HEALTH_CHECK_INTERVAL,\n    DEFAULT_AUTO_FALLBACK_INTERVAL,\n)\nfrom redis.asyncio.multidb.database import Database\nfrom redis.asyncio.multidb.failover import (\n    WeightBasedFailoverStrategy,\n    AsyncFailoverStrategy,\n)\nfrom redis.asyncio.multidb.failure_detector import (\n    FailureDetectorAsyncWrapper,\n    AsyncFailureDetector,\n)\nfrom redis.asyncio.multidb.healthcheck import PingHealthCheck, HealthCheck\nfrom redis.asyncio.retry import Retry\nfrom redis.multidb.circuit import CircuitBreaker\n\n\n@pytest.mark.onlynoncluster\nclass TestMultiDbConfig:\n    def test_default_config(self):\n        db_configs = [\n            DatabaseConfig(\n                client_kwargs={\"host\": \"host1\", \"port\": \"port1\"}, weight=1.0\n            ),\n            DatabaseConfig(\n                client_kwargs={\"host\": \"host2\", \"port\": \"port2\"}, weight=0.9\n            ),\n            DatabaseConfig(\n                client_kwargs={\"host\": \"host3\", \"port\": \"port3\"}, weight=0.8\n            ),\n        ]\n\n        config = MultiDbConfig(databases_config=db_configs)\n\n        assert config.databases_config == db_configs\n        databases = config.databases()\n        assert len(databases) == 3\n\n        i = 0\n        for db, weight in databases:\n            assert isinstance(db, Database)\n            assert weight == db_configs[i].weight\n            assert db.circuit.grace_period == DEFAULT_GRACE_PERIOD\n            assert db.client.get_retry() is not config.command_retry\n            i += 1\n\n        assert len(config.default_failure_detectors()) == 1\n        assert isinstance(\n            config.default_failure_detectors()[0], FailureDetectorAsyncWrapper\n        )\n        assert len(config.default_health_checks()) == 1\n        assert isinstance(config.default_health_checks()[0], PingHealthCheck)\n        assert config.health_check_interval == DEFAULT_HEALTH_CHECK_INTERVAL\n        assert isinstance(\n            config.default_failover_strategy(), WeightBasedFailoverStrategy\n        )\n        assert config.auto_fallback_interval == DEFAULT_AUTO_FALLBACK_INTERVAL\n        assert isinstance(config.command_retry, Retry)\n\n    def test_overridden_config(self):\n        grace_period = 2\n        mock_connection_pools = [\n            Mock(spec=ConnectionPool),\n            Mock(spec=ConnectionPool),\n            Mock(spec=ConnectionPool),\n        ]\n        mock_connection_pools[0].connection_kwargs = {}\n        mock_connection_pools[1].connection_kwargs = {}\n        mock_connection_pools[2].connection_kwargs = {}\n        mock_cb1 = Mock(spec=CircuitBreaker)\n        mock_cb1.grace_period = grace_period\n        mock_cb2 = Mock(spec=CircuitBreaker)\n        mock_cb2.grace_period = grace_period\n        mock_cb3 = Mock(spec=CircuitBreaker)\n        mock_cb3.grace_period = grace_period\n        mock_failure_detectors = [\n            Mock(spec=AsyncFailureDetector),\n            Mock(spec=AsyncFailureDetector),\n        ]\n        mock_health_checks = [Mock(spec=HealthCheck), Mock(spec=HealthCheck)]\n        health_check_interval = 10\n        mock_failover_strategy = Mock(spec=AsyncFailoverStrategy)\n        auto_fallback_interval = 10\n        db_configs = [\n            DatabaseConfig(\n                client_kwargs={\"connection_pool\": mock_connection_pools[0]},\n                weight=1.0,\n                circuit=mock_cb1,\n            ),\n            DatabaseConfig(\n                client_kwargs={\"connection_pool\": mock_connection_pools[1]},\n                weight=0.9,\n                circuit=mock_cb2,\n            ),\n            DatabaseConfig(\n                client_kwargs={\"connection_pool\": mock_connection_pools[2]},\n                weight=0.8,\n                circuit=mock_cb3,\n            ),\n        ]\n\n        config = MultiDbConfig(\n            databases_config=db_configs,\n            failure_detectors=mock_failure_detectors,\n            health_checks=mock_health_checks,\n            health_check_interval=health_check_interval,\n            failover_strategy=mock_failover_strategy,\n            auto_fallback_interval=auto_fallback_interval,\n        )\n\n        assert config.databases_config == db_configs\n        databases = config.databases()\n        assert len(databases) == 3\n\n        i = 0\n        for db, weight in databases:\n            assert isinstance(db, Database)\n            assert weight == db_configs[i].weight\n            assert db.client.connection_pool == mock_connection_pools[i]\n            assert db.circuit.grace_period == grace_period\n            i += 1\n\n        assert len(config.failure_detectors) == 2\n        assert config.failure_detectors[0] == mock_failure_detectors[0]\n        assert config.failure_detectors[1] == mock_failure_detectors[1]\n        assert len(config.health_checks) == 2\n        assert config.health_checks[0] == mock_health_checks[0]\n        assert config.health_checks[1] == mock_health_checks[1]\n        assert config.health_check_interval == health_check_interval\n        assert config.failover_strategy == mock_failover_strategy\n        assert config.auto_fallback_interval == auto_fallback_interval\n\n\n@pytest.mark.onlynoncluster\nclass TestDatabaseConfig:\n    def test_default_config(self):\n        config = DatabaseConfig(\n            client_kwargs={\"host\": \"host1\", \"port\": \"port1\"}, weight=1.0\n        )\n\n        assert config.client_kwargs == {\"host\": \"host1\", \"port\": \"port1\"}\n        assert config.weight == 1.0\n        assert isinstance(config.default_circuit_breaker(), CircuitBreaker)\n\n    def test_overridden_config(self):\n        mock_connection_pool = Mock(spec=ConnectionPool)\n        mock_circuit = Mock(spec=CircuitBreaker)\n\n        config = DatabaseConfig(\n            client_kwargs={\"connection_pool\": mock_connection_pool},\n            weight=1.0,\n            circuit=mock_circuit,\n        )\n\n        assert config.client_kwargs == {\"connection_pool\": mock_connection_pool}\n        assert config.weight == 1.0\n        assert config.circuit == mock_circuit\n"
  },
  {
    "path": "tests/test_asyncio/test_multidb/test_failover.py",
    "content": "import asyncio\n\nimport pytest\n\nfrom redis.data_structure import WeightedList\nfrom redis.multidb.circuit import State as CBState\nfrom redis.multidb.exception import (\n    NoValidDatabaseException,\n    TemporaryUnavailableException,\n)\nfrom redis.asyncio.multidb.failover import (\n    WeightBasedFailoverStrategy,\n    DefaultFailoverStrategyExecutor,\n)\n\n\n@pytest.mark.onlynoncluster\nclass TestAsyncWeightBasedFailoverStrategy:\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_db,mock_db1,mock_db2\",\n        [\n            (\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n            (\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.OPEN}},\n            ),\n        ],\n        ids=[\"all closed - highest weight\", \"highest weight - open\"],\n        indirect=True,\n    )\n    async def test_get_valid_database(self, mock_db, mock_db1, mock_db2):\n        databases = WeightedList()\n        databases.add(mock_db, mock_db.weight)\n        databases.add(mock_db1, mock_db1.weight)\n        databases.add(mock_db2, mock_db2.weight)\n\n        strategy = WeightBasedFailoverStrategy()\n        strategy.set_databases(databases)\n\n        assert await strategy.database() == mock_db1\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_db,mock_db1,mock_db2\",\n        [\n            (\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.OPEN}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.OPEN}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.OPEN}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_throws_exception_on_empty_databases(\n        self, mock_db, mock_db1, mock_db2\n    ):\n        failover_strategy = WeightBasedFailoverStrategy()\n\n        with pytest.raises(\n            NoValidDatabaseException,\n            match=\"No valid database available for communication\",\n        ):\n            assert await failover_strategy.database()\n\n\nclass TestDefaultStrategyExecutor:\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_db\",\n        [\n            {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n        ],\n        indirect=True,\n    )\n    async def test_execute_returns_valid_database_with_failover_attempts(\n        self, mock_db, mock_fs\n    ):\n        failover_attempts = 3\n        mock_fs.database.side_effect = [\n            NoValidDatabaseException,\n            NoValidDatabaseException,\n            NoValidDatabaseException,\n            mock_db,\n        ]\n        executor = DefaultFailoverStrategyExecutor(\n            mock_fs, failover_attempts=failover_attempts, failover_delay=0.1\n        )\n\n        for i in range(failover_attempts + 1):\n            try:\n                database = await executor.execute()\n                assert database == mock_db\n            except TemporaryUnavailableException as e:\n                assert e.args[0] == (\n                    \"No database connections currently available. \"\n                    \"This is a temporary condition - please retry the operation.\"\n                )\n                await asyncio.sleep(0.11)\n                pass\n\n        assert mock_fs.database.call_count == 4\n\n    @pytest.mark.asyncio\n    async def test_execute_throws_exception_on_attempts_exceed(self, mock_fs):\n        failover_attempts = 3\n        mock_fs.database.side_effect = [\n            NoValidDatabaseException,\n            NoValidDatabaseException,\n            NoValidDatabaseException,\n            NoValidDatabaseException,\n        ]\n        executor = DefaultFailoverStrategyExecutor(\n            mock_fs, failover_attempts=failover_attempts, failover_delay=0.1\n        )\n\n        with pytest.raises(NoValidDatabaseException):\n            for i in range(failover_attempts + 1):\n                try:\n                    await executor.execute()\n                except TemporaryUnavailableException as e:\n                    assert e.args[0] == (\n                        \"No database connections currently available. \"\n                        \"This is a temporary condition - please retry the operation.\"\n                    )\n                    await asyncio.sleep(0.11)\n                    pass\n\n            assert mock_fs.database.call_count == 4\n\n    @pytest.mark.asyncio\n    async def test_execute_throws_exception_on_attempts_does_not_exceed_delay(\n        self, mock_fs\n    ):\n        failover_attempts = 3\n        mock_fs.database.side_effect = [\n            NoValidDatabaseException,\n            NoValidDatabaseException,\n            NoValidDatabaseException,\n            NoValidDatabaseException,\n        ]\n        executor = DefaultFailoverStrategyExecutor(\n            mock_fs, failover_attempts=failover_attempts, failover_delay=0.1\n        )\n\n        with pytest.raises(\n            TemporaryUnavailableException,\n            match=(\n                \"No database connections currently available. \"\n                \"This is a temporary condition - please retry the operation.\"\n            ),\n        ):\n            for i in range(failover_attempts + 1):\n                try:\n                    await executor.execute()\n                except TemporaryUnavailableException as e:\n                    assert e.args[0] == (\n                        \"No database connections currently available. \"\n                        \"This is a temporary condition - please retry the operation.\"\n                    )\n                    if i == failover_attempts:\n                        raise e\n\n            assert mock_fs.database.call_count == 4\n"
  },
  {
    "path": "tests/test_asyncio/test_multidb/test_failure_detector.py",
    "content": "import asyncio\nfrom unittest.mock import Mock\n\nimport pytest\n\nfrom redis.asyncio.multidb.command_executor import AsyncCommandExecutor\nfrom redis.asyncio.multidb.database import Database\nfrom redis.asyncio.multidb.failure_detector import FailureDetectorAsyncWrapper\nfrom redis.multidb.circuit import State as CBState\nfrom redis.multidb.failure_detector import CommandFailureDetector\n\n\n@pytest.mark.onlynoncluster\nclass TestFailureDetectorAsyncWrapper:\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"min_num_failures,failure_rate_threshold,circuit_state\",\n        [\n            (2, 0.4, CBState.OPEN),\n            (2, 0, CBState.OPEN),\n            (0, 0.4, CBState.OPEN),\n            (3, 0.4, CBState.CLOSED),\n            (2, 0.41, CBState.CLOSED),\n        ],\n        ids=[\n            \"exceeds min num failures AND failures rate\",\n            \"exceeds min num failures AND failures rate == 0\",\n            \"min num failures == 0 AND exceeds failures rate\",\n            \"do not exceeds min num failures\",\n            \"do not exceeds failures rate\",\n        ],\n    )\n    async def test_failure_detector_correctly_reacts_to_failures(\n        self, min_num_failures, failure_rate_threshold, circuit_state\n    ):\n        fd = FailureDetectorAsyncWrapper(\n            CommandFailureDetector(min_num_failures, failure_rate_threshold)\n        )\n        mock_db = Mock(spec=Database)\n        mock_db.circuit.state = CBState.CLOSED\n        mock_ce = Mock(spec=AsyncCommandExecutor)\n        mock_ce.active_database = mock_db\n        fd.set_command_executor(mock_ce)\n\n        await fd.register_command_execution((\"GET\", \"key\"))\n        await fd.register_command_execution((\"GET\", \"key\"))\n        await fd.register_failure(Exception(), (\"GET\", \"key\"))\n\n        await fd.register_command_execution((\"GET\", \"key\"))\n        await fd.register_command_execution((\"GET\", \"key\"))\n        await fd.register_command_execution((\"GET\", \"key\"))\n        await fd.register_failure(Exception(), (\"GET\", \"key\"))\n\n        assert mock_db.circuit.state == circuit_state\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"min_num_failures,failure_rate_threshold\",\n        [\n            (3, 0.0),\n            (3, 0.6),\n        ],\n        ids=[\n            \"do not exceeds min num failures, during interval\",\n            \"do not exceeds min num failures AND failure rate, during interval\",\n        ],\n    )\n    async def test_failure_detector_do_not_open_circuit_on_interval_exceed(\n        self, min_num_failures, failure_rate_threshold\n    ):\n        fd = FailureDetectorAsyncWrapper(\n            CommandFailureDetector(min_num_failures, failure_rate_threshold, 0.3)\n        )\n        mock_db = Mock(spec=Database)\n        mock_db.circuit.state = CBState.CLOSED\n        mock_ce = Mock(spec=AsyncCommandExecutor)\n        mock_ce.active_database = mock_db\n        fd.set_command_executor(mock_ce)\n        assert mock_db.circuit.state == CBState.CLOSED\n\n        await fd.register_command_execution((\"GET\", \"key\"))\n        await fd.register_failure(Exception(), (\"GET\", \"key\"))\n        await asyncio.sleep(0.16)\n        await fd.register_command_execution((\"GET\", \"key\"))\n        await fd.register_command_execution((\"GET\", \"key\"))\n        await fd.register_command_execution((\"GET\", \"key\"))\n        await fd.register_failure(Exception(), (\"GET\", \"key\"))\n        await asyncio.sleep(0.16)\n        await fd.register_command_execution((\"GET\", \"key\"))\n        await fd.register_failure(Exception(), (\"GET\", \"key\"))\n\n        assert mock_db.circuit.state == CBState.CLOSED\n\n        # 2 more failure as last one already refreshed timer\n        await fd.register_command_execution((\"GET\", \"key\"))\n        await fd.register_failure(Exception(), (\"GET\", \"key\"))\n        await fd.register_command_execution((\"GET\", \"key\"))\n        await fd.register_failure(Exception(), (\"GET\", \"key\"))\n\n        assert mock_db.circuit.state == CBState.OPEN\n\n    @pytest.mark.asyncio\n    async def test_failure_detector_open_circuit_on_specific_exception_threshold_exceed(\n        self,\n    ):\n        fd = FailureDetectorAsyncWrapper(\n            CommandFailureDetector(5, 1, error_types=[ConnectionError])\n        )\n        mock_db = Mock(spec=Database)\n        mock_db.circuit.state = CBState.CLOSED\n        mock_ce = Mock(spec=AsyncCommandExecutor)\n        mock_ce.active_database = mock_db\n        fd.set_command_executor(mock_ce)\n        assert mock_db.circuit.state == CBState.CLOSED\n\n        await fd.register_failure(Exception(), (\"SET\", \"key1\", \"value1\"))\n        await fd.register_failure(ConnectionError(), (\"SET\", \"key1\", \"value1\"))\n        await fd.register_failure(ConnectionError(), (\"SET\", \"key1\", \"value1\"))\n        await fd.register_failure(Exception(), (\"SET\", \"key1\", \"value1\"))\n        await fd.register_failure(Exception(), (\"SET\", \"key1\", \"value1\"))\n\n        assert mock_db.circuit.state == CBState.CLOSED\n\n        await fd.register_failure(ConnectionError(), (\"SET\", \"key1\", \"value1\"))\n        await fd.register_failure(ConnectionError(), (\"SET\", \"key1\", \"value1\"))\n        await fd.register_failure(ConnectionError(), (\"SET\", \"key1\", \"value1\"))\n\n        assert mock_db.circuit.state == CBState.OPEN\n"
  },
  {
    "path": "tests/test_asyncio/test_multidb/test_healthcheck.py",
    "content": "import asyncio\nfrom unittest.mock import AsyncMock, Mock\n\nimport pytest\n\nfrom redis.asyncio.multidb.database import Database\nfrom redis.asyncio.multidb.healthcheck import (\n    PingHealthCheck,\n    LagAwareHealthCheck,\n    HealthCheck,\n    HealthyAllPolicy,\n    HealthyMajorityPolicy,\n    HealthyAnyPolicy,\n)\nfrom redis.http.http_client import HttpError\nfrom redis.multidb.circuit import State as CBState\nfrom redis.exceptions import ConnectionError\nfrom redis.multidb.exception import UnhealthyDatabaseException\n\n\ndef _configure_mock_health_check(mock_hc, probes=3, delay=0.01, timeout=1.0):\n    \"\"\"Helper to configure mock health check with required properties.\"\"\"\n    mock_hc.health_check_probes = probes\n    mock_hc.health_check_delay = delay\n    mock_hc.health_check_timeout = timeout\n    # check_health is async, use AsyncMock\n    mock_hc.check_health = AsyncMock(return_value=True)\n    return mock_hc\n\n\n@pytest.mark.onlynoncluster\nclass TestHealthyAllPolicy:\n    @pytest.mark.asyncio\n    async def test_policy_returns_true_for_all_successful_probes(self):\n        mock_hc1 = _configure_mock_health_check(Mock(spec=HealthCheck))\n        mock_hc2 = _configure_mock_health_check(Mock(spec=HealthCheck))\n        mock_hc1.check_health.return_value = True\n        mock_hc2.check_health.return_value = True\n        mock_db = Mock(spec=Database)\n\n        policy = HealthyAllPolicy()\n        assert await policy.execute([mock_hc1, mock_hc2], mock_db)\n        # Both health checks run in parallel, each with 3 probes\n        assert mock_hc1.check_health.call_count == 3\n        assert mock_hc2.check_health.call_count == 3\n\n    @pytest.mark.asyncio\n    async def test_policy_returns_false_on_failed_probe(self):\n        mock_hc1 = _configure_mock_health_check(Mock(spec=HealthCheck))\n        mock_hc2 = _configure_mock_health_check(Mock(spec=HealthCheck))\n        mock_hc1.check_health.side_effect = [True, True, False]\n        mock_hc2.check_health.return_value = True\n        mock_db = Mock(spec=Database)\n\n        policy = HealthyAllPolicy()\n        # Policy returns False because mock_hc1 fails on the third probe\n        assert not await policy.execute([mock_hc1, mock_hc2], mock_db)\n        # Both health checks run in parallel\n        assert mock_hc1.check_health.call_count == 3\n        assert mock_hc2.check_health.call_count == 3\n\n    @pytest.mark.asyncio\n    async def test_policy_raise_unhealthy_database_exception(self):\n        mock_hc1 = _configure_mock_health_check(Mock(spec=HealthCheck))\n        mock_hc2 = _configure_mock_health_check(Mock(spec=HealthCheck))\n        mock_hc1.check_health.side_effect = [True, True, ConnectionError]\n        mock_hc2.check_health.return_value = True\n        mock_db = Mock(spec=Database)\n\n        policy = HealthyAllPolicy()\n        with pytest.raises(UnhealthyDatabaseException, match=\"Unhealthy database\"):\n            await policy.execute([mock_hc1, mock_hc2], mock_db)\n\n    @pytest.mark.asyncio\n    async def test_policy_raises_exception_when_health_check_times_out(self):\n        \"\"\"\n        Verify that health_check_timeout is respected and raises UnhealthyDatabaseException\n        when a health check takes longer than the configured timeout.\n        \"\"\"\n        import asyncio\n\n        async def slow_health_check(database, connection=None):\n            await asyncio.sleep(0.5)  # Sleep longer than the timeout\n            return True\n\n        # Configure with a very short timeout (0.1 seconds)\n        mock_hc = _configure_mock_health_check(\n            Mock(spec=HealthCheck), probes=1, delay=0.01, timeout=0.1\n        )\n        mock_hc.check_health.side_effect = slow_health_check\n        mock_db = Mock(spec=Database)\n\n        policy = HealthyAllPolicy()\n        with pytest.raises(\n            UnhealthyDatabaseException, match=\"Unhealthy database\"\n        ) as exc_info:\n            await policy.execute([mock_hc], mock_db)\n\n        # Verify the original exception is a TimeoutError\n        assert isinstance(exc_info.value.original_exception, asyncio.TimeoutError)\n\n    @pytest.mark.asyncio\n    async def test_policy_succeeds_when_health_check_completes_within_timeout(self):\n        \"\"\"\n        Verify that health checks that complete within the timeout succeed normally.\n        \"\"\"\n        import asyncio\n\n        async def fast_health_check(database, connection=None):\n            await asyncio.sleep(0.01)  # Sleep much less than the timeout\n            return True\n\n        # Configure with a generous timeout (1 second)\n        mock_hc = _configure_mock_health_check(\n            Mock(spec=HealthCheck), probes=1, delay=0.01, timeout=1.0\n        )\n        mock_hc.check_health.side_effect = fast_health_check\n        mock_db = Mock(spec=Database)\n\n        policy = HealthyAllPolicy()\n        result = await policy.execute([mock_hc], mock_db)\n        assert result is True\n\n\n@pytest.mark.onlynoncluster\nclass TestHealthyMajorityPolicy:\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"probes,hc1_side_effect,hc2_side_effect,expected_result\",\n        [\n            (3, [True, False, False], [True, True, True], False),\n            (3, [True, True, True], [True, False, False], False),\n            (3, [True, False, True], [True, True, True], True),\n            (3, [True, True, True], [True, False, True], True),\n            (3, [True, True, False], [True, False, True], True),\n            (4, [True, True, False, False], [True, True, True, True], False),\n            (4, [True, True, True, True], [True, True, False, False], False),\n            (4, [False, True, True, True], [True, True, True, True], True),\n            (4, [True, True, True, True], [True, False, True, True], True),\n            (4, [False, True, True, True], [True, True, False, True], True),\n        ],\n        ids=[\n            \"HC1 - no majority - odd\",\n            \"HC2 - no majority - odd\",\n            \"HC1 - majority- odd\",\n            \"HC2 - majority - odd\",\n            \"HC1 + HC2 - majority - odd\",\n            \"HC1 - no majority - even\",\n            \"HC2 - no majority - even\",\n            \"HC1 - majority - even\",\n            \"HC2 - majority - even\",\n            \"HC1 + HC2 - majority - even\",\n        ],\n    )\n    async def test_policy_returns_true_for_majority_successful_probes(\n        self,\n        probes,\n        hc1_side_effect,\n        hc2_side_effect,\n        expected_result,\n    ):\n        mock_hc1 = _configure_mock_health_check(Mock(spec=HealthCheck), probes=probes)\n        mock_hc2 = _configure_mock_health_check(Mock(spec=HealthCheck), probes=probes)\n        mock_hc1.check_health.side_effect = hc1_side_effect\n        mock_hc2.check_health.side_effect = hc2_side_effect\n        mock_db = Mock(spec=Database)\n\n        policy = HealthyMajorityPolicy()\n        assert await policy.execute([mock_hc1, mock_hc2], mock_db) == expected_result\n        # Both health checks run in parallel; call counts may vary due to early returns\n        assert mock_hc1.check_health.call_count >= 1\n        assert mock_hc2.check_health.call_count >= 1\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"probes,hc1_side_effect,hc2_side_effect\",\n        [\n            (3, [True, ConnectionError, ConnectionError], [True, True, True]),\n            (3, [True, True, True], [True, ConnectionError, ConnectionError]),\n            (\n                4,\n                [True, ConnectionError, ConnectionError, True],\n                [True, True, True, True],\n            ),\n            (\n                4,\n                [True, True, True, True],\n                [True, ConnectionError, ConnectionError, False],\n            ),\n        ],\n        ids=[\n            \"HC1 - majority- odd\",\n            \"HC2 - majority - odd\",\n            \"HC1 - majority - even\",\n            \"HC2 - majority - even\",\n        ],\n    )\n    async def test_policy_raise_unhealthy_database_exception_on_majority_probes_exceptions(\n        self, probes, hc1_side_effect, hc2_side_effect\n    ):\n        mock_hc1 = _configure_mock_health_check(Mock(spec=HealthCheck), probes=probes)\n        mock_hc2 = _configure_mock_health_check(Mock(spec=HealthCheck), probes=probes)\n        mock_hc1.check_health.side_effect = hc1_side_effect\n        mock_hc2.check_health.side_effect = hc2_side_effect\n        mock_db = Mock(spec=Database)\n\n        policy = HealthyMajorityPolicy()\n        with pytest.raises(UnhealthyDatabaseException, match=\"Unhealthy database\"):\n            await policy.execute([mock_hc1, mock_hc2], mock_db)\n\n\n@pytest.mark.onlynoncluster\nclass TestHealthyAnyPolicy:\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"hc1_side_effect,hc2_side_effect,expected_result\",\n        [\n            # HC1 fails all probes, HC2 succeeds - overall False (both HCs must pass)\n            ([False, False, False], [True, True, True], False),\n            # Both fail all probes - overall False\n            ([False, False, False], [False, False, False], False),\n            # Both succeed on at least one probe - overall True\n            ([False, True, True], [False, False, True], True),\n            # Both succeed on first probe - overall True\n            ([True, True, True], [True, True, True], True),\n        ],\n        ids=[\n            \"HC1 fails all, HC2 succeeds\",\n            \"Both fail all probes\",\n            \"Both succeed on at least one\",\n            \"Both succeed on first\",\n        ],\n    )\n    async def test_policy_returns_true_for_any_successful_probe(\n        self,\n        hc1_side_effect,\n        hc2_side_effect,\n        expected_result,\n    ):\n        mock_hc1 = _configure_mock_health_check(Mock(spec=HealthCheck))\n        mock_hc2 = _configure_mock_health_check(Mock(spec=HealthCheck))\n        mock_hc1.check_health.side_effect = hc1_side_effect\n        mock_hc2.check_health.side_effect = hc2_side_effect\n        mock_db = Mock(spec=Database)\n\n        policy = HealthyAnyPolicy()\n        assert await policy.execute([mock_hc1, mock_hc2], mock_db) == expected_result\n        # Both health checks run in parallel; call counts depend on when success is found\n        assert mock_hc1.check_health.call_count >= 1\n        assert mock_hc2.check_health.call_count >= 1\n\n    @pytest.mark.asyncio\n    async def test_policy_raise_unhealthy_database_exception_if_exception_occurs_on_failed_health_check(\n        self,\n    ):\n        mock_hc1 = _configure_mock_health_check(Mock(spec=HealthCheck))\n        mock_hc2 = _configure_mock_health_check(Mock(spec=HealthCheck))\n        mock_hc1.check_health.side_effect = [False, False, ConnectionError]\n        mock_hc2.check_health.side_effect = [False, False, False]\n        mock_db = Mock(spec=Database)\n\n        policy = HealthyAnyPolicy()\n        with pytest.raises(UnhealthyDatabaseException, match=\"Unhealthy database\"):\n            await policy.execute([mock_hc1, mock_hc2], mock_db)\n\n\n@pytest.mark.onlynoncluster\nclass TestPingHealthCheck:\n    \"\"\"Tests for PingHealthCheck with standalone and cluster clients.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_standalone_database_is_healthy_on_ping_true(\n        self, mock_client, mock_cb\n    ):\n        \"\"\"\n        Verify that PingHealthCheck returns True for standalone client when PING succeeds.\n        \"\"\"\n        from redis.asyncio import Redis as AsyncRedis\n\n        mock_hc_client = AsyncMock(spec=AsyncRedis)\n        mock_hc_client.execute_command = AsyncMock(return_value=True)\n        hc = PingHealthCheck()\n        db = Database(mock_client, mock_cb, 0.9)\n\n        assert await hc.check_health(db, mock_hc_client)\n        mock_hc_client.execute_command.assert_called_once_with(\"PING\")\n\n    @pytest.mark.asyncio\n    async def test_standalone_database_is_unhealthy_on_ping_false(\n        self, mock_client, mock_cb\n    ):\n        \"\"\"\n        Verify that PingHealthCheck returns False for standalone client when PING fails.\n        \"\"\"\n        from redis.asyncio import Redis as AsyncRedis\n\n        mock_hc_client = AsyncMock(spec=AsyncRedis)\n        mock_hc_client.execute_command = AsyncMock(return_value=False)\n        hc = PingHealthCheck()\n        db = Database(mock_client, mock_cb, 0.9)\n\n        assert not await hc.check_health(db, mock_hc_client)\n        mock_hc_client.execute_command.assert_called_once_with(\"PING\")\n\n    @pytest.mark.asyncio\n    async def test_standalone_database_close_circuit_on_successful_healthcheck(\n        self, mock_client, mock_cb\n    ):\n        \"\"\"\n        Verify health check succeeds for standalone client with HALF_OPEN circuit.\n        \"\"\"\n        from redis.asyncio import Redis as AsyncRedis\n\n        mock_hc_client = AsyncMock(spec=AsyncRedis)\n        mock_hc_client.execute_command = AsyncMock(return_value=True)\n        mock_cb.state = CBState.HALF_OPEN\n        hc = PingHealthCheck()\n        db = Database(mock_client, mock_cb, 0.9)\n\n        assert await hc.check_health(db, mock_hc_client)\n        mock_hc_client.execute_command.assert_called_once_with(\"PING\")\n\n    @pytest.mark.asyncio\n    async def test_cluster_database_is_healthy_when_all_nodes_respond(\n        self, mock_client, mock_cb\n    ):\n        \"\"\"\n        Verify that PingHealthCheck returns True for cluster when all nodes respond.\n        \"\"\"\n        from redis.asyncio.cluster import RedisCluster as AsyncRedisCluster\n\n        # Create mock nodes\n        mock_node1 = Mock()\n        mock_node1.redis_connection = AsyncMock()\n        mock_node1.redis_connection.execute_command = AsyncMock(return_value=True)\n\n        mock_node2 = Mock()\n        mock_node2.redis_connection = AsyncMock()\n        mock_node2.redis_connection.execute_command = AsyncMock(return_value=True)\n\n        mock_node3 = Mock()\n        mock_node3.redis_connection = AsyncMock()\n        mock_node3.redis_connection.execute_command = AsyncMock(return_value=True)\n\n        # Create mock cluster client (not AsyncRedis, so isinstance check fails)\n        mock_hc_client = Mock(spec=AsyncRedisCluster)\n        mock_hc_client.get_nodes = Mock(\n            return_value=[mock_node1, mock_node2, mock_node3]\n        )\n\n        hc = PingHealthCheck()\n        db = Database(mock_client, mock_cb, 0.9)\n\n        assert await hc.check_health(db, mock_hc_client)\n\n        # All nodes should have been pinged\n        mock_node1.redis_connection.execute_command.assert_called_once_with(\"PING\")\n        mock_node2.redis_connection.execute_command.assert_called_once_with(\"PING\")\n        mock_node3.redis_connection.execute_command.assert_called_once_with(\"PING\")\n\n    @pytest.mark.asyncio\n    async def test_cluster_database_is_unhealthy_when_one_node_fails(\n        self, mock_client, mock_cb\n    ):\n        \"\"\"\n        Verify that PingHealthCheck returns False for cluster when any node fails.\n        \"\"\"\n        from redis.asyncio.cluster import RedisCluster as AsyncRedisCluster\n\n        # Create mock nodes - second node fails\n        mock_node1 = Mock()\n        mock_node1.redis_connection = AsyncMock()\n        mock_node1.redis_connection.execute_command = AsyncMock(return_value=True)\n\n        mock_node2 = Mock()\n        mock_node2.redis_connection = AsyncMock()\n        mock_node2.redis_connection.execute_command = AsyncMock(return_value=False)\n\n        mock_node3 = Mock()\n        mock_node3.redis_connection = AsyncMock()\n        mock_node3.redis_connection.execute_command = AsyncMock(return_value=True)\n\n        # Create mock cluster client\n        mock_hc_client = Mock(spec=AsyncRedisCluster)\n        mock_hc_client.get_nodes = Mock(\n            return_value=[mock_node1, mock_node2, mock_node3]\n        )\n\n        hc = PingHealthCheck()\n        db = Database(mock_client, mock_cb, 0.9)\n\n        assert not await hc.check_health(db, mock_hc_client)\n\n        # Should stop after the failing node\n        mock_node1.redis_connection.execute_command.assert_called_once_with(\"PING\")\n        mock_node2.redis_connection.execute_command.assert_called_once_with(\"PING\")\n        # Node 3 should not be checked (early exit on failure)\n        mock_node3.redis_connection.execute_command.assert_not_called()\n\n\n@pytest.mark.onlynoncluster\nclass TestLagAwareHealthCheck:\n    @pytest.mark.asyncio\n    async def test_database_is_healthy_when_bdb_matches_by_dns_name(\n        self, mock_client, mock_cb\n    ):\n        \"\"\"\n        Ensures health check succeeds when /v1/bdbs contains an endpoint whose dns_name\n        matches database host, and availability endpoint returns success.\n        \"\"\"\n        host = \"db1.example.com\"\n        mock_client.get_connection_kwargs.return_value = {\"host\": host}\n\n        # Mock HttpClient used inside LagAwareHealthCheck\n        mock_http = AsyncMock()\n        mock_http.get.side_effect = [\n            # First call: list of bdbs\n            [\n                {\n                    \"uid\": \"bdb-1\",\n                    \"endpoints\": [\n                        {\"dns_name\": host, \"addr\": [\"10.0.0.1\", \"10.0.0.2\"]},\n                    ],\n                }\n            ],\n            # Second call: availability check (no JSON expected)\n            None,\n        ]\n\n        hc = LagAwareHealthCheck(rest_api_port=1234, lag_aware_tolerance=150)\n        # Inject our mocked http client\n        hc._http_client = mock_http\n\n        db = Database(mock_client, mock_cb, 1.0, \"https://healthcheck.example.com\")\n        mock_hc_client = (\n            AsyncMock()\n        )  # Not used by LagAwareHealthCheck but required by signature\n\n        assert await hc.check_health(db, mock_hc_client) is True\n        # Base URL must be set correctly\n        assert hc._http_client.client.base_url == \"https://healthcheck.example.com:1234\"\n        # Calls: first to list bdbs, then to availability\n        assert mock_http.get.call_count == 2\n        first_call = mock_http.get.call_args_list[0]\n        second_call = mock_http.get.call_args_list[1]\n        assert first_call.args[0] == \"/v1/bdbs\"\n        assert (\n            second_call.args[0]\n            == \"/v1/bdbs/bdb-1/availability?extend_check=lag&availability_lag_tolerance_ms=150\"\n        )\n        assert second_call.kwargs.get(\"expect_json\") is False\n\n    @pytest.mark.asyncio\n    async def test_database_is_healthy_when_bdb_matches_by_addr(\n        self, mock_client, mock_cb\n    ):\n        \"\"\"\n        Ensures health check succeeds when endpoint addr list contains the database host.\n        \"\"\"\n        host_ip = \"203.0.113.5\"\n        mock_client.get_connection_kwargs.return_value = {\"host\": host_ip}\n\n        mock_http = AsyncMock()\n        mock_http.get.side_effect = [\n            [\n                {\n                    \"uid\": \"bdb-42\",\n                    \"endpoints\": [\n                        {\"dns_name\": \"not-matching.example.com\", \"addr\": [host_ip]},\n                    ],\n                }\n            ],\n            None,\n        ]\n\n        hc = LagAwareHealthCheck()\n        hc._http_client = mock_http\n\n        db = Database(mock_client, mock_cb, 1.0, \"https://healthcheck.example.com\")\n        mock_hc_client = (\n            AsyncMock()\n        )  # Not used by LagAwareHealthCheck but required by signature\n\n        assert await hc.check_health(db, mock_hc_client) is True\n        assert mock_http.get.call_count == 2\n        assert (\n            mock_http.get.call_args_list[1].args[0]\n            == \"/v1/bdbs/bdb-42/availability?extend_check=lag&availability_lag_tolerance_ms=5000\"\n        )\n\n    @pytest.mark.asyncio\n    async def test_raises_value_error_when_no_matching_bdb(self, mock_client, mock_cb):\n        \"\"\"\n        Ensures health check raises ValueError when there's no bdb matching the database host.\n        \"\"\"\n        host = \"db2.example.com\"\n        mock_client.get_connection_kwargs.return_value = {\"host\": host}\n\n        mock_http = AsyncMock()\n        # Return bdbs that do not match host by dns_name nor addr\n        mock_http.get.return_value = [\n            {\n                \"uid\": \"a\",\n                \"endpoints\": [{\"dns_name\": \"other.example.com\", \"addr\": [\"10.0.0.9\"]}],\n            },\n            {\n                \"uid\": \"b\",\n                \"endpoints\": [\n                    {\"dns_name\": \"another.example.com\", \"addr\": [\"10.0.0.10\"]}\n                ],\n            },\n        ]\n\n        hc = LagAwareHealthCheck()\n        hc._http_client = mock_http\n\n        db = Database(mock_client, mock_cb, 1.0, \"https://healthcheck.example.com\")\n        mock_hc_client = (\n            AsyncMock()\n        )  # Not used by LagAwareHealthCheck but required by signature\n\n        with pytest.raises(ValueError, match=\"Could not find a matching bdb\"):\n            await hc.check_health(db, mock_hc_client)\n\n        # Only the listing call should have happened\n        mock_http.get.assert_called_once_with(\"/v1/bdbs\")\n\n    @pytest.mark.asyncio\n    async def test_propagates_http_error_from_availability(self, mock_client, mock_cb):\n        \"\"\"\n        Ensures that any HTTP error raised by the availability endpoint is propagated.\n        \"\"\"\n        host = \"db3.example.com\"\n        mock_client.get_connection_kwargs.return_value = {\"host\": host}\n\n        mock_http = AsyncMock()\n        # First: list bdbs -> match by dns_name\n        mock_http.get.side_effect = [\n            [{\"uid\": \"bdb-err\", \"endpoints\": [{\"dns_name\": host, \"addr\": []}]}],\n            # Second: availability -> raise HttpError\n            HttpError(\n                url=f\"https://{host}:9443/v1/bdbs/bdb-err/availability\",\n                status=503,\n                message=\"busy\",\n            ),\n        ]\n\n        hc = LagAwareHealthCheck()\n        hc._http_client = mock_http\n\n        db = Database(mock_client, mock_cb, 1.0, \"https://healthcheck.example.com\")\n        mock_hc_client = (\n            AsyncMock()\n        )  # Not used by LagAwareHealthCheck but required by signature\n\n        with pytest.raises(HttpError, match=\"busy\") as e:\n            await hc.check_health(db, mock_hc_client)\n            assert e.status == 503\n\n        # Ensure both calls were attempted\n        assert mock_http.get.call_count == 2\n\n\n@pytest.mark.onlynoncluster\n@pytest.mark.no_mock_connections\nclass TestAbstractHealthCheckPolicy:\n    \"\"\"\n    Tests for AbstractHealthCheckPolicy public interface methods.\n    These tests verify client lifecycle and caching behavior by\n    directly manipulating the internal _clients dictionary.\n\n    Uses @pytest.mark.no_mock_connections to disable the autouse fixture\n    that mocks get_client.\n    \"\"\"\n\n    @pytest.mark.asyncio\n    async def test_get_client_caches_clients_by_database_id(self):\n        \"\"\"\n        Verify that clients are cached and reused across multiple calls.\n        Uses a real sync Redis client to trigger the standalone path.\n        \"\"\"\n        # Create a real sync Redis client (without connecting)\n        from redis import Redis as SyncRedis\n\n        sync_client = SyncRedis(host=\"localhost\", port=6379)\n\n        mock_db = Mock(spec=Database)\n        mock_db.client = sync_client\n\n        policy = HealthyAllPolicy()\n\n        # Manually inject a mock client to test caching behavior\n        mock_redis = AsyncMock()\n        db_id = id(mock_db)\n        policy._clients[db_id] = mock_redis\n\n        # First call should return cached client\n        client1 = await policy.get_client(mock_db)\n        # Second call should return same client\n        client2 = await policy.get_client(mock_db)\n\n        # Should be the same instance\n        assert client1 is client2\n        assert client1 is mock_redis\n\n    @pytest.mark.asyncio\n    async def test_get_client_creates_separate_clients_for_different_databases(self):\n        \"\"\"\n        Verify that different databases get separate clients.\n        \"\"\"\n        from redis import Redis as SyncRedis\n\n        sync_client1 = SyncRedis(host=\"db1.local\", port=6379)\n        sync_client2 = SyncRedis(host=\"db2.local\", port=6379)\n\n        mock_db1 = Mock(spec=Database)\n        mock_db1.client = sync_client1\n\n        mock_db2 = Mock(spec=Database)\n        mock_db2.client = sync_client2\n\n        policy = HealthyAllPolicy()\n\n        # Manually inject mock clients\n        mock_redis1 = AsyncMock()\n        mock_redis2 = AsyncMock()\n        policy._clients[id(mock_db1)] = mock_redis1\n        policy._clients[id(mock_db2)] = mock_redis2\n\n        client1 = await policy.get_client(mock_db1)\n        client2 = await policy.get_client(mock_db2)\n\n        # Should get different clients\n        assert client1 is mock_redis1\n        assert client2 is mock_redis2\n        assert client1 is not client2\n\n    @pytest.mark.asyncio\n    async def test_close_closes_all_clients(self):\n        \"\"\"\n        Verify that close() closes all cached clients.\n        \"\"\"\n        policy = HealthyAllPolicy()\n\n        # Manually inject a mock client\n        mock_redis = AsyncMock()\n        mock_redis.aclose = AsyncMock()\n        policy._clients[123] = mock_redis\n\n        # Close the policy\n        await policy.close()\n\n        # Client should be closed\n        mock_redis.aclose.assert_called_once()\n\n        # Internal clients dict should be cleared\n        assert len(policy._clients) == 0\n\n    @pytest.mark.asyncio\n    async def test_close_closes_multiple_database_clients(self):\n        \"\"\"\n        Verify that close() closes clients for all databases.\n        \"\"\"\n        policy = HealthyAllPolicy()\n\n        # Manually inject mock clients\n        mock_redis1 = AsyncMock()\n        mock_redis1.aclose = AsyncMock()\n        mock_redis2 = AsyncMock()\n        mock_redis2.aclose = AsyncMock()\n        policy._clients[123] = mock_redis1\n        policy._clients[456] = mock_redis2\n\n        # Close the policy\n        await policy.close()\n\n        # Both clients should be closed\n        mock_redis1.aclose.assert_called_once()\n        mock_redis2.aclose.assert_called_once()\n        assert len(policy._clients) == 0\n\n    @pytest.mark.asyncio\n    async def test_close_handles_close_exceptions_gracefully(self):\n        \"\"\"\n        Verify that close() completes even if some clients fail to close.\n        \"\"\"\n        policy = HealthyAllPolicy()\n\n        # Manually inject mock clients\n        mock_redis1 = AsyncMock()\n        mock_redis1.aclose = AsyncMock(side_effect=ConnectionError(\"Close failed\"))\n        mock_redis2 = AsyncMock()\n        mock_redis2.aclose = AsyncMock()\n        policy._clients[123] = mock_redis1\n        policy._clients[456] = mock_redis2\n\n        # Should not raise even if one client fails to close\n        await policy.close()\n\n        # Both close attempts should have been made\n        mock_redis1.aclose.assert_called_once()\n        mock_redis2.aclose.assert_called_once()\n        assert len(policy._clients) == 0\n\n    @pytest.mark.asyncio\n    async def test_execute_runs_health_checks_concurrently(self):\n        \"\"\"\n        Verify that execute() runs multiple health checks concurrently.\n        \"\"\"\n\n        # Track concurrent execution\n        concurrent_count = 0\n        max_concurrent = 0\n\n        async def tracking_health_check(database, client=None):\n            nonlocal concurrent_count, max_concurrent\n            concurrent_count += 1\n            max_concurrent = max(max_concurrent, concurrent_count)\n            await asyncio.sleep(0.01)  # Small delay to allow overlap detection\n            concurrent_count -= 1\n            return True\n\n        mock_hc1 = _configure_mock_health_check(Mock(spec=HealthCheck), probes=1)\n        mock_hc1.check_health.side_effect = tracking_health_check\n\n        mock_hc2 = _configure_mock_health_check(Mock(spec=HealthCheck), probes=1)\n        mock_hc2.check_health.side_effect = tracking_health_check\n\n        mock_db = Mock(spec=Database)\n\n        policy = HealthyAllPolicy()\n\n        # Mock get_client to avoid real connections\n        async def mock_get_client(database):\n            mock_client = AsyncMock()\n            mock_client.ping = AsyncMock(return_value=True)\n            return mock_client\n\n        policy.get_client = mock_get_client\n\n        result = await policy.execute([mock_hc1, mock_hc2], mock_db)\n\n        assert result is True\n        # Both health checks should have been called\n        assert mock_hc1.check_health.call_count == 1\n        assert mock_hc2.check_health.call_count == 1\n        # If running concurrently, max_concurrent should be 2\n        assert max_concurrent == 2, (\n            f\"Expected 2 concurrent executions, got max {max_concurrent}\"\n        )\n\n    @pytest.mark.asyncio\n    async def test_execute_applies_timeout_per_health_check(self):\n        \"\"\"\n        Verify that execute() applies individual timeouts per health check.\n        \"\"\"\n        import asyncio\n\n        async def slow_health_check(database, client=None):\n            await asyncio.sleep(1.0)  # Takes 1 second\n            return True\n\n        async def fast_health_check(database, client=None):\n            await asyncio.sleep(0.01)\n            return True\n\n        # First health check times out\n        mock_hc1 = _configure_mock_health_check(\n            Mock(spec=HealthCheck), probes=1, timeout=0.05\n        )\n        mock_hc1.check_health.side_effect = slow_health_check\n\n        # Second health check completes within timeout\n        mock_hc2 = _configure_mock_health_check(\n            Mock(spec=HealthCheck), probes=1, timeout=1.0\n        )\n        mock_hc2.check_health.side_effect = fast_health_check\n\n        mock_db = Mock(spec=Database)\n\n        policy = HealthyAllPolicy()\n\n        async def mock_get_client(database):\n            mock_client = AsyncMock()\n            return mock_client\n\n        policy.get_client = mock_get_client\n\n        # Should raise because first health check times out\n        with pytest.raises(UnhealthyDatabaseException) as exc_info:\n            await policy.execute([mock_hc1, mock_hc2], mock_db)\n\n        assert isinstance(exc_info.value.original_exception, asyncio.TimeoutError)\n"
  },
  {
    "path": "tests/test_asyncio/test_multidb/test_pipeline.py",
    "content": "import asyncio\nfrom unittest.mock import Mock, AsyncMock, patch\n\nimport pybreaker\nimport pytest\n\nfrom redis.asyncio.client import Pipeline\nfrom redis.asyncio.multidb.client import MultiDBClient\nfrom redis.asyncio.multidb.config import InitialHealthCheck\nfrom redis.asyncio.multidb.failover import WeightBasedFailoverStrategy\nfrom redis.multidb.circuit import State as CBState, PBCircuitBreakerAdapter\nfrom tests.test_asyncio.helpers import wait_for_condition\nfrom tests.test_asyncio.test_multidb.conftest import create_weighted_list\n\n\ndef mock_pipe() -> Pipeline:\n    mock_pipe = Mock(spec=Pipeline)\n    mock_pipe.__aenter__ = AsyncMock(return_value=mock_pipe)\n    mock_pipe.__aexit__ = AsyncMock(return_value=None)\n    return mock_pipe\n\n\n@pytest.mark.onlynoncluster\nclass TestPipeline:\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_executes_pipeline_against_correct_db(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n\n        with (\n            patch.object(mock_multi_db_config, \"databases\", return_value=databases),\n            patch.object(\n                mock_multi_db_config, \"default_health_checks\", return_value=[mock_hc]\n            ),\n        ):\n            pipe = mock_pipe()\n            pipe.execute.return_value = [\"OK1\", \"value1\"]\n            mock_db1.client.pipeline.return_value = pipe\n\n            mock_hc.check_health.return_value = True\n\n            async with MultiDBClient(mock_multi_db_config) as client:\n                assert (\n                    mock_multi_db_config.failover_strategy.set_databases.call_count == 1\n                )\n\n                pipe = client.pipeline()\n                pipe.set(\"key1\", \"value1\")\n                pipe.get(\"key1\")\n\n                assert await pipe.execute() == [\"OK1\", \"value1\"]\n                assert len(mock_hc.check_health.call_args_list) >= 9\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {\"initial_health_check_policy\": InitialHealthCheck.MAJORITY_AVAILABLE},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.OPEN}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_execute_pipeline_against_correct_db_and_closed_circuit(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n\n        with (\n            patch.object(mock_multi_db_config, \"databases\", return_value=databases),\n            patch.object(\n                mock_multi_db_config, \"default_health_checks\", return_value=[mock_hc]\n            ),\n        ):\n            pipe = mock_pipe()\n            pipe.execute.return_value = [\"OK1\", \"value1\"]\n            mock_db1.client.pipeline.return_value = pipe\n\n            async def mock_check_health(database, connection=None):\n                if database == mock_db2:\n                    return False\n                else:\n                    return True\n\n            mock_hc.check_health.side_effect = mock_check_health\n            async with MultiDBClient(mock_multi_db_config) as client:\n                assert (\n                    mock_multi_db_config.failover_strategy.set_databases.call_count == 1\n                )\n\n                async with client.pipeline() as pipe:\n                    pipe.set(\"key1\", \"value1\")\n                    pipe.get(\"key1\")\n\n                assert await pipe.execute() == [\"OK1\", \"value1\"]\n                assert len(mock_hc.check_health.call_args_list) >= 7\n\n                assert mock_db.circuit.state == CBState.CLOSED\n                assert mock_db1.circuit.state == CBState.CLOSED\n                assert mock_db2.circuit.state == CBState.OPEN\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_execute_pipeline_against_correct_db_on_background_health_check_determine_active_db_unhealthy(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        cb = PBCircuitBreakerAdapter(pybreaker.CircuitBreaker(reset_timeout=0.1))\n        cb.database = mock_db\n        mock_db.circuit = cb\n\n        cb1 = PBCircuitBreakerAdapter(pybreaker.CircuitBreaker(reset_timeout=0.1))\n        cb1.database = mock_db1\n        mock_db1.circuit = cb1\n\n        cb2 = PBCircuitBreakerAdapter(pybreaker.CircuitBreaker(reset_timeout=0.1))\n        cb2.database = mock_db2\n        mock_db2.circuit = cb2\n\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n\n        # Track health check rounds per database\n        db_rounds = {id(mock_db): 0, id(mock_db1): 0, id(mock_db2): 0}\n        db_probes_in_round = {id(mock_db): 0, id(mock_db1): 0, id(mock_db2): 0}\n\n        # Create events for each failover scenario\n        db1_became_unhealthy = asyncio.Event()\n        db2_became_unhealthy = asyncio.Event()\n        db_became_unhealthy = asyncio.Event()\n        counter_lock = asyncio.Lock()\n\n        async def mock_check_health(database, connection=None):\n            async with counter_lock:\n                db_probes_in_round[id(database)] += 1\n                # After 3 probes, increment the round counter\n                if db_probes_in_round[id(database)] > 3:\n                    db_rounds[id(database)] += 1\n                    db_probes_in_round[id(database)] = 1\n                current_round = db_rounds[id(database)]\n\n            # Round 0 (initial health check): All databases healthy\n            if current_round == 0:\n                return True\n\n            # Round 1: mock_db1 becomes unhealthy, others healthy\n            if current_round == 1:\n                if database == mock_db1:\n                    db1_became_unhealthy.set()\n                    return False\n                return True\n\n            # Round 2: mock_db2 also becomes unhealthy, mock_db healthy\n            if current_round == 2:\n                if database == mock_db1:\n                    return False\n                if database == mock_db2:\n                    db2_became_unhealthy.set()\n                    return False\n                return True\n\n            # Round 3: mock_db also becomes unhealthy, but mock_db1 recovers\n            if current_round == 3:\n                if database == mock_db:\n                    db_became_unhealthy.set()\n                    return False\n                if database == mock_db2:\n                    return False\n                return True\n\n            # Round 4+: mock_db1 is healthy, others unhealthy\n            if database == mock_db1:\n                return True\n            return False\n\n        mock_hc.check_health.side_effect = mock_check_health\n\n        with (\n            patch.object(mock_multi_db_config, \"databases\", return_value=databases),\n            patch.object(\n                mock_multi_db_config,\n                \"default_health_checks\",\n                return_value=[mock_hc],\n            ),\n        ):\n            pipe = mock_pipe()\n            pipe.execute.return_value = [\"OK\", \"value\"]\n            mock_db.client.pipeline.return_value = pipe\n\n            pipe1 = mock_pipe()\n            pipe1.execute.return_value = [\"OK1\", \"value\"]\n            mock_db1.client.pipeline.return_value = pipe1\n\n            pipe2 = mock_pipe()\n            pipe2.execute.return_value = [\"OK2\", \"value\"]\n            mock_db2.client.pipeline.return_value = pipe2\n\n            mock_multi_db_config.health_check_interval = 0.05\n            mock_multi_db_config.failover_strategy = WeightBasedFailoverStrategy()\n\n            client = MultiDBClient(mock_multi_db_config)\n\n            async with client.pipeline() as pipe:\n                pipe.set(\"key1\", \"value\")\n                pipe.get(\"key1\")\n\n            # Run 1: All databases healthy - should use mock_db1 (highest weight 0.7)\n            assert await pipe.execute() == [\"OK1\", \"value\"]\n\n            # Wait for mock_db1 to become unhealthy\n            await wait_for_condition(\n                lambda: cb1.state == CBState.OPEN,\n                timeout=0.5,\n                error_message=\"Timeout waiting for cb1 to open\",\n            )\n\n            # Run 2: mock_db1 unhealthy - should failover to mock_db2 (weight 0.5)\n            assert await pipe.execute() == [\"OK2\", \"value\"]\n\n            # Wait for mock_db2 to become unhealthy\n            await wait_for_condition(\n                lambda: cb2.state == CBState.OPEN,\n                timeout=0.5,\n                error_message=\"Timeout waiting for cb2 to open\",\n            )\n\n            # Run 3: mock_db1 and mock_db2 unhealthy - should use mock_db (weight 0.2)\n            assert await pipe.execute() == [\"OK\", \"value\"]\n\n            # Wait for mock_db to become unhealthy\n            assert await asyncio.wait_for(db_became_unhealthy.wait(), timeout=1.0), (\n                \"Timeout waiting for mock_db to become unhealthy\"\n            )\n            await wait_for_condition(\n                lambda: cb.state == CBState.OPEN,\n                timeout=0.5,\n                error_message=\"Timeout waiting for cb to open\",\n            )\n\n            # Wait for mock_db1 to recover (circuit breaker to close)\n            await wait_for_condition(\n                lambda: cb1.state == CBState.CLOSED,\n                timeout=1.0,\n                error_message=\"Timeout waiting for cb1 to close (mock_db1 to recover)\",\n            )\n\n            # Run 4: mock_db unhealthy, others healthy - should use mock_db1 (highest weight)\n            assert await pipe.execute() == [\"OK1\", \"value\"]\n\n            await client.aclose()\n\n\n@pytest.mark.onlynoncluster\nclass TestTransaction:\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_executes_transaction_against_correct_db(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n\n        with (\n            patch.object(mock_multi_db_config, \"databases\", return_value=databases),\n            patch.object(\n                mock_multi_db_config, \"default_health_checks\", return_value=[mock_hc]\n            ),\n        ):\n            mock_db1.client.transaction.return_value = [\"OK1\", \"value1\"]\n\n            mock_hc.check_health.return_value = True\n\n            async with MultiDBClient(mock_multi_db_config) as client:\n                assert (\n                    mock_multi_db_config.failover_strategy.set_databases.call_count == 1\n                )\n\n                async def callback(pipe: Pipeline):\n                    pipe.set(\"key1\", \"value1\")\n                    pipe.get(\"key1\")\n\n                assert await client.transaction(callback) == [\"OK1\", \"value1\"]\n                # if we assume at least 3 health checks have run per each database\n                # we should have at least 9 total calls\n                assert len(mock_hc.check_health.call_args_list) >= 9\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {\"initial_health_check_policy\": InitialHealthCheck.MAJORITY_AVAILABLE},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.OPEN}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_execute_transaction_against_correct_db_and_closed_circuit(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n\n        with (\n            patch.object(mock_multi_db_config, \"databases\", return_value=databases),\n            patch.object(\n                mock_multi_db_config, \"default_health_checks\", return_value=[mock_hc]\n            ),\n        ):\n            mock_db1.client.transaction.return_value = [\"OK1\", \"value1\"]\n\n            async def mock_check_health(database, connection=None):\n                if database == mock_db2:\n                    return False\n                else:\n                    return True\n\n            mock_hc.check_health.side_effect = mock_check_health\n\n            async with MultiDBClient(mock_multi_db_config) as client:\n                assert (\n                    mock_multi_db_config.failover_strategy.set_databases.call_count == 1\n                )\n\n                async def callback(pipe: Pipeline):\n                    pipe.set(\"key1\", \"value1\")\n                    pipe.get(\"key1\")\n\n                assert await client.transaction(callback) == [\"OK1\", \"value1\"]\n                assert len(mock_hc.check_health.call_args_list) >= 7\n\n                assert mock_db.circuit.state == CBState.CLOSED\n                assert mock_db1.circuit.state == CBState.CLOSED\n                assert mock_db2.circuit.state == CBState.OPEN\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    async def test_execute_transaction_against_correct_db_on_background_health_check_determine_active_db_unhealthy(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        cb = PBCircuitBreakerAdapter(pybreaker.CircuitBreaker(reset_timeout=0.1))\n        cb.database = mock_db\n        mock_db.circuit = cb\n\n        cb1 = PBCircuitBreakerAdapter(pybreaker.CircuitBreaker(reset_timeout=0.1))\n        cb1.database = mock_db1\n        mock_db1.circuit = cb1\n\n        cb2 = PBCircuitBreakerAdapter(pybreaker.CircuitBreaker(reset_timeout=0.1))\n        cb2.database = mock_db2\n        mock_db2.circuit = cb2\n\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n\n        # Track health check rounds per database\n        db_rounds = {id(mock_db): 0, id(mock_db1): 0, id(mock_db2): 0}\n        db_probes_in_round = {id(mock_db): 0, id(mock_db1): 0, id(mock_db2): 0}\n\n        # Create events for each failover scenario\n        db1_became_unhealthy = asyncio.Event()\n        db2_became_unhealthy = asyncio.Event()\n        db_became_unhealthy = asyncio.Event()\n        counter_lock = asyncio.Lock()\n\n        async def mock_check_health(database, connection=None):\n            async with counter_lock:\n                db_probes_in_round[id(database)] += 1\n                # After 3 probes, increment the round counter\n                if db_probes_in_round[id(database)] > 3:\n                    db_rounds[id(database)] += 1\n                    db_probes_in_round[id(database)] = 1\n                current_round = db_rounds[id(database)]\n\n            # Round 0 (initial health check): All databases healthy\n            if current_round == 0:\n                return True\n\n            # Round 1: mock_db1 becomes unhealthy, others healthy\n            if current_round == 1:\n                if database == mock_db1:\n                    db1_became_unhealthy.set()\n                    return False\n                return True\n\n            # Round 2: mock_db2 also becomes unhealthy, mock_db healthy\n            if current_round == 2:\n                if database == mock_db1:\n                    return False\n                if database == mock_db2:\n                    db2_became_unhealthy.set()\n                    return False\n                return True\n\n            # Round 3: mock_db also becomes unhealthy, but mock_db1 recovers\n            if current_round == 3:\n                if database == mock_db:\n                    db_became_unhealthy.set()\n                    return False\n                if database == mock_db2:\n                    return False\n                return True\n\n            # Round 4+: mock_db1 is healthy, others unhealthy\n            if database == mock_db1:\n                return True\n            return False\n\n        mock_hc.check_health.side_effect = mock_check_health\n\n        with (\n            patch.object(mock_multi_db_config, \"databases\", return_value=databases),\n            patch.object(\n                mock_multi_db_config,\n                \"default_health_checks\",\n                return_value=[mock_hc],\n            ),\n        ):\n            mock_db.client.transaction.return_value = [\"OK\", \"value\"]\n            mock_db1.client.transaction.return_value = [\"OK1\", \"value\"]\n            mock_db2.client.transaction.return_value = [\"OK2\", \"value\"]\n\n            mock_multi_db_config.health_check_interval = 0.05\n            mock_multi_db_config.failover_strategy = WeightBasedFailoverStrategy()\n\n            async with MultiDBClient(mock_multi_db_config) as client:\n\n                async def callback(pipe: Pipeline):\n                    pipe.set(\"key1\", \"value1\")\n                    pipe.get(\"key1\")\n\n                assert await client.transaction(callback) == [\"OK1\", \"value\"]\n\n                # Wait for mock_db1 to become unhealthy\n                await wait_for_condition(\n                    lambda: cb1.state == CBState.OPEN,\n                    timeout=0.5,\n                    error_message=\"Timeout waiting for cb1 to open\",\n                )\n\n                assert await client.transaction(callback) == [\"OK2\", \"value\"]\n\n                # Wait for mock_db2 to become unhealthy\n                await wait_for_condition(\n                    lambda: cb2.state == CBState.OPEN,\n                    timeout=0.5,\n                    error_message=\"Timeout waiting for cb2 to open\",\n                )\n\n                assert await client.transaction(callback) == [\"OK\", \"value\"]\n\n                # Wait for mock_db to become unhealthy\n                assert await asyncio.wait_for(\n                    db_became_unhealthy.wait(), timeout=1.0\n                ), \"Timeout waiting for mock_db to become unhealthy\"\n                await wait_for_condition(\n                    lambda: cb.state == CBState.OPEN,\n                    timeout=0.5,\n                    error_message=\"Timeout waiting for cb to open\",\n                )\n\n                # Wait for mock_db1 to recover (circuit breaker to close)\n                await wait_for_condition(\n                    lambda: cb1.state == CBState.CLOSED,\n                    timeout=1.0,\n                    error_message=\"Timeout waiting for cb1 to close (mock_db1 to recover)\",\n                )\n\n                assert await client.transaction(callback) == [\"OK1\", \"value\"]\n"
  },
  {
    "path": "tests/test_asyncio/test_observability/__init__.py",
    "content": "\"\"\"\nTests for redis.asyncio.observability module.\n\"\"\"\n"
  },
  {
    "path": "tests/test_asyncio/test_observability/test_cluster_metrics_error_handling.py",
    "content": "\"\"\"\nUnit tests for async cluster metrics recording during error handling.\n\nThese tests verify that the async cluster error handling correctly records metrics\nwhen exceptions occur, even when using ClusterNode objects instead of Connection objects.\n\"\"\"\n\nimport pytest\nfrom unittest.mock import MagicMock, patch, AsyncMock\nfrom redis.asyncio.cluster import RedisCluster, ClusterNode\nfrom redis.exceptions import (\n    AuthenticationError,\n    ConnectionError as RedisConnectionError,\n    TimeoutError as RedisTimeoutError,\n    ClusterDownError,\n    SlotNotCoveredError,\n    MaxConnectionsError,\n    ResponseError,\n)\n\n\n@pytest.mark.asyncio\nclass TestAsyncClusterMetricsRecordingDuringErrorHandling:\n    \"\"\"\n    Tests for async cluster metrics recording during error handling.\n\n    These tests verify that when exceptions occur during command execution,\n    metrics are recorded correctly using either the Connection object (when\n    available) or the ClusterNode (as fallback when connection is None).\n    \"\"\"\n\n    async def test_authentication_error_uses_target_node_for_metrics(self):\n        \"\"\"\n        Test that AuthenticationError uses target_node for metrics when connection is None.\n\n        AuthenticationError typically occurs during connection establishment.\n        Since the error is raised before connection is established, we use\n        target_node for metrics.\n        \"\"\"\n        target_node = ClusterNode(host=\"127.0.0.1\", port=7000, server_type=\"primary\")\n\n        with patch.object(RedisCluster, \"__init__\", return_value=None):\n            cluster = RedisCluster.__new__(RedisCluster)\n            cluster.nodes_manager = MagicMock()\n            cluster._initialize = False\n            cluster.RedisClusterRequestTTL = 3\n            cluster.retry = MagicMock()\n            cluster.retry.get_retries.return_value = 0\n            cluster._parse_target_nodes = MagicMock(return_value=[target_node])\n            cluster._policy_resolver = MagicMock()\n            cluster._policy_resolver.resolve = AsyncMock(return_value=None)\n            cluster.command_flags = {}\n\n            with patch.object(\n                ClusterNode,\n                \"execute_command\",\n                new_callable=AsyncMock,\n                side_effect=AuthenticationError(\"Auth failed\"),\n            ):\n                with patch(\n                    \"redis.asyncio.cluster.record_operation_duration\",\n                    new_callable=AsyncMock,\n                ) as mock_record:\n                    with pytest.raises(AuthenticationError) as exc_info:\n                        await cluster.execute_command(\n                            \"GET\", \"key\", target_nodes=target_node\n                        )\n\n                    assert \"Auth failed\" in str(exc_info.value)\n\n                    mock_record.assert_called_once()\n                    call_kwargs = mock_record.call_args.kwargs\n                    assert call_kwargs[\"command_name\"] == \"GET\"\n                    assert call_kwargs[\"server_address\"] == \"127.0.0.1\"\n                    assert call_kwargs[\"server_port\"] == 7000\n                    assert call_kwargs[\"db_namespace\"] == \"0\"\n                    assert isinstance(call_kwargs[\"error\"], AuthenticationError)\n\n    async def test_connection_error_uses_target_node_when_no_connection(self):\n        \"\"\"\n        Test that ConnectionError records metrics with target_node.\n\n        This validates the async implementation handles the case where connection\n        fails and metrics are recorded using ClusterNode data.\n        \"\"\"\n        target_node = ClusterNode(host=\"10.0.0.50\", port=7001, server_type=\"primary\")\n\n        with patch.object(RedisCluster, \"__init__\", return_value=None):\n            cluster = RedisCluster.__new__(RedisCluster)\n            cluster.nodes_manager = MagicMock()\n            cluster.nodes_manager.move_node_to_end_of_cached_nodes = MagicMock()\n            cluster._initialize = False\n            cluster.RedisClusterRequestTTL = 3\n            cluster.retry = MagicMock()\n            cluster.retry.get_retries.return_value = 0\n            cluster._parse_target_nodes = MagicMock(return_value=[target_node])\n            cluster._policy_resolver = MagicMock()\n            cluster._policy_resolver.resolve = AsyncMock(return_value=None)\n            cluster.command_flags = {}\n\n            with patch.object(\n                ClusterNode,\n                \"execute_command\",\n                new_callable=AsyncMock,\n                side_effect=RedisConnectionError(\"Connection refused\"),\n            ):\n                with patch.object(\n                    ClusterNode, \"update_active_connections_for_reconnect\"\n                ):\n                    with patch.object(\n                        ClusterNode,\n                        \"disconnect_free_connections\",\n                        new_callable=AsyncMock,\n                    ):\n                        with patch(\n                            \"redis.asyncio.cluster.record_operation_duration\",\n                            new_callable=AsyncMock,\n                        ) as mock_record:\n                            with pytest.raises(RedisConnectionError) as exc_info:\n                                await cluster.execute_command(\n                                    \"GET\", \"key\", target_nodes=target_node\n                                )\n\n                            assert \"Connection refused\" in str(exc_info.value)\n\n                            mock_record.assert_called_once()\n                            call_kwargs = mock_record.call_args.kwargs\n                            assert call_kwargs[\"command_name\"] == \"GET\"\n                            assert call_kwargs[\"server_address\"] == \"10.0.0.50\"\n                            assert call_kwargs[\"server_port\"] == 7001\n                            assert call_kwargs[\"db_namespace\"] == \"0\"\n                            assert isinstance(\n                                call_kwargs[\"error\"], RedisConnectionError\n                            )\n\n    async def test_response_error_uses_target_node(self):\n        \"\"\"\n        Test that ResponseError uses target_node for metrics.\n\n        When a command succeeds in reaching the server but gets an error response,\n        we use the target_node for metrics since async cluster doesn't have a\n        persistent connection object in the same way sync does.\n        \"\"\"\n        target_node = ClusterNode(host=\"172.16.0.10\", port=6380, server_type=\"primary\")\n\n        with patch.object(RedisCluster, \"__init__\", return_value=None):\n            cluster = RedisCluster.__new__(RedisCluster)\n            cluster.nodes_manager = MagicMock()\n            cluster._initialize = False\n            cluster.RedisClusterRequestTTL = 3\n            cluster.retry = MagicMock()\n            cluster.retry.get_retries.return_value = 0\n            cluster._parse_target_nodes = MagicMock(return_value=[target_node])\n            cluster._policy_resolver = MagicMock()\n            cluster._policy_resolver.resolve = AsyncMock(return_value=None)\n            cluster.command_flags = {}\n\n            with patch.object(\n                ClusterNode,\n                \"execute_command\",\n                new_callable=AsyncMock,\n                side_effect=ResponseError(\"WRONGTYPE Operation against a key\"),\n            ):\n                with patch(\n                    \"redis.asyncio.cluster.record_operation_duration\",\n                    new_callable=AsyncMock,\n                ) as mock_record:\n                    with pytest.raises(ResponseError) as exc_info:\n                        await cluster.execute_command(\n                            \"GET\", \"key\", target_nodes=target_node\n                        )\n\n                    assert \"WRONGTYPE\" in str(exc_info.value)\n\n                    mock_record.assert_called_once()\n                    call_kwargs = mock_record.call_args.kwargs\n                    assert call_kwargs[\"command_name\"] == \"GET\"\n                    assert call_kwargs[\"server_address\"] == \"172.16.0.10\"\n                    assert call_kwargs[\"server_port\"] == 6380\n                    assert call_kwargs[\"db_namespace\"] == \"0\"\n                    assert isinstance(call_kwargs[\"error\"], ResponseError)\n\n    async def test_max_connections_error_records_metrics_with_cluster_node(self):\n        \"\"\"\n        Test that MaxConnectionsError records metrics using ClusterNode info.\n\n        When MaxConnectionsError occurs, connection is None because we couldn't\n        get a connection from the pool. Metrics should be recorded using the\n        ClusterNode's host/port.\n        \"\"\"\n        target_node = ClusterNode(\n            host=\"192.168.1.100\", port=7005, server_type=\"primary\"\n        )\n\n        with patch.object(RedisCluster, \"__init__\", return_value=None):\n            cluster = RedisCluster.__new__(RedisCluster)\n            cluster.nodes_manager = MagicMock()\n            cluster._initialize = False\n            cluster.RedisClusterRequestTTL = 3\n            cluster.retry = MagicMock()\n            cluster.retry.get_retries.return_value = 0\n            cluster._parse_target_nodes = MagicMock(return_value=[target_node])\n            cluster._policy_resolver = MagicMock()\n            cluster._policy_resolver.resolve = AsyncMock(return_value=None)\n            cluster.command_flags = {}\n\n            with patch.object(\n                ClusterNode,\n                \"execute_command\",\n                new_callable=AsyncMock,\n                side_effect=MaxConnectionsError(\"Pool exhausted\"),\n            ):\n                with patch(\n                    \"redis.asyncio.cluster.record_operation_duration\",\n                    new_callable=AsyncMock,\n                ) as mock_record:\n                    with pytest.raises(MaxConnectionsError):\n                        await cluster.execute_command(\n                            \"GET\", \"key\", target_nodes=target_node\n                        )\n\n                    mock_record.assert_called_once()\n                    call_kwargs = mock_record.call_args.kwargs\n                    assert call_kwargs[\"server_address\"] == \"192.168.1.100\"\n                    assert call_kwargs[\"server_port\"] == 7005\n                    assert call_kwargs[\"db_namespace\"] == \"0\"\n                    assert isinstance(call_kwargs[\"error\"], MaxConnectionsError)\n\n    async def test_timeout_error_uses_target_node_for_metrics(self):\n        \"\"\"\n        Test that TimeoutError records metrics with target_node data.\n        \"\"\"\n        target_node = ClusterNode(host=\"10.0.0.100\", port=7003, server_type=\"primary\")\n\n        with patch.object(RedisCluster, \"__init__\", return_value=None):\n            cluster = RedisCluster.__new__(RedisCluster)\n            cluster.nodes_manager = MagicMock()\n            cluster.nodes_manager.move_node_to_end_of_cached_nodes = MagicMock()\n            cluster._initialize = False\n            cluster.RedisClusterRequestTTL = 3\n            cluster.retry = MagicMock()\n            cluster.retry.get_retries.return_value = 0\n            cluster._parse_target_nodes = MagicMock(return_value=[target_node])\n            cluster._policy_resolver = MagicMock()\n            cluster._policy_resolver.resolve = AsyncMock(return_value=None)\n            cluster.command_flags = {}\n\n            with patch.object(\n                ClusterNode,\n                \"execute_command\",\n                new_callable=AsyncMock,\n                side_effect=RedisTimeoutError(\"Timeout connecting\"),\n            ):\n                with patch.object(\n                    ClusterNode, \"update_active_connections_for_reconnect\"\n                ):\n                    with patch.object(\n                        ClusterNode,\n                        \"disconnect_free_connections\",\n                        new_callable=AsyncMock,\n                    ):\n                        with patch(\n                            \"redis.asyncio.cluster.record_operation_duration\",\n                            new_callable=AsyncMock,\n                        ) as mock_record:\n                            with pytest.raises(RedisTimeoutError) as exc_info:\n                                await cluster.execute_command(\n                                    \"GET\", \"key\", target_nodes=target_node\n                                )\n\n                            assert \"Timeout\" in str(exc_info.value)\n\n                            mock_record.assert_called_once()\n                            call_kwargs = mock_record.call_args.kwargs\n                            assert call_kwargs[\"server_address\"] == \"10.0.0.100\"\n                            assert call_kwargs[\"server_port\"] == 7003\n                            assert call_kwargs[\"db_namespace\"] == \"0\"\n                            assert isinstance(call_kwargs[\"error\"], RedisTimeoutError)\n\n    async def test_cluster_down_error_with_cluster_node_metrics(self):\n        \"\"\"\n        Test that ClusterDownError records metrics correctly with target_node data.\n        \"\"\"\n        target_node = ClusterNode(host=\"172.20.0.10\", port=7006, server_type=\"primary\")\n\n        with patch.object(RedisCluster, \"__init__\", return_value=None):\n            cluster = RedisCluster.__new__(RedisCluster)\n            cluster.nodes_manager = MagicMock()\n            cluster._initialize = False\n            cluster.RedisClusterRequestTTL = 3\n            cluster.aclose = AsyncMock()\n            cluster.retry = MagicMock()\n            cluster.retry.get_retries.return_value = 0\n            cluster._parse_target_nodes = MagicMock(return_value=[target_node])\n            cluster._policy_resolver = MagicMock()\n            cluster._policy_resolver.resolve = AsyncMock(return_value=None)\n            cluster.command_flags = {}\n\n            with patch.object(\n                ClusterNode,\n                \"execute_command\",\n                new_callable=AsyncMock,\n                side_effect=ClusterDownError(\"CLUSTERDOWN\"),\n            ):\n                with patch(\n                    \"redis.asyncio.cluster.record_operation_duration\",\n                    new_callable=AsyncMock,\n                ) as mock_record:\n                    with patch(\"asyncio.sleep\", new_callable=AsyncMock):\n                        with pytest.raises(ClusterDownError):\n                            await cluster.execute_command(\n                                \"GET\", \"key\", target_nodes=target_node\n                            )\n\n                    mock_record.assert_called_once()\n                    call_kwargs = mock_record.call_args.kwargs\n                    assert call_kwargs[\"server_address\"] == \"172.20.0.10\"\n                    assert call_kwargs[\"server_port\"] == 7006\n                    assert call_kwargs[\"db_namespace\"] == \"0\"\n                    assert isinstance(call_kwargs[\"error\"], ClusterDownError)\n\n    async def test_slot_not_covered_error_with_cluster_node_metrics(self):\n        \"\"\"\n        Test that SlotNotCoveredError records metrics correctly with target_node data.\n        \"\"\"\n        target_node = ClusterNode(host=\"172.20.0.20\", port=7007, server_type=\"primary\")\n\n        with patch.object(RedisCluster, \"__init__\", return_value=None):\n            cluster = RedisCluster.__new__(RedisCluster)\n            cluster.nodes_manager = MagicMock()\n            cluster._initialize = False\n            cluster.RedisClusterRequestTTL = 3\n            cluster.aclose = AsyncMock()\n            cluster.retry = MagicMock()\n            cluster.retry.get_retries.return_value = 0\n            cluster._parse_target_nodes = MagicMock(return_value=[target_node])\n            cluster._policy_resolver = MagicMock()\n            cluster._policy_resolver.resolve = AsyncMock(return_value=None)\n            cluster.command_flags = {}\n\n            with patch.object(\n                ClusterNode,\n                \"execute_command\",\n                new_callable=AsyncMock,\n                side_effect=SlotNotCoveredError(\"Slot 1234 not covered\"),\n            ):\n                with patch(\n                    \"redis.asyncio.cluster.record_operation_duration\",\n                    new_callable=AsyncMock,\n                ) as mock_record:\n                    with patch(\"asyncio.sleep\", new_callable=AsyncMock):\n                        with pytest.raises(SlotNotCoveredError):\n                            await cluster.execute_command(\n                                \"GET\", \"key\", target_nodes=target_node\n                            )\n\n                    mock_record.assert_called_once()\n                    call_kwargs = mock_record.call_args.kwargs\n                    assert call_kwargs[\"server_address\"] == \"172.20.0.20\"\n                    assert call_kwargs[\"server_port\"] == 7007\n                    assert call_kwargs[\"db_namespace\"] == \"0\"\n                    assert isinstance(call_kwargs[\"error\"], SlotNotCoveredError)\n\n    async def test_successful_command_records_metrics_with_connection_db(self):\n        \"\"\"\n        Test that successful command execution records metrics with proper db value.\n\n        In async cluster, the execute_command is called on target_node directly,\n        so we use target_node's connection_kwargs for db lookup.\n        \"\"\"\n        from redis._parsers.commands import ResponsePolicy\n\n        target_node = ClusterNode(\n            host=\"192.168.50.10\", port=7008, server_type=\"primary\", db=3\n        )\n\n        with patch.object(RedisCluster, \"__init__\", return_value=None):\n            cluster = RedisCluster.__new__(RedisCluster)\n            cluster.nodes_manager = MagicMock()\n            cluster._initialize = False\n            cluster.RedisClusterRequestTTL = 3\n            cluster.retry = MagicMock()\n            cluster.retry.get_retries.return_value = 0\n            cluster._parse_target_nodes = MagicMock(return_value=[target_node])\n            cluster._policy_resolver = MagicMock()\n            cluster._policy_resolver.resolve = AsyncMock(return_value=None)\n            cluster.command_flags = {}\n            cluster.result_callbacks = {}\n            cluster._policies_callback_mapping = {\n                ResponsePolicy.DEFAULT_KEYLESS: lambda x: x,\n                ResponsePolicy.DEFAULT_KEYED: lambda x: x,\n            }\n\n            with patch.object(\n                ClusterNode,\n                \"execute_command\",\n                new_callable=AsyncMock,\n                return_value=b\"value\",\n            ):\n                with patch(\n                    \"redis.asyncio.cluster.record_operation_duration\",\n                    new_callable=AsyncMock,\n                ) as mock_record:\n                    result = await cluster.execute_command(\n                        \"GET\", \"key\", target_nodes=target_node\n                    )\n\n                    assert result == b\"value\"\n\n                    mock_record.assert_called_once()\n                    call_kwargs = mock_record.call_args.kwargs\n                    assert call_kwargs[\"command_name\"] == \"GET\"\n                    assert call_kwargs[\"server_address\"] == \"192.168.50.10\"\n                    assert call_kwargs[\"server_port\"] == 7008\n                    assert call_kwargs[\"db_namespace\"] == \"3\"\n                    assert call_kwargs.get(\"error\") is None\n"
  },
  {
    "path": "tests/test_asyncio/test_observability/test_recorder.py",
    "content": "\"\"\"\nUnit tests for redis.asyncio.observability.recorder module.\n\nThese tests verify that async recorder functions correctly pass arguments through\nto the underlying OTel Meter instruments (Counter, Histogram, UpDownCounter).\nThe MeterProvider is mocked to verify the actual integration point where\nmetrics are exported to OTel.\n\"\"\"\n\nimport pytest\nfrom unittest.mock import MagicMock, patch, AsyncMock\n\nfrom redis.asyncio.observability import recorder\nfrom redis.observability.attributes import (\n    ConnectionState,\n    GeoFailoverReason,\n    PubSubDirection,\n    SERVER_ADDRESS,\n    SERVER_PORT,\n    DB_NAMESPACE,\n    DB_OPERATION_NAME,\n    DB_RESPONSE_STATUS_CODE,\n    DB_CLIENT_CONNECTION_STATE,\n    ERROR_TYPE,\n    NETWORK_PEER_ADDRESS,\n    NETWORK_PEER_PORT,\n    DB_CLIENT_CONNECTION_POOL_NAME,\n    DB_CLIENT_GEOFAILOVER_FAIL_FROM,\n    DB_CLIENT_GEOFAILOVER_FAIL_TO,\n    DB_CLIENT_GEOFAILOVER_REASON,\n    REDIS_CLIENT_OPERATION_RETRY_ATTEMPTS,\n    REDIS_CLIENT_STREAM_NAME,\n    REDIS_CLIENT_CONSUMER_GROUP,\n    REDIS_CLIENT_PUBSUB_CHANNEL,\n    REDIS_CLIENT_PUBSUB_MESSAGE_DIRECTION,\n    REDIS_CLIENT_PUBSUB_SHARDED,\n)\nfrom redis.observability.config import OTelConfig, MetricGroup\nfrom redis.observability.metrics import RedisMetricsCollector, CloseReason\nfrom redis.observability.registry import get_observables_registry_instance\n\n\nclass MockInstruments:\n    \"\"\"Container for mock OTel instruments.\"\"\"\n\n    def __init__(self):\n        # Counters\n        self.client_errors = MagicMock()\n        self.maintenance_notifications = MagicMock()\n        self.connection_timeouts = MagicMock()\n        self.connection_closed = MagicMock()\n        self.connection_handoff = MagicMock()\n        self.pubsub_messages = MagicMock()\n        self.geo_failovers = MagicMock()\n\n        # Gauges\n        self.connection_count = MagicMock()\n\n        # UpDownCounters\n        self.connection_relaxed_timeout = MagicMock()\n\n        # Histograms\n        self.connection_create_time = MagicMock()\n        self.connection_wait_time = MagicMock()\n        self.connection_use_time = MagicMock()\n        self.operation_duration = MagicMock()\n        self.stream_lag = MagicMock()\n\n\n@pytest.fixture\ndef mock_instruments():\n    \"\"\"Create mock OTel instruments.\"\"\"\n    return MockInstruments()\n\n\n@pytest.fixture\ndef mock_meter(mock_instruments):\n    \"\"\"Create a mock Meter that returns our mock instruments.\"\"\"\n    meter = MagicMock()\n\n    def create_counter_side_effect(name, **kwargs):\n        instrument_map = {\n            \"redis.client.errors\": mock_instruments.client_errors,\n            \"redis.client.maintenance.notifications\": mock_instruments.maintenance_notifications,\n            \"db.client.connection.timeouts\": mock_instruments.connection_timeouts,\n            \"redis.client.connection.closed\": mock_instruments.connection_closed,\n            \"redis.client.connection.handoff\": mock_instruments.connection_handoff,\n            \"redis.client.pubsub.messages\": mock_instruments.pubsub_messages,\n            \"redis.client.geofailover.failovers\": mock_instruments.geo_failovers,\n        }\n        return instrument_map.get(name, MagicMock())\n\n    def create_gauge_side_effect(name, **kwargs):\n        instrument_map = {\n            \"db.client.connection.count\": mock_instruments.connection_count,\n        }\n        return instrument_map.get(name, MagicMock())\n\n    def create_up_down_counter_side_effect(name, **kwargs):\n        instrument_map = {\n            \"redis.client.connection.relaxed_timeout\": mock_instruments.connection_relaxed_timeout,\n        }\n        return instrument_map.get(name, MagicMock())\n\n    def create_histogram_side_effect(name, **kwargs):\n        instrument_map = {\n            \"db.client.connection.create_time\": mock_instruments.connection_create_time,\n            \"db.client.connection.wait_time\": mock_instruments.connection_wait_time,\n            \"db.client.connection.use_time\": mock_instruments.connection_use_time,\n            \"db.client.operation.duration\": mock_instruments.operation_duration,\n            \"redis.client.stream.lag\": mock_instruments.stream_lag,\n        }\n        return instrument_map.get(name, MagicMock())\n\n    meter.create_counter.side_effect = create_counter_side_effect\n    meter.create_gauge.side_effect = create_gauge_side_effect\n    meter.create_observable_gauge.side_effect = create_gauge_side_effect\n    meter.create_up_down_counter.side_effect = create_up_down_counter_side_effect\n    meter.create_histogram.side_effect = create_histogram_side_effect\n\n    return meter\n\n\n@pytest.fixture\ndef mock_config():\n    \"\"\"Create a config with all metric groups enabled.\"\"\"\n    return OTelConfig(\n        metric_groups=[\n            MetricGroup.RESILIENCY,\n            MetricGroup.CONNECTION_BASIC,\n            MetricGroup.CONNECTION_ADVANCED,\n            MetricGroup.COMMAND,\n            MetricGroup.PUBSUB,\n            MetricGroup.STREAMING,\n        ]\n    )\n\n\n@pytest.fixture\ndef metrics_collector(mock_meter, mock_config):\n    \"\"\"Create a real RedisMetricsCollector with mocked Meter.\"\"\"\n    with patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n        collector = RedisMetricsCollector(mock_meter, mock_config)\n        return collector\n\n\n@pytest.fixture\ndef setup_async_recorder(metrics_collector, mock_instruments):\n    \"\"\"\n    Setup the async recorder module with our collector that has mocked instruments.\n    \"\"\"\n    # Reset the global collector before test\n    recorder.reset_collector()\n    get_observables_registry_instance().clear()\n\n    # Patch _get_or_create_collector to return our collector with mocked instruments\n    with patch.object(\n        recorder,\n        \"_get_or_create_collector\",\n        return_value=metrics_collector,\n    ):\n        yield mock_instruments\n\n    # Reset after test\n    recorder.reset_collector()\n    get_observables_registry_instance().clear()\n\n\n@pytest.mark.asyncio\nclass TestRecordOperationDuration:\n    \"\"\"Tests for record_operation_duration - verifies Histogram.record() calls.\"\"\"\n\n    async def test_record_operation_duration_success(self, setup_async_recorder):\n        \"\"\"Test that operation duration is recorded to the histogram with correct attributes.\"\"\"\n        instruments = setup_async_recorder\n\n        await recorder.record_operation_duration(\n            command_name=\"SET\",\n            duration_seconds=0.005,\n            server_address=\"localhost\",\n            server_port=6379,\n            db_namespace=\"0\",\n            error=None,\n        )\n\n        # Verify histogram.record() was called\n        instruments.operation_duration.record.assert_called_once()\n        call_args = instruments.operation_duration.record.call_args\n\n        # Verify duration value\n        assert call_args[0][0] == 0.005\n\n        # Verify attributes\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[SERVER_ADDRESS] == \"localhost\"\n        assert attrs[SERVER_PORT] == 6379\n        assert attrs[DB_NAMESPACE] == \"0\"\n        assert attrs[DB_OPERATION_NAME] == \"SET\"\n\n    async def test_record_operation_duration_with_error(self, setup_async_recorder):\n        \"\"\"Test that error information is included in attributes.\"\"\"\n        instruments = setup_async_recorder\n\n        error = ConnectionError(\"Connection refused\")\n        await recorder.record_operation_duration(\n            command_name=\"GET\",\n            duration_seconds=0.001,\n            server_address=\"localhost\",\n            server_port=6379,\n            error=error,\n        )\n\n        instruments.operation_duration.record.assert_called_once()\n        call_args = instruments.operation_duration.record.call_args\n\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[DB_OPERATION_NAME] == \"GET\"\n        assert attrs[DB_RESPONSE_STATUS_CODE] == \"error\"\n        assert attrs[ERROR_TYPE] == \"ConnectionError\"\n\n\n@pytest.mark.asyncio\nclass TestRecordConnectionCreateTime:\n    \"\"\"Tests for record_connection_create_time - verifies Histogram.record() calls.\"\"\"\n\n    async def test_record_connection_create_time(self, setup_async_recorder):\n        \"\"\"Test that connection create time is recorded correctly.\"\"\"\n        instruments = setup_async_recorder\n\n        mock_pool = MagicMock()\n        mock_pool.__class__.__name__ = \"ConnectionPool\"\n        mock_pool.connection_kwargs = {\"host\": \"localhost\", \"port\": 6379, \"db\": 0}\n        mock_pool._pool_id = \"a1b2c3d4\"  # Mock the unique pool ID\n\n        await recorder.record_connection_create_time(\n            connection_pool=mock_pool,\n            duration_seconds=0.050,\n        )\n\n        instruments.connection_create_time.record.assert_called_once()\n        call_args = instruments.connection_create_time.record.call_args\n\n        # Verify duration value\n        assert call_args[0][0] == 0.050\n\n        # Verify attributes\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[DB_CLIENT_CONNECTION_POOL_NAME] == \"localhost:6379_a1b2c3d4\"\n\n\n@pytest.mark.asyncio\nclass TestRecordConnectionTimeout:\n    \"\"\"Tests for record_connection_timeout - verifies Counter.add() calls.\"\"\"\n\n    async def test_record_connection_timeout(self, setup_async_recorder):\n        \"\"\"Test that connection timeout is recorded correctly.\"\"\"\n        instruments = setup_async_recorder\n\n        await recorder.record_connection_timeout(pool_name=\"test_pool\")\n\n        instruments.connection_timeouts.add.assert_called_once()\n        call_args = instruments.connection_timeouts.add.call_args\n\n        assert call_args[0][0] == 1\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[DB_CLIENT_CONNECTION_POOL_NAME] == \"test_pool\"\n\n\n@pytest.mark.asyncio\nclass TestRecordConnectionWaitTime:\n    \"\"\"Tests for record_connection_wait_time - verifies Histogram.record() calls.\"\"\"\n\n    async def test_record_connection_wait_time(self, setup_async_recorder):\n        \"\"\"Test that connection wait time is recorded correctly.\"\"\"\n        instruments = setup_async_recorder\n\n        await recorder.record_connection_wait_time(\n            pool_name=\"test_pool\",\n            duration_seconds=0.010,\n        )\n\n        instruments.connection_wait_time.record.assert_called_once()\n        call_args = instruments.connection_wait_time.record.call_args\n\n        assert call_args[0][0] == 0.010\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[DB_CLIENT_CONNECTION_POOL_NAME] == \"test_pool\"\n\n\n@pytest.mark.asyncio\nclass TestRecordConnectionClosed:\n    \"\"\"Tests for record_connection_closed - verifies Counter.add() calls.\"\"\"\n\n    async def test_record_connection_closed(self, setup_async_recorder):\n        \"\"\"Test that connection closed is recorded correctly.\"\"\"\n        instruments = setup_async_recorder\n\n        await recorder.record_connection_closed(\n            close_reason=CloseReason.ERROR,\n            error_type=ConnectionError(\"Connection lost\"),\n        )\n\n        instruments.connection_closed.add.assert_called_once()\n\n\n@pytest.mark.asyncio\nclass TestRecordConnectionRelaxedTimeout:\n    \"\"\"Tests for record_connection_relaxed_timeout - verifies UpDownCounter calls.\"\"\"\n\n    async def test_record_connection_relaxed_timeout_relaxed(\n        self, setup_async_recorder\n    ):\n        \"\"\"Test that relaxed timeout is recorded correctly when relaxed=True.\"\"\"\n        instruments = setup_async_recorder\n\n        await recorder.record_connection_relaxed_timeout(\n            connection_name=\"localhost:6379_abc123\",\n            maint_notification=\"MOVING\",\n            relaxed=True,\n        )\n\n        instruments.connection_relaxed_timeout.add.assert_called_once()\n        call_args = instruments.connection_relaxed_timeout.add.call_args\n        assert call_args[0][0] == 1  # +1 for relaxed\n\n    async def test_record_connection_relaxed_timeout_unrelaxed(\n        self, setup_async_recorder\n    ):\n        \"\"\"Test that relaxed timeout is recorded correctly when relaxed=False.\"\"\"\n        instruments = setup_async_recorder\n\n        await recorder.record_connection_relaxed_timeout(\n            connection_name=\"localhost:6379_abc123\",\n            maint_notification=\"MOVING\",\n            relaxed=False,\n        )\n\n        instruments.connection_relaxed_timeout.add.assert_called_once()\n        call_args = instruments.connection_relaxed_timeout.add.call_args\n        assert call_args[0][0] == -1  # -1 for unrelaxed\n\n\n@pytest.mark.asyncio\nclass TestRecordConnectionHandoff:\n    \"\"\"Tests for record_connection_handoff - verifies Counter.add() calls.\"\"\"\n\n    async def test_record_connection_handoff(self, setup_async_recorder):\n        \"\"\"Test that connection handoff is recorded correctly.\"\"\"\n        instruments = setup_async_recorder\n\n        await recorder.record_connection_handoff(pool_name=\"test_pool\")\n\n        instruments.connection_handoff.add.assert_called_once()\n        call_args = instruments.connection_handoff.add.call_args\n        assert call_args[0][0] == 1\n\n\n@pytest.mark.asyncio\nclass TestRecordErrorCount:\n    \"\"\"Tests for record_error_count - verifies Counter.add() calls.\"\"\"\n\n    async def test_record_error_count(self, setup_async_recorder):\n        \"\"\"Test recording error count with all attributes.\"\"\"\n        instruments = setup_async_recorder\n\n        error = ConnectionError(\"Connection refused\")\n        await recorder.record_error_count(\n            server_address=\"localhost\",\n            server_port=6379,\n            network_peer_address=\"127.0.0.1\",\n            network_peer_port=6379,\n            error_type=error,\n            retry_attempts=3,\n            is_internal=True,\n        )\n\n        instruments.client_errors.add.assert_called_once()\n        call_args = instruments.client_errors.add.call_args\n\n        assert call_args[0][0] == 1\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[SERVER_ADDRESS] == \"localhost\"\n        assert attrs[SERVER_PORT] == 6379\n        assert attrs[NETWORK_PEER_ADDRESS] == \"127.0.0.1\"\n        assert attrs[NETWORK_PEER_PORT] == 6379\n        assert attrs[ERROR_TYPE] == \"ConnectionError\"\n        assert attrs[REDIS_CLIENT_OPERATION_RETRY_ATTEMPTS] == 3\n\n    async def test_record_error_count_with_is_internal_false(\n        self, setup_async_recorder\n    ):\n        \"\"\"Test recording error count with is_internal=False.\"\"\"\n        instruments = setup_async_recorder\n\n        error = TimeoutError(\"Connection timed out\")\n        await recorder.record_error_count(\n            server_address=\"localhost\",\n            server_port=6379,\n            network_peer_address=\"127.0.0.1\",\n            network_peer_port=6379,\n            error_type=error,\n            retry_attempts=2,\n            is_internal=False,\n        )\n\n        instruments.client_errors.add.assert_called_once()\n        call_args = instruments.client_errors.add.call_args\n\n        assert call_args[0][0] == 1\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[ERROR_TYPE] == \"TimeoutError\"\n        assert attrs[REDIS_CLIENT_OPERATION_RETRY_ATTEMPTS] == 2\n\n\n@pytest.mark.asyncio\nclass TestRecordPubsubMessage:\n    \"\"\"Tests for record_pubsub_message - verifies Counter.add() calls.\"\"\"\n\n    async def test_record_pubsub_message_publish(self, setup_async_recorder):\n        \"\"\"Test that pubsub publish message is recorded correctly.\"\"\"\n        instruments = setup_async_recorder\n\n        await recorder.record_pubsub_message(\n            direction=PubSubDirection.PUBLISH,\n            channel=\"test_channel\",\n            sharded=False,\n        )\n\n        instruments.pubsub_messages.add.assert_called_once()\n\n    async def test_record_pubsub_message_receive(self, setup_async_recorder):\n        \"\"\"Test that pubsub receive message is recorded correctly.\"\"\"\n        instruments = setup_async_recorder\n\n        await recorder.record_pubsub_message(\n            direction=PubSubDirection.RECEIVE,\n            channel=\"test_channel\",\n            sharded=True,\n        )\n\n        instruments.pubsub_messages.add.assert_called_once()\n\n\n@pytest.mark.asyncio\nclass TestRecordGeoFailover:\n    \"\"\"Tests for record_geo_failover - verifies Counter.add() calls.\"\"\"\n\n    @pytest.fixture\n    def mock_database(self):\n        \"\"\"Create a mock database with required attributes.\"\"\"\n        mock_db = MagicMock()\n        mock_db.client.get_connection_kwargs.return_value = {\n            \"host\": \"localhost\",\n            \"port\": 6379,\n        }\n        mock_db.weight = 1.0\n        return mock_db\n\n    @pytest.fixture\n    def mock_database_secondary(self):\n        \"\"\"Create a secondary mock database with different attributes.\"\"\"\n        mock_db = MagicMock()\n        mock_db.client.get_connection_kwargs.return_value = {\n            \"host\": \"redis-secondary\",\n            \"port\": 6380,\n        }\n        mock_db.weight = 0.5\n        return mock_db\n\n    async def test_record_geo_failover_automatic(\n        self, setup_async_recorder, mock_database, mock_database_secondary\n    ):\n        \"\"\"Test recording automatic geo failover.\"\"\"\n        instruments = setup_async_recorder\n\n        await recorder.record_geo_failover(\n            fail_from=mock_database,\n            fail_to=mock_database_secondary,\n            reason=GeoFailoverReason.AUTOMATIC,\n        )\n\n        instruments.geo_failovers.add.assert_called_once()\n        call_args = instruments.geo_failovers.add.call_args\n\n        # Counter increments by 1\n        assert call_args[0][0] == 1\n\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[DB_CLIENT_GEOFAILOVER_FAIL_FROM] == \"localhost:6379/1.0\"\n        assert attrs[DB_CLIENT_GEOFAILOVER_FAIL_TO] == \"redis-secondary:6380/0.5\"\n        assert attrs[DB_CLIENT_GEOFAILOVER_REASON] == \"automatic\"\n\n    async def test_record_geo_failover_manual(\n        self, setup_async_recorder, mock_database, mock_database_secondary\n    ):\n        \"\"\"Test recording manual geo failover.\"\"\"\n        instruments = setup_async_recorder\n\n        await recorder.record_geo_failover(\n            fail_from=mock_database_secondary,\n            fail_to=mock_database,\n            reason=GeoFailoverReason.MANUAL,\n        )\n\n        instruments.geo_failovers.add.assert_called_once()\n        call_args = instruments.geo_failovers.add.call_args\n\n        assert call_args[0][0] == 1\n\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[DB_CLIENT_GEOFAILOVER_FAIL_FROM] == \"redis-secondary:6380/0.5\"\n        assert attrs[DB_CLIENT_GEOFAILOVER_FAIL_TO] == \"localhost:6379/1.0\"\n        assert attrs[DB_CLIENT_GEOFAILOVER_REASON] == \"manual\"\n\n\n@pytest.mark.asyncio\nclass TestHidePubSubChannelNames:\n    \"\"\"Tests for hide_pubsub_channel_names configuration option.\"\"\"\n\n    @pytest.fixture\n    def setup_async_recorder_with_hidden_channels(self, mock_meter, mock_instruments):\n        \"\"\"Setup async recorder with hide_pubsub_channel_names=True.\"\"\"\n        config = OTelConfig(\n            metric_groups=[MetricGroup.PUBSUB],\n            hide_pubsub_channel_names=True,\n        )\n\n        recorder.reset_collector()\n        get_observables_registry_instance().clear()\n\n        with patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        # Note: _get_or_create_collector is now sync, _get_config is still async\n        with patch.object(\n            recorder,\n            \"_get_or_create_collector\",\n            return_value=collector,\n        ):\n            with patch.object(\n                recorder,\n                \"_get_config\",\n                new_callable=lambda: AsyncMock(return_value=config),\n            ):\n                yield mock_instruments\n\n        recorder.reset_collector()\n        get_observables_registry_instance().clear()\n\n    async def test_channel_name_hidden_when_configured(\n        self, setup_async_recorder_with_hidden_channels\n    ):\n        \"\"\"Test that channel name is hidden when hide_pubsub_channel_names=True.\"\"\"\n        instruments = setup_async_recorder_with_hidden_channels\n\n        await recorder.record_pubsub_message(\n            direction=PubSubDirection.PUBLISH,\n            channel=\"secret-channel\",\n            sharded=False,\n        )\n\n        instruments.pubsub_messages.add.assert_called_once()\n        attrs = instruments.pubsub_messages.add.call_args[1][\"attributes\"]\n        assert (\n            attrs[REDIS_CLIENT_PUBSUB_MESSAGE_DIRECTION]\n            == PubSubDirection.PUBLISH.value\n        )\n        # Channel should NOT be in attributes when hidden\n        assert REDIS_CLIENT_PUBSUB_CHANNEL not in attrs\n        assert attrs[REDIS_CLIENT_PUBSUB_SHARDED] is False\n\n    async def test_channel_name_visible_when_not_configured(self, setup_async_recorder):\n        \"\"\"Test that channel name is visible when hide_pubsub_channel_names=False (default).\"\"\"\n        instruments = setup_async_recorder\n\n        await recorder.record_pubsub_message(\n            direction=PubSubDirection.PUBLISH,\n            channel=\"visible-channel\",\n            sharded=False,\n        )\n\n        instruments.pubsub_messages.add.assert_called_once()\n        attrs = instruments.pubsub_messages.add.call_args[1][\"attributes\"]\n        assert attrs[REDIS_CLIENT_PUBSUB_CHANNEL] == \"visible-channel\"\n\n    async def test_bytes_channel_normalized_to_str(self, setup_async_recorder):\n        \"\"\"Test that bytes channel names are normalized to str.\"\"\"\n        instruments = setup_async_recorder\n\n        await recorder.record_pubsub_message(\n            direction=PubSubDirection.RECEIVE,\n            channel=b\"bytes-channel\",\n            sharded=True,\n        )\n\n        instruments.pubsub_messages.add.assert_called_once()\n        attrs = instruments.pubsub_messages.add.call_args[1][\"attributes\"]\n        # Channel should be normalized from bytes to str\n        assert attrs[REDIS_CLIENT_PUBSUB_CHANNEL] == \"bytes-channel\"\n\n\n@pytest.mark.asyncio\nclass TestRecordStreamingLag:\n    \"\"\"Tests for record_streaming_lag - verifies Histogram.record() calls.\"\"\"\n\n    async def test_record_streaming_lag(self, setup_async_recorder):\n        \"\"\"Test that streaming lag is recorded correctly.\"\"\"\n        instruments = setup_async_recorder\n\n        await recorder.record_streaming_lag(\n            lag_seconds=0.150,\n            stream_name=\"test_stream\",\n            consumer_group=\"test_group\",\n        )\n\n        instruments.stream_lag.record.assert_called_once()\n        call_args = instruments.stream_lag.record.call_args\n        assert call_args[0][0] == 0.150\n\n\n@pytest.mark.asyncio\nclass TestRecordStreamingLagFromResponse:\n    \"\"\"Tests for record_streaming_lag_from_response - RESP2/RESP3 parsing and timestamp extraction.\"\"\"\n\n    @pytest.fixture\n    def setup_async_recorder_with_hidden_streams(self, mock_meter, mock_instruments):\n        \"\"\"Setup async recorder with hide_stream_names=True.\"\"\"\n        config = OTelConfig(\n            metric_groups=[MetricGroup.STREAMING],\n            hide_stream_names=True,\n        )\n\n        recorder.reset_collector()\n        get_observables_registry_instance().clear()\n\n        with patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        # Note: _get_or_create_collector is now sync, _get_config is still async\n        with patch.object(\n            recorder,\n            \"_get_or_create_collector\",\n            return_value=collector,\n        ):\n            with patch.object(\n                recorder,\n                \"_get_config\",\n                new_callable=lambda: AsyncMock(return_value=config),\n            ):\n                yield mock_instruments\n\n        recorder.reset_collector()\n        get_observables_registry_instance().clear()\n\n    async def test_record_streaming_lag_from_response_resp3_format(\n        self, setup_async_recorder\n    ):\n        \"\"\"Test RESP3 format parsing (dict with stream name as key).\"\"\"\n        instruments = setup_async_recorder\n\n        import time\n\n        current_time_ms = int(time.time() * 1000)\n        message_id = f\"{current_time_ms}-0\"\n\n        # RESP3 format: dict with stream name as key\n        response = {\n            \"test-stream\": [\n                [\n                    (message_id, {\"field\": \"value\"}),\n                ]\n            ]\n        }\n\n        await recorder.record_streaming_lag_from_response(\n            response=response,\n            consumer_group=\"my-group\",\n        )\n\n        instruments.stream_lag.record.assert_called_once()\n        call_args = instruments.stream_lag.record.call_args\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[REDIS_CLIENT_STREAM_NAME] == \"test-stream\"\n        assert attrs[REDIS_CLIENT_CONSUMER_GROUP] == \"my-group\"\n        # Lag should be non-negative and small (just created)\n        assert call_args[0][0] >= 0.0\n        assert call_args[0][0] < 1.0  # Should be less than 1 second\n\n    async def test_record_streaming_lag_from_response_resp2_format(\n        self, setup_async_recorder\n    ):\n        \"\"\"Test RESP2 format parsing (list with bytes stream name).\"\"\"\n        instruments = setup_async_recorder\n\n        import time\n\n        current_time_ms = int(time.time() * 1000)\n        message_id = f\"{current_time_ms}-0\"\n\n        # RESP2 format: list of [stream_name, messages] with bytes stream name\n        response = [\n            [\n                b\"test-stream\",\n                [\n                    (message_id, {\"field\": \"value\"}),\n                ],\n            ]\n        ]\n\n        await recorder.record_streaming_lag_from_response(\n            response=response,\n            consumer_group=\"my-group\",\n        )\n\n        instruments.stream_lag.record.assert_called_once()\n        call_args = instruments.stream_lag.record.call_args\n        attrs = call_args[1][\"attributes\"]\n        # Stream name should be normalized from bytes to str\n        assert attrs[REDIS_CLIENT_STREAM_NAME] == \"test-stream\"\n        assert attrs[REDIS_CLIENT_CONSUMER_GROUP] == \"my-group\"\n\n    async def test_record_streaming_lag_from_response_multiple_messages(\n        self, setup_async_recorder\n    ):\n        \"\"\"Test that multiple messages are processed correctly.\"\"\"\n        instruments = setup_async_recorder\n\n        import time\n\n        current_time_ms = int(time.time() * 1000)\n        message_id_1 = f\"{current_time_ms}-0\"\n        message_id_2 = f\"{current_time_ms}-1\"\n\n        response = {\n            \"test-stream\": [\n                [\n                    (message_id_1, {\"field\": \"value1\"}),\n                    (message_id_2, {\"field\": \"value2\"}),\n                ]\n            ]\n        }\n\n        await recorder.record_streaming_lag_from_response(\n            response=response,\n            consumer_group=\"my-group\",\n        )\n\n        # Should record lag for each message\n        assert instruments.stream_lag.record.call_count == 2\n\n    async def test_record_streaming_lag_from_response_multiple_streams(\n        self, setup_async_recorder\n    ):\n        \"\"\"Test that multiple streams are processed correctly.\"\"\"\n        instruments = setup_async_recorder\n\n        import time\n\n        current_time_ms = int(time.time() * 1000)\n        message_id = f\"{current_time_ms}-0\"\n\n        response = {\n            \"stream-1\": [[(message_id, {\"field\": \"value1\"})]],\n            \"stream-2\": [[(message_id, {\"field\": \"value2\"})]],\n        }\n\n        await recorder.record_streaming_lag_from_response(\n            response=response,\n            consumer_group=\"my-group\",\n        )\n\n        # Should record lag for each stream\n        assert instruments.stream_lag.record.call_count == 2\n\n    async def test_stream_name_hidden_in_record_streaming_lag_from_response_resp3(\n        self, setup_async_recorder_with_hidden_streams\n    ):\n        \"\"\"Test that stream names are hidden in record_streaming_lag_from_response for RESP3 format.\"\"\"\n        instruments = setup_async_recorder_with_hidden_streams\n\n        import time\n\n        current_time_ms = int(time.time() * 1000)\n        message_id = f\"{current_time_ms}-0\"\n\n        response = {\n            \"secret-stream\": [\n                [\n                    (message_id, {\"field\": \"value\"}),\n                ]\n            ]\n        }\n\n        await recorder.record_streaming_lag_from_response(\n            response=response,\n            consumer_group=\"my-group\",\n        )\n\n        instruments.stream_lag.record.assert_called_once()\n        attrs = instruments.stream_lag.record.call_args[1][\"attributes\"]\n        # Stream name should NOT be in attributes when hidden\n        assert REDIS_CLIENT_STREAM_NAME not in attrs\n\n    async def test_stream_name_hidden_in_record_streaming_lag_from_response_resp2(\n        self, setup_async_recorder_with_hidden_streams\n    ):\n        \"\"\"Test that stream names are hidden in record_streaming_lag_from_response for RESP2 format.\"\"\"\n        instruments = setup_async_recorder_with_hidden_streams\n\n        import time\n\n        current_time_ms = int(time.time() * 1000)\n        message_id = f\"{current_time_ms}-0\"\n\n        response = [\n            [\n                b\"secret-stream\",\n                [\n                    (message_id, {\"field\": \"value\"}),\n                ],\n            ]\n        ]\n\n        await recorder.record_streaming_lag_from_response(\n            response=response,\n            consumer_group=\"my-group\",\n        )\n\n        instruments.stream_lag.record.assert_called_once()\n        attrs = instruments.stream_lag.record.call_args[1][\"attributes\"]\n        # Stream name should NOT be in attributes when hidden\n        assert REDIS_CLIENT_STREAM_NAME not in attrs\n\n    async def test_record_streaming_lag_from_response_empty_response(\n        self, setup_async_recorder\n    ):\n        \"\"\"Test that empty response is handled gracefully.\"\"\"\n        instruments = setup_async_recorder\n\n        await recorder.record_streaming_lag_from_response(\n            response=None,\n            consumer_group=\"my-group\",\n        )\n\n        # Should not record anything for empty response\n        instruments.stream_lag.record.assert_not_called()\n\n    async def test_record_streaming_lag_from_response_bytes_message_id(\n        self, setup_async_recorder\n    ):\n        \"\"\"Test that bytes message IDs are handled correctly.\"\"\"\n        instruments = setup_async_recorder\n\n        import time\n\n        current_time_ms = int(time.time() * 1000)\n        message_id = f\"{current_time_ms}-0\".encode()  # bytes message ID\n\n        response = [\n            [\n                b\"test-stream\",\n                [\n                    (message_id, {\"field\": \"value\"}),\n                ],\n            ]\n        ]\n\n        await recorder.record_streaming_lag_from_response(\n            response=response,\n            consumer_group=\"my-group\",\n        )\n\n        instruments.stream_lag.record.assert_called_once()\n        # Should not raise - bytes message ID should be handled\n\n\n@pytest.mark.asyncio\nclass TestRecorderDisabled:\n    \"\"\"Tests for recorder behavior when observability is disabled.\"\"\"\n\n    async def test_record_operation_duration_when_disabled(self):\n        \"\"\"Test that recording does nothing when collector is None.\"\"\"\n        recorder.reset_collector()\n\n        # Note: _get_or_create_collector is now sync\n        with patch.object(\n            recorder,\n            \"_get_or_create_collector\",\n            return_value=None,\n        ):\n            # Should not raise any exception\n            await recorder.record_operation_duration(\n                command_name=\"SET\",\n                duration_seconds=0.005,\n                server_address=\"localhost\",\n                server_port=6379,\n            )\n\n        recorder.reset_collector()\n\n    async def test_is_enabled_returns_false_when_disabled(self):\n        \"\"\"Test is_enabled returns False when collector is None.\"\"\"\n        recorder.reset_collector()\n\n        # Note: _get_or_create_collector is now sync\n        with patch.object(\n            recorder,\n            \"_get_or_create_collector\",\n            return_value=None,\n        ):\n            assert await recorder.is_enabled() is False\n\n        recorder.reset_collector()\n\n    async def test_all_record_functions_safe_when_disabled(self):\n        \"\"\"Test that all record functions are safe to call when disabled.\"\"\"\n        recorder.reset_collector()\n\n        # Note: _get_or_create_collector is now sync\n        with patch.object(\n            recorder,\n            \"_get_or_create_collector\",\n            return_value=None,\n        ):\n            # None of these should raise\n            mock_pool = MagicMock()\n            mock_pool.pool_name = \"test_pool\"\n\n            await recorder.record_connection_create_time(mock_pool, 0.1)\n            await recorder.record_connection_timeout(\"pool\")\n            await recorder.record_connection_wait_time(\"pool\", 0.1)\n            await recorder.record_connection_closed()\n            await recorder.record_connection_relaxed_timeout(\"pool\", \"MOVING\", True)\n            await recorder.record_connection_handoff(\"pool\")\n            await recorder.record_error_count(\n                \"host\", 6379, \"127.0.0.1\", 6379, Exception(), 0\n            )\n            await recorder.record_maint_notification_count(\n                \"host\", 6379, \"127.0.0.1\", 6379, \"MOVING\"\n            )\n            await recorder.record_pubsub_message(PubSubDirection.PUBLISH)\n            await recorder.record_streaming_lag(0.1, \"stream\", \"group\")\n\n        recorder.reset_collector()\n\n\n@pytest.mark.asyncio\nclass TestRecordConnectionCount:\n    \"\"\"Tests for record_connection_count (UpDownCounter).\"\"\"\n\n    @pytest.fixture\n    def mock_up_down_counter(self):\n        \"\"\"Create a mock UpDownCounter.\"\"\"\n        return MagicMock()\n\n    @pytest.fixture\n    def mock_meter_with_counter(self, mock_up_down_counter):\n        \"\"\"Create a mock meter that returns our mock UpDownCounter.\"\"\"\n        meter = MagicMock()\n        meter.create_up_down_counter.return_value = mock_up_down_counter\n        meter.create_counter.return_value = MagicMock()\n        meter.create_histogram.return_value = MagicMock()\n        meter.create_observable_gauge.return_value = MagicMock()\n        return meter\n\n    @pytest.fixture\n    def mock_config_with_connection_basic(self):\n        \"\"\"Create a config with CONNECTION_BASIC enabled.\"\"\"\n        return OTelConfig(metric_groups=[MetricGroup.CONNECTION_BASIC])\n\n    @pytest.fixture\n    def setup_connection_count_recorder(\n        self, mock_meter_with_counter, mock_config_with_connection_basic\n    ):\n        \"\"\"Setup recorder with mocked meter for connection count tests.\"\"\"\n        recorder.reset_collector()\n\n        with patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(\n                mock_meter_with_counter, mock_config_with_connection_basic\n            )\n\n        with patch.object(recorder, \"_get_or_create_collector\", return_value=collector):\n            yield mock_meter_with_counter\n\n        recorder.reset_collector()\n\n    async def test_record_connection_count_increment(\n        self, setup_connection_count_recorder, mock_up_down_counter\n    ):\n        \"\"\"Test recording connection count increment.\"\"\"\n        await recorder.record_connection_count(\n            pool_name=\"localhost:6379_abc123\",\n            connection_state=ConnectionState.IDLE,\n            counter=1,\n        )\n\n        assert mock_up_down_counter.add.call_count == 1\n\n        calls = mock_up_down_counter.add.call_args_list\n        assert calls[0][0][0] == 1\n        assert (\n            calls[0][1][\"attributes\"][DB_CLIENT_CONNECTION_POOL_NAME]\n            == \"localhost:6379_abc123\"\n        )\n        assert calls[0][1][\"attributes\"][DB_CLIENT_CONNECTION_STATE] == \"idle\"\n\n    async def test_record_connection_count_decrement(\n        self, setup_connection_count_recorder, mock_up_down_counter\n    ):\n        \"\"\"Test recording connection count decrement.\"\"\"\n        await recorder.record_connection_count(\n            pool_name=\"localhost:6379_abc123\",\n            connection_state=ConnectionState.USED,\n            counter=-1,\n        )\n\n        assert mock_up_down_counter.add.call_count == 1\n\n        calls = mock_up_down_counter.add.call_args_list\n        assert calls[0][0][0] == -1\n        assert (\n            calls[0][1][\"attributes\"][DB_CLIENT_CONNECTION_POOL_NAME]\n            == \"localhost:6379_abc123\"\n        )\n        assert calls[0][1][\"attributes\"][DB_CLIENT_CONNECTION_STATE] == \"used\"\n\n    async def test_record_connection_count_batch_decrement(\n        self, setup_connection_count_recorder, mock_up_down_counter\n    ):\n        \"\"\"Test recording batch connection count decrement (e.g., pool disconnect).\"\"\"\n        await recorder.record_connection_count(\n            pool_name=\"localhost:6379_abc123\",\n            connection_state=ConnectionState.IDLE,\n            counter=-5,\n        )\n\n        assert mock_up_down_counter.add.call_count == 1\n\n        calls = mock_up_down_counter.add.call_args_list\n        assert calls[0][0][0] == -5\n        assert calls[0][1][\"attributes\"][DB_CLIENT_CONNECTION_STATE] == \"idle\"\n\n    async def test_record_connection_count_lifecycle_scenario(\n        self, setup_connection_count_recorder, mock_up_down_counter\n    ):\n        \"\"\"Test realistic lifecycle: create connection, acquire, release, re-acquire, disconnect.\"\"\"\n        # 1. New connection created (goes to IDLE)\n        await recorder.record_connection_count(\n            pool_name=\"pool1\",\n            connection_state=ConnectionState.IDLE,\n            counter=1,\n        )\n\n        # 2. Connection acquired from pool (transition: IDLE -> USED)\n        await recorder.record_connection_count(\n            pool_name=\"pool1\",\n            connection_state=ConnectionState.IDLE,\n            counter=-1,\n        )\n        await recorder.record_connection_count(\n            pool_name=\"pool1\",\n            connection_state=ConnectionState.USED,\n            counter=1,\n        )\n\n        # 3. Connection released to pool (transition: USED -> IDLE)\n        await recorder.record_connection_count(\n            pool_name=\"pool1\",\n            connection_state=ConnectionState.USED,\n            counter=-1,\n        )\n        await recorder.record_connection_count(\n            pool_name=\"pool1\",\n            connection_state=ConnectionState.IDLE,\n            counter=1,\n        )\n\n        # 4. Pool disconnect (IDLE -> destroyed)\n        await recorder.record_connection_count(\n            pool_name=\"pool1\",\n            connection_state=ConnectionState.IDLE,\n            counter=-1,\n        )\n\n        # Total calls: 6\n        assert mock_up_down_counter.add.call_count == 6\n\n        calls = mock_up_down_counter.add.call_args_list\n\n        # Step 1: IDLE +1 (creation)\n        assert calls[0][0][0] == 1\n        assert calls[0][1][\"attributes\"][DB_CLIENT_CONNECTION_STATE] == \"idle\"\n\n        # Step 2: IDLE -1, USED +1 (acquire)\n        assert calls[1][0][0] == -1\n        assert calls[1][1][\"attributes\"][DB_CLIENT_CONNECTION_STATE] == \"idle\"\n        assert calls[2][0][0] == 1\n        assert calls[2][1][\"attributes\"][DB_CLIENT_CONNECTION_STATE] == \"used\"\n\n        # Step 3: USED -1, IDLE +1 (release)\n        assert calls[3][0][0] == -1\n        assert calls[3][1][\"attributes\"][DB_CLIENT_CONNECTION_STATE] == \"used\"\n        assert calls[4][0][0] == 1\n        assert calls[4][1][\"attributes\"][DB_CLIENT_CONNECTION_STATE] == \"idle\"\n\n        # Step 4: IDLE -1 (disconnect)\n        assert calls[5][0][0] == -1\n        assert calls[5][1][\"attributes\"][DB_CLIENT_CONNECTION_STATE] == \"idle\"\n"
  },
  {
    "path": "tests/test_asyncio/test_pipeline.py",
    "content": "from unittest import mock\n\nimport pytest\nfrom redis import RedisClusterException\nimport redis\nfrom tests.conftest import skip_if_server_version_lt\n\nfrom .compat import aclosing\nfrom .conftest import wait_for_command\n\nfrom unittest.mock import MagicMock, patch, AsyncMock\nfrom redis.asyncio.client import Pipeline\nfrom redis.asyncio.observability import recorder as async_recorder\nfrom redis.observability.config import OTelConfig, MetricGroup\nfrom redis.observability.metrics import RedisMetricsCollector\n\n\nclass TestPipeline:\n    async def test_pipeline_is_true(self, r):\n        \"\"\"Ensure pipeline instances are not false-y\"\"\"\n        async with r.pipeline() as pipe:\n            assert pipe\n\n    async def test_pipeline(self, r):\n        async with r.pipeline() as pipe:\n            (\n                pipe.set(\"a\", \"a1\")\n                .get(\"a\")\n                .zadd(\"z\", {\"z1\": 1})\n                .zadd(\"z\", {\"z2\": 4})\n                .zincrby(\"z\", 1, \"z1\")\n            )\n            assert await pipe.execute() == [\n                True,\n                b\"a1\",\n                True,\n                True,\n                2.0,\n            ]\n\n    async def test_pipeline_memoryview(self, r):\n        async with r.pipeline() as pipe:\n            (pipe.set(\"a\", memoryview(b\"a1\")).get(\"a\"))\n            assert await pipe.execute() == [True, b\"a1\"]\n\n    async def test_pipeline_length(self, r):\n        async with r.pipeline() as pipe:\n            # Initially empty.\n            assert len(pipe) == 0\n\n            # Fill 'er up!\n            pipe.set(\"a\", \"a1\").set(\"b\", \"b1\").set(\"c\", \"c1\")\n            assert len(pipe) == 3\n\n            # Execute calls reset(), so empty once again.\n            await pipe.execute()\n            assert len(pipe) == 0\n\n    async def test_pipeline_no_transaction(self, r):\n        async with r.pipeline(transaction=False) as pipe:\n            pipe.set(\"a\", \"a1\").set(\"b\", \"b1\").set(\"c\", \"c1\")\n            assert await pipe.execute() == [True, True, True]\n            assert await r.get(\"a\") == b\"a1\"\n            assert await r.get(\"b\") == b\"b1\"\n            assert await r.get(\"c\") == b\"c1\"\n\n    @pytest.mark.onlynoncluster\n    async def test_pipeline_no_transaction_watch(self, r):\n        await r.set(\"a\", 0)\n\n        async with r.pipeline(transaction=False) as pipe:\n            await pipe.watch(\"a\")\n            a = await pipe.get(\"a\")\n\n            pipe.multi()\n            pipe.set(\"a\", int(a) + 1)\n            assert await pipe.execute() == [True]\n\n    @pytest.mark.onlynoncluster\n    async def test_pipeline_no_transaction_watch_failure(self, r):\n        await r.set(\"a\", 0)\n\n        async with r.pipeline(transaction=False) as pipe:\n            await pipe.watch(\"a\")\n            a = await pipe.get(\"a\")\n\n            await r.set(\"a\", \"bad\")\n\n            pipe.multi()\n            pipe.set(\"a\", int(a) + 1)\n\n            with pytest.raises(redis.WatchError):\n                await pipe.execute()\n\n            assert await r.get(\"a\") == b\"bad\"\n\n    async def test_exec_error_in_response(self, r):\n        \"\"\"\n        an invalid pipeline command at exec time adds the exception instance\n        to the list of returned values\n        \"\"\"\n        await r.set(\"c\", \"a\")\n        async with r.pipeline() as pipe:\n            pipe.set(\"a\", 1).set(\"b\", 2).lpush(\"c\", 3).set(\"d\", 4)\n            result = await pipe.execute(raise_on_error=False)\n\n            assert result[0]\n            assert await r.get(\"a\") == b\"1\"\n            assert result[1]\n            assert await r.get(\"b\") == b\"2\"\n\n            # we can't lpush to a key that's a string value, so this should\n            # be a ResponseError exception\n            assert isinstance(result[2], redis.ResponseError)\n            assert await r.get(\"c\") == b\"a\"\n\n            # since this isn't a transaction, the other commands after the\n            # error are still executed\n            assert result[3]\n            assert await r.get(\"d\") == b\"4\"\n\n            # make sure the pipe was restored to a working state\n            assert await pipe.set(\"z\", \"zzz\").execute() == [True]\n            assert await r.get(\"z\") == b\"zzz\"\n\n    async def test_exec_error_raised(self, r):\n        await r.set(\"c\", \"a\")\n        async with r.pipeline() as pipe:\n            pipe.set(\"a\", 1).set(\"b\", 2).lpush(\"c\", 3).set(\"d\", 4)\n            with pytest.raises(redis.ResponseError) as ex:\n                await pipe.execute()\n            assert str(ex.value).startswith(\n                \"Command # 3 (LPUSH c 3) of pipeline caused error: \"\n            )\n\n            # make sure the pipe was restored to a working state\n            assert await pipe.set(\"z\", \"zzz\").execute() == [True]\n            assert await r.get(\"z\") == b\"zzz\"\n\n    @pytest.mark.onlynoncluster\n    async def test_transaction_with_empty_error_command(self, r):\n        \"\"\"\n        Commands with custom EMPTY_ERROR functionality return their default\n        values in the pipeline no matter the raise_on_error preference\n        \"\"\"\n        for error_switch in (True, False):\n            async with r.pipeline() as pipe:\n                pipe.set(\"a\", 1).mget([]).set(\"c\", 3)\n                result = await pipe.execute(raise_on_error=error_switch)\n\n                assert result[0]\n                assert result[1] == []\n                assert result[2]\n\n    @pytest.mark.onlynoncluster\n    async def test_pipeline_with_empty_error_command(self, r):\n        \"\"\"\n        Commands with custom EMPTY_ERROR functionality return their default\n        values in the pipeline no matter the raise_on_error preference\n        \"\"\"\n        for error_switch in (True, False):\n            async with r.pipeline(transaction=False) as pipe:\n                pipe.set(\"a\", 1).mget([]).set(\"c\", 3)\n                result = await pipe.execute(raise_on_error=error_switch)\n\n                assert result[0]\n                assert result[1] == []\n                assert result[2]\n\n    async def test_parse_error_raised(self, r):\n        async with r.pipeline() as pipe:\n            # the zrem is invalid because we don't pass any keys to it\n            pipe.set(\"a\", 1).zrem(\"b\").set(\"b\", 2)\n            with pytest.raises(redis.ResponseError) as ex:\n                await pipe.execute()\n\n            assert str(ex.value).startswith(\n                \"Command # 2 (ZREM b) of pipeline caused error: \"\n            )\n\n            # make sure the pipe was restored to a working state\n            assert await pipe.set(\"z\", \"zzz\").execute() == [True]\n            assert await r.get(\"z\") == b\"zzz\"\n\n    @pytest.mark.onlynoncluster\n    async def test_parse_error_raised_transaction(self, r):\n        async with r.pipeline() as pipe:\n            pipe.multi()\n            # the zrem is invalid because we don't pass any keys to it\n            pipe.set(\"a\", 1).zrem(\"b\").set(\"b\", 2)\n            with pytest.raises(redis.ResponseError) as ex:\n                await pipe.execute()\n\n            assert str(ex.value).startswith(\n                \"Command # 2 (ZREM b) of pipeline caused error: \"\n            )\n\n            # make sure the pipe was restored to a working state\n            assert await pipe.set(\"z\", \"zzz\").execute() == [True]\n            assert await r.get(\"z\") == b\"zzz\"\n\n    @pytest.mark.onlynoncluster\n    async def test_watch_succeed(self, r):\n        await r.set(\"a\", 1)\n        await r.set(\"b\", 2)\n\n        async with r.pipeline() as pipe:\n            await pipe.watch(\"a\", \"b\")\n            assert pipe.watching\n            a_value = await pipe.get(\"a\")\n            b_value = await pipe.get(\"b\")\n            assert a_value == b\"1\"\n            assert b_value == b\"2\"\n            pipe.multi()\n\n            pipe.set(\"c\", 3)\n            assert await pipe.execute() == [True]\n            assert not pipe.watching\n\n    @pytest.mark.onlynoncluster\n    async def test_watch_failure(self, r):\n        await r.set(\"a\", 1)\n        await r.set(\"b\", 2)\n\n        async with r.pipeline() as pipe:\n            await pipe.watch(\"a\", \"b\")\n            await r.set(\"b\", 3)\n            pipe.multi()\n            pipe.get(\"a\")\n            with pytest.raises(redis.WatchError):\n                await pipe.execute()\n\n            assert not pipe.watching\n\n    @pytest.mark.onlynoncluster\n    async def test_watch_failure_in_empty_transaction(self, r):\n        await r.set(\"a\", 1)\n        await r.set(\"b\", 2)\n\n        async with r.pipeline() as pipe:\n            await pipe.watch(\"a\", \"b\")\n            await r.set(\"b\", 3)\n            pipe.multi()\n            with pytest.raises(redis.WatchError):\n                await pipe.execute()\n\n            assert not pipe.watching\n\n    @pytest.mark.onlynoncluster\n    async def test_unwatch(self, r):\n        await r.set(\"a\", 1)\n        await r.set(\"b\", 2)\n\n        async with r.pipeline() as pipe:\n            await pipe.watch(\"a\", \"b\")\n            await r.set(\"b\", 3)\n            await pipe.unwatch()\n            assert not pipe.watching\n            pipe.get(\"a\")\n            assert await pipe.execute() == [b\"1\"]\n\n    @pytest.mark.onlynoncluster\n    async def test_watch_exec_no_unwatch(self, r):\n        await r.set(\"a\", 1)\n        await r.set(\"b\", 2)\n\n        async with r.monitor() as m:\n            async with r.pipeline() as pipe:\n                await pipe.watch(\"a\", \"b\")\n                assert pipe.watching\n                a_value = await pipe.get(\"a\")\n                b_value = await pipe.get(\"b\")\n                assert a_value == b\"1\"\n                assert b_value == b\"2\"\n                pipe.multi()\n                pipe.set(\"c\", 3)\n                assert await pipe.execute() == [True]\n                assert not pipe.watching\n\n            unwatch_command = await wait_for_command(r, m, \"UNWATCH\")\n            assert unwatch_command is None, \"should not send UNWATCH\"\n\n    @pytest.mark.onlynoncluster\n    async def test_watch_reset_unwatch(self, r):\n        await r.set(\"a\", 1)\n\n        async with r.monitor() as m:\n            async with r.pipeline() as pipe:\n                await pipe.watch(\"a\")\n                assert pipe.watching\n                await pipe.reset()\n                assert not pipe.watching\n\n            unwatch_command = await wait_for_command(r, m, \"UNWATCH\")\n            assert unwatch_command is not None\n            assert unwatch_command[\"command\"] == \"UNWATCH\"\n\n    @pytest.mark.onlynoncluster\n    async def test_aclose_is_reset(self, r):\n        async with r.pipeline() as pipe:\n            called = 0\n\n            async def mock_reset():\n                nonlocal called\n                called += 1\n\n            with mock.patch.object(pipe, \"reset\", mock_reset):\n                await pipe.aclose()\n                assert called == 1\n\n    @pytest.mark.onlynoncluster\n    async def test_aclosing(self, r):\n        async with aclosing(r.pipeline()):\n            pass\n\n    @pytest.mark.onlynoncluster\n    async def test_transaction_callable(self, r):\n        await r.set(\"a\", 1)\n        await r.set(\"b\", 2)\n        has_run = []\n\n        async def my_transaction(pipe):\n            a_value = await pipe.get(\"a\")\n            assert a_value in (b\"1\", b\"2\")\n            b_value = await pipe.get(\"b\")\n            assert b_value == b\"2\"\n\n            # silly run-once code... incr's \"a\" so WatchError should be raised\n            # forcing this all to run again. this should incr \"a\" once to \"2\"\n            if not has_run:\n                await r.incr(\"a\")\n                has_run.append(\"it has\")\n\n            pipe.multi()\n            pipe.set(\"c\", int(a_value) + int(b_value))\n\n        result = await r.transaction(my_transaction, \"a\", \"b\")\n        assert result == [True]\n        assert await r.get(\"c\") == b\"4\"\n\n    @pytest.mark.onlynoncluster\n    async def test_transaction_callable_returns_value_from_callable(self, r):\n        async def callback(pipe):\n            # No need to do anything here since we only want the return value\n            return \"a\"\n\n        res = await r.transaction(callback, \"my-key\", value_from_callable=True)\n        assert res == \"a\"\n\n    async def test_exec_error_in_no_transaction_pipeline(self, r):\n        await r.set(\"a\", 1)\n        async with r.pipeline(transaction=False) as pipe:\n            pipe.llen(\"a\")\n            pipe.expire(\"a\", 100)\n\n            with pytest.raises(redis.ResponseError) as ex:\n                await pipe.execute()\n\n            assert str(ex.value).startswith(\n                \"Command # 1 (LLEN a) of pipeline caused error: \"\n            )\n\n        assert await r.get(\"a\") == b\"1\"\n\n    async def test_exec_error_in_no_transaction_pipeline_unicode_command(self, r):\n        key = chr(3456) + \"abcd\" + chr(3421)\n        await r.set(key, 1)\n        async with r.pipeline(transaction=False) as pipe:\n            pipe.llen(key)\n            pipe.expire(key, 100)\n\n            with pytest.raises(redis.ResponseError) as ex:\n                await pipe.execute()\n\n            expected = f\"Command # 1 (LLEN {key}) of pipeline caused error: \"\n            assert str(ex.value).startswith(expected)\n\n        assert await r.get(key) == b\"1\"\n\n    async def test_exec_error_in_pipeline_truncated(self, r):\n        key = \"a\" * 50\n        a_value = \"a\" * 20\n        b_value = \"b\" * 20\n\n        await r.set(key, 1)\n        async with r.pipeline(transaction=False) as pipe:\n            pipe.hset(key, mapping={\"field_a\": a_value, \"field_b\": b_value})\n            pipe.expire(key, 100)\n\n            with pytest.raises(redis.ResponseError) as ex:\n                await pipe.execute()\n\n            expected = f\"Command # 1 (HSET {key} field_a {a_value} field_b...) of pipeline caused error: \"\n            assert str(ex.value).startswith(expected)\n\n    async def test_pipeline_with_bitfield(self, r):\n        async with r.pipeline() as pipe:\n            pipe.set(\"a\", \"1\")\n            bf = pipe.bitfield(\"b\")\n            pipe2 = (\n                bf.set(\"u8\", 8, 255)\n                .get(\"u8\", 0)\n                .get(\"u4\", 8)  # 1111\n                .get(\"u4\", 12)  # 1111\n                .get(\"u4\", 13)  # 1110\n                .execute()\n            )\n            pipe.get(\"a\")\n            response = await pipe.execute()\n\n            assert pipe == pipe2\n            assert response == [True, [0, 0, 15, 15, 14], b\"1\"]\n\n    async def test_pipeline_get(self, r):\n        await r.set(\"a\", \"a1\")\n        async with r.pipeline() as pipe:\n            pipe.get(\"a\")\n            assert await pipe.execute() == [b\"a1\"]\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"2.0.0\")\n    async def test_pipeline_discard(self, r):\n        # empty pipeline should raise an error\n        async with r.pipeline() as pipe:\n            pipe.set(\"key\", \"someval\")\n            await pipe.discard()\n            with pytest.raises(redis.exceptions.ResponseError):\n                await pipe.execute()\n\n        # setting a pipeline and discarding should do the same\n        async with r.pipeline() as pipe:\n            pipe.set(\"key\", \"someval\")\n            pipe.set(\"someotherkey\", \"val\")\n            response = await pipe.execute()\n            pipe.set(\"key\", \"another value!\")\n            await pipe.discard()\n            pipe.set(\"key\", \"another vae!\")\n            with pytest.raises(redis.exceptions.ResponseError):\n                await pipe.execute()\n\n            pipe.set(\"foo\", \"bar\")\n            response = await pipe.execute()\n        assert response[0]\n        assert await r.get(\"foo\") == b\"bar\"\n\n    @pytest.mark.onlynoncluster\n    async def test_send_set_commands_over_async_pipeline(self, r: redis.asyncio.Redis):\n        pipe = r.pipeline()\n        pipe.hset(\"hash:1\", \"foo\", \"bar\")\n        pipe.hset(\"hash:1\", \"bar\", \"foo\")\n        pipe.hset(\"hash:1\", \"baz\", \"bar\")\n        pipe.hgetall(\"hash:1\")\n        resp = await pipe.execute()\n        assert resp == [1, 1, 1, {b\"bar\": b\"foo\", b\"baz\": b\"bar\", b\"foo\": b\"bar\"}]\n\n    @pytest.mark.onlycluster\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_pipeline_with_msetex(self, r):\n        p = r.pipeline()\n        with pytest.raises(RedisClusterException):\n            p.msetex({\"key1\": \"value1\", \"key2\": \"value2\"}, ex=1000)\n\n        p_transaction = r.pipeline(transaction=True)\n        with pytest.raises(RedisClusterException):\n            p_transaction.msetex(\n                {\"key1_transaction\": \"value1\", \"key2_transaction\": \"value2\"}, ex=10\n            )\n\n    async def test_pipeline_json_module_access(self, r):\n        \"\"\"\n        Test that pipeline can access the json() module.\n        The JSON module requires nodes_manager (for cluster) and set_response_callback\n        on the client during initialization.\n\n        \"\"\"\n        pipeline = r.pipeline()\n\n        # This should not raise an AttributeError\n        json_pipeline = pipeline.json()\n\n        # Verify the JSON module was created successfully\n        assert json_pipeline is not None\n        assert json_pipeline.client is pipeline\n\n        # Verify that JSON callbacks were registered\n        assert \"JSON.SET\" in r.response_callbacks\n        assert \"JSON.GET\" in r.response_callbacks\n\n\n@pytest.mark.asyncio\nclass TestAsyncPipelineOperationDurationMetricsRecording:\n    \"\"\"\n    Unit tests that verify operation duration metrics are properly recorded\n    from async Pipeline via record_operation_duration function calls.\n\n    These tests use fully mocked connection and connection pool - no real Redis\n    or OTel integration is used.\n    \"\"\"\n\n    @pytest.fixture\n    def mock_async_connection(self):\n        \"\"\"Create a mock async connection with required attributes.\"\"\"\n        conn = MagicMock()\n        conn.host = \"localhost\"\n        conn.port = 6379\n        conn.db = 0\n\n        # Create a real Retry object that just executes the function directly\n        async def mock_call_with_retry(\n            do, fail, is_retryable=None, with_failure_count=False\n        ):\n            return await do()\n\n        conn.retry = MagicMock()\n        conn.retry.call_with_retry = mock_call_with_retry\n        conn.retry.get_retries.return_value = 0\n\n        return conn\n\n    @pytest.fixture\n    def mock_async_connection_pool(self, mock_async_connection):\n        \"\"\"Create a mock async connection pool.\"\"\"\n        pool = MagicMock()\n        pool.get_connection = AsyncMock(return_value=mock_async_connection)\n        pool.release = AsyncMock()\n        pool.get_encoder.return_value = MagicMock()\n        pool.get_protocol.return_value = 2\n        return pool\n\n    @pytest.fixture\n    def mock_meter(self):\n        \"\"\"Create a mock Meter that tracks all instrument calls.\"\"\"\n        meter = MagicMock()\n\n        # Create mock histogram for operation duration\n        self.operation_duration = MagicMock()\n        # Create mock counter for client errors\n        self.client_errors = MagicMock()\n\n        def create_histogram_side_effect(name, **kwargs):\n            if name == \"db.client.operation.duration\":\n                return self.operation_duration\n            return MagicMock()\n\n        def create_counter_side_effect(name, **kwargs):\n            if name == \"redis.client.errors\":\n                return self.client_errors\n            return MagicMock()\n\n        meter.create_counter.side_effect = create_counter_side_effect\n        meter.create_up_down_counter.return_value = MagicMock()\n        meter.create_histogram.side_effect = create_histogram_side_effect\n        meter.create_observable_gauge.return_value = MagicMock()\n\n        return meter\n\n    @pytest.fixture\n    def setup_pipeline_with_otel(\n        self, mock_async_connection_pool, mock_async_connection, mock_meter\n    ):\n        \"\"\"\n        Setup a Pipeline with mocked connection and OTel collector.\n        Returns tuple of (pipeline, operation_duration_mock).\n        \"\"\"\n\n        # Reset any existing collector state\n        async_recorder.reset_collector()\n\n        # Create config with COMMAND group enabled\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND])\n\n        # Create collector with mocked meter\n        with patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        # Patch the recorder to use our collector\n        # Note: _get_or_create_collector is now sync\n        with patch.object(\n            async_recorder,\n            \"_get_or_create_collector\",\n            return_value=collector,\n        ):\n            # Create pipeline with mocked connection pool\n            pipeline = Pipeline(\n                connection_pool=mock_async_connection_pool,\n                response_callbacks={},\n                transaction=True,\n                shard_hint=None,\n            )\n\n            yield pipeline, self.operation_duration\n\n        # Cleanup\n        async_recorder.reset_collector()\n\n    async def test_pipeline_execute_records_operation_duration(\n        self, setup_pipeline_with_otel\n    ):\n        \"\"\"\n        Test that executing a pipeline records operation duration metric\n        which is delivered to the Meter's histogram.record() method.\n        \"\"\"\n        pipeline, operation_duration_mock = setup_pipeline_with_otel\n\n        # Mock _execute_transaction to return successful responses\n        pipeline._execute_transaction = AsyncMock(return_value=[True, True, b\"value1\"])\n\n        # Queue commands in the pipeline\n        pipeline.command_stack = [\n            ((\"SET\", \"key1\", \"value1\"), {}),\n            ((\"SET\", \"key2\", \"value2\"), {}),\n            ((\"GET\", \"key1\"), {}),\n        ]\n\n        # Execute the pipeline\n        await pipeline.execute()\n\n        # Verify the Meter's histogram.record() was called\n        operation_duration_mock.record.assert_called_once()\n\n        # Get the call arguments\n        call_args = operation_duration_mock.record.call_args\n\n        # Verify duration was recorded (first positional arg)\n        duration = call_args[0][0]\n        assert isinstance(duration, float)\n        assert duration >= 0\n\n        # Verify attributes\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[\"db.operation.name\"] == \"MULTI\"\n        assert attrs[\"server.address\"] == \"localhost\"\n        assert attrs[\"server.port\"] == 6379\n        assert attrs[\"db.namespace\"] == \"0\"\n\n    async def test_pipeline_no_transaction_records_pipeline_command_name(\n        self, mock_async_connection_pool, mock_async_connection, mock_meter\n    ):\n        \"\"\"\n        Test that executing a pipeline without transaction\n        records metric with command_name='PIPELINE'.\n        \"\"\"\n        async_recorder.reset_collector()\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND])\n\n        with patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        # Note: _get_or_create_collector is now sync\n        with patch.object(\n            async_recorder,\n            \"_get_or_create_collector\",\n            return_value=collector,\n        ):\n            # Create pipeline with transaction=False\n            pipeline = Pipeline(\n                connection_pool=mock_async_connection_pool,\n                response_callbacks={},\n                transaction=False,  # Non-transaction mode\n                shard_hint=None,\n            )\n\n            pipeline._execute_pipeline = AsyncMock(return_value=[True, True])\n            pipeline.command_stack = [\n                ((\"SET\", \"key1\", \"value1\"), {}),\n                ((\"SET\", \"key2\", \"value2\"), {}),\n            ]\n\n            await pipeline.execute()\n\n            # Verify command name is PIPELINE\n            call_args = self.operation_duration.record.call_args\n            attrs = call_args[1][\"attributes\"]\n            assert attrs[\"db.operation.name\"] == \"PIPELINE\"\n\n        async_recorder.reset_collector()\n\n    async def test_pipeline_server_attributes_recorded(self, setup_pipeline_with_otel):\n        \"\"\"\n        Test that server address, port, and db namespace are correctly recorded.\n        \"\"\"\n        pipeline, operation_duration_mock = setup_pipeline_with_otel\n\n        pipeline._execute_transaction = AsyncMock(return_value=[True])\n        pipeline.command_stack = [((\"PING\",), {})]\n\n        await pipeline.execute()\n\n        call_args = operation_duration_mock.record.call_args\n        attrs = call_args[1][\"attributes\"]\n\n        # Verify server attributes match mock connection\n        assert attrs[\"server.address\"] == \"localhost\"\n        assert attrs[\"server.port\"] == 6379\n        assert attrs[\"db.namespace\"] == \"0\"\n\n    async def test_multiple_pipeline_executions_record_multiple_metrics(\n        self, mock_async_connection_pool, mock_async_connection, mock_meter\n    ):\n        \"\"\"\n        Test that each pipeline execution records a separate metric to the Meter.\n        \"\"\"\n        async_recorder.reset_collector()\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND])\n\n        with patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        # Note: _get_or_create_collector is now sync\n        with patch.object(\n            async_recorder,\n            \"_get_or_create_collector\",\n            return_value=collector,\n        ):\n            # First pipeline execution\n            pipeline1 = Pipeline(\n                connection_pool=mock_async_connection_pool,\n                response_callbacks={},\n                transaction=True,\n                shard_hint=None,\n            )\n            pipeline1._execute_transaction = AsyncMock(return_value=[True])\n            pipeline1.command_stack = [((\"SET\", \"key1\", \"value1\"), {})]\n            await pipeline1.execute()\n\n            # Second pipeline execution\n            pipeline2 = Pipeline(\n                connection_pool=mock_async_connection_pool,\n                response_callbacks={},\n                transaction=True,\n                shard_hint=None,\n            )\n            pipeline2._execute_transaction = AsyncMock(return_value=[True, True])\n            pipeline2.command_stack = [\n                ((\"SET\", \"key2\", \"value2\"), {}),\n                ((\"SET\", \"key3\", \"value3\"), {}),\n            ]\n            await pipeline2.execute()\n\n            # Verify histogram.record() was called twice\n            assert self.operation_duration.record.call_count == 2\n\n        async_recorder.reset_collector()\n\n    async def test_empty_pipeline_does_not_record_metric(\n        self, mock_async_connection_pool, mock_async_connection, mock_meter\n    ):\n        \"\"\"\n        Test that an empty pipeline (no commands) does not record a metric.\n        \"\"\"\n        async_recorder.reset_collector()\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND])\n\n        with patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        # Note: _get_or_create_collector is now sync\n        with patch.object(\n            async_recorder,\n            \"_get_or_create_collector\",\n            return_value=collector,\n        ):\n            pipeline = Pipeline(\n                connection_pool=mock_async_connection_pool,\n                response_callbacks={},\n                transaction=True,\n                shard_hint=None,\n            )\n\n            # Empty command stack\n            pipeline.command_stack = []\n\n            # Execute empty pipeline\n            result = await pipeline.execute()\n\n            # Should return empty list\n            assert result == []\n\n            # No metric should be recorded for empty pipeline\n            self.operation_duration.record.assert_not_called()\n\n        async_recorder.reset_collector()\n"
  },
  {
    "path": "tests/test_asyncio/test_pubsub.py",
    "content": "import asyncio\nimport functools\nimport socket\nimport sys\nfrom typing import Optional\nfrom unittest.mock import patch, AsyncMock, MagicMock\n\n# the functionality is available in 3.11.x but has a major issue before\n# 3.11.3. See https://github.com/redis/redis-py/issues/2633\nif sys.version_info >= (3, 11, 3):\n    from asyncio import timeout as async_timeout\nelse:\n    from async_timeout import timeout as async_timeout\n\nfrom unittest import mock\n\nimport pytest\nimport pytest_asyncio\nimport redis.asyncio as redis\nfrom redis.asyncio.client import PubSub\n\nfrom redis.exceptions import ConnectionError\nfrom redis.typing import EncodableT\nfrom tests.conftest import get_protocol_version, skip_if_server_version_lt\n\nfrom .compat import aclosing, create_task\n\n\ndef with_timeout(t):\n    def wrapper(corofunc):\n        @functools.wraps(corofunc)\n        async def run(*args, **kwargs):\n            async with async_timeout(t):\n                return await corofunc(*args, **kwargs)\n\n        return run\n\n    return wrapper\n\n\nasync def wait_for_message(pubsub, timeout=0.2, ignore_subscribe_messages=False):\n    now = asyncio.get_running_loop().time()\n    timeout = now + timeout\n    while now < timeout:\n        message = await pubsub.get_message(\n            ignore_subscribe_messages=ignore_subscribe_messages\n        )\n        if message is not None:\n            return message\n        await asyncio.sleep(0.01)\n        now = asyncio.get_running_loop().time()\n    return None\n\n\ndef make_message(\n    type, channel: Optional[str], data: EncodableT, pattern: Optional[str] = None\n):\n    return {\n        \"type\": type,\n        \"pattern\": pattern and pattern.encode(\"utf-8\") or None,\n        \"channel\": channel and channel.encode(\"utf-8\") or None,\n        \"data\": data.encode(\"utf-8\") if isinstance(data, str) else data,\n    }\n\n\ndef make_subscribe_test_data(pubsub, type):\n    if type == \"channel\":\n        return {\n            \"p\": pubsub,\n            \"sub_type\": \"subscribe\",\n            \"unsub_type\": \"unsubscribe\",\n            \"sub_func\": pubsub.subscribe,\n            \"unsub_func\": pubsub.unsubscribe,\n            \"keys\": [\"foo\", \"bar\", \"uni\" + chr(4456) + \"code\"],\n        }\n    elif type == \"pattern\":\n        return {\n            \"p\": pubsub,\n            \"sub_type\": \"psubscribe\",\n            \"unsub_type\": \"punsubscribe\",\n            \"sub_func\": pubsub.psubscribe,\n            \"unsub_func\": pubsub.punsubscribe,\n            \"keys\": [\"f*\", \"b*\", \"uni\" + chr(4456) + \"*\"],\n        }\n    assert False, f\"invalid subscribe type: {type}\"\n\n\n@pytest_asyncio.fixture()\nasync def pubsub(r: redis.Redis):\n    async with r.pubsub() as p:\n        yield p\n\n\n@pytest.mark.onlynoncluster\nclass TestPubSubSubscribeUnsubscribe:\n    async def _test_subscribe_unsubscribe(\n        self, p, sub_type, unsub_type, sub_func, unsub_func, keys\n    ):\n        for key in keys:\n            assert await sub_func(key) is None\n\n        # should be a message for each channel/pattern we just subscribed to\n        for i, key in enumerate(keys):\n            assert await wait_for_message(p) == make_message(sub_type, key, i + 1)\n\n        for key in keys:\n            assert await unsub_func(key) is None\n\n        # should be a message for each channel/pattern we just unsubscribed\n        # from\n        for i, key in enumerate(keys):\n            i = len(keys) - 1 - i\n            assert await wait_for_message(p) == make_message(unsub_type, key, i)\n\n    async def test_channel_subscribe_unsubscribe(self, pubsub):\n        kwargs = make_subscribe_test_data(pubsub, \"channel\")\n        await self._test_subscribe_unsubscribe(**kwargs)\n\n    async def test_pattern_subscribe_unsubscribe(self, pubsub):\n        kwargs = make_subscribe_test_data(pubsub, \"pattern\")\n        await self._test_subscribe_unsubscribe(**kwargs)\n\n    @pytest.mark.onlynoncluster\n    async def _test_resubscribe_on_reconnection(\n        self, p, sub_type, unsub_type, sub_func, unsub_func, keys\n    ):\n        for key in keys:\n            assert await sub_func(key) is None\n\n        # should be a message for each channel/pattern we just subscribed to\n        for i, key in enumerate(keys):\n            assert await wait_for_message(p) == make_message(sub_type, key, i + 1)\n\n        # manually disconnect\n        await p.connection.disconnect()\n\n        # calling get_message again reconnects and resubscribes\n        # note, we may not re-subscribe to channels in exactly the same order\n        # so we have to do some extra checks to make sure we got them all\n        messages = []\n        for i in range(len(keys)):\n            messages.append(await wait_for_message(p))\n\n        unique_channels = set()\n        assert len(messages) == len(keys)\n        for i, message in enumerate(messages):\n            assert message[\"type\"] == sub_type\n            assert message[\"data\"] == i + 1\n            assert isinstance(message[\"channel\"], bytes)\n            channel = message[\"channel\"].decode(\"utf-8\")\n            unique_channels.add(channel)\n\n        assert len(unique_channels) == len(keys)\n        for channel in unique_channels:\n            assert channel in keys\n\n    async def test_resubscribe_to_channels_on_reconnection(self, pubsub):\n        kwargs = make_subscribe_test_data(pubsub, \"channel\")\n        await self._test_resubscribe_on_reconnection(**kwargs)\n\n    async def test_resubscribe_to_patterns_on_reconnection(self, pubsub):\n        kwargs = make_subscribe_test_data(pubsub, \"pattern\")\n        await self._test_resubscribe_on_reconnection(**kwargs)\n\n    async def test_resubscribe_binary_channel_on_reconnection(self, pubsub):\n        \"\"\"Binary channel names that are not valid UTF-8 must survive\n        reconnection without raising ``UnicodeDecodeError``.\n        See https://github.com/redis/redis-py/issues/3912\n        \"\"\"\n        # b'\\x80\\x81\\x82' is deliberately invalid UTF-8\n        binary_channel = b\"\\x80\\x81\\x82\"\n        p = pubsub\n        await p.subscribe(binary_channel)\n        assert await wait_for_message(p) is not None  # consume subscribe ack\n\n        # force reconnect\n        await p.connection.disconnect()\n\n        # get_message triggers on_connect → re-subscribe; must not raise\n        messages = []\n        for _ in range(1):\n            message = await wait_for_message(p)\n            assert message is not None\n            messages.append(message)\n\n        assert len(messages) == 1\n        assert messages[0][\"type\"] == \"subscribe\"\n        assert messages[0][\"channel\"] == binary_channel\n\n    async def test_resubscribe_binary_pattern_on_reconnection(self, pubsub):\n        \"\"\"Binary pattern names that are not valid UTF-8 must survive\n        reconnection without raising ``UnicodeDecodeError``.\n        See https://github.com/redis/redis-py/issues/3912\n        \"\"\"\n        binary_pattern = b\"\\x80\\x81*\"\n        p = pubsub\n        await p.psubscribe(binary_pattern)\n        assert await wait_for_message(p) is not None  # consume psubscribe ack\n\n        # force reconnect\n        await p.connection.disconnect()\n\n        messages = []\n        for _ in range(1):\n            message = await wait_for_message(p)\n            assert message is not None\n            messages.append(message)\n\n        assert len(messages) == 1\n        assert messages[0][\"type\"] == \"psubscribe\"\n        assert messages[0][\"channel\"] == binary_pattern\n\n    async def _test_subscribed_property(\n        self, p, sub_type, unsub_type, sub_func, unsub_func, keys\n    ):\n        assert p.subscribed is False\n        await sub_func(keys[0])\n        # we're now subscribed even though we haven't processed the\n        # reply from the server just yet\n        assert p.subscribed is True\n        assert await wait_for_message(p) == make_message(sub_type, keys[0], 1)\n        # we're still subscribed\n        assert p.subscribed is True\n\n        # unsubscribe from all channels\n        await unsub_func()\n        # we're still technically subscribed until we process the\n        # response messages from the server\n        assert p.subscribed is True\n        assert await wait_for_message(p) == make_message(unsub_type, keys[0], 0)\n        # now we're no longer subscribed as no more messages can be delivered\n        # to any channels we were listening to\n        assert p.subscribed is False\n\n        # subscribing again flips the flag back\n        await sub_func(keys[0])\n        assert p.subscribed is True\n        assert await wait_for_message(p) == make_message(sub_type, keys[0], 1)\n\n        # unsubscribe again\n        await unsub_func()\n        assert p.subscribed is True\n        # subscribe to another channel before reading the unsubscribe response\n        await sub_func(keys[1])\n        assert p.subscribed is True\n        # read the unsubscribe for key1\n        assert await wait_for_message(p) == make_message(unsub_type, keys[0], 0)\n        # we're still subscribed to key2, so subscribed should still be True\n        assert p.subscribed is True\n        # read the key2 subscribe message\n        assert await wait_for_message(p) == make_message(sub_type, keys[1], 1)\n        await unsub_func()\n        # haven't read the message yet, so we're still subscribed\n        assert p.subscribed is True\n        assert await wait_for_message(p) == make_message(unsub_type, keys[1], 0)\n        # now we're finally unsubscribed\n        assert p.subscribed is False\n\n    async def test_subscribe_property_with_channels(self, pubsub):\n        kwargs = make_subscribe_test_data(pubsub, \"channel\")\n        await self._test_subscribed_property(**kwargs)\n\n    @pytest.mark.onlynoncluster\n    async def test_subscribe_property_with_patterns(self, pubsub):\n        kwargs = make_subscribe_test_data(pubsub, \"pattern\")\n        await self._test_subscribed_property(**kwargs)\n\n    async def test_aclosing(self, r: redis.Redis):\n        p = r.pubsub()\n        async with aclosing(p):\n            assert p.subscribed is False\n            await p.subscribe(\"foo\")\n            assert p.subscribed is True\n        assert p.subscribed is False\n\n    async def test_context_manager(self, r: redis.Redis):\n        p = r.pubsub()\n        async with p:\n            assert p.subscribed is False\n            await p.subscribe(\"foo\")\n            assert p.subscribed is True\n        assert p.subscribed is False\n\n    async def test_close_is_aclose(self, r: redis.Redis):\n        \"\"\"\n        Test backwards compatible close method\n        \"\"\"\n        p = r.pubsub()\n        assert p.subscribed is False\n        await p.subscribe(\"foo\")\n        assert p.subscribed is True\n        with pytest.deprecated_call():\n            await p.close()\n        assert p.subscribed is False\n\n    async def test_reset_is_aclose(self, r: redis.Redis):\n        \"\"\"\n        Test backwards compatible reset method\n        \"\"\"\n        p = r.pubsub()\n        assert p.subscribed is False\n        await p.subscribe(\"foo\")\n        assert p.subscribed is True\n        with pytest.deprecated_call():\n            await p.reset()\n        assert p.subscribed is False\n\n    async def test_ignore_all_subscribe_messages(self, r: redis.Redis):\n        p = r.pubsub(ignore_subscribe_messages=True)\n\n        checks = (\n            (p.subscribe, \"foo\"),\n            (p.unsubscribe, \"foo\"),\n            (p.psubscribe, \"f*\"),\n            (p.punsubscribe, \"f*\"),\n        )\n\n        assert p.subscribed is False\n        for func, channel in checks:\n            assert await func(channel) is None\n            assert p.subscribed is True\n            assert await wait_for_message(p) is None\n        assert p.subscribed is False\n        await p.aclose()\n\n    async def test_ignore_individual_subscribe_messages(self, pubsub):\n        p = pubsub\n\n        checks = (\n            (p.subscribe, \"foo\"),\n            (p.unsubscribe, \"foo\"),\n            (p.psubscribe, \"f*\"),\n            (p.punsubscribe, \"f*\"),\n        )\n\n        assert p.subscribed is False\n        for func, channel in checks:\n            assert await func(channel) is None\n            assert p.subscribed is True\n            message = await wait_for_message(p, ignore_subscribe_messages=True)\n            assert message is None\n        assert p.subscribed is False\n\n    async def test_sub_unsub_resub_channels(self, pubsub):\n        kwargs = make_subscribe_test_data(pubsub, \"channel\")\n        await self._test_sub_unsub_resub(**kwargs)\n\n    @pytest.mark.onlynoncluster\n    async def test_sub_unsub_resub_patterns(self, pubsub):\n        kwargs = make_subscribe_test_data(pubsub, \"pattern\")\n        await self._test_sub_unsub_resub(**kwargs)\n\n    async def _test_sub_unsub_resub(\n        self, p, sub_type, unsub_type, sub_func, unsub_func, keys\n    ):\n        # https://github.com/andymccurdy/redis-py/issues/764\n        key = keys[0]\n        await sub_func(key)\n        await unsub_func(key)\n        await sub_func(key)\n        assert p.subscribed is True\n        assert await wait_for_message(p) == make_message(sub_type, key, 1)\n        assert await wait_for_message(p) == make_message(unsub_type, key, 0)\n        assert await wait_for_message(p) == make_message(sub_type, key, 1)\n        assert p.subscribed is True\n\n    async def test_sub_unsub_all_resub_channels(self, pubsub):\n        kwargs = make_subscribe_test_data(pubsub, \"channel\")\n        await self._test_sub_unsub_all_resub(**kwargs)\n\n    async def test_sub_unsub_all_resub_patterns(self, pubsub):\n        kwargs = make_subscribe_test_data(pubsub, \"pattern\")\n        await self._test_sub_unsub_all_resub(**kwargs)\n\n    async def _test_sub_unsub_all_resub(\n        self, p, sub_type, unsub_type, sub_func, unsub_func, keys\n    ):\n        # https://github.com/andymccurdy/redis-py/issues/764\n        key = keys[0]\n        await sub_func(key)\n        await unsub_func()\n        await sub_func(key)\n        assert p.subscribed is True\n        assert await wait_for_message(p) == make_message(sub_type, key, 1)\n        assert await wait_for_message(p) == make_message(unsub_type, key, 0)\n        assert await wait_for_message(p) == make_message(sub_type, key, 1)\n        assert p.subscribed is True\n\n\n@pytest.mark.onlynoncluster\nclass TestPubSubMessages:\n    def setup_method(self, method):\n        self.message = None\n\n    def message_handler(self, message):\n        self.message = message\n\n    async def async_message_handler(self, message):\n        self.async_message = message\n\n    async def test_published_message_to_channel(self, r: redis.Redis, pubsub):\n        p = pubsub\n        await p.subscribe(\"foo\")\n        assert await wait_for_message(p) == make_message(\"subscribe\", \"foo\", 1)\n        assert await r.publish(\"foo\", \"test message\") == 1\n\n        message = await wait_for_message(p)\n        assert isinstance(message, dict)\n        assert message == make_message(\"message\", \"foo\", \"test message\")\n\n    async def test_published_message_to_pattern(self, r: redis.Redis, pubsub):\n        p = pubsub\n        await p.subscribe(\"foo\")\n        await p.psubscribe(\"f*\")\n        assert await wait_for_message(p) == make_message(\"subscribe\", \"foo\", 1)\n        assert await wait_for_message(p) == make_message(\"psubscribe\", \"f*\", 2)\n        # 1 to pattern, 1 to channel\n        assert await r.publish(\"foo\", \"test message\") == 2\n\n        message1 = await wait_for_message(p)\n        message2 = await wait_for_message(p)\n        assert isinstance(message1, dict)\n        assert isinstance(message2, dict)\n\n        expected = [\n            make_message(\"message\", \"foo\", \"test message\"),\n            make_message(\"pmessage\", \"foo\", \"test message\", pattern=\"f*\"),\n        ]\n\n        assert message1 in expected\n        assert message2 in expected\n        assert message1 != message2\n\n    async def test_channel_message_handler(self, r: redis.Redis):\n        p = r.pubsub(ignore_subscribe_messages=True)\n        await p.subscribe(foo=self.message_handler)\n        assert await wait_for_message(p) is None\n        assert await r.publish(\"foo\", \"test message\") == 1\n        assert await wait_for_message(p) is None\n        assert self.message == make_message(\"message\", \"foo\", \"test message\")\n        await p.aclose()\n\n    async def test_channel_async_message_handler(self, r):\n        p = r.pubsub(ignore_subscribe_messages=True)\n        await p.subscribe(foo=self.async_message_handler)\n        assert await wait_for_message(p) is None\n        assert await r.publish(\"foo\", \"test message\") == 1\n        assert await wait_for_message(p) is None\n        assert self.async_message == make_message(\"message\", \"foo\", \"test message\")\n        await p.aclose()\n\n    async def test_channel_sync_async_message_handler(self, r):\n        p = r.pubsub(ignore_subscribe_messages=True)\n        await p.subscribe(foo=self.message_handler)\n        await p.subscribe(bar=self.async_message_handler)\n        assert await wait_for_message(p) is None\n        assert await r.publish(\"foo\", \"test message\") == 1\n        assert await r.publish(\"bar\", \"test message 2\") == 1\n        assert await wait_for_message(p) is None\n        assert self.message == make_message(\"message\", \"foo\", \"test message\")\n        assert self.async_message == make_message(\"message\", \"bar\", \"test message 2\")\n        await p.aclose()\n\n    @pytest.mark.onlynoncluster\n    async def test_pattern_message_handler(self, r: redis.Redis):\n        p = r.pubsub(ignore_subscribe_messages=True)\n        await p.psubscribe(**{\"f*\": self.message_handler})\n        assert await wait_for_message(p) is None\n        assert await r.publish(\"foo\", \"test message\") == 1\n        assert await wait_for_message(p) is None\n        assert self.message == make_message(\n            \"pmessage\", \"foo\", \"test message\", pattern=\"f*\"\n        )\n        await p.aclose()\n\n    async def test_unicode_channel_message_handler(self, r: redis.Redis):\n        p = r.pubsub(ignore_subscribe_messages=True)\n        channel = \"uni\" + chr(4456) + \"code\"\n        channels = {channel: self.message_handler}\n        await p.subscribe(**channels)\n        assert await wait_for_message(p) is None\n        assert await r.publish(channel, \"test message\") == 1\n        assert await wait_for_message(p) is None\n        assert self.message == make_message(\"message\", channel, \"test message\")\n        await p.aclose()\n\n    @pytest.mark.onlynoncluster\n    # see: https://redis-py-cluster.readthedocs.io/en/stable/pubsub.html\n    # #known-limitations-with-pubsub\n    async def test_unicode_pattern_message_handler(self, r: redis.Redis):\n        p = r.pubsub(ignore_subscribe_messages=True)\n        pattern = \"uni\" + chr(4456) + \"*\"\n        channel = \"uni\" + chr(4456) + \"code\"\n        await p.psubscribe(**{pattern: self.message_handler})\n        assert await wait_for_message(p) is None\n        assert await r.publish(channel, \"test message\") == 1\n        assert await wait_for_message(p) is None\n        assert self.message == make_message(\n            \"pmessage\", channel, \"test message\", pattern=pattern\n        )\n        await p.aclose()\n\n    async def test_get_message_without_subscribe(self, r: redis.Redis, pubsub):\n        p = pubsub\n        with pytest.raises(RuntimeError) as info:\n            await p.get_message()\n        expect = (\n            \"connection not set: did you forget to call subscribe() or psubscribe()?\"\n        )\n        assert expect in info.exconly()\n\n\n@pytest.mark.onlynoncluster\nclass TestPubSubRESP3Handler:\n    async def my_handler(self, message):\n        self.message = [\"my handler\", message]\n\n    async def test_push_handler(self, r):\n        if get_protocol_version(r) in [2, \"2\", None]:\n            return\n        p = r.pubsub(push_handler_func=self.my_handler)\n        await p.subscribe(\"foo\")\n        assert await wait_for_message(p) is None\n        assert self.message == [\"my handler\", [b\"subscribe\", b\"foo\", 1]]\n        assert await r.publish(\"foo\", \"test message\") == 1\n        assert await wait_for_message(p) is None\n        assert self.message == [\"my handler\", [b\"message\", b\"foo\", b\"test message\"]]\n\n\n@pytest.mark.onlynoncluster\nclass TestPubSubAutoDecoding:\n    \"\"\"These tests only validate that we get unicode values back\"\"\"\n\n    channel = \"uni\" + chr(4456) + \"code\"\n    pattern = \"uni\" + chr(4456) + \"*\"\n    data = \"abc\" + chr(4458) + \"123\"\n\n    def make_message(self, type, channel, data, pattern=None):\n        return {\"type\": type, \"channel\": channel, \"pattern\": pattern, \"data\": data}\n\n    def setup_method(self, method):\n        self.message = None\n\n    def message_handler(self, message):\n        self.message = message\n\n    @pytest_asyncio.fixture()\n    async def r(self, create_redis):\n        return await create_redis(decode_responses=True)\n\n    async def test_channel_subscribe_unsubscribe(self, pubsub):\n        p = pubsub\n        await p.subscribe(self.channel)\n        assert await wait_for_message(p) == self.make_message(\n            \"subscribe\", self.channel, 1\n        )\n\n        await p.unsubscribe(self.channel)\n        assert await wait_for_message(p) == self.make_message(\n            \"unsubscribe\", self.channel, 0\n        )\n\n    async def test_pattern_subscribe_unsubscribe(self, pubsub):\n        p = pubsub\n        await p.psubscribe(self.pattern)\n        assert await wait_for_message(p) == self.make_message(\n            \"psubscribe\", self.pattern, 1\n        )\n\n        await p.punsubscribe(self.pattern)\n        assert await wait_for_message(p) == self.make_message(\n            \"punsubscribe\", self.pattern, 0\n        )\n\n    async def test_channel_publish(self, r: redis.Redis, pubsub):\n        p = pubsub\n        await p.subscribe(self.channel)\n        assert await wait_for_message(p) == self.make_message(\n            \"subscribe\", self.channel, 1\n        )\n        await r.publish(self.channel, self.data)\n        assert await wait_for_message(p) == self.make_message(\n            \"message\", self.channel, self.data\n        )\n\n    @pytest.mark.onlynoncluster\n    async def test_pattern_publish(self, r: redis.Redis, pubsub):\n        p = pubsub\n        await p.psubscribe(self.pattern)\n        assert await wait_for_message(p) == self.make_message(\n            \"psubscribe\", self.pattern, 1\n        )\n        await r.publish(self.channel, self.data)\n        assert await wait_for_message(p) == self.make_message(\n            \"pmessage\", self.channel, self.data, pattern=self.pattern\n        )\n\n    async def test_channel_message_handler(self, r: redis.Redis):\n        p = r.pubsub(ignore_subscribe_messages=True)\n        await p.subscribe(**{self.channel: self.message_handler})\n        assert await wait_for_message(p) is None\n        await r.publish(self.channel, self.data)\n        assert await wait_for_message(p) is None\n        assert self.message == self.make_message(\"message\", self.channel, self.data)\n\n        # test that we reconnected to the correct channel\n        self.message = None\n        await p.connection.disconnect()\n        assert await wait_for_message(p) is None  # should reconnect\n        new_data = self.data + \"new data\"\n        await r.publish(self.channel, new_data)\n        assert await wait_for_message(p) is None\n        assert self.message == self.make_message(\"message\", self.channel, new_data)\n        await p.aclose()\n\n    async def test_pattern_message_handler(self, r: redis.Redis):\n        p = r.pubsub(ignore_subscribe_messages=True)\n        await p.psubscribe(**{self.pattern: self.message_handler})\n        assert await wait_for_message(p) is None\n        await r.publish(self.channel, self.data)\n        assert await wait_for_message(p) is None\n        assert self.message == self.make_message(\n            \"pmessage\", self.channel, self.data, pattern=self.pattern\n        )\n\n        # test that we reconnected to the correct pattern\n        self.message = None\n        await p.connection.disconnect()\n        assert await wait_for_message(p) is None  # should reconnect\n        new_data = self.data + \"new data\"\n        await r.publish(self.channel, new_data)\n        assert await wait_for_message(p) is None\n        assert self.message == self.make_message(\n            \"pmessage\", self.channel, new_data, pattern=self.pattern\n        )\n        await p.aclose()\n\n    async def test_context_manager(self, r: redis.Redis):\n        async with r.pubsub() as pubsub:\n            await pubsub.subscribe(\"foo\")\n            assert pubsub.connection is not None\n\n        assert pubsub.connection is None\n        assert pubsub.channels == {}\n        assert pubsub.patterns == {}\n        await pubsub.aclose()\n\n\n@pytest.mark.onlynoncluster\nclass TestPubSubRedisDown:\n    async def test_channel_subscribe(self, r: redis.Redis):\n        r = redis.Redis(host=\"localhost\", port=6390)\n        p = r.pubsub()\n        with pytest.raises(ConnectionError):\n            await p.subscribe(\"foo\")\n\n\n@pytest.mark.onlynoncluster\nclass TestPubSubSubcommands:\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"2.8.0\")\n    async def test_pubsub_channels(self, r: redis.Redis, pubsub):\n        p = pubsub\n        await p.subscribe(\"foo\", \"bar\", \"baz\", \"quux\")\n        for i in range(4):\n            assert (await wait_for_message(p))[\"type\"] == \"subscribe\"\n        expected = [b\"bar\", b\"baz\", b\"foo\", b\"quux\"]\n        assert all([channel in await r.pubsub_channels() for channel in expected])\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"2.8.0\")\n    async def test_pubsub_numsub(self, r: redis.Redis):\n        p1 = r.pubsub()\n        await p1.subscribe(\"foo\", \"bar\", \"baz\")\n        for i in range(3):\n            assert (await wait_for_message(p1))[\"type\"] == \"subscribe\"\n        p2 = r.pubsub()\n        await p2.subscribe(\"bar\", \"baz\")\n        for i in range(2):\n            assert (await wait_for_message(p2))[\"type\"] == \"subscribe\"\n        p3 = r.pubsub()\n        await p3.subscribe(\"baz\")\n        assert (await wait_for_message(p3))[\"type\"] == \"subscribe\"\n\n        channels = [(b\"foo\", 1), (b\"bar\", 2), (b\"baz\", 3)]\n        assert await r.pubsub_numsub(\"foo\", \"bar\", \"baz\") == channels\n        await p1.aclose()\n        await p2.aclose()\n        await p3.aclose()\n\n    @skip_if_server_version_lt(\"2.8.0\")\n    async def test_pubsub_numpat(self, r: redis.Redis):\n        p = r.pubsub()\n        await p.psubscribe(\"*oo\", \"*ar\", \"b*z\")\n        for i in range(3):\n            assert (await wait_for_message(p))[\"type\"] == \"psubscribe\"\n        assert await r.pubsub_numpat() == 3\n        await p.aclose()\n\n\n@pytest.mark.onlynoncluster\nclass TestPubSubPings:\n    @skip_if_server_version_lt(\"3.0.0\")\n    async def test_send_pubsub_ping(self, r: redis.Redis):\n        p = r.pubsub(ignore_subscribe_messages=True)\n        await p.subscribe(\"foo\")\n        await p.ping()\n        assert await wait_for_message(p) == make_message(\n            type=\"pong\", channel=None, data=\"\", pattern=None\n        )\n        await p.aclose()\n\n    @skip_if_server_version_lt(\"3.0.0\")\n    async def test_send_pubsub_ping_message(self, r: redis.Redis):\n        p = r.pubsub(ignore_subscribe_messages=True)\n        await p.subscribe(\"foo\")\n        await p.ping(message=\"hello world\")\n        assert await wait_for_message(p) == make_message(\n            type=\"pong\", channel=None, data=\"hello world\", pattern=None\n        )\n        await p.aclose()\n\n\n@pytest.mark.onlynoncluster\nclass TestPubSubHealthCheckResponse:\n    \"\"\"Tests for health check response validation with different decode_responses settings\"\"\"\n\n    async def test_health_check_response_decode_false_list_format(self, r: redis.Redis):\n        \"\"\"Test health_check_response includes list format with decode_responses=False\"\"\"\n        p = r.pubsub()\n        # List format: [b\"pong\", b\"redis-py-health-check\"]\n        assert [b\"pong\", b\"redis-py-health-check\"] in p.health_check_response\n        await p.aclose()\n\n    async def test_health_check_response_decode_false_bytes_format(\n        self, r: redis.Redis\n    ):\n        \"\"\"Test health_check_response includes bytes format with decode_responses=False\"\"\"\n        p = r.pubsub()\n        # Bytes format: b\"redis-py-health-check\"\n        assert b\"redis-py-health-check\" in p.health_check_response\n        await p.aclose()\n\n    async def test_health_check_response_decode_true_list_format(self, create_redis):\n        \"\"\"Test health_check_response includes list format with decode_responses=True\"\"\"\n        r = await create_redis(decode_responses=True)\n        p = r.pubsub()\n        # List format: [\"pong\", \"redis-py-health-check\"]\n        assert [\"pong\", \"redis-py-health-check\"] in p.health_check_response\n        await p.aclose()\n        await r.aclose()\n\n    async def test_health_check_response_decode_true_string_format(self, create_redis):\n        \"\"\"Test health_check_response includes string format with decode_responses=True\"\"\"\n        r = await create_redis(decode_responses=True)\n        p = r.pubsub()\n        # String format: \"redis-py-health-check\" (THE FIX!)\n        assert \"redis-py-health-check\" in p.health_check_response\n        await p.aclose()\n        await r.aclose()\n\n    async def test_health_check_response_decode_false_excludes_string(\n        self, r: redis.Redis\n    ):\n        \"\"\"Test health_check_response excludes string format with decode_responses=False\"\"\"\n        p = r.pubsub()\n        # String format should NOT be in the list when decode_responses=False\n        assert \"redis-py-health-check\" not in p.health_check_response\n        await p.aclose()\n\n    async def test_health_check_response_decode_true_excludes_bytes(self, create_redis):\n        \"\"\"Test health_check_response excludes bytes format with decode_responses=True\"\"\"\n        r = await create_redis(decode_responses=True)\n        p = r.pubsub()\n        # Bytes format should NOT be in the list when decode_responses=True\n        assert b\"redis-py-health-check\" not in p.health_check_response\n        await p.aclose()\n        await r.aclose()\n\n\n@pytest.mark.onlynoncluster\nclass TestPubSubConnectionKilled:\n    @skip_if_server_version_lt(\"3.0.0\")\n    async def test_connection_error_raised_when_connection_dies(\n        self, r: redis.Redis, pubsub\n    ):\n        p = pubsub\n        await p.subscribe(\"foo\")\n        assert await wait_for_message(p) == make_message(\"subscribe\", \"foo\", 1)\n        for client in await r.client_list():\n            if client[\"cmd\"] == \"subscribe\":\n                await r.client_kill_filter(_id=client[\"id\"])\n        with pytest.raises(ConnectionError):\n            await wait_for_message(p)\n\n\n@pytest.mark.onlynoncluster\nclass TestPubSubTimeouts:\n    async def test_get_message_with_timeout_returns_none(self, pubsub):\n        p = pubsub\n        await p.subscribe(\"foo\")\n        assert await wait_for_message(p) == make_message(\"subscribe\", \"foo\", 1)\n        assert await p.get_message(timeout=0.01) is None\n\n\n@pytest.mark.onlynoncluster\nclass TestPubSubReconnect:\n    @with_timeout(2)\n    async def test_reconnect_listen(self, r: redis.Redis, pubsub):\n        \"\"\"\n        Test that a loop processing PubSub messages can survive\n        a disconnect, by issuing a connect() call.\n        \"\"\"\n        messages = asyncio.Queue()\n        interrupt = False\n\n        async def loop():\n            # must make sure the task exits\n            async with async_timeout(2):\n                nonlocal interrupt\n                await pubsub.subscribe(\"foo\")\n                while True:\n                    try:\n                        try:\n                            await pubsub.connect()\n                            await loop_step()\n                        except redis.ConnectionError:\n                            await asyncio.sleep(0.1)\n                    except asyncio.CancelledError:\n                        # we use a cancel to interrupt the \"listen\"\n                        # when we perform a disconnect\n                        if interrupt:\n                            interrupt = False\n                        else:\n                            raise\n\n        async def loop_step():\n            # get a single message via listen()\n            async for message in pubsub.listen():\n                await messages.put(message)\n                break\n\n        task = asyncio.get_running_loop().create_task(loop())\n        # get the initial connect message\n        async with async_timeout(1):\n            message = await messages.get()\n        assert message == {\n            \"channel\": b\"foo\",\n            \"data\": 1,\n            \"pattern\": None,\n            \"type\": \"subscribe\",\n        }\n        # now, disconnect the connection.\n        await pubsub.connection.disconnect()\n        interrupt = True\n        task.cancel()  # interrupt the listen call\n        # await another auto-connect message\n        message = await messages.get()\n        assert message == {\n            \"channel\": b\"foo\",\n            \"data\": 1,\n            \"pattern\": None,\n            \"type\": \"subscribe\",\n        }\n        task.cancel()\n        with pytest.raises(asyncio.CancelledError):\n            await task\n\n\n@pytest.mark.onlynoncluster\nclass TestPubSubRun:\n    async def _subscribe(self, p, *args, **kwargs):\n        await p.subscribe(*args, **kwargs)\n        # Wait for the server to act on the subscription, to be sure that\n        # a subsequent publish on another connection will reach the pubsub.\n        while True:\n            message = await p.get_message(timeout=1)\n            if (\n                message is not None\n                and message[\"type\"] == \"subscribe\"\n                and message[\"channel\"] == b\"foo\"\n            ):\n                return\n\n    async def test_callbacks(self, r: redis.Redis, pubsub):\n        def callback(message):\n            messages.put_nowait(message)\n\n        messages = asyncio.Queue()\n        p = pubsub\n        await self._subscribe(p, foo=callback)\n        task = asyncio.get_running_loop().create_task(p.run())\n        await r.publish(\"foo\", \"bar\")\n        message = await messages.get()\n        task.cancel()\n        try:\n            await task\n        except asyncio.CancelledError:\n            pass\n        assert message == {\n            \"channel\": b\"foo\",\n            \"data\": b\"bar\",\n            \"pattern\": None,\n            \"type\": \"message\",\n        }\n\n    async def test_exception_handler(self, r: redis.Redis, pubsub):\n        def exception_handler_callback(e, pubsub) -> None:\n            assert pubsub == p\n            exceptions.put_nowait(e)\n\n        exceptions = asyncio.Queue()\n        p = pubsub\n        await self._subscribe(p, foo=lambda x: None)\n        with mock.patch.object(p, \"get_message\", side_effect=Exception(\"error\")):\n            task = asyncio.get_running_loop().create_task(\n                p.run(exception_handler=exception_handler_callback)\n            )\n            e = await exceptions.get()\n            task.cancel()\n            try:\n                await task\n            except asyncio.CancelledError:\n                pass\n        assert str(e) == \"error\"\n\n    async def test_late_subscribe(self, r: redis.Redis, pubsub):\n        def callback(message):\n            messages.put_nowait(message)\n\n        messages = asyncio.Queue()\n        p = pubsub\n        task = asyncio.get_running_loop().create_task(p.run())\n        # wait until loop gets settled.  Add a subscription\n        await asyncio.sleep(0.1)\n        await p.subscribe(foo=callback)\n        # wait tof the subscribe to finish.  Cannot use _subscribe() because\n        # p.run() is already accepting messages\n        while True:\n            n = await r.publish(\"foo\", \"bar\")\n            if n == 1:\n                break\n            await asyncio.sleep(0.1)\n        async with async_timeout(0.1):\n            message = await messages.get()\n        task.cancel()\n        # we expect a cancelled error, not the Runtime error\n        # (\"did you forget to call subscribe()\"\")\n        with pytest.raises(asyncio.CancelledError):\n            await task\n        assert message == {\n            \"channel\": b\"foo\",\n            \"data\": b\"bar\",\n            \"pattern\": None,\n            \"type\": \"message\",\n        }\n\n\n# @pytest.mark.xfail\n@pytest.mark.parametrize(\"method\", [\"get_message\", \"listen\"])\n@pytest.mark.onlynoncluster\nclass TestPubSubAutoReconnect:\n    timeout = 2\n\n    async def mysetup(self, r, method):\n        self.messages = asyncio.Queue()\n        self.pubsub = r.pubsub()\n        # State: 0 = initial state , 1 = after disconnect, 2 = ConnectionError is seen,\n        # 3=successfully reconnected 4 = exit\n        self.state = 0\n        self.cond = asyncio.Condition()\n        if method == \"get_message\":\n            self.get_message = self.loop_step_get_message\n        else:\n            self.get_message = self.loop_step_listen\n\n        self.task = create_task(self.loop())\n        # get the initial connect message\n        message = await self.messages.get()\n        assert message == {\n            \"channel\": b\"foo\",\n            \"data\": 1,\n            \"pattern\": None,\n            \"type\": \"subscribe\",\n        }\n\n    async def myfinish(self):\n        message = await self.messages.get()\n        assert message == {\n            \"channel\": b\"foo\",\n            \"data\": 1,\n            \"pattern\": None,\n            \"type\": \"subscribe\",\n        }\n\n    async def mykill(self):\n        # kill thread\n        async with self.cond:\n            self.state = 4  # quit\n        await self.task\n\n    async def test_reconnect_socket_error(self, r: redis.Redis, method):\n        \"\"\"\n        Test that a socket error will cause reconnect\n        \"\"\"\n        try:\n            async with async_timeout(self.timeout):\n                await self.mysetup(r, method)\n                # now, disconnect the connection, and wait for it to be re-established\n                async with self.cond:\n                    assert self.state == 0\n                    self.state = 1\n                    with mock.patch.object(self.pubsub.connection, \"_parser\") as m:\n                        m.read_response.side_effect = socket.error\n                        m.can_read_destructive.side_effect = socket.error\n                        # wait until task noticies the disconnect until we\n                        # undo the patch\n                        await self.cond.wait_for(lambda: self.state >= 2)\n                        assert not self.pubsub.connection.is_connected\n                        # it is in a disconnecte state\n                    # wait for reconnect\n                    await self.cond.wait_for(\n                        lambda: self.pubsub.connection.is_connected\n                    )\n                    assert self.state == 3\n\n                await self.myfinish()\n        finally:\n            await self.mykill()\n\n    async def test_reconnect_disconnect(self, r: redis.Redis, method):\n        \"\"\"\n        Test that a manual disconnect() will cause reconnect\n        \"\"\"\n        try:\n            async with async_timeout(self.timeout):\n                await self.mysetup(r, method)\n                # now, disconnect the connection, and wait for it to be re-established\n                async with self.cond:\n                    self.state = 1\n                    await self.pubsub.connection.disconnect()\n                    assert not self.pubsub.connection.is_connected\n                    # wait for reconnect\n                    await self.cond.wait_for(\n                        lambda: self.pubsub.connection.is_connected\n                    )\n                    assert self.state == 3\n\n                await self.myfinish()\n        finally:\n            await self.mykill()\n\n    async def loop(self):\n        # reader loop, performing state transitions as it\n        # discovers disconnects and reconnects\n        await self.pubsub.subscribe(\"foo\")\n        while True:\n            await asyncio.sleep(0.01)  # give main thread chance to get lock\n            async with self.cond:\n                old_state = self.state\n                try:\n                    if self.state == 4:\n                        break\n                    got_msg = await self.get_message()\n                    assert got_msg\n                    if self.state in (1, 2):\n                        self.state = 3  # successful reconnect\n                except redis.ConnectionError:\n                    assert self.state in (1, 2)\n                    self.state = 2  # signal that we noticed the disconnect\n                finally:\n                    self.cond.notify()\n                # make sure that we did notice the connection error\n                # or reconnected without any error\n                if old_state == 1:\n                    assert self.state in (2, 3)\n\n    async def loop_step_get_message(self):\n        # get a single message via get_message\n        message = await self.pubsub.get_message(timeout=0.1)\n        if message is not None:\n            await self.messages.put(message)\n            return True\n        return False\n\n    async def loop_step_listen(self):\n        # get a single message via listen()\n        try:\n            async with async_timeout(0.1):\n                async for message in self.pubsub.listen():\n                    await self.messages.put(message)\n                    return True\n        except asyncio.TimeoutError:\n            return False\n\n\n@pytest.mark.onlynoncluster\nclass TestBaseException:\n    @pytest.mark.skipif(\n        sys.version_info < (3, 8), reason=\"requires python 3.8 or higher\"\n    )\n    async def test_outer_timeout(self, r: redis.Redis):\n        \"\"\"\n        Using asyncio_timeout manually outside the inner method timeouts works.\n        This works on Python versions 3.8 and greater, at which time asyncio.\n        CancelledError became a BaseException instead of an Exception before.\n        \"\"\"\n        pubsub = r.pubsub()\n        await pubsub.subscribe(\"foo\")\n        assert pubsub.connection.is_connected\n\n        async def get_msg_or_timeout(timeout=0.1):\n            async with async_timeout(timeout):\n                # blocking method to return messages\n                while True:\n                    response = await pubsub.parse_response(block=True)\n                    message = await pubsub.handle_message(\n                        response, ignore_subscribe_messages=False\n                    )\n                    if message is not None:\n                        return message\n\n        # get subscribe message\n        msg = await get_msg_or_timeout(10)\n        assert msg is not None\n        # timeout waiting for another message which never arrives\n        assert pubsub.connection.is_connected\n        with pytest.raises(asyncio.TimeoutError):\n            await get_msg_or_timeout()\n        # the timeout on the read should not cause disconnect\n        assert pubsub.connection.is_connected\n\n    @pytest.mark.skipif(\n        sys.version_info < (3, 8), reason=\"requires python 3.8 or higher\"\n    )\n    async def test_base_exception(self, r: redis.Redis):\n        \"\"\"\n        Manually trigger a BaseException inside the parser's .read_response method\n        and verify that it isn't caught\n        \"\"\"\n        pubsub = r.pubsub()\n        await pubsub.subscribe(\"foo\")\n        assert pubsub.connection.is_connected\n\n        async def get_msg():\n            # blocking method to return messages\n            while True:\n                response = await pubsub.parse_response(block=True)\n                message = await pubsub.handle_message(\n                    response, ignore_subscribe_messages=False\n                )\n                if message is not None:\n                    return message\n\n        # get subscribe message\n        msg = await get_msg()\n        assert msg is not None\n        # timeout waiting for another message which never arrives\n        assert pubsub.connection.is_connected\n        with (\n            patch(\"redis._parsers._AsyncRESP2Parser.read_response\") as mock1,\n            patch(\"redis._parsers._AsyncHiredisParser.read_response\") as mock2,\n            patch(\"redis._parsers._AsyncRESP3Parser.read_response\") as mock3,\n        ):\n            mock1.side_effect = BaseException(\"boom\")\n            mock2.side_effect = BaseException(\"boom\")\n            mock3.side_effect = BaseException(\"boom\")\n\n            with pytest.raises(BaseException):\n                await get_msg()\n\n        # the timeout on the read should not cause disconnect\n        assert pubsub.connection.is_connected\n\n\n@pytest.mark.onlynoncluster\nclass TestAsyncPubSubTimeoutPropagation:\n    \"\"\"\n    Tests for timeout propagation through the entire async pubsub read chain.\n    Ensures that timeouts are properly passed from get_message() through\n    parse_response() to the parser and socket buffer layers.\n    \"\"\"\n\n    @pytest.mark.asyncio\n    async def test_get_message_timeout_is_respected(self, r):\n        \"\"\"\n        Test that get_message() with timeout parameter respects the timeout\n        and returns None when no message arrives within the timeout period.\n        \"\"\"\n        p = r.pubsub()\n        await p.subscribe(\"foo\")\n        # Read subscription message\n        msg = await wait_for_message(p, timeout=1.0)\n        assert msg is not None\n        assert msg[\"type\"] == \"subscribe\"\n\n        # Call get_message with a short timeout - should return None\n        start = asyncio.get_running_loop().time()\n        msg = await p.get_message(timeout=0.1)\n        elapsed = asyncio.get_running_loop().time() - start\n        assert msg is None\n        # Verify timeout was actually respected (within reasonable bounds)\n        assert elapsed < 0.5\n        await p.aclose()\n\n    @pytest.mark.asyncio\n    async def test_get_message_timeout_with_published_message(self, r):\n        \"\"\"\n        Test that get_message() with timeout returns a message if one\n        arrives before the timeout expires.\n        \"\"\"\n        p = r.pubsub()\n        await p.subscribe(\"foo\")\n        # Read subscription message\n        msg = await wait_for_message(p, timeout=1.0)\n        assert msg is not None\n\n        # Publish a message\n        await r.publish(\"foo\", \"hello\")\n\n        # get_message with timeout should return the message\n        msg = await p.get_message(timeout=1.0)\n        assert msg is not None\n        assert msg[\"type\"] == \"message\"\n        assert msg[\"data\"] == b\"hello\"\n        await p.aclose()\n\n    @pytest.mark.asyncio\n    async def test_parse_response_timeout_propagation(self, r):\n        \"\"\"\n        Test that parse_response() properly propagates timeout to read_response().\n        \"\"\"\n        p = r.pubsub()\n        await p.subscribe(\"foo\")\n        # Read subscription message\n        msg = await wait_for_message(p, timeout=1.0)\n        assert msg is not None\n\n        # Call parse_response with timeout - should respect it\n        start = asyncio.get_running_loop().time()\n        response = await p.parse_response(block=False, timeout=0.1)\n        elapsed = asyncio.get_running_loop().time() - start\n        assert response is None\n        assert elapsed < 0.5\n        await p.aclose()\n\n    @pytest.mark.asyncio\n    async def test_get_message_timeout_zero_returns_immediately(self, r):\n        \"\"\"\n        Test that get_message(timeout=0) returns immediately without blocking.\n        \"\"\"\n        p = r.pubsub()\n        await p.subscribe(\"foo\")\n        # Read subscription message\n        msg = await wait_for_message(p, timeout=1.0)\n        assert msg is not None\n\n        # get_message with timeout=0 should return immediately\n        start = asyncio.get_running_loop().time()\n        msg = await p.get_message(timeout=0)\n        elapsed = asyncio.get_running_loop().time() - start\n        assert msg is None\n        assert elapsed < 0.1\n        await p.aclose()\n\n    @pytest.mark.asyncio\n    async def test_get_message_timeout_none_blocks(self, r):\n        \"\"\"\n        Test that get_message(timeout=None) blocks indefinitely.\n        We test this by using a task to publish a message after a delay.\n        \"\"\"\n        p = r.pubsub()\n        await p.subscribe(\"foo\")\n        # Read subscription message\n        msg = await wait_for_message(p, timeout=1.0)\n        assert msg is not None\n\n        # Publish a message after a short delay in a task\n        async def publish_after_delay():\n            await asyncio.sleep(0.2)\n            await r.publish(\"foo\", \"delayed_message\")\n\n        task = asyncio.create_task(publish_after_delay())\n\n        # get_message with timeout=None should block until message arrives\n        start = asyncio.get_running_loop().time()\n        msg = await p.get_message(timeout=None)\n        elapsed = asyncio.get_running_loop().time() - start\n        assert msg is not None\n        assert msg[\"type\"] == \"message\"\n        assert msg[\"data\"] == b\"delayed_message\"\n        # Should have waited at least 0.15 seconds\n        assert elapsed >= 0.15\n        await task\n        await p.aclose()\n\n    @pytest.mark.asyncio\n    async def test_multiple_messages_with_timeout(self, r):\n        \"\"\"\n        Test that timeout is properly handled when reading multiple messages.\n        \"\"\"\n        p = r.pubsub()\n        await p.subscribe(\"foo\")\n        # Read subscription message\n        msg = await wait_for_message(p, timeout=1.0)\n        assert msg is not None\n\n        # Publish multiple messages\n        await r.publish(\"foo\", \"msg1\")\n        await r.publish(\"foo\", \"msg2\")\n        await r.publish(\"foo\", \"msg3\")\n\n        # Read all messages with timeout\n        messages = []\n        for _ in range(3):\n            msg = await wait_for_message(p, timeout=1.0)\n            if msg:\n                messages.append(msg)\n\n        assert len(messages) == 3\n        assert messages[0][\"data\"] == b\"msg1\"\n        assert messages[1][\"data\"] == b\"msg2\"\n        assert messages[2][\"data\"] == b\"msg3\"\n        await p.aclose()\n\n    @pytest.mark.asyncio\n    async def test_timeout_with_pattern_subscribe(self, r):\n        \"\"\"\n        Test that timeout works correctly with pattern subscriptions.\n        \"\"\"\n        p = r.pubsub()\n        await p.psubscribe(\"foo*\")\n        # Read subscription message\n        msg = await wait_for_message(p, timeout=1.0)\n        assert msg is not None\n        assert msg[\"type\"] == \"psubscribe\"\n\n        # Publish a message matching the pattern\n        await r.publish(\"foobar\", \"hello\")\n\n        # get_message with timeout should return the message\n        msg = await p.get_message(timeout=1.0)\n        assert msg is not None\n        assert msg[\"type\"] == \"pmessage\"\n        assert msg[\"data\"] == b\"hello\"\n        await p.aclose()\n\n    @pytest.mark.asyncio\n    async def test_timeout_with_no_subscription(self, r):\n        \"\"\"\n        Test that get_message with timeout returns None when subscribed but no messages.\n        \"\"\"\n        p = r.pubsub()\n        await p.subscribe(\"foo\")\n        # Read subscription message\n        msg = await wait_for_message(p, timeout=1.0)\n        assert msg is not None\n\n        # get_message with timeout should return None when no messages\n        msg = await p.get_message(timeout=0.1)\n        assert msg is None\n        await p.aclose()\n\n\n@pytest.mark.asyncio\nclass TestPubSubHandleMessageMetrics:\n    \"\"\"Tests for handle_message recording pubsub metrics.\"\"\"\n\n    @pytest.fixture\n    def mock_pubsub(self):\n        \"\"\"Create a mock PubSub instance for testing handle_message.\"\"\"\n        pubsub = MagicMock()\n        pubsub.UNSUBSCRIBE_MESSAGE_TYPES = (\"unsubscribe\", \"punsubscribe\")\n        pubsub.PUBLISH_MESSAGE_TYPES = (\"message\", \"pmessage\")\n        pubsub.pending_unsubscribe_patterns = set()\n        pubsub.pending_unsubscribe_channels = set()\n        pubsub.patterns = {}\n        pubsub.channels = {}\n        pubsub.ignore_subscribe_messages = False\n        return pubsub\n\n    async def test_handle_message_records_metric_for_message_type(self, mock_pubsub):\n        \"\"\"Test that handle_message calls record_pubsub_message for 'message' type.\"\"\"\n\n        response = [b\"message\", b\"test-channel\", b\"test-data\"]\n\n        with patch(\n            \"redis.asyncio.client.record_pubsub_message\", new_callable=AsyncMock\n        ) as mock_record:\n            # Call the actual handle_message method\n            await PubSub.handle_message(\n                mock_pubsub, response, ignore_subscribe_messages=False\n            )\n\n            # Verify record_pubsub_message was called\n            mock_record.assert_awaited_once()\n            call_kwargs = mock_record.call_args[1]\n            from redis.observability.attributes import PubSubDirection\n\n            assert call_kwargs[\"direction\"] == PubSubDirection.RECEIVE\n            assert call_kwargs[\"channel\"] == \"test-channel\"\n\n    async def test_handle_message_records_metric_for_pmessage_type(self, mock_pubsub):\n        \"\"\"Test that handle_message calls record_pubsub_message for 'pmessage' type.\"\"\"\n\n        response = [b\"pmessage\", b\"test-pattern*\", b\"test-channel\", b\"test-data\"]\n\n        with patch(\n            \"redis.asyncio.client.record_pubsub_message\", new_callable=AsyncMock\n        ) as mock_record:\n            await PubSub.handle_message(\n                mock_pubsub, response, ignore_subscribe_messages=False\n            )\n\n            mock_record.assert_awaited_once()\n            call_kwargs = mock_record.call_args[1]\n            from redis.observability.attributes import PubSubDirection\n\n            assert call_kwargs[\"direction\"] == PubSubDirection.RECEIVE\n            assert call_kwargs[\"channel\"] == \"test-channel\"\n\n    async def test_handle_message_does_not_record_metric_for_subscribe_type(\n        self, mock_pubsub\n    ):\n        \"\"\"Test that handle_message does NOT call record_pubsub_message for 'subscribe' type.\"\"\"\n\n        response = [b\"subscribe\", b\"test-channel\", 1]\n\n        with patch(\n            \"redis.asyncio.client.record_pubsub_message\", new_callable=AsyncMock\n        ) as mock_record:\n            await PubSub.handle_message(\n                mock_pubsub, response, ignore_subscribe_messages=False\n            )\n\n            mock_record.assert_not_called()\n\n    async def test_handle_message_does_not_record_metric_for_pong_type(\n        self, mock_pubsub\n    ):\n        \"\"\"Test that handle_message does NOT call record_pubsub_message for 'pong' type.\"\"\"\n\n        response = b\"PONG\"\n\n        with patch(\n            \"redis.asyncio.client.record_pubsub_message\", new_callable=AsyncMock\n        ) as mock_record:\n            await PubSub.handle_message(\n                mock_pubsub, response, ignore_subscribe_messages=False\n            )\n\n            mock_record.assert_not_called()\n"
  },
  {
    "path": "tests/test_asyncio/test_retry.py",
    "content": "import pytest\nfrom redis.asyncio import Redis\nfrom redis.asyncio.connection import Connection, UnixDomainSocketConnection\nfrom redis.asyncio.retry import Retry\nfrom redis.backoff import AbstractBackoff, ExponentialBackoff, NoBackoff\nfrom redis.exceptions import ConnectionError, TimeoutError\n\n\nclass BackoffMock(AbstractBackoff):\n    def __init__(self):\n        self.reset_calls = 0\n        self.calls = 0\n\n    def reset(self):\n        self.reset_calls += 1\n\n    def compute(self, failures):\n        self.calls += 1\n        return 0\n\n\nclass TestConnectionConstructorWithRetry:\n    \"Test that the Connection constructors properly handles Retry objects\"\n\n    @pytest.mark.parametrize(\"Class\", [Connection, UnixDomainSocketConnection])\n    def test_retry_on_error_set(self, Class):\n        class CustomError(Exception):\n            pass\n\n        retry_on_error = [ConnectionError, TimeoutError, CustomError]\n        c = Class(retry_on_error=retry_on_error)\n        assert c.retry_on_error == retry_on_error\n        assert isinstance(c.retry, Retry)\n        assert c.retry._retries == 1\n        assert set(c.retry._supported_errors) == set(retry_on_error)\n\n    @pytest.mark.parametrize(\"Class\", [Connection, UnixDomainSocketConnection])\n    def test_retry_on_error_not_set(self, Class):\n        c = Class()\n        assert c.retry_on_error == []\n        assert isinstance(c.retry, Retry)\n        assert c.retry._retries == 0\n\n    @pytest.mark.parametrize(\"retry_on_timeout\", [False, True])\n    @pytest.mark.parametrize(\"Class\", [Connection, UnixDomainSocketConnection])\n    def test_retry_on_timeout(self, Class, retry_on_timeout):\n        c = Class(retry_on_timeout=retry_on_timeout)\n        assert c.retry_on_timeout == retry_on_timeout\n        assert isinstance(c.retry, Retry)\n        assert c.retry._retries == (1 if retry_on_timeout else 0)\n\n    @pytest.mark.parametrize(\"retries\", range(10))\n    @pytest.mark.parametrize(\"Class\", [Connection, UnixDomainSocketConnection])\n    def test_retry_with_retry_on_timeout(self, Class, retries: int):\n        retry_on_timeout = retries > 0\n        c = Class(retry_on_timeout=retry_on_timeout, retry=Retry(NoBackoff(), retries))\n        assert c.retry_on_timeout == retry_on_timeout\n        assert isinstance(c.retry, Retry)\n        assert c.retry._retries == retries\n\n    @pytest.mark.parametrize(\"retries\", range(10))\n    @pytest.mark.parametrize(\"Class\", [Connection, UnixDomainSocketConnection])\n    def test_retry_with_retry_on_error(self, Class, retries: int):\n        class CustomError(Exception):\n            pass\n\n        retry_on_error = [ConnectionError, TimeoutError, CustomError]\n        c = Class(retry_on_error=retry_on_error, retry=Retry(NoBackoff(), retries))\n        assert c.retry_on_error == retry_on_error\n        assert isinstance(c.retry, Retry)\n        assert c.retry._retries == retries\n        assert set(c.retry._supported_errors) == set(retry_on_error)\n\n\nclass TestRetry:\n    \"Test that Retry calls backoff and retries the expected number of times\"\n\n    def setup_method(self, test_method):\n        self.actual_attempts = 0\n        self.actual_failures = 0\n\n    async def _do(self):\n        self.actual_attempts += 1\n        raise ConnectionError()\n\n    async def _fail(self, error):\n        self.actual_failures += 1\n\n    async def _fail_inf(self, error):\n        self.actual_failures += 1\n        if self.actual_failures == 5:\n            raise ConnectionError()\n\n    @pytest.mark.parametrize(\"retries\", range(10))\n    @pytest.mark.asyncio\n    async def test_retry(self, retries: int):\n        backoff = BackoffMock()\n        retry = Retry(backoff, retries)\n        with pytest.raises(ConnectionError):\n            await retry.call_with_retry(self._do, self._fail)\n\n        assert self.actual_attempts == 1 + retries\n        assert self.actual_failures == 1 + retries\n        assert backoff.reset_calls == 1\n        assert backoff.calls == retries\n\n    @pytest.mark.asyncio\n    async def test_infinite_retry(self):\n        backoff = BackoffMock()\n        # specify infinite retries, but give up after 5\n        retry = Retry(backoff, -1)\n        with pytest.raises(ConnectionError):\n            await retry.call_with_retry(self._do, self._fail_inf)\n\n        assert self.actual_attempts == 5\n        assert self.actual_failures == 5\n\n\nclass TestRedisClientRetry:\n    \"Test the Redis client behavior with retries\"\n\n    async def test_get_set_retry_object(self, request):\n        retry = Retry(NoBackoff(), 2)\n        url = request.config.getoption(\"--redis-url\")\n        r = await Redis.from_url(url, retry_on_timeout=True, retry=retry)\n        assert r.get_retry()._retries == retry._retries\n        assert isinstance(r.get_retry()._backoff, NoBackoff)\n        new_retry_policy = Retry(ExponentialBackoff(), 3)\n        exiting_conn = await r.connection_pool.get_connection()\n        r.set_retry(new_retry_policy)\n        assert r.get_retry()._retries == new_retry_policy._retries\n        assert isinstance(r.get_retry()._backoff, ExponentialBackoff)\n        assert exiting_conn.retry._retries == new_retry_policy._retries\n        await r.connection_pool.release(exiting_conn)\n        new_conn = await r.connection_pool.get_connection()\n        assert new_conn.retry._retries == new_retry_policy._retries\n        await r.connection_pool.release(new_conn)\n        await r.aclose()\n"
  },
  {
    "path": "tests/test_asyncio/test_scenario/__init__.py",
    "content": ""
  },
  {
    "path": "tests/test_asyncio/test_scenario/conftest.py",
    "content": "import asyncio\nimport os\nfrom typing import Any, AsyncGenerator\n\nimport pytest\nimport pytest_asyncio\n\nfrom redis.asyncio import Redis\nfrom redis.asyncio.multidb.client import MultiDBClient\nfrom redis.asyncio.multidb.config import (\n    DatabaseConfig,\n    MultiDbConfig,\n)\nfrom redis.asyncio.multidb.event import AsyncActiveDatabaseChanged\nfrom redis.asyncio.retry import Retry\nfrom redis.backoff import ExponentialBackoff\nfrom redis.event import AsyncEventListenerInterface, EventDispatcher\nfrom redis.multidb.failure_detector import DEFAULT_MIN_NUM_FAILURES\nfrom tests.test_scenario.conftest import get_endpoints_config, extract_cluster_fqdn\nfrom tests.test_scenario.fault_injector_client import REFaultInjector\n\n\nclass CheckActiveDatabaseChangedListener(AsyncEventListenerInterface):\n    def __init__(self):\n        self.is_changed_flag = False\n\n    async def listen(self, event: AsyncActiveDatabaseChanged):\n        self.is_changed_flag = True\n\n\n@pytest.fixture()\ndef fault_injector_client():\n    url = os.getenv(\"FAULT_INJECTION_API_URL\", \"http://127.0.0.1:20324\")\n    return REFaultInjector(url)\n\n\n@pytest_asyncio.fixture()\nasync def r_multi_db(\n    request,\n) -> AsyncGenerator[tuple[MultiDBClient, CheckActiveDatabaseChangedListener, Any], Any]:\n    client_class = request.param.get(\"client_class\", Redis)\n\n    if client_class == Redis:\n        endpoint_config = get_endpoints_config(\"re-active-active\")\n    else:\n        endpoint_config = get_endpoints_config(\"re-active-active-oss-cluster\")\n\n    username = endpoint_config.get(\"username\", None)\n    password = endpoint_config.get(\"password\", None)\n    min_num_failures = request.param.get(\"min_num_failures\", DEFAULT_MIN_NUM_FAILURES)\n    command_retry = request.param.get(\n        \"command_retry\", Retry(ExponentialBackoff(cap=0.1, base=0.01), retries=10)\n    )\n\n    # Retry configuration different for health checks as initial health check require more time in case\n    # if infrastructure wasn't restored from the previous test.\n    health_check_interval = request.param.get(\"health_check_interval\", 10)\n    health_checks = request.param.get(\"health_checks\", [])\n    event_dispatcher = EventDispatcher()\n    listener = CheckActiveDatabaseChangedListener()\n    event_dispatcher.register_listeners(\n        {\n            AsyncActiveDatabaseChanged: [listener],\n        }\n    )\n    db_configs = []\n\n    db_config = DatabaseConfig(\n        weight=1.0,\n        from_url=endpoint_config[\"endpoints\"][0],\n        client_kwargs={\n            \"username\": username,\n            \"password\": password,\n            \"decode_responses\": True,\n        },\n        health_check_url=extract_cluster_fqdn(endpoint_config[\"endpoints\"][0]),\n    )\n    db_configs.append(db_config)\n\n    db_config1 = DatabaseConfig(\n        weight=0.9,\n        from_url=endpoint_config[\"endpoints\"][1],\n        client_kwargs={\n            \"username\": username,\n            \"password\": password,\n            \"decode_responses\": True,\n        },\n        health_check_url=extract_cluster_fqdn(endpoint_config[\"endpoints\"][1]),\n    )\n    db_configs.append(db_config1)\n\n    config = MultiDbConfig(\n        client_class=client_class,\n        databases_config=db_configs,\n        command_retry=command_retry,\n        min_num_failures=min_num_failures,\n        health_checks=health_checks,\n        health_check_probes=3,\n        health_check_interval=health_check_interval,\n        event_dispatcher=event_dispatcher,\n    )\n\n    client = MultiDBClient(config)\n\n    async def teardown():\n        await client.aclose()\n\n        if client.command_executor.active_database and isinstance(\n            client.command_executor.active_database.client, Redis\n        ):\n            await client.command_executor.active_database.client.connection_pool.disconnect()\n\n        await asyncio.sleep(10)\n\n    yield client, listener, endpoint_config\n    await teardown()\n"
  },
  {
    "path": "tests/test_asyncio/test_scenario/test_active_active.py",
    "content": "import asyncio\nimport json\nimport logging\nimport os\n\nimport pytest\n\nfrom redis.asyncio import RedisCluster\nfrom redis.asyncio.client import Pipeline, Redis\nfrom redis.asyncio.multidb.failover import (\n    DEFAULT_FAILOVER_ATTEMPTS,\n    DEFAULT_FAILOVER_DELAY,\n)\nfrom redis.asyncio.multidb.healthcheck import LagAwareHealthCheck\nfrom redis.asyncio.retry import Retry\nfrom redis.backoff import ConstantBackoff\nfrom redis.multidb.exception import TemporaryUnavailableException\nfrom redis.utils import dummy_fail_async\nfrom tests.test_scenario.fault_injector_client import ActionRequest, ActionType\n\nlogger = logging.getLogger(__name__)\n\n\nasync def trigger_network_failure_action(\n    fault_injector_client, config, event: asyncio.Event = None\n):\n    action_request = ActionRequest(\n        action_type=ActionType.NETWORK_FAILURE,\n        parameters={\"bdb_id\": config[\"bdb_id\"], \"delay\": 3, \"cluster_index\": 0},\n    )\n\n    result = fault_injector_client.trigger_action(action_request)\n    status_result = fault_injector_client.get_action_status(result[\"action_id\"])\n\n    while status_result[\"status\"] != \"success\":\n        await asyncio.sleep(0.1)\n        status_result = fault_injector_client.get_action_status(result[\"action_id\"])\n        logger.info(\n            f\"Waiting for action to complete. Status: {status_result['status']}\"\n        )\n\n    if event:\n        event.set()\n\n    logger.info(f\"Action completed. Status: {status_result['status']}\")\n\n\n@pytest.mark.skip(reason=\"Temporarily disabled\")\nclass TestActiveActive:\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"r_multi_db\",\n        [\n            {\"client_class\": Redis, \"min_num_failures\": 2},\n            {\"client_class\": RedisCluster, \"min_num_failures\": 2},\n        ],\n        ids=[\"standalone\", \"cluster\"],\n        indirect=True,\n    )\n    @pytest.mark.timeout(200)\n    async def test_multi_db_client_failover_to_another_db(\n        self, r_multi_db, fault_injector_client\n    ):\n        client, listener, endpoint_config = r_multi_db\n\n        # Handle unavailable databases from previous test.\n        retry = Retry(\n            supported_errors=(TemporaryUnavailableException,),\n            retries=DEFAULT_FAILOVER_ATTEMPTS,\n            backoff=ConstantBackoff(backoff=DEFAULT_FAILOVER_DELAY),\n        )\n\n        async with client as r_multi_db:\n            event = asyncio.Event()\n            asyncio.create_task(\n                trigger_network_failure_action(\n                    fault_injector_client, endpoint_config, event\n                )\n            )\n\n            await retry.call_with_retry(\n                lambda: r_multi_db.set(\"key\", \"value\"), lambda _: dummy_fail_async()\n            )\n\n            # Execute commands before network failure\n            while not event.is_set():\n                assert (\n                    await retry.call_with_retry(\n                        lambda: r_multi_db.get(\"key\"), lambda _: dummy_fail_async()\n                    )\n                    == \"value\"\n                )\n                await asyncio.sleep(0.5)\n\n            # Execute commands until database failover\n            while not listener.is_changed_flag:\n                assert (\n                    await retry.call_with_retry(\n                        lambda: r_multi_db.get(\"key\"), lambda _: dummy_fail_async()\n                    )\n                    == \"value\"\n                )\n                await asyncio.sleep(0.5)\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"r_multi_db\",\n        [\n            {\n                \"client_class\": Redis,\n                \"min_num_failures\": 2,\n                \"health_checks\": [\n                    LagAwareHealthCheck(\n                        verify_tls=False,\n                        auth_basic=(\n                            os.getenv(\"ENV0_USERNAME\"),\n                            os.getenv(\"ENV0_PASSWORD\"),\n                        ),\n                    )\n                ],\n                \"health_check_interval\": 20,\n            },\n            {\n                \"client_class\": RedisCluster,\n                \"min_num_failures\": 2,\n                \"health_checks\": [\n                    LagAwareHealthCheck(\n                        verify_tls=False,\n                        auth_basic=(\n                            os.getenv(\"ENV0_USERNAME\"),\n                            os.getenv(\"ENV0_PASSWORD\"),\n                        ),\n                    )\n                ],\n                \"health_check_interval\": 20,\n            },\n        ],\n        ids=[\"standalone\", \"cluster\"],\n        indirect=True,\n    )\n    @pytest.mark.timeout(200)\n    async def test_multi_db_client_uses_lag_aware_health_check(\n        self, r_multi_db, fault_injector_client\n    ):\n        client, listener, endpoint_config = r_multi_db\n        retry = Retry(\n            supported_errors=(TemporaryUnavailableException,),\n            retries=DEFAULT_FAILOVER_ATTEMPTS,\n            backoff=ConstantBackoff(backoff=DEFAULT_FAILOVER_DELAY),\n        )\n\n        async with client as r_multi_db:\n            event = asyncio.Event()\n            asyncio.create_task(\n                trigger_network_failure_action(\n                    fault_injector_client, endpoint_config, event\n                )\n            )\n\n            await retry.call_with_retry(\n                lambda: r_multi_db.set(\"key\", \"value\"), lambda _: dummy_fail_async()\n            )\n\n            # Execute commands before network failure\n            while not event.is_set():\n                assert (\n                    await retry.call_with_retry(\n                        lambda: r_multi_db.get(\"key\"), lambda _: dummy_fail_async()\n                    )\n                    == \"value\"\n                )\n                await asyncio.sleep(0.5)\n\n            # Execute commands after network failure\n            while not listener.is_changed_flag:\n                assert (\n                    await retry.call_with_retry(\n                        lambda: r_multi_db.get(\"key\"), lambda _: dummy_fail_async()\n                    )\n                    == \"value\"\n                )\n                await asyncio.sleep(0.5)\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"r_multi_db\",\n        [\n            {\"client_class\": Redis, \"min_num_failures\": 2},\n            {\"client_class\": RedisCluster, \"min_num_failures\": 2},\n        ],\n        ids=[\"standalone\", \"cluster\"],\n        indirect=True,\n    )\n    @pytest.mark.timeout(200)\n    async def test_context_manager_pipeline_failover_to_another_db(\n        self, r_multi_db, fault_injector_client\n    ):\n        client, listener, endpoint_config = r_multi_db\n        retry = Retry(\n            supported_errors=(TemporaryUnavailableException,),\n            retries=DEFAULT_FAILOVER_ATTEMPTS,\n            backoff=ConstantBackoff(backoff=DEFAULT_FAILOVER_DELAY),\n        )\n\n        async def callback():\n            async with r_multi_db.pipeline() as pipe:\n                pipe.set(\"{hash}key1\", \"value1\")\n                pipe.set(\"{hash}key2\", \"value2\")\n                pipe.set(\"{hash}key3\", \"value3\")\n                pipe.get(\"{hash}key1\")\n                pipe.get(\"{hash}key2\")\n                pipe.get(\"{hash}key3\")\n                assert await pipe.execute() == [\n                    True,\n                    True,\n                    True,\n                    \"value1\",\n                    \"value2\",\n                    \"value3\",\n                ]\n\n        async with client as r_multi_db:\n            event = asyncio.Event()\n            asyncio.create_task(\n                trigger_network_failure_action(\n                    fault_injector_client, endpoint_config, event\n                )\n            )\n\n            # Execute pipeline before network failure\n            while not event.is_set():\n                await retry.call_with_retry(\n                    lambda: callback(), lambda _: dummy_fail_async()\n                )\n                await asyncio.sleep(0.5)\n\n            # Execute commands until database failover\n            while not listener.is_changed_flag:\n                await retry.call_with_retry(\n                    lambda: callback(), lambda _: dummy_fail_async()\n                )\n                await asyncio.sleep(0.5)\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"r_multi_db\",\n        [\n            {\"client_class\": Redis, \"min_num_failures\": 2},\n            {\"client_class\": RedisCluster, \"min_num_failures\": 2},\n        ],\n        ids=[\"standalone\", \"cluster\"],\n        indirect=True,\n    )\n    @pytest.mark.timeout(200)\n    async def test_chaining_pipeline_failover_to_another_db(\n        self, r_multi_db, fault_injector_client\n    ):\n        client, listener, endpoint_config = r_multi_db\n        retry = Retry(\n            supported_errors=(TemporaryUnavailableException,),\n            retries=DEFAULT_FAILOVER_ATTEMPTS,\n            backoff=ConstantBackoff(backoff=DEFAULT_FAILOVER_DELAY),\n        )\n\n        async def callback():\n            pipe = r_multi_db.pipeline()\n            pipe.set(\"{hash}key1\", \"value1\")\n            pipe.set(\"{hash}key2\", \"value2\")\n            pipe.set(\"{hash}key3\", \"value3\")\n            pipe.get(\"{hash}key1\")\n            pipe.get(\"{hash}key2\")\n            pipe.get(\"{hash}key3\")\n            assert await pipe.execute() == [\n                True,\n                True,\n                True,\n                \"value1\",\n                \"value2\",\n                \"value3\",\n            ]\n\n        async with client as r_multi_db:\n            event = asyncio.Event()\n            asyncio.create_task(\n                trigger_network_failure_action(\n                    fault_injector_client, endpoint_config, event\n                )\n            )\n\n            # Execute pipeline before network failure\n            while not event.is_set():\n                await retry.call_with_retry(\n                    lambda: callback(), lambda _: dummy_fail_async()\n                )\n                await asyncio.sleep(0.5)\n\n            # Execute pipeline until database failover\n            while not listener.is_changed_flag:\n                await retry.call_with_retry(\n                    lambda: callback(), lambda _: dummy_fail_async()\n                )\n                await asyncio.sleep(0.5)\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"r_multi_db\",\n        [\n            {\"client_class\": Redis, \"min_num_failures\": 2},\n            {\"client_class\": RedisCluster, \"min_num_failures\": 2},\n        ],\n        ids=[\"standalone\", \"cluster\"],\n        indirect=True,\n    )\n    @pytest.mark.timeout(200)\n    async def test_transaction_failover_to_another_db(\n        self, r_multi_db, fault_injector_client\n    ):\n        client, listener, endpoint_config = r_multi_db\n\n        retry = Retry(\n            supported_errors=(TemporaryUnavailableException,),\n            retries=DEFAULT_FAILOVER_ATTEMPTS,\n            backoff=ConstantBackoff(backoff=DEFAULT_FAILOVER_DELAY),\n        )\n\n        async def callback(pipe: Pipeline):\n            pipe.set(\"{hash}key1\", \"value1\")\n            pipe.set(\"{hash}key2\", \"value2\")\n            pipe.set(\"{hash}key3\", \"value3\")\n            pipe.get(\"{hash}key1\")\n            pipe.get(\"{hash}key2\")\n            pipe.get(\"{hash}key3\")\n\n        async with client as r_multi_db:\n            event = asyncio.Event()\n            asyncio.create_task(\n                trigger_network_failure_action(\n                    fault_injector_client, endpoint_config, event\n                )\n            )\n\n            # Execute transaction before network failure\n            while not event.is_set():\n                await retry.call_with_retry(\n                    lambda: r_multi_db.transaction(callback),\n                    lambda _: dummy_fail_async(),\n                )\n                await asyncio.sleep(0.5)\n\n            # Execute transaction until database failover\n            while not listener.is_changed_flag:\n                assert await retry.call_with_retry(\n                    lambda: r_multi_db.transaction(callback),\n                    lambda _: dummy_fail_async(),\n                ) == [True, True, True, \"value1\", \"value2\", \"value3\"]\n                await asyncio.sleep(0.5)\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\"r_multi_db\", [{\"min_num_failures\": 2}], indirect=True)\n    @pytest.mark.timeout(200)\n    async def test_pubsub_failover_to_another_db(\n        self, r_multi_db, fault_injector_client\n    ):\n        client, listener, endpoint_config = r_multi_db\n        retry = Retry(\n            supported_errors=(TemporaryUnavailableException,),\n            retries=DEFAULT_FAILOVER_ATTEMPTS,\n            backoff=ConstantBackoff(backoff=DEFAULT_FAILOVER_DELAY),\n        )\n\n        data = json.dumps({\"message\": \"test\"})\n        messages_count = 0\n\n        async def handler(message):\n            nonlocal messages_count\n            messages_count += 1\n\n        async with client as r_multi_db:\n            event = asyncio.Event()\n            asyncio.create_task(\n                trigger_network_failure_action(\n                    fault_injector_client, endpoint_config, event\n                )\n            )\n\n            pubsub = await r_multi_db.pubsub()\n\n            # Assign a handler and run in a separate thread.\n            await retry.call_with_retry(\n                lambda: pubsub.subscribe(**{\"test-channel\": handler}),\n                lambda _: dummy_fail_async(),\n            )\n            task = asyncio.create_task(pubsub.run(poll_timeout=0.1))\n\n            # Execute publish before network failure\n            while not event.is_set():\n                await retry.call_with_retry(\n                    lambda: r_multi_db.publish(\"test-channel\", data),\n                    lambda _: dummy_fail_async(),\n                )\n                await asyncio.sleep(0.5)\n\n            # Execute publish until database failover\n            while not listener.is_changed_flag:\n                await retry.call_with_retry(\n                    lambda: r_multi_db.publish(\"test-channel\", data),\n                    lambda _: dummy_fail_async(),\n                )\n                await asyncio.sleep(0.5)\n\n            # After db changed still generates some traffic.\n            for _ in range(5):\n                await retry.call_with_retry(\n                    lambda: r_multi_db.publish(\"test-channel\", data),\n                    lambda _: dummy_fail_async(),\n                )\n\n            # A timeout to ensure that an async handler will handle all previous messages.\n            await asyncio.sleep(0.1)\n            task.cancel()\n            assert messages_count >= 2\n"
  },
  {
    "path": "tests/test_asyncio/test_scripting.py",
    "content": "import pytest\nimport pytest_asyncio\nfrom redis import exceptions\nfrom tests.conftest import skip_if_server_version_lt\n\nmultiply_script = \"\"\"\nlocal value = redis.call('GET', KEYS[1])\nvalue = tonumber(value)\nreturn value * ARGV[1]\"\"\"\n\nmsgpack_hello_script = \"\"\"\nlocal message = cmsgpack.unpack(ARGV[1])\nlocal name = message['name']\nreturn \"hello \" .. name\n\"\"\"\nmsgpack_hello_script_broken = \"\"\"\nlocal message = cmsgpack.unpack(ARGV[1])\nlocal names = message['name']\nreturn \"hello \" .. name\n\"\"\"\n\n\n@pytest.mark.onlynoncluster\nclass TestScripting:\n    @pytest_asyncio.fixture\n    async def r(self, create_redis):\n        redis = await create_redis()\n        yield redis\n        await redis.script_flush()\n\n    @pytest.mark.asyncio()\n    async def test_eval(self, r):\n        await r.flushdb()\n        await r.set(\"a\", 2)\n        # 2 * 3 == 6\n        assert await r.eval(multiply_script, 1, \"a\", 3) == 6\n\n    @pytest.mark.asyncio()\n    @skip_if_server_version_lt(\"6.2.0\")\n    async def test_script_flush(self, r):\n        await r.set(\"a\", 2)\n        await r.script_load(multiply_script)\n        await r.script_flush(\"ASYNC\")\n\n        await r.set(\"a\", 2)\n        await r.script_load(multiply_script)\n        await r.script_flush(\"SYNC\")\n\n        await r.set(\"a\", 2)\n        await r.script_load(multiply_script)\n        await r.script_flush()\n\n        with pytest.raises(exceptions.DataError):\n            await r.set(\"a\", 2)\n            await r.script_load(multiply_script)\n            await r.script_flush(\"NOTREAL\")\n\n    @pytest.mark.asyncio()\n    async def test_evalsha(self, r):\n        await r.set(\"a\", 2)\n        sha = await r.script_load(multiply_script)\n        # 2 * 3 == 6\n        assert await r.evalsha(sha, 1, \"a\", 3) == 6\n\n    @pytest.mark.asyncio()\n    async def test_evalsha_script_not_loaded(self, r):\n        await r.set(\"a\", 2)\n        sha = await r.script_load(multiply_script)\n        # remove the script from Redis's cache\n        await r.script_flush()\n        with pytest.raises(exceptions.NoScriptError):\n            await r.evalsha(sha, 1, \"a\", 3)\n\n    @pytest.mark.asyncio()\n    async def test_script_loading(self, r):\n        # get the sha, then clear the cache\n        sha = await r.script_load(multiply_script)\n        await r.script_flush()\n        assert await r.script_exists(sha) == [False]\n        await r.script_load(multiply_script)\n        assert await r.script_exists(sha) == [True]\n\n    @pytest.mark.asyncio()\n    async def test_script_object(self, r):\n        await r.script_flush()\n        await r.set(\"a\", 2)\n        multiply = r.register_script(multiply_script)\n        precalculated_sha = multiply.sha\n        assert precalculated_sha\n        assert await r.script_exists(multiply.sha) == [False]\n        # Test second evalsha block (after NoScriptError)\n        assert await multiply(keys=[\"a\"], args=[3]) == 6\n        # At this point, the script should be loaded\n        assert await r.script_exists(multiply.sha) == [True]\n        # Test that the precalculated sha matches the one from redis\n        assert multiply.sha == precalculated_sha\n        # Test first evalsha block\n        assert await multiply(keys=[\"a\"], args=[3]) == 6\n\n    @pytest.mark.asyncio()\n    async def test_script_object_in_pipeline(self, r):\n        await r.script_flush()\n        multiply = r.register_script(multiply_script)\n        precalculated_sha = multiply.sha\n        assert precalculated_sha\n        pipe = r.pipeline()\n        pipe.set(\"a\", 2)\n        pipe.get(\"a\")\n        await multiply(keys=[\"a\"], args=[3], client=pipe)\n        assert await r.script_exists(multiply.sha) == [False]\n        # [SET worked, GET 'a', result of multiple script]\n        assert await pipe.execute() == [True, b\"2\", 6]\n        # The script should have been loaded by pipe.execute()\n        assert await r.script_exists(multiply.sha) == [True]\n        # The precalculated sha should have been the correct one\n        assert multiply.sha == precalculated_sha\n\n        # purge the script from redis's cache and re-run the pipeline\n        # the multiply script should be reloaded by pipe.execute()\n        await r.script_flush()\n        pipe = r.pipeline()\n        pipe.set(\"a\", 2)\n        pipe.get(\"a\")\n        await multiply(keys=[\"a\"], args=[3], client=pipe)\n        assert await r.script_exists(multiply.sha) == [False]\n        # [SET worked, GET 'a', result of multiple script]\n        assert await pipe.execute() == [True, b\"2\", 6]\n        assert await r.script_exists(multiply.sha) == [True]\n\n    @pytest.mark.asyncio()\n    async def test_eval_msgpack_pipeline_error_in_lua(self, r):\n        msgpack_hello = r.register_script(msgpack_hello_script)\n        assert msgpack_hello.sha\n\n        pipe = r.pipeline()\n\n        # avoiding a dependency to msgpack, this is the output of\n        # msgpack.dumps({\"name\": \"joe\"})\n        msgpack_message_1 = b\"\\x81\\xa4name\\xa3Joe\"\n\n        await msgpack_hello(args=[msgpack_message_1], client=pipe)\n\n        assert await r.script_exists(msgpack_hello.sha) == [False]\n        assert (await pipe.execute())[0] == b\"hello Joe\"\n        assert await r.script_exists(msgpack_hello.sha) == [True]\n\n        msgpack_hello_broken = r.register_script(msgpack_hello_script_broken)\n\n        await msgpack_hello_broken(args=[msgpack_message_1], client=pipe)\n        with pytest.raises(exceptions.ResponseError) as excinfo:\n            await pipe.execute()\n        assert excinfo.type == exceptions.ResponseError\n"
  },
  {
    "path": "tests/test_asyncio/test_search.py",
    "content": "import bz2\nimport csv\nimport os\nimport asyncio\nfrom io import TextIOWrapper\nimport random\n\nimport numpy as np\nimport pytest\nimport pytest_asyncio\nfrom redis import ResponseError\nimport redis.asyncio as redis\nimport redis.commands.search.aggregation as aggregations\nfrom redis.commands.search.hybrid_query import (\n    CombinationMethods,\n    CombineResultsMethod,\n    HybridCursorQuery,\n    HybridFilter,\n    HybridPostProcessingConfig,\n    HybridQuery,\n    HybridSearchQuery,\n    HybridVsimQuery,\n    VectorSearchMethods,\n)\nfrom redis.commands.search.hybrid_result import HybridCursorResult\nimport redis.commands.search.reducers as reducers\nfrom redis.commands.search import AsyncSearch\nfrom redis.commands.search.field import (\n    GeoField,\n    NumericField,\n    TagField,\n    TextField,\n    VectorField,\n)\nfrom redis.commands.search.index_definition import IndexDefinition, IndexType\nfrom redis.commands.search.query import GeoFilter, NumericFilter, Query, SortbyField\nfrom redis.commands.search.result import Result\nfrom redis.commands.search.suggestion import Suggestion\nfrom redis.utils import safe_str\nfrom tests.conftest import (\n    is_resp2_connection,\n    skip_if_redis_enterprise,\n    skip_if_resp_version,\n    skip_if_server_version_gte,\n    skip_if_server_version_lt,\n    skip_ifmodversion_lt,\n)\n\nWILL_PLAY_TEXT = os.path.abspath(\n    os.path.join(os.path.dirname(__file__), \"testdata\", \"will_play_text.csv.bz2\")\n)\n\nTITLES_CSV = os.path.abspath(\n    os.path.join(os.path.dirname(__file__), \"testdata\", \"titles.csv\")\n)\n\n\nclass AsyncSearchTestsBase:\n    @pytest_asyncio.fixture()\n    async def decoded_r(self, create_redis, stack_url):\n        return await create_redis(decode_responses=True, url=stack_url)\n\n    @staticmethod\n    async def waitForIndex(env, idx, timeout=None):\n        delay = 0.1\n        while True:\n            try:\n                res = await env.execute_command(\"FT.INFO\", idx)\n                if int(res[res.index(\"indexing\") + 1]) == 0:\n                    break\n            except ValueError:\n                break\n            except AttributeError:\n                try:\n                    if int(res[\"indexing\"]) == 0:\n                        break\n                except ValueError:\n                    break\n            except ResponseError:\n                # index doesn't exist yet\n                # continue to sleep and try again\n                pass\n\n            await asyncio.sleep(delay)\n            if timeout is not None:\n                timeout -= delay\n                if timeout <= 0:\n                    break\n\n    @staticmethod\n    def getClient(decoded_r: redis.Redis):\n        \"\"\"\n        Gets a client client attached to an index name which is ready to be\n        created\n        \"\"\"\n        return decoded_r\n\n    @staticmethod\n    async def createIndex(decoded_r, num_docs=100, definition=None):\n        try:\n            await decoded_r.create_index(\n                (\n                    TextField(\"play\", weight=5.0),\n                    TextField(\"txt\"),\n                    NumericField(\"chapter\"),\n                ),\n                definition=definition,\n            )\n        except redis.ResponseError:\n            await decoded_r.dropindex(delete_documents=True)\n            return await AsyncSearchTestsBase.createIndex(\n                decoded_r, num_docs=num_docs, definition=definition\n            )\n\n        chapters = {}\n        bzfp = TextIOWrapper(bz2.BZ2File(WILL_PLAY_TEXT), encoding=\"utf8\")\n\n        r = csv.reader(bzfp, delimiter=\";\")\n        for n, line in enumerate(r):\n            play, chapter, _, text = line[1], line[2], line[4], line[5]\n\n            key = f\"{play}:{chapter}\".lower()\n            d = chapters.setdefault(key, {})\n            d[\"play\"] = play\n            d[\"txt\"] = d.get(\"txt\", \"\") + \" \" + text\n            d[\"chapter\"] = int(chapter or 0)\n            if len(chapters) == num_docs:\n                break\n\n        indexer = decoded_r.batch_indexer(chunk_size=50)\n        assert isinstance(indexer, AsyncSearch.BatchIndexer)\n        assert 50 == indexer.chunk_size\n\n        for key, doc in chapters.items():\n            await indexer.client.client.hset(key, mapping=doc)\n        await indexer.commit()\n\n\nclass TestBaseSearchFunctionality(AsyncSearchTestsBase):\n    @pytest.mark.redismod\n    async def test_client(self, decoded_r: redis.Redis):\n        num_docs = 500\n        await self.createIndex(decoded_r.ft(), num_docs=num_docs)\n        await self.waitForIndex(decoded_r, \"idx\")\n        # verify info\n        info = await decoded_r.ft().info()\n        for k in [\n            \"index_name\",\n            \"index_options\",\n            \"attributes\",\n            \"num_docs\",\n            \"max_doc_id\",\n            \"num_terms\",\n            \"num_records\",\n            \"inverted_sz_mb\",\n            \"offset_vectors_sz_mb\",\n            \"doc_table_size_mb\",\n            \"key_table_size_mb\",\n            \"records_per_doc_avg\",\n            \"bytes_per_record_avg\",\n            \"offsets_per_term_avg\",\n            \"offset_bits_per_record_avg\",\n        ]:\n            assert k in info\n\n        assert decoded_r.ft().index_name == info[\"index_name\"]\n        assert num_docs == int(info[\"num_docs\"])\n\n        res = await decoded_r.ft().search(\"henry iv\")\n        if is_resp2_connection(decoded_r):\n            assert isinstance(res, Result)\n            assert 225 == res.total\n            assert 10 == len(res.docs)\n            assert res.duration > 0\n\n            for doc in res.docs:\n                assert doc.id\n                assert doc.play == \"Henry IV\"\n                assert len(doc.txt) > 0\n\n            # test no content\n            res = await decoded_r.ft().search(Query(\"king\").no_content())\n            assert 194 == res.total\n            assert 10 == len(res.docs)\n            for doc in res.docs:\n                assert \"txt\" not in doc.__dict__\n                assert \"play\" not in doc.__dict__\n\n            # test verbatim vs no verbatim\n            total = (await decoded_r.ft().search(Query(\"kings\").no_content())).total\n            vtotal = (\n                await decoded_r.ft().search(Query(\"kings\").no_content().verbatim())\n            ).total\n            assert total > vtotal\n\n            # test in fields\n            txt_total = (\n                await decoded_r.ft().search(\n                    Query(\"henry\").no_content().limit_fields(\"txt\")\n                )\n            ).total\n            play_total = (\n                await decoded_r.ft().search(\n                    Query(\"henry\").no_content().limit_fields(\"play\")\n                )\n            ).total\n            both_total = (\n                await decoded_r.ft().search(\n                    Query(\"henry\").no_content().limit_fields(\"play\", \"txt\")\n                )\n            ).total\n            assert 129 == txt_total\n            assert 494 == play_total\n            assert 494 == both_total\n\n            # test load_document\n            doc = await decoded_r.ft().load_document(\"henry vi part 3:62\")\n            assert doc is not None\n            assert \"henry vi part 3:62\" == doc.id\n            assert doc.play == \"Henry VI Part 3\"\n            assert len(doc.txt) > 0\n\n            # test in-keys\n            ids = [x.id for x in (await decoded_r.ft().search(Query(\"henry\"))).docs]\n            assert 10 == len(ids)\n            subset = ids[:5]\n            docs = await decoded_r.ft().search(Query(\"henry\").limit_ids(*subset))\n            assert len(subset) == docs.total\n            ids = [x.id for x in docs.docs]\n            assert set(ids) == set(subset)\n\n            # test slop and in order\n            assert 193 == (await decoded_r.ft().search(Query(\"henry king\"))).total\n            assert (\n                3\n                == (\n                    await decoded_r.ft().search(Query(\"henry king\").slop(0).in_order())\n                ).total\n            )\n            assert (\n                52\n                == (\n                    await decoded_r.ft().search(Query(\"king henry\").slop(0).in_order())\n                ).total\n            )\n            assert (\n                53 == (await decoded_r.ft().search(Query(\"henry king\").slop(0))).total\n            )\n            assert (\n                167\n                == (await decoded_r.ft().search(Query(\"henry king\").slop(100))).total\n            )\n\n            # test delete document\n            await decoded_r.hset(\"doc-5ghs2\", mapping={\"play\": \"Death of a Salesman\"})\n            res = await decoded_r.ft().search(Query(\"death of a salesman\"))\n            assert 1 == res.total\n\n            assert 1 == await decoded_r.ft().delete_document(\"doc-5ghs2\")\n            res = await decoded_r.ft().search(Query(\"death of a salesman\"))\n            assert 0 == res.total\n            assert 0 == await decoded_r.ft().delete_document(\"doc-5ghs2\")\n\n            await decoded_r.hset(\"doc-5ghs2\", mapping={\"play\": \"Death of a Salesman\"})\n            res = await decoded_r.ft().search(Query(\"death of a salesman\"))\n            assert 1 == res.total\n            await decoded_r.ft().delete_document(\"doc-5ghs2\")\n        else:\n            assert isinstance(res, dict)\n            assert 225 == res[\"total_results\"]\n            assert 10 == len(res[\"results\"])\n\n            for doc in res[\"results\"]:\n                assert doc[\"id\"]\n                assert doc[\"extra_attributes\"][\"play\"] == \"Henry IV\"\n                assert len(doc[\"extra_attributes\"][\"txt\"]) > 0\n\n            # test no content\n            res = await decoded_r.ft().search(Query(\"king\").no_content())\n            assert 194 == res[\"total_results\"]\n            assert 10 == len(res[\"results\"])\n            for doc in res[\"results\"]:\n                assert \"extra_attributes\" not in doc.keys()\n\n            # test verbatim vs no verbatim\n            total = (await decoded_r.ft().search(Query(\"kings\").no_content()))[\n                \"total_results\"\n            ]\n            vtotal = (\n                await decoded_r.ft().search(Query(\"kings\").no_content().verbatim())\n            )[\"total_results\"]\n            assert total > vtotal\n\n            # test in fields\n            txt_total = (\n                await decoded_r.ft().search(\n                    Query(\"henry\").no_content().limit_fields(\"txt\")\n                )\n            )[\"total_results\"]\n            play_total = (\n                await decoded_r.ft().search(\n                    Query(\"henry\").no_content().limit_fields(\"play\")\n                )\n            )[\"total_results\"]\n            both_total = (\n                await decoded_r.ft().search(\n                    Query(\"henry\").no_content().limit_fields(\"play\", \"txt\")\n                )\n            )[\"total_results\"]\n            assert 129 == txt_total\n            assert 494 == play_total\n            assert 494 == both_total\n\n            # test load_document\n            doc = await decoded_r.ft().load_document(\"henry vi part 3:62\")\n            assert doc is not None\n            assert \"henry vi part 3:62\" == doc.id\n            assert doc.play == \"Henry VI Part 3\"\n            assert len(doc.txt) > 0\n\n            # test in-keys\n            ids = [\n                x[\"id\"]\n                for x in (await decoded_r.ft().search(Query(\"henry\")))[\"results\"]\n            ]\n            assert 10 == len(ids)\n            subset = ids[:5]\n            docs = await decoded_r.ft().search(Query(\"henry\").limit_ids(*subset))\n            assert len(subset) == docs[\"total_results\"]\n            ids = [x[\"id\"] for x in docs[\"results\"]]\n            assert set(ids) == set(subset)\n\n            # test slop and in order\n            assert (\n                193\n                == (await decoded_r.ft().search(Query(\"henry king\")))[\"total_results\"]\n            )\n            assert (\n                3\n                == (\n                    await decoded_r.ft().search(Query(\"henry king\").slop(0).in_order())\n                )[\"total_results\"]\n            )\n            assert (\n                52\n                == (\n                    await decoded_r.ft().search(Query(\"king henry\").slop(0).in_order())\n                )[\"total_results\"]\n            )\n            assert (\n                53\n                == (await decoded_r.ft().search(Query(\"henry king\").slop(0)))[\n                    \"total_results\"\n                ]\n            )\n            assert (\n                167\n                == (await decoded_r.ft().search(Query(\"henry king\").slop(100)))[\n                    \"total_results\"\n                ]\n            )\n\n            # test delete document\n            await decoded_r.hset(\"doc-5ghs2\", mapping={\"play\": \"Death of a Salesman\"})\n            res = await decoded_r.ft().search(Query(\"death of a salesman\"))\n            assert 1 == res[\"total_results\"]\n\n            assert 1 == await decoded_r.ft().delete_document(\"doc-5ghs2\")\n            res = await decoded_r.ft().search(Query(\"death of a salesman\"))\n            assert 0 == res[\"total_results\"]\n            assert 0 == await decoded_r.ft().delete_document(\"doc-5ghs2\")\n\n            await decoded_r.hset(\"doc-5ghs2\", mapping={\"play\": \"Death of a Salesman\"})\n            res = await decoded_r.ft().search(Query(\"death of a salesman\"))\n            assert 1 == res[\"total_results\"]\n            await decoded_r.ft().delete_document(\"doc-5ghs2\")\n\n    @pytest.mark.redismod\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_gte(\"7.9.0\")\n    async def test_scores(self, decoded_r: redis.Redis):\n        await decoded_r.ft().create_index((TextField(\"txt\"),))\n\n        await decoded_r.hset(\"doc1\", mapping={\"txt\": \"foo baz\"})\n        await decoded_r.hset(\"doc2\", mapping={\"txt\": \"foo bar\"})\n\n        q = Query(\"foo ~bar\").with_scores()\n        res = await decoded_r.ft().search(q)\n        if is_resp2_connection(decoded_r):\n            assert 2 == res.total\n            assert \"doc2\" == res.docs[0].id\n            assert 3.0 == res.docs[0].score\n            assert \"doc1\" == res.docs[1].id\n        else:\n            assert 2 == res[\"total_results\"]\n            assert \"doc2\" == res[\"results\"][0][\"id\"]\n            assert 3.0 == res[\"results\"][0][\"score\"]\n            assert \"doc1\" == res[\"results\"][1][\"id\"]\n\n    @pytest.mark.redismod\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"7.9.0\")\n    async def test_scores_with_new_default_scorer(self, decoded_r: redis.Redis):\n        await decoded_r.ft().create_index((TextField(\"txt\"),))\n\n        await decoded_r.hset(\"doc1\", mapping={\"txt\": \"foo baz\"})\n        await decoded_r.hset(\"doc2\", mapping={\"txt\": \"foo bar\"})\n\n        q = Query(\"foo ~bar\").with_scores()\n        res = await decoded_r.ft().search(q)\n        if is_resp2_connection(decoded_r):\n            assert 2 == res.total\n            assert \"doc2\" == res.docs[0].id\n            assert 0.87 == pytest.approx(res.docs[0].score, 0.01)\n            assert \"doc1\" == res.docs[1].id\n        else:\n            assert 2 == res[\"total_results\"]\n            assert \"doc2\" == res[\"results\"][0][\"id\"]\n            assert 0.87 == pytest.approx(res[\"results\"][0][\"score\"], 0.01)\n            assert \"doc1\" == res[\"results\"][1][\"id\"]\n\n    @pytest.mark.redismod\n    async def test_stopwords(self, decoded_r: redis.Redis):\n        stopwords = [\"foo\", \"bar\", \"baz\"]\n        await decoded_r.ft().create_index((TextField(\"txt\"),), stopwords=stopwords)\n        await decoded_r.hset(\"doc1\", mapping={\"txt\": \"foo bar\"})\n        await decoded_r.hset(\"doc2\", mapping={\"txt\": \"hello world\"})\n        await self.waitForIndex(decoded_r, \"idx\")\n\n        q1 = Query(\"foo bar\").no_content()\n        q2 = Query(\"foo bar hello world\").no_content()\n        res1, res2 = await decoded_r.ft().search(q1), await decoded_r.ft().search(q2)\n        if is_resp2_connection(decoded_r):\n            assert 0 == res1.total\n            assert 1 == res2.total\n        else:\n            assert 0 == res1[\"total_results\"]\n            assert 1 == res2[\"total_results\"]\n\n    @pytest.mark.redismod\n    async def test_filters(self, decoded_r: redis.Redis):\n        await decoded_r.ft().create_index(\n            (TextField(\"txt\"), NumericField(\"num\"), GeoField(\"loc\"))\n        )\n        await decoded_r.hset(\n            \"doc1\", mapping={\"txt\": \"foo bar\", \"num\": 3.141, \"loc\": \"-0.441,51.458\"}\n        )\n        await decoded_r.hset(\n            \"doc2\", mapping={\"txt\": \"foo baz\", \"num\": 2, \"loc\": \"-0.1,51.2\"}\n        )\n\n        await self.waitForIndex(decoded_r, \"idx\")\n        # Test numerical filter\n        q1 = Query(\"foo\").add_filter(NumericFilter(\"num\", 0, 2)).no_content()\n        q2 = (\n            Query(\"foo\")\n            .add_filter(NumericFilter(\"num\", 2, NumericFilter.INF, minExclusive=True))\n            .no_content()\n        )\n        res1, res2 = await decoded_r.ft().search(q1), await decoded_r.ft().search(q2)\n\n        if is_resp2_connection(decoded_r):\n            assert 1 == res1.total\n            assert 1 == res2.total\n            assert \"doc2\" == res1.docs[0].id\n            assert \"doc1\" == res2.docs[0].id\n        else:\n            assert 1 == res1[\"total_results\"]\n            assert 1 == res2[\"total_results\"]\n            assert \"doc2\" == res1[\"results\"][0][\"id\"]\n            assert \"doc1\" == res2[\"results\"][0][\"id\"]\n\n        # Test geo filter\n        q1 = Query(\"foo\").add_filter(GeoFilter(\"loc\", -0.44, 51.45, 10)).no_content()\n        q2 = Query(\"foo\").add_filter(GeoFilter(\"loc\", -0.44, 51.45, 100)).no_content()\n        res1, res2 = await decoded_r.ft().search(q1), await decoded_r.ft().search(q2)\n\n        if is_resp2_connection(decoded_r):\n            assert 1 == res1.total\n            assert 2 == res2.total\n            assert \"doc1\" == res1.docs[0].id\n\n            # Sort results, after RDB reload order may change\n            res = [res2.docs[0].id, res2.docs[1].id]\n            res.sort()\n            assert [\"doc1\", \"doc2\"] == res\n        else:\n            assert 1 == res1[\"total_results\"]\n            assert 2 == res2[\"total_results\"]\n            assert \"doc1\" == res1[\"results\"][0][\"id\"]\n\n            # Sort results, after RDB reload order may change\n            res = [res2[\"results\"][0][\"id\"], res2[\"results\"][1][\"id\"]]\n            res.sort()\n            assert [\"doc1\", \"doc2\"] == res\n\n    @pytest.mark.redismod\n    async def test_sort_by(self, decoded_r: redis.Redis):\n        await decoded_r.ft().create_index(\n            (TextField(\"txt\"), NumericField(\"num\", sortable=True))\n        )\n        await decoded_r.hset(\"doc1\", mapping={\"txt\": \"foo bar\", \"num\": 1})\n        await decoded_r.hset(\"doc2\", mapping={\"txt\": \"foo baz\", \"num\": 2})\n        await decoded_r.hset(\"doc3\", mapping={\"txt\": \"foo qux\", \"num\": 3})\n\n        # Test sort\n        q1 = Query(\"foo\").sort_by(\"num\", asc=True).no_content()\n        q2 = Query(\"foo\").sort_by(\"num\", asc=False).no_content()\n        res1, res2 = await decoded_r.ft().search(q1), await decoded_r.ft().search(q2)\n\n        if is_resp2_connection(decoded_r):\n            assert 3 == res1.total\n            assert \"doc1\" == res1.docs[0].id\n            assert \"doc2\" == res1.docs[1].id\n            assert \"doc3\" == res1.docs[2].id\n            assert 3 == res2.total\n            assert \"doc1\" == res2.docs[2].id\n            assert \"doc2\" == res2.docs[1].id\n            assert \"doc3\" == res2.docs[0].id\n        else:\n            assert 3 == res1[\"total_results\"]\n            assert \"doc1\" == res1[\"results\"][0][\"id\"]\n            assert \"doc2\" == res1[\"results\"][1][\"id\"]\n            assert \"doc3\" == res1[\"results\"][2][\"id\"]\n            assert 3 == res2[\"total_results\"]\n            assert \"doc1\" == res2[\"results\"][2][\"id\"]\n            assert \"doc2\" == res2[\"results\"][1][\"id\"]\n            assert \"doc3\" == res2[\"results\"][0][\"id\"]\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.0.0\", \"search\")\n    async def test_drop_index(self, decoded_r: redis.Redis):\n        \"\"\"\n        Ensure the index gets dropped by data remains by default\n        \"\"\"\n        for x in range(20):\n            for keep_docs in [[True, {}], [False, {\"name\": \"haveit\"}]]:\n                idx = \"HaveIt\"\n                index = self.getClient(decoded_r)\n                await index.hset(\"index:haveit\", mapping={\"name\": \"haveit\"})\n                idef = IndexDefinition(prefix=[\"index:\"])\n                await index.ft(idx).create_index((TextField(\"name\"),), definition=idef)\n                await self.waitForIndex(index, idx)\n                await index.ft(idx).dropindex(delete_documents=keep_docs[0])\n                i = await index.hgetall(\"index:haveit\")\n                assert i == keep_docs[1]\n\n    @pytest.mark.redismod\n    async def test_example(self, decoded_r: redis.Redis):\n        # Creating the index definition and schema\n        await decoded_r.ft().create_index(\n            (TextField(\"title\", weight=5.0), TextField(\"body\"))\n        )\n\n        # Indexing a document\n        await decoded_r.hset(\n            \"doc1\",\n            mapping={\n                \"title\": \"RediSearch\",\n                \"body\": \"Redisearch impements a search engine on top of redis\",\n            },\n        )\n\n        # Searching with complex parameters:\n        q = Query(\"search engine\").verbatim().no_content().paging(0, 5)\n\n        res = await decoded_r.ft().search(q)\n        assert res is not None\n\n    @pytest.mark.redismod\n    async def test_auto_complete(self, decoded_r: redis.Redis):\n        n = 0\n        with open(TITLES_CSV) as f:\n            cr = csv.reader(f)\n\n            for row in cr:\n                n += 1\n                term, score = row[0], float(row[1])\n                assert n == await decoded_r.ft().sugadd(\n                    \"ac\", Suggestion(term, score=score)\n                )\n\n        assert n == await decoded_r.ft().suglen(\"ac\")\n        ret = await decoded_r.ft().sugget(\"ac\", \"bad\", with_scores=True)\n        assert 2 == len(ret)\n        assert \"badger\" == ret[0].string\n        assert isinstance(ret[0].score, float)\n        assert 1.0 != ret[0].score\n        assert \"badalte rishtey\" == ret[1].string\n        assert isinstance(ret[1].score, float)\n        assert 1.0 != ret[1].score\n\n        ret = await decoded_r.ft().sugget(\"ac\", \"bad\", fuzzy=True, num=10)\n        assert 10 == len(ret)\n        assert 1.0 == ret[0].score\n        strs = {x.string for x in ret}\n\n        for sug in strs:\n            assert 1 == await decoded_r.ft().sugdel(\"ac\", sug)\n        # make sure a second delete returns 0\n        for sug in strs:\n            assert 0 == await decoded_r.ft().sugdel(\"ac\", sug)\n\n        # make sure they were actually deleted\n        ret2 = await decoded_r.ft().sugget(\"ac\", \"bad\", fuzzy=True, num=10)\n        for sug in ret2:\n            assert sug.string not in strs\n\n        # Test with payload\n        await decoded_r.ft().sugadd(\"ac\", Suggestion(\"pay1\", payload=\"pl1\"))\n        await decoded_r.ft().sugadd(\"ac\", Suggestion(\"pay2\", payload=\"pl2\"))\n        await decoded_r.ft().sugadd(\"ac\", Suggestion(\"pay3\", payload=\"pl3\"))\n\n        sugs = await decoded_r.ft().sugget(\n            \"ac\", \"pay\", with_payloads=True, with_scores=True\n        )\n        assert 3 == len(sugs)\n        for sug in sugs:\n            assert sug.payload\n            assert sug.payload.startswith(\"pl\")\n\n    @pytest.mark.redismod\n    async def test_no_index(self, decoded_r: redis.Redis):\n        await decoded_r.ft().create_index(\n            (\n                TextField(\"field\"),\n                TextField(\"text\", no_index=True, sortable=True),\n                NumericField(\"numeric\", no_index=True, sortable=True),\n                GeoField(\"geo\", no_index=True, sortable=True),\n                TagField(\"tag\", no_index=True, sortable=True),\n            )\n        )\n\n        await decoded_r.hset(\n            \"doc1\",\n            mapping={\n                \"field\": \"aaa\",\n                \"text\": \"1\",\n                \"numeric\": \"1\",\n                \"geo\": \"1,1\",\n                \"tag\": \"1\",\n            },\n        )\n        await decoded_r.hset(\n            \"doc2\",\n            mapping={\n                \"field\": \"aab\",\n                \"text\": \"2\",\n                \"numeric\": \"2\",\n                \"geo\": \"2,2\",\n                \"tag\": \"2\",\n            },\n        )\n        await self.waitForIndex(decoded_r, \"idx\")\n\n        if is_resp2_connection(decoded_r):\n            res = await decoded_r.ft().search(Query(\"@text:aa*\"))\n            assert 0 == res.total\n\n            res = await decoded_r.ft().search(Query(\"@field:aa*\"))\n            assert 2 == res.total\n\n            res = await decoded_r.ft().search(Query(\"*\").sort_by(\"text\", asc=False))\n            assert 2 == res.total\n            assert \"doc2\" == res.docs[0].id\n\n            res = await decoded_r.ft().search(Query(\"*\").sort_by(\"text\", asc=True))\n            assert \"doc1\" == res.docs[0].id\n\n            res = await decoded_r.ft().search(Query(\"*\").sort_by(\"numeric\", asc=True))\n            assert \"doc1\" == res.docs[0].id\n\n            res = await decoded_r.ft().search(Query(\"*\").sort_by(\"geo\", asc=True))\n            assert \"doc1\" == res.docs[0].id\n\n            res = await decoded_r.ft().search(Query(\"*\").sort_by(\"tag\", asc=True))\n            assert \"doc1\" == res.docs[0].id\n        else:\n            res = await decoded_r.ft().search(Query(\"@text:aa*\"))\n            assert 0 == res[\"total_results\"]\n\n            res = await decoded_r.ft().search(Query(\"@field:aa*\"))\n            assert 2 == res[\"total_results\"]\n\n            res = await decoded_r.ft().search(Query(\"*\").sort_by(\"text\", asc=False))\n            assert 2 == res[\"total_results\"]\n            assert \"doc2\" == res[\"results\"][0][\"id\"]\n\n            res = await decoded_r.ft().search(Query(\"*\").sort_by(\"text\", asc=True))\n            assert \"doc1\" == res[\"results\"][0][\"id\"]\n\n            res = await decoded_r.ft().search(Query(\"*\").sort_by(\"numeric\", asc=True))\n            assert \"doc1\" == res[\"results\"][0][\"id\"]\n\n            res = await decoded_r.ft().search(Query(\"*\").sort_by(\"geo\", asc=True))\n            assert \"doc1\" == res[\"results\"][0][\"id\"]\n\n            res = await decoded_r.ft().search(Query(\"*\").sort_by(\"tag\", asc=True))\n            assert \"doc1\" == res[\"results\"][0][\"id\"]\n\n        # Ensure exception is raised for non-indexable, non-sortable fields\n        with pytest.raises(Exception):\n            TextField(\"name\", no_index=True, sortable=False)\n        with pytest.raises(Exception):\n            NumericField(\"name\", no_index=True, sortable=False)\n        with pytest.raises(Exception):\n            GeoField(\"name\", no_index=True, sortable=False)\n        with pytest.raises(Exception):\n            TagField(\"name\", no_index=True, sortable=False)\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"7.4.0\")\n    @skip_ifmodversion_lt(\"2.10.0\", \"search\")\n    async def test_create_index_empty_or_missing_fields_with_sortable(\n        self,\n        decoded_r: redis.Redis,\n    ):\n        definition = IndexDefinition(prefix=[\"property:\"], index_type=IndexType.HASH)\n\n        fields = [\n            TextField(\"title\", sortable=True, index_empty=True),\n            TagField(\"features\", index_missing=True, sortable=True),\n            TextField(\"description\", no_index=True, sortable=True),\n        ]\n\n        await decoded_r.ft().create_index(fields, definition=definition)\n\n    @pytest.mark.redismod\n    async def test_explain(self, decoded_r: redis.Redis):\n        await decoded_r.ft().create_index(\n            (TextField(\"f1\"), TextField(\"f2\"), TextField(\"f3\"))\n        )\n        res = await decoded_r.ft().explain(\"@f3:f3_val @f2:f2_val @f1:f1_val\")\n        assert res\n\n    @pytest.mark.redismod\n    async def test_explaincli(self, decoded_r: redis.Redis):\n        with pytest.raises(NotImplementedError):\n            await decoded_r.ft().explain_cli(\"foo\")\n\n    @pytest.mark.redismod\n    async def test_summarize(self, decoded_r: redis.Redis):\n        await self.createIndex(decoded_r.ft())\n        await self.waitForIndex(decoded_r, \"idx\")\n\n        q = Query('\"king henry\"').paging(0, 1)\n        q.highlight(fields=(\"play\", \"txt\"), tags=(\"<b>\", \"</b>\"))\n        q.summarize(\"txt\")\n\n        if is_resp2_connection(decoded_r):\n            doc = sorted((await decoded_r.ft().search(q)).docs)[0]\n            assert \"<b>Henry</b> IV\" == doc.play\n            assert (\n                \"ACT I SCENE I. London. The palace. Enter <b>KING</b> <b>HENRY</b>, LORD JOHN OF LANCASTER, the EARL of WESTMORELAND, SIR... \"  # noqa\n                == doc.txt\n            )\n\n            q = Query('\"king henry\"').paging(0, 1).summarize().highlight()\n\n            doc = sorted((await decoded_r.ft().search(q)).docs)[0]\n            assert \"<b>Henry</b> ... \" == doc.play\n            assert (\n                \"ACT I SCENE I. London. The palace. Enter <b>KING</b> <b>HENRY</b>, LORD JOHN OF LANCASTER, the EARL of WESTMORELAND, SIR... \"  # noqa\n                == doc.txt\n            )\n        else:\n            doc = sorted((await decoded_r.ft().search(q))[\"results\"])[0]\n            assert \"<b>Henry</b> IV\" == doc[\"extra_attributes\"][\"play\"]\n            assert (\n                \"ACT I SCENE I. London. The palace. Enter <b>KING</b> <b>HENRY</b>, LORD JOHN OF LANCASTER, the EARL of WESTMORELAND, SIR... \"  # noqa\n                == doc[\"extra_attributes\"][\"txt\"]\n            )\n\n            q = Query('\"king henry\"').paging(0, 1).summarize().highlight()\n\n            doc = sorted((await decoded_r.ft().search(q))[\"results\"])[0]\n            assert \"<b>Henry</b> ... \" == doc[\"extra_attributes\"][\"play\"]\n            assert (\n                \"ACT I SCENE I. London. The palace. Enter <b>KING</b> <b>HENRY</b>, LORD JOHN OF LANCASTER, the EARL of WESTMORELAND, SIR... \"  # noqa\n                == doc[\"extra_attributes\"][\"txt\"]\n            )\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.0.0\", \"search\")\n    async def test_alias(self, decoded_r: redis.Redis):\n        index1 = self.getClient(decoded_r)\n        index2 = self.getClient(decoded_r)\n\n        def1 = IndexDefinition(prefix=[\"index1:\"])\n        def2 = IndexDefinition(prefix=[\"index2:\"])\n\n        ftindex1 = index1.ft(\"testAlias\")\n        ftindex2 = index2.ft(\"testAlias2\")\n        await ftindex1.create_index((TextField(\"name\"),), definition=def1)\n        await ftindex2.create_index((TextField(\"name\"),), definition=def2)\n\n        await index1.hset(\"index1:lonestar\", mapping={\"name\": \"lonestar\"})\n        await index2.hset(\"index2:yogurt\", mapping={\"name\": \"yogurt\"})\n\n        if is_resp2_connection(decoded_r):\n            res = (await ftindex1.search(\"*\")).docs[0]\n            assert \"index1:lonestar\" == res.id\n\n            # create alias and check for results\n            await ftindex1.aliasadd(\"spaceballs\")\n            alias_client = self.getClient(decoded_r).ft(\"spaceballs\")\n            res = (await alias_client.search(\"*\")).docs[0]\n            assert \"index1:lonestar\" == res.id\n\n            # Throw an exception when trying to add an alias that already exists\n            with pytest.raises(Exception):\n                await ftindex2.aliasadd(\"spaceballs\")\n\n            # update alias and ensure new results\n            await ftindex2.aliasupdate(\"spaceballs\")\n            alias_client2 = self.getClient(decoded_r).ft(\"spaceballs\")\n\n            res = (await alias_client2.search(\"*\")).docs[0]\n            assert \"index2:yogurt\" == res.id\n        else:\n            res = (await ftindex1.search(\"*\"))[\"results\"][0]\n            assert \"index1:lonestar\" == res[\"id\"]\n\n            # create alias and check for results\n            await ftindex1.aliasadd(\"spaceballs\")\n            alias_client = self.getClient(await decoded_r).ft(\"spaceballs\")\n            res = (await alias_client.search(\"*\"))[\"results\"][0]\n            assert \"index1:lonestar\" == res[\"id\"]\n\n            # Throw an exception when trying to add an alias that already exists\n            with pytest.raises(Exception):\n                await ftindex2.aliasadd(\"spaceballs\")\n\n            # update alias and ensure new results\n            await ftindex2.aliasupdate(\"spaceballs\")\n            alias_client2 = self.getClient(await decoded_r).ft(\"spaceballs\")\n\n            res = (await alias_client2.search(\"*\"))[\"results\"][0]\n            assert \"index2:yogurt\" == res[\"id\"]\n\n        await ftindex2.aliasdel(\"spaceballs\")\n        with pytest.raises(Exception):\n            (await alias_client2.search(\"*\")).docs[0]\n\n    @pytest.mark.redismod\n    @pytest.mark.xfail(strict=False)\n    async def test_alias_basic(self, decoded_r: redis.Redis):\n        # Creating a client with one index\n        client = self.getClient(decoded_r)\n        await client.flushdb()\n        index1 = self.getClient(decoded_r).ft(\"testAlias\")\n\n        await index1.create_index((TextField(\"txt\"),))\n        await index1.client.hset(\"doc1\", mapping={\"txt\": \"text goes here\"})\n\n        index2 = self.getClient(decoded_r).ft(\"testAlias2\")\n        await index2.create_index((TextField(\"txt\"),))\n        await index2.client.hset(\"doc2\", mapping={\"txt\": \"text goes here\"})\n\n        # add the actual alias and check\n        await index1.aliasadd(\"myalias\")\n        alias_client = self.getClient(decoded_r).ft(\"myalias\")\n        if is_resp2_connection(decoded_r):\n            res = sorted((await alias_client.search(\"*\")).docs, key=lambda x: x.id)\n            assert \"doc1\" == res[0].id\n\n            # Throw an exception when trying to add an alias that already exists\n            with pytest.raises(Exception):\n                await index2.aliasadd(\"myalias\")\n\n            # update the alias and ensure we get doc2\n            await index2.aliasupdate(\"myalias\")\n            alias_client2 = self.getClient(decoded_r).ft(\"myalias\")\n            res = sorted((await alias_client2.search(\"*\")).docs, key=lambda x: x.id)\n            assert \"doc1\" == res[0].id\n        else:\n            res = sorted(\n                (await alias_client.search(\"*\"))[\"results\"], key=lambda x: x[\"id\"]\n            )\n            assert \"doc1\" == res[0][\"id\"]\n\n            # Throw an exception when trying to add an alias that already exists\n            with pytest.raises(Exception):\n                await index2.aliasadd(\"myalias\")\n\n            # update the alias and ensure we get doc2\n            await index2.aliasupdate(\"myalias\")\n            alias_client2 = self.getClient(client).ft(\"myalias\")\n            res = sorted(\n                (await alias_client2.search(\"*\"))[\"results\"], key=lambda x: x[\"id\"]\n            )\n            assert \"doc1\" == res[0][\"id\"]\n\n        # delete the alias and expect an error if we try to query again\n        await index2.aliasdel(\"myalias\")\n        with pytest.raises(Exception):\n            _ = (await alias_client2.search(\"*\")).docs[0]\n\n    @pytest.mark.redismod\n    async def test_tags(self, decoded_r: redis.Redis):\n        await decoded_r.ft().create_index((TextField(\"txt\"), TagField(\"tags\")))\n        tags = \"foo,foo bar,hello;world\"\n        tags2 = \"soba,ramen\"\n\n        await decoded_r.hset(\"doc1\", mapping={\"txt\": \"fooz barz\", \"tags\": tags})\n        await decoded_r.hset(\"doc2\", mapping={\"txt\": \"noodles\", \"tags\": tags2})\n        await self.waitForIndex(decoded_r, \"idx\")\n\n        q = Query(\"@tags:{foo}\")\n        if is_resp2_connection(decoded_r):\n            res = await decoded_r.ft().search(q)\n            assert 1 == res.total\n\n            q = Query(\"@tags:{foo bar}\")\n            res = await decoded_r.ft().search(q)\n            assert 1 == res.total\n\n            q = Query(\"@tags:{foo\\\\ bar}\")\n            res = await decoded_r.ft().search(q)\n            assert 1 == res.total\n\n            q = Query(\"@tags:{hello\\\\;world}\")\n            res = await decoded_r.ft().search(q)\n            assert 1 == res.total\n\n            q2 = await decoded_r.ft().tagvals(\"tags\")\n            assert (tags.split(\",\") + tags2.split(\",\")).sort() == q2.sort()\n        else:\n            res = await decoded_r.ft().search(q)\n            assert 1 == res[\"total_results\"]\n\n            q = Query(\"@tags:{foo bar}\")\n            res = await decoded_r.ft().search(q)\n            assert 1 == res[\"total_results\"]\n\n            q = Query(\"@tags:{foo\\\\ bar}\")\n            res = await decoded_r.ft().search(q)\n            assert 1 == res[\"total_results\"]\n\n            q = Query(\"@tags:{hello\\\\;world}\")\n            res = await decoded_r.ft().search(q)\n            assert 1 == res[\"total_results\"]\n\n            q2 = await decoded_r.ft().tagvals(\"tags\")\n            assert set(tags.split(\",\") + tags2.split(\",\")) == set(q2)\n\n    @pytest.mark.redismod\n    async def test_textfield_sortable_nostem(self, decoded_r: redis.Redis):\n        # Creating the index definition with sortable and no_stem\n        await decoded_r.ft().create_index(\n            (TextField(\"txt\", sortable=True, no_stem=True),)\n        )\n\n        # Now get the index info to confirm its contents\n        response = await decoded_r.ft().info()\n        if is_resp2_connection(decoded_r):\n            assert \"SORTABLE\" in response[\"attributes\"][0]\n            assert \"NOSTEM\" in response[\"attributes\"][0]\n        else:\n            assert \"SORTABLE\" in response[\"attributes\"][0][\"flags\"]\n            assert \"NOSTEM\" in response[\"attributes\"][0][\"flags\"]\n\n    @pytest.mark.redismod\n    async def test_alter_schema_add(self, decoded_r: redis.Redis):\n        # Creating the index definition and schema\n        await decoded_r.ft().create_index(TextField(\"title\"))\n\n        # Using alter to add a field\n        await decoded_r.ft().alter_schema_add(TextField(\"body\"))\n\n        # Indexing a document\n        await decoded_r.hset(\n            \"doc1\",\n            mapping={\"title\": \"MyTitle\", \"body\": \"Some content only in the body\"},\n        )\n\n        # Searching with parameter only in the body (the added field)\n        q = Query(\"only in the body\")\n\n        # Ensure we find the result searching on the added body field\n        res = await decoded_r.ft().search(q)\n        if is_resp2_connection(decoded_r):\n            assert 1 == res.total\n        else:\n            assert 1 == res[\"total_results\"]\n\n    @pytest.mark.redismod\n    async def test_spell_check(self, decoded_r: redis.Redis):\n        await decoded_r.ft().create_index((TextField(\"f1\"), TextField(\"f2\")))\n\n        await decoded_r.hset(\n            \"doc1\", mapping={\"f1\": \"some valid content\", \"f2\": \"this is sample text\"}\n        )\n        await decoded_r.hset(\n            \"doc2\", mapping={\"f1\": \"very important\", \"f2\": \"lorem ipsum\"}\n        )\n        await self.waitForIndex(decoded_r, \"idx\")\n\n        if is_resp2_connection(decoded_r):\n            # test spellcheck\n            res = await decoded_r.ft().spellcheck(\"impornant\")\n            assert \"important\" == res[\"impornant\"][0][\"suggestion\"]\n\n            res = await decoded_r.ft().spellcheck(\"contnt\")\n            assert \"content\" == res[\"contnt\"][0][\"suggestion\"]\n\n            # test spellcheck with Levenshtein distance\n            res = await decoded_r.ft().spellcheck(\"vlis\")\n            assert res == {}\n            res = await decoded_r.ft().spellcheck(\"vlis\", distance=2)\n            assert \"valid\" == res[\"vlis\"][0][\"suggestion\"]\n\n            # test spellcheck include\n            await decoded_r.ft().dict_add(\"dict\", \"lore\", \"lorem\", \"lorm\")\n            res = await decoded_r.ft().spellcheck(\"lorm\", include=\"dict\")\n            assert len(res[\"lorm\"]) == 3\n            assert (\n                res[\"lorm\"][0][\"suggestion\"],\n                res[\"lorm\"][1][\"suggestion\"],\n                res[\"lorm\"][2][\"suggestion\"],\n            ) == (\"lorem\", \"lore\", \"lorm\")\n            assert (res[\"lorm\"][0][\"score\"], res[\"lorm\"][1][\"score\"]) == (\"0.5\", \"0\")\n\n            # test spellcheck exclude\n            res = await decoded_r.ft().spellcheck(\"lorm\", exclude=\"dict\")\n            assert res == {}\n        else:\n            # test spellcheck\n            res = await decoded_r.ft().spellcheck(\"impornant\")\n            assert \"important\" in res[\"results\"][\"impornant\"][0].keys()\n\n            res = await decoded_r.ft().spellcheck(\"contnt\")\n            assert \"content\" in res[\"results\"][\"contnt\"][0].keys()\n\n            # test spellcheck with Levenshtein distance\n            res = await decoded_r.ft().spellcheck(\"vlis\")\n            assert res == {\"results\": {\"vlis\": []}}\n            res = await decoded_r.ft().spellcheck(\"vlis\", distance=2)\n            assert \"valid\" in res[\"results\"][\"vlis\"][0].keys()\n\n            # test spellcheck include\n            await decoded_r.ft().dict_add(\"dict\", \"lore\", \"lorem\", \"lorm\")\n            res = await decoded_r.ft().spellcheck(\"lorm\", include=\"dict\")\n            assert len(res[\"results\"][\"lorm\"]) == 3\n            assert \"lorem\" in res[\"results\"][\"lorm\"][0].keys()\n            assert \"lore\" in res[\"results\"][\"lorm\"][1].keys()\n            assert \"lorm\" in res[\"results\"][\"lorm\"][2].keys()\n            assert (\n                res[\"results\"][\"lorm\"][0][\"lorem\"],\n                res[\"results\"][\"lorm\"][1][\"lore\"],\n            ) == (0.5, 0)\n\n            # test spellcheck exclude\n            res = await decoded_r.ft().spellcheck(\"lorm\", exclude=\"dict\")\n            assert res == {\"results\": {}}\n\n    @pytest.mark.redismod\n    async def test_dict_operations(self, decoded_r: redis.Redis):\n        await decoded_r.ft().create_index((TextField(\"f1\"), TextField(\"f2\")))\n        # Add three items\n        res = await decoded_r.ft().dict_add(\"custom_dict\", \"item1\", \"item2\", \"item3\")\n        assert 3 == res\n\n        # Remove one item\n        res = await decoded_r.ft().dict_del(\"custom_dict\", \"item2\")\n        assert 1 == res\n\n        # Dump dict and inspect content\n        res = await decoded_r.ft().dict_dump(\"custom_dict\")\n        assert res == [\"item1\", \"item3\"]\n\n        # Remove rest of the items before reload\n        await decoded_r.ft().dict_del(\"custom_dict\", *res)\n\n    @pytest.mark.redismod\n    async def test_phonetic_matcher(self, decoded_r: redis.Redis):\n        await decoded_r.ft().create_index((TextField(\"name\"),))\n        await decoded_r.hset(\"doc1\", mapping={\"name\": \"Jon\"})\n        await decoded_r.hset(\"doc2\", mapping={\"name\": \"John\"})\n\n        res = await decoded_r.ft().search(Query(\"Jon\"))\n        if is_resp2_connection(decoded_r):\n            assert 1 == len(res.docs)\n            assert \"Jon\" == res.docs[0].name\n        else:\n            assert 1 == res[\"total_results\"]\n            assert \"Jon\" == res[\"results\"][0][\"extra_attributes\"][\"name\"]\n\n        # Drop and create index with phonetic matcher\n        await decoded_r.flushdb()\n\n        await decoded_r.ft().create_index(\n            (TextField(\"name\", phonetic_matcher=\"dm:en\"),)\n        )\n        await decoded_r.hset(\"doc1\", mapping={\"name\": \"Jon\"})\n        await decoded_r.hset(\"doc2\", mapping={\"name\": \"John\"})\n\n        res = await decoded_r.ft().search(Query(\"Jon\"))\n        if is_resp2_connection(decoded_r):\n            assert 2 == len(res.docs)\n            assert [\"John\", \"Jon\"] == sorted(d.name for d in res.docs)\n        else:\n            assert 2 == res[\"total_results\"]\n            assert [\"John\", \"Jon\"] == sorted(\n                d[\"extra_attributes\"][\"name\"] for d in res[\"results\"]\n            )\n\n    @pytest.mark.redismod\n    async def test_get(self, decoded_r: redis.Redis):\n        await decoded_r.ft().create_index((TextField(\"f1\"), TextField(\"f2\")))\n\n        assert [None] == await decoded_r.ft().get(\"doc1\")\n        assert [None, None] == await decoded_r.ft().get(\"doc2\", \"doc1\")\n\n        await decoded_r.hset(\n            \"doc1\",\n            mapping={\"f1\": \"some valid content dd1\", \"f2\": \"this is sample text f1\"},\n        )\n        await decoded_r.hset(\n            \"doc2\",\n            mapping={\"f1\": \"some valid content dd2\", \"f2\": \"this is sample text f2\"},\n        )\n\n        assert [\n            [\"f1\", \"some valid content dd2\", \"f2\", \"this is sample text f2\"]\n        ] == await decoded_r.ft().get(\"doc2\")\n        assert [\n            [\"f1\", \"some valid content dd1\", \"f2\", \"this is sample text f1\"],\n            [\"f1\", \"some valid content dd2\", \"f2\", \"this is sample text f2\"],\n        ] == await decoded_r.ft().get(\"doc1\", \"doc2\")\n\n    @pytest.mark.redismod\n    async def test_query_timeout(self, decoded_r: redis.Redis):\n        q1 = Query(\"foo\").timeout(5000)\n        assert q1.get_args() == [\"foo\", \"TIMEOUT\", 5000, \"DIALECT\", 2, \"LIMIT\", 0, 10]\n        q2 = Query(\"foo\").timeout(\"not_a_number\")\n        with pytest.raises(redis.ResponseError):\n            await decoded_r.ft().search(q2)\n\n    @pytest.mark.redismod\n    @skip_if_resp_version(3)\n    async def test_binary_and_text_fields(self, decoded_r: redis.Redis):\n        fake_vec = np.array([0.1, 0.2, 0.3, 0.4], dtype=np.float32)\n\n        index_name = \"mixed_index\"\n        mixed_data = {\"first_name\": \"🐍python\", \"vector_emb\": fake_vec.tobytes()}\n        await decoded_r.hset(f\"{index_name}:1\", mapping=mixed_data)\n\n        schema = [\n            TagField(\"first_name\"),\n            VectorField(\n                \"embeddings_bio\",\n                algorithm=\"HNSW\",\n                attributes={\n                    \"TYPE\": \"FLOAT32\",\n                    \"DIM\": 4,\n                    \"DISTANCE_METRIC\": \"COSINE\",\n                },\n            ),\n        ]\n\n        await decoded_r.ft(index_name).create_index(\n            fields=schema,\n            definition=IndexDefinition(\n                prefix=[f\"{index_name}:\"], index_type=IndexType.HASH\n            ),\n        )\n        await self.waitForIndex(decoded_r, index_name)\n\n        query = (\n            Query(\"*\")\n            .return_field(\"vector_emb\", decode_field=False)\n            .return_field(\"first_name\")\n        )\n        result = await decoded_r.ft(index_name).search(query=query, query_params={})\n        docs = result.docs\n\n        if len(docs) == 0:\n            hash_content = await decoded_r.hget(f\"{index_name}:1\", \"first_name\")\n        assert len(docs) > 0, (\n            f\"Returned search results are empty. Result: {result}; Hash: {hash_content}\"\n        )\n\n        decoded_vec_from_search_results = np.frombuffer(\n            docs[0][\"vector_emb\"], dtype=np.float32\n        )\n\n        assert np.array_equal(decoded_vec_from_search_results, fake_vec), (\n            \"The vectors are not equal\"\n        )\n\n        assert docs[0][\"first_name\"] == mixed_data[\"first_name\"], (\n            \"The text field is not decoded correctly\"\n        )\n\n\nclass TestScorers(AsyncSearchTestsBase):\n    @pytest.mark.redismod\n    @pytest.mark.onlynoncluster\n    # NOTE(imalinovskyi): This test contains hardcoded scores valid only for RediSearch 2.8+\n    @skip_ifmodversion_lt(\"2.8.0\", \"search\")\n    @skip_if_server_version_gte(\"7.9.0\")\n    async def test_scorer(self, decoded_r: redis.Redis):\n        await decoded_r.ft().create_index((TextField(\"description\"),))\n\n        await decoded_r.hset(\n            \"doc1\",\n            mapping={\"description\": \"The quick brown fox jumps over the lazy dog\"},\n        )\n        await decoded_r.hset(\n            \"doc2\",\n            mapping={\n                \"description\": \"Quick alice was beginning to get very tired of sitting by her quick sister on the bank, and of having nothing to do.\"  # noqa\n            },\n        )\n\n        if is_resp2_connection(decoded_r):\n            # default scorer is TFIDF\n            res = await decoded_r.ft().search(Query(\"quick\").with_scores())\n            assert 1.0 == res.docs[0].score\n            res = await decoded_r.ft().search(\n                Query(\"quick\").scorer(\"TFIDF\").with_scores()\n            )\n            assert 1.0 == res.docs[0].score\n            res = await decoded_r.ft().search(\n                Query(\"quick\").scorer(\"TFIDF.DOCNORM\").with_scores()\n            )\n            assert 0.14285714285714285 == res.docs[0].score\n            res = await decoded_r.ft().search(\n                Query(\"quick\").scorer(\"BM25\").with_scores()\n            )\n            assert 0.22471909420069797 == res.docs[0].score\n            res = await decoded_r.ft().search(\n                Query(\"quick\").scorer(\"DISMAX\").with_scores()\n            )\n            assert 2.0 == res.docs[0].score\n            res = await decoded_r.ft().search(\n                Query(\"quick\").scorer(\"DOCSCORE\").with_scores()\n            )\n            assert 1.0 == res.docs[0].score\n            res = await decoded_r.ft().search(\n                Query(\"quick\").scorer(\"HAMMING\").with_scores()\n            )\n            assert 0.0 == res.docs[0].score\n        else:\n            res = await decoded_r.ft().search(Query(\"quick\").with_scores())\n            assert 1.0 == res[\"results\"][0][\"score\"]\n            res = await decoded_r.ft().search(\n                Query(\"quick\").scorer(\"TFIDF\").with_scores()\n            )\n            assert 1.0 == res[\"results\"][0][\"score\"]\n            res = await decoded_r.ft().search(\n                Query(\"quick\").scorer(\"TFIDF.DOCNORM\").with_scores()\n            )\n            assert 0.14285714285714285 == res[\"results\"][0][\"score\"]\n            res = await decoded_r.ft().search(\n                Query(\"quick\").scorer(\"BM25\").with_scores()\n            )\n            assert 0.22471909420069797 == res[\"results\"][0][\"score\"]\n            res = await decoded_r.ft().search(\n                Query(\"quick\").scorer(\"DISMAX\").with_scores()\n            )\n            assert 2.0 == res[\"results\"][0][\"score\"]\n            res = await decoded_r.ft().search(\n                Query(\"quick\").scorer(\"DOCSCORE\").with_scores()\n            )\n            assert 1.0 == res[\"results\"][0][\"score\"]\n            res = await decoded_r.ft().search(\n                Query(\"quick\").scorer(\"HAMMING\").with_scores()\n            )\n            assert 0.0 == res[\"results\"][0][\"score\"]\n\n    @pytest.mark.redismod\n    @pytest.mark.onlynoncluster\n    # NOTE(imalinovskyi): This test contains hardcoded scores valid only for RediSearch 2.8+\n    @skip_ifmodversion_lt(\"2.8.0\", \"search\")\n    @skip_if_server_version_lt(\"7.9.0\")\n    async def test_scorer_with_new_default_scorer(self, decoded_r: redis.Redis):\n        await decoded_r.ft().create_index((TextField(\"description\"),))\n\n        await decoded_r.hset(\n            \"doc1\",\n            mapping={\"description\": \"The quick brown fox jumps over the lazy dog\"},\n        )\n        await decoded_r.hset(\n            \"doc2\",\n            mapping={\n                \"description\": \"Quick alice was beginning to get very tired of sitting by her quick sister on the bank, and of having nothing to do.\"  # noqa\n            },\n        )\n\n        if is_resp2_connection(decoded_r):\n            # default scorer is BM25STD\n            res = await decoded_r.ft().search(Query(\"quick\").with_scores())\n            assert 0.23 == pytest.approx(res.docs[0].score, 0.05)\n            res = await decoded_r.ft().search(\n                Query(\"quick\").scorer(\"TFIDF\").with_scores()\n            )\n            assert 1.0 == res.docs[0].score\n            res = await decoded_r.ft().search(\n                Query(\"quick\").scorer(\"TFIDF.DOCNORM\").with_scores()\n            )\n            assert 0.14285714285714285 == res.docs[0].score\n            res = await decoded_r.ft().search(\n                Query(\"quick\").scorer(\"BM25\").with_scores()\n            )\n            assert 0.22471909420069797 == res.docs[0].score\n            res = await decoded_r.ft().search(\n                Query(\"quick\").scorer(\"DISMAX\").with_scores()\n            )\n            assert 2.0 == res.docs[0].score\n            res = await decoded_r.ft().search(\n                Query(\"quick\").scorer(\"DOCSCORE\").with_scores()\n            )\n            assert 1.0 == res.docs[0].score\n            res = await decoded_r.ft().search(\n                Query(\"quick\").scorer(\"HAMMING\").with_scores()\n            )\n            assert 0.0 == res.docs[0].score\n        else:\n            res = await decoded_r.ft().search(Query(\"quick\").with_scores())\n            assert 0.23 == pytest.approx(res[\"results\"][0][\"score\"], 0.05)\n            res = await decoded_r.ft().search(\n                Query(\"quick\").scorer(\"TFIDF\").with_scores()\n            )\n            assert 1.0 == res[\"results\"][0][\"score\"]\n            res = await decoded_r.ft().search(\n                Query(\"quick\").scorer(\"TFIDF.DOCNORM\").with_scores()\n            )\n            assert 0.14285714285714285 == res[\"results\"][0][\"score\"]\n            res = await decoded_r.ft().search(\n                Query(\"quick\").scorer(\"BM25\").with_scores()\n            )\n            assert 0.22471909420069797 == res[\"results\"][0][\"score\"]\n            res = await decoded_r.ft().search(\n                Query(\"quick\").scorer(\"DISMAX\").with_scores()\n            )\n            assert 2.0 == res[\"results\"][0][\"score\"]\n            res = await decoded_r.ft().search(\n                Query(\"quick\").scorer(\"DOCSCORE\").with_scores()\n            )\n            assert 1.0 == res[\"results\"][0][\"score\"]\n            res = await decoded_r.ft().search(\n                Query(\"quick\").scorer(\"HAMMING\").with_scores()\n            )\n            assert 0.0 == res[\"results\"][0][\"score\"]\n\n\nclass TestConfig(AsyncSearchTestsBase):\n    @pytest.mark.redismod\n    @pytest.mark.onlynoncluster\n    @skip_ifmodversion_lt(\"2.2.0\", \"search\")\n    @skip_if_server_version_gte(\"7.9.0\")\n    async def test_config(self, decoded_r: redis.Redis):\n        assert await decoded_r.ft().config_set(\"TIMEOUT\", \"100\")\n        with pytest.raises(redis.ResponseError):\n            await decoded_r.ft().config_set(\"TIMEOUT\", \"null\")\n        res = await decoded_r.ft().config_get(\"*\")\n        assert \"100\" == res[\"TIMEOUT\"]\n        res = await decoded_r.ft().config_get(\"TIMEOUT\")\n        assert \"100\" == res[\"TIMEOUT\"]\n\n    @pytest.mark.redismod\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"7.9.0\")\n    async def test_config_with_removed_ftconfig(self, decoded_r: redis.Redis):\n        assert await decoded_r.config_set(\"timeout\", \"100\")\n        with pytest.raises(redis.ResponseError):\n            await decoded_r.config_set(\"timeout\", \"null\")\n        res = await decoded_r.config_get(\"*\")\n        assert \"100\" == res[\"timeout\"]\n        res = await decoded_r.config_get(\"timeout\")\n        assert \"100\" == res[\"timeout\"]\n\n\nclass TestAggregations(AsyncSearchTestsBase):\n    @pytest.mark.redismod\n    @pytest.mark.onlynoncluster\n    async def test_aggregations_groupby(self, decoded_r: redis.Redis):\n        # Creating the index definition and schema\n        await decoded_r.ft().create_index(\n            (\n                NumericField(\"random_num\"),\n                TextField(\"title\"),\n                TextField(\"body\"),\n                TextField(\"parent\"),\n            )\n        )\n\n        # Indexing a document\n        await decoded_r.hset(\n            \"search\",\n            mapping={\n                \"title\": \"RediSearch\",\n                \"body\": \"Redisearch impements a search engine on top of redis\",\n                \"parent\": \"redis\",\n                \"random_num\": 10,\n            },\n        )\n        await decoded_r.hset(\n            \"ai\",\n            mapping={\n                \"title\": \"RedisAI\",\n                \"body\": \"RedisAI executes Deep Learning/Machine Learning models and managing their data.\",  # noqa\n                \"parent\": \"redis\",\n                \"random_num\": 3,\n            },\n        )\n        await decoded_r.hset(\n            \"json\",\n            mapping={\n                \"title\": \"RedisJson\",\n                \"body\": \"RedisJSON implements ECMA-404 The JSON Data Interchange Standard as a native data type.\",  # noqa\n                \"parent\": \"redis\",\n                \"random_num\": 8,\n            },\n        )\n\n        for dialect in [1, 2]:\n            if is_resp2_connection(decoded_r):\n                req = (\n                    aggregations.AggregateRequest(\"redis\")\n                    .group_by(\"@parent\", reducers.count())\n                    .dialect(dialect)\n                )\n\n                res = (await decoded_r.ft().aggregate(req)).rows[0]\n                assert res[1] == \"redis\"\n                assert res[3] == \"3\"\n\n                req = (\n                    aggregations.AggregateRequest(\"redis\")\n                    .group_by(\"@parent\", reducers.count_distinct(\"@title\"))\n                    .dialect(dialect)\n                )\n\n                res = (await decoded_r.ft().aggregate(req)).rows[0]\n                assert res[1] == \"redis\"\n                assert res[3] == \"3\"\n\n                req = (\n                    aggregations.AggregateRequest(\"redis\")\n                    .group_by(\"@parent\", reducers.count_distinctish(\"@title\"))\n                    .dialect(dialect)\n                )\n\n                res = (await decoded_r.ft().aggregate(req)).rows[0]\n                assert res[1] == \"redis\"\n                assert res[3] == \"3\"\n\n                req = (\n                    aggregations.AggregateRequest(\"redis\")\n                    .group_by(\"@parent\", reducers.sum(\"@random_num\"))\n                    .dialect(dialect)\n                )\n\n                res = (await decoded_r.ft().aggregate(req)).rows[0]\n                assert res[1] == \"redis\"\n                assert res[3] == \"21\"  # 10+8+3\n\n                req = (\n                    aggregations.AggregateRequest(\"redis\")\n                    .group_by(\"@parent\", reducers.min(\"@random_num\"))\n                    .dialect(dialect)\n                )\n\n                res = (await decoded_r.ft().aggregate(req)).rows[0]\n                assert res[1] == \"redis\"\n                assert res[3] == \"3\"  # min(10,8,3)\n\n                req = (\n                    aggregations.AggregateRequest(\"redis\")\n                    .group_by(\"@parent\", reducers.max(\"@random_num\"))\n                    .dialect(dialect)\n                )\n\n                res = (await decoded_r.ft().aggregate(req)).rows[0]\n                assert res[1] == \"redis\"\n                assert res[3] == \"10\"  # max(10,8,3)\n\n                req = (\n                    aggregations.AggregateRequest(\"redis\")\n                    .group_by(\"@parent\", reducers.avg(\"@random_num\"))\n                    .dialect(dialect)\n                )\n\n                res = (await decoded_r.ft().aggregate(req)).rows[0]\n                assert res[1] == \"redis\"\n                assert res[3] == \"7\"  # (10+3+8)/3\n\n                req = (\n                    aggregations.AggregateRequest(\"redis\")\n                    .group_by(\"@parent\", reducers.stddev(\"random_num\"))\n                    .dialect(dialect)\n                )\n\n                res = (await decoded_r.ft().aggregate(req)).rows[0]\n                assert res[1] == \"redis\"\n                assert res[3] == \"3.60555127546\"\n\n                req = (\n                    aggregations.AggregateRequest(\"redis\")\n                    .group_by(\"@parent\", reducers.quantile(\"@random_num\", 0.5))\n                    .dialect(dialect)\n                )\n\n                res = (await decoded_r.ft().aggregate(req)).rows[0]\n                assert res[1] == \"redis\"\n                assert res[3] == \"8\"  # median of 3,8,10\n\n                req = (\n                    aggregations.AggregateRequest(\"redis\")\n                    .group_by(\"@parent\", reducers.tolist(\"@title\"))\n                    .dialect(dialect)\n                )\n\n                res = (await decoded_r.ft().aggregate(req)).rows[0]\n                assert res[1] == \"redis\"\n                assert set(res[3]) == {\"RediSearch\", \"RedisAI\", \"RedisJson\"}\n\n                req = (\n                    aggregations.AggregateRequest(\"redis\")\n                    .group_by(\"@parent\", reducers.first_value(\"@title\").alias(\"first\"))\n                    .dialect(dialect)\n                )\n\n                res = (await decoded_r.ft().aggregate(req)).rows[0]\n                assert res == [\"parent\", \"redis\", \"first\", \"RediSearch\"]\n\n                req = (\n                    aggregations.AggregateRequest(\"redis\")\n                    .group_by(\n                        \"@parent\", reducers.random_sample(\"@title\", 2).alias(\"random\")\n                    )\n                    .dialect(dialect)\n                )\n\n                res = (await decoded_r.ft().aggregate(req)).rows[0]\n                assert res[1] == \"redis\"\n                assert res[2] == \"random\"\n                assert len(res[3]) == 2\n                assert res[3][0] in [\"RediSearch\", \"RedisAI\", \"RedisJson\"]\n            else:\n                req = (\n                    aggregations.AggregateRequest(\"redis\")\n                    .group_by(\"@parent\", reducers.count())\n                    .dialect(dialect)\n                )\n\n                res = (await decoded_r.ft().aggregate(req))[\"results\"][0]\n                assert res[\"extra_attributes\"][\"parent\"] == \"redis\"\n                assert res[\"extra_attributes\"][\"__generated_aliascount\"] == \"3\"\n\n                req = (\n                    aggregations.AggregateRequest(\"redis\")\n                    .group_by(\"@parent\", reducers.count_distinct(\"@title\"))\n                    .dialect(dialect)\n                )\n\n                res = (await decoded_r.ft().aggregate(req))[\"results\"][0]\n                assert res[\"extra_attributes\"][\"parent\"] == \"redis\"\n                assert (\n                    res[\"extra_attributes\"][\"__generated_aliascount_distincttitle\"]\n                    == \"3\"\n                )\n\n                req = (\n                    aggregations.AggregateRequest(\"redis\")\n                    .group_by(\"@parent\", reducers.count_distinctish(\"@title\"))\n                    .dialect(dialect)\n                )\n\n                res = (await decoded_r.ft().aggregate(req))[\"results\"][0]\n                assert res[\"extra_attributes\"][\"parent\"] == \"redis\"\n                assert (\n                    res[\"extra_attributes\"][\"__generated_aliascount_distinctishtitle\"]\n                    == \"3\"\n                )\n\n                req = (\n                    aggregations.AggregateRequest(\"redis\")\n                    .group_by(\"@parent\", reducers.sum(\"@random_num\"))\n                    .dialect(dialect)\n                )\n\n                res = (await decoded_r.ft().aggregate(req))[\"results\"][0]\n                assert res[\"extra_attributes\"][\"parent\"] == \"redis\"\n                assert res[\"extra_attributes\"][\"__generated_aliassumrandom_num\"] == \"21\"\n\n                req = (\n                    aggregations.AggregateRequest(\"redis\")\n                    .group_by(\"@parent\", reducers.min(\"@random_num\"))\n                    .dialect(dialect)\n                )\n\n                res = (await decoded_r.ft().aggregate(req))[\"results\"][0]\n                assert res[\"extra_attributes\"][\"parent\"] == \"redis\"\n                assert res[\"extra_attributes\"][\"__generated_aliasminrandom_num\"] == \"3\"\n\n                req = (\n                    aggregations.AggregateRequest(\"redis\")\n                    .group_by(\"@parent\", reducers.max(\"@random_num\"))\n                    .dialect(dialect)\n                )\n\n                res = (await decoded_r.ft().aggregate(req))[\"results\"][0]\n                assert res[\"extra_attributes\"][\"parent\"] == \"redis\"\n                assert res[\"extra_attributes\"][\"__generated_aliasmaxrandom_num\"] == \"10\"\n\n                req = (\n                    aggregations.AggregateRequest(\"redis\")\n                    .group_by(\"@parent\", reducers.avg(\"@random_num\"))\n                    .dialect(dialect)\n                )\n\n                res = (await decoded_r.ft().aggregate(req))[\"results\"][0]\n                assert res[\"extra_attributes\"][\"parent\"] == \"redis\"\n                assert res[\"extra_attributes\"][\"__generated_aliasavgrandom_num\"] == \"7\"\n\n                req = (\n                    aggregations.AggregateRequest(\"redis\")\n                    .group_by(\"@parent\", reducers.stddev(\"random_num\"))\n                    .dialect(dialect)\n                )\n\n                res = (await decoded_r.ft().aggregate(req))[\"results\"][0]\n                assert res[\"extra_attributes\"][\"parent\"] == \"redis\"\n                assert (\n                    res[\"extra_attributes\"][\"__generated_aliasstddevrandom_num\"]\n                    == \"3.60555127546\"\n                )\n\n                req = (\n                    aggregations.AggregateRequest(\"redis\")\n                    .group_by(\"@parent\", reducers.quantile(\"@random_num\", 0.5))\n                    .dialect(dialect)\n                )\n\n                res = (await decoded_r.ft().aggregate(req))[\"results\"][0]\n                assert res[\"extra_attributes\"][\"parent\"] == \"redis\"\n                assert (\n                    res[\"extra_attributes\"][\"__generated_aliasquantilerandom_num,0.5\"]\n                    == \"8\"\n                )\n\n                req = (\n                    aggregations.AggregateRequest(\"redis\")\n                    .group_by(\"@parent\", reducers.tolist(\"@title\"))\n                    .dialect(dialect)\n                )\n\n                res = (await decoded_r.ft().aggregate(req))[\"results\"][0]\n                assert res[\"extra_attributes\"][\"parent\"] == \"redis\"\n                assert set(res[\"extra_attributes\"][\"__generated_aliastolisttitle\"]) == {\n                    \"RediSearch\",\n                    \"RedisAI\",\n                    \"RedisJson\",\n                }\n\n                req = (\n                    aggregations.AggregateRequest(\"redis\")\n                    .group_by(\"@parent\", reducers.first_value(\"@title\").alias(\"first\"))\n                    .dialect(dialect)\n                )\n\n                res = (await decoded_r.ft().aggregate(req))[\"results\"][0]\n                assert res[\"extra_attributes\"] == {\n                    \"parent\": \"redis\",\n                    \"first\": \"RediSearch\",\n                }\n\n                req = (\n                    aggregations.AggregateRequest(\"redis\")\n                    .group_by(\n                        \"@parent\", reducers.random_sample(\"@title\", 2).alias(\"random\")\n                    )\n                    .dialect(dialect)\n                )\n\n                res = (await decoded_r.ft().aggregate(req))[\"results\"][0]\n                assert res[\"extra_attributes\"][\"parent\"] == \"redis\"\n                assert \"random\" in res[\"extra_attributes\"].keys()\n                assert len(res[\"extra_attributes\"][\"random\"]) == 2\n                assert res[\"extra_attributes\"][\"random\"][0] in [\n                    \"RediSearch\",\n                    \"RedisAI\",\n                    \"RedisJson\",\n                ]\n\n    @pytest.mark.redismod\n    async def test_aggregations_sort_by_and_limit(self, decoded_r: redis.Redis):\n        await decoded_r.ft().create_index((TextField(\"t1\"), TextField(\"t2\")))\n\n        await decoded_r.ft().client.hset(\"doc1\", mapping={\"t1\": \"a\", \"t2\": \"b\"})\n        await decoded_r.ft().client.hset(\"doc2\", mapping={\"t1\": \"b\", \"t2\": \"a\"})\n\n        if is_resp2_connection(decoded_r):\n            # test sort_by using SortDirection\n            req = aggregations.AggregateRequest(\"*\").sort_by(\n                aggregations.Asc(\"@t2\"), aggregations.Desc(\"@t1\")\n            )\n            res = await decoded_r.ft().aggregate(req)\n            assert res.rows[0] == [\"t2\", \"a\", \"t1\", \"b\"]\n            assert res.rows[1] == [\"t2\", \"b\", \"t1\", \"a\"]\n\n            # test sort_by without SortDirection\n            req = aggregations.AggregateRequest(\"*\").sort_by(\"@t1\")\n            res = await decoded_r.ft().aggregate(req)\n            assert res.rows[0] == [\"t1\", \"a\"]\n            assert res.rows[1] == [\"t1\", \"b\"]\n\n            # test sort_by with max\n            req = aggregations.AggregateRequest(\"*\").sort_by(\"@t1\", max=1)\n            res = await decoded_r.ft().aggregate(req)\n            assert len(res.rows) == 1\n\n            # test limit\n            req = aggregations.AggregateRequest(\"*\").sort_by(\"@t1\").limit(1, 1)\n            res = await decoded_r.ft().aggregate(req)\n            assert len(res.rows) == 1\n            assert res.rows[0] == [\"t1\", \"b\"]\n        else:\n            # test sort_by using SortDirection\n            req = aggregations.AggregateRequest(\"*\").sort_by(\n                aggregations.Asc(\"@t2\"), aggregations.Desc(\"@t1\")\n            )\n            res = (await decoded_r.ft().aggregate(req))[\"results\"]\n            assert res[0][\"extra_attributes\"] == {\"t2\": \"a\", \"t1\": \"b\"}\n            assert res[1][\"extra_attributes\"] == {\"t2\": \"b\", \"t1\": \"a\"}\n\n            # test sort_by without SortDirection\n            req = aggregations.AggregateRequest(\"*\").sort_by(\"@t1\")\n            res = (await decoded_r.ft().aggregate(req))[\"results\"]\n            assert res[0][\"extra_attributes\"] == {\"t1\": \"a\"}\n            assert res[1][\"extra_attributes\"] == {\"t1\": \"b\"}\n\n            # test sort_by with max\n            req = aggregations.AggregateRequest(\"*\").sort_by(\"@t1\", max=1)\n            res = await decoded_r.ft().aggregate(req)\n            assert len(res[\"results\"]) == 1\n\n            # test limit\n            req = aggregations.AggregateRequest(\"*\").sort_by(\"@t1\").limit(1, 1)\n            res = await decoded_r.ft().aggregate(req)\n            assert len(res[\"results\"]) == 1\n            assert res[\"results\"][0][\"extra_attributes\"] == {\"t1\": \"b\"}\n\n    @pytest.mark.redismod\n    @pytest.mark.experimental\n    async def test_withsuffixtrie(self, decoded_r: redis.Redis):\n        # create index\n        assert await decoded_r.ft().create_index((TextField(\"txt\"),))\n        await self.waitForIndex(decoded_r, getattr(decoded_r.ft(), \"index_name\", \"idx\"))\n        if is_resp2_connection(decoded_r):\n            info = await decoded_r.ft().info()\n            assert \"WITHSUFFIXTRIE\" not in info[\"attributes\"][0]\n            assert await decoded_r.ft().dropindex()\n\n            # create withsuffixtrie index (text field)\n            assert await decoded_r.ft().create_index(\n                TextField(\"t\", withsuffixtrie=True)\n            )\n            await self.waitForIndex(\n                decoded_r, getattr(decoded_r.ft(), \"index_name\", \"idx\")\n            )\n            info = await decoded_r.ft().info()\n            assert \"WITHSUFFIXTRIE\" in info[\"attributes\"][0]\n            assert await decoded_r.ft().dropindex()\n\n            # create withsuffixtrie index (tag field)\n            assert await decoded_r.ft().create_index(TagField(\"t\", withsuffixtrie=True))\n            await self.waitForIndex(\n                decoded_r, getattr(decoded_r.ft(), \"index_name\", \"idx\")\n            )\n            info = await decoded_r.ft().info()\n            assert \"WITHSUFFIXTRIE\" in info[\"attributes\"][0]\n        else:\n            info = await decoded_r.ft().info()\n            assert \"WITHSUFFIXTRIE\" not in info[\"attributes\"][0][\"flags\"]\n            assert await decoded_r.ft().dropindex()\n\n            # create withsuffixtrie index (text fields)\n            assert await decoded_r.ft().create_index(\n                TextField(\"t\", withsuffixtrie=True)\n            )\n            await self.waitForIndex(\n                decoded_r, getattr(decoded_r.ft(), \"index_name\", \"idx\")\n            )\n            info = await decoded_r.ft().info()\n            assert \"WITHSUFFIXTRIE\" in info[\"attributes\"][0][\"flags\"]\n            assert await decoded_r.ft().dropindex()\n\n            # create withsuffixtrie index (tag field)\n            assert await decoded_r.ft().create_index(TagField(\"t\", withsuffixtrie=True))\n            await self.waitForIndex(\n                decoded_r, getattr(decoded_r.ft(), \"index_name\", \"idx\")\n            )\n            info = await decoded_r.ft().info()\n            assert \"WITHSUFFIXTRIE\" in info[\"attributes\"][0][\"flags\"]\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.10.05\", \"search\")\n    async def test_aggregations_add_scores(self, decoded_r: redis.Redis):\n        assert await decoded_r.ft().create_index(\n            (\n                TextField(\"name\", sortable=True, weight=5.0),\n                NumericField(\"age\", sortable=True),\n            )\n        )\n\n        assert await decoded_r.hset(\"doc1\", mapping={\"name\": \"bar\", \"age\": \"25\"})\n        assert await decoded_r.hset(\"doc2\", mapping={\"name\": \"foo\", \"age\": \"19\"})\n\n        req = aggregations.AggregateRequest(\"*\").add_scores()\n        res = await decoded_r.ft().aggregate(req)\n\n        if isinstance(res, dict):\n            assert len(res[\"results\"]) == 2\n            assert res[\"results\"][0][\"extra_attributes\"] == {\"__score\": \"0.2\"}\n            assert res[\"results\"][1][\"extra_attributes\"] == {\"__score\": \"0.2\"}\n        else:\n            assert len(res.rows) == 2\n            assert res.rows[0] == [\"__score\", \"0.2\"]\n            assert res.rows[1] == [\"__score\", \"0.2\"]\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.10.05\", \"search\")\n    async def test_aggregations_hybrid_scoring(self, decoded_r: redis.Redis):\n        assert await decoded_r.ft().create_index(\n            (\n                TextField(\"name\", sortable=True, weight=5.0),\n                TextField(\"description\", sortable=True, weight=5.0),\n                VectorField(\n                    \"vector\",\n                    \"HNSW\",\n                    {\"TYPE\": \"FLOAT32\", \"DIM\": 2, \"DISTANCE_METRIC\": \"COSINE\"},\n                ),\n            )\n        )\n\n        assert await decoded_r.hset(\n            \"doc1\",\n            mapping={\n                \"name\": \"cat book\",\n                \"description\": \"an animal book about cats\",\n                \"vector\": np.array([0.1, 0.2]).astype(np.float32).tobytes(),\n            },\n        )\n        assert await decoded_r.hset(\n            \"doc2\",\n            mapping={\n                \"name\": \"dog book\",\n                \"description\": \"an animal book about dogs\",\n                \"vector\": np.array([0.2, 0.1]).astype(np.float32).tobytes(),\n            },\n        )\n\n        query_string = \"(@description:animal)=>[KNN 3 @vector $vec_param AS dist]\"\n        req = (\n            aggregations.AggregateRequest(query_string)\n            .scorer(\"BM25\")\n            .add_scores()\n            .apply(hybrid_score=\"@__score + @dist\")\n            .load(\"*\")\n            .dialect(4)\n        )\n\n        res = await decoded_r.ft().aggregate(\n            req,\n            query_params={\n                \"vec_param\": np.array([0.11, 0.22]).astype(np.float32).tobytes()\n            },\n        )\n\n        if isinstance(res, dict):\n            assert len(res[\"results\"]) == 2\n        else:\n            assert len(res.rows) == 2\n            for row in res.rows:\n                len(row) == 6\n\n\nclass TestPipeline(AsyncSearchTestsBase):\n    @pytest.mark.redismod\n    @skip_if_redis_enterprise()\n    async def test_search_commands_in_pipeline(self, decoded_r: redis.Redis):\n        p = await decoded_r.ft().pipeline()\n        p.create_index((TextField(\"txt\"),))\n        p.hset(\"doc1\", mapping={\"txt\": \"foo bar\"})\n        p.hset(\"doc2\", mapping={\"txt\": \"foo bar\"})\n        q = Query(\"foo bar\").with_payloads()\n        await p.search(q)\n        res = await p.execute()\n        if is_resp2_connection(decoded_r):\n            assert res[:3] == [\"OK\", True, True]\n            assert 2 == res[3][0]\n            assert \"doc1\" == res[3][1]\n            assert \"doc2\" == res[3][4]\n            assert res[3][5] is None\n            assert res[3][3] == res[3][6] == [\"txt\", \"foo bar\"]\n        else:\n            assert res[:3] == [\"OK\", True, True]\n            assert 2 == res[3][\"total_results\"]\n            assert \"doc1\" == res[3][\"results\"][0][\"id\"]\n            assert \"doc2\" == res[3][\"results\"][1][\"id\"]\n            assert res[3][\"results\"][0][\"payload\"] is None\n            assert (\n                res[3][\"results\"][0][\"extra_attributes\"]\n                == res[3][\"results\"][1][\"extra_attributes\"]\n                == {\"txt\": \"foo bar\"}\n            )\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_hybrid_search_query_with_pipeline(self, decoded_r: redis.Redis):\n        p = decoded_r.ft().pipeline()\n        p.create_index(\n            (\n                TextField(\"txt\"),\n                VectorField(\n                    \"embedding\",\n                    \"FLAT\",\n                    {\"TYPE\": \"FLOAT32\", \"DIM\": 4, \"DISTANCE_METRIC\": \"L2\"},\n                ),\n            )\n        )\n\n        p.hset(\n            \"doc1\",\n            mapping={\n                \"txt\": \"foo bar\",\n                \"embedding\": np.array([1, 2, 3, 4], dtype=np.float32).tobytes(),\n            },\n        )\n        p.hset(\n            \"doc2\",\n            mapping={\n                \"txt\": \"foo bar\",\n                \"embedding\": np.array([1, 2, 2, 3], dtype=np.float32).tobytes(),\n            },\n        )\n\n        # set search query\n        search_query = HybridSearchQuery(\"foo\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding\",\n            vector_data=\"$vec\",\n        )\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        await p.hybrid_search(\n            query=hybrid_query,\n            params_substitution={\n                \"vec\": np.array([2, 2, 3, 3], dtype=np.float32).tobytes()\n            },\n        )\n        res = await p.execute()\n\n        # the default results count limit is 10\n        assert res[:3] == [\"OK\", 2, 2]\n        hybrid_search_res = res[3]\n        if is_resp2_connection(decoded_r):\n            # it doesn't get parsed to object in pipeline\n            assert hybrid_search_res[0] == \"total_results\"\n            assert hybrid_search_res[1] == 2\n            assert hybrid_search_res[2] == \"results\"\n            assert len(hybrid_search_res[3]) == 2\n            assert hybrid_search_res[4] == \"warnings\"\n            assert hybrid_search_res[5] == []\n            assert hybrid_search_res[6] == \"execution_time\"\n            assert float(hybrid_search_res[7]) > 0\n        else:\n            assert hybrid_search_res[\"total_results\"] == 2\n            assert len(hybrid_search_res[\"results\"]) == 2\n            assert hybrid_search_res[\"warnings\"] == []\n            assert hybrid_search_res[\"execution_time\"] > 0\n\n\nclass TestSearchWithVamana(AsyncSearchTestsBase):\n    # SVS-VAMANA Async Tests\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.1.224\")\n    async def test_async_svs_vamana_basic_functionality(self, decoded_r: redis.Redis):\n        await decoded_r.ft().create_index(\n            (\n                VectorField(\n                    \"v\",\n                    \"SVS-VAMANA\",\n                    {\"TYPE\": \"FLOAT32\", \"DIM\": 4, \"DISTANCE_METRIC\": \"L2\"},\n                ),\n            )\n        )\n\n        vectors = [\n            [1.0, 2.0, 3.0, 4.0],\n            [2.0, 3.0, 4.0, 5.0],\n            [3.0, 4.0, 5.0, 6.0],\n            [10.0, 11.0, 12.0, 13.0],\n        ]\n\n        for i, vec in enumerate(vectors):\n            await decoded_r.hset(\n                f\"doc{i}\", \"v\", np.array(vec, dtype=np.float32).tobytes()\n            )\n\n        query = \"*=>[KNN 3 @v $vec]\"\n        q = Query(query).return_field(\"__v_score\").sort_by(\"__v_score\", True)\n        res = await decoded_r.ft().search(\n            q, query_params={\"vec\": np.array(vectors[0], dtype=np.float32).tobytes()}\n        )\n\n        if is_resp2_connection(decoded_r):\n            assert res.total == 3\n            assert \"doc0\" == res.docs[0].id\n        else:\n            assert res[\"total_results\"] == 3\n            assert \"doc0\" == res[\"results\"][0][\"id\"]\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.1.224\")\n    async def test_async_svs_vamana_distance_metrics(self, decoded_r: redis.Redis):\n        # Test COSINE distance\n        await decoded_r.ft().create_index(\n            (\n                VectorField(\n                    \"v\",\n                    \"SVS-VAMANA\",\n                    {\"TYPE\": \"FLOAT32\", \"DIM\": 3, \"DISTANCE_METRIC\": \"COSINE\"},\n                ),\n            )\n        )\n\n        vectors = [\n            [1.0, 0.0, 0.0],\n            [0.707, 0.707, 0.0],\n            [0.0, 1.0, 0.0],\n            [-1.0, 0.0, 0.0],\n        ]\n\n        for i, vec in enumerate(vectors):\n            await decoded_r.hset(\n                f\"doc{i}\", \"v\", np.array(vec, dtype=np.float32).tobytes()\n            )\n\n        query = Query(\"*=>[KNN 2 @v $vec as score]\").sort_by(\"score\").no_content()\n        query_params = {\"vec\": np.array(vectors[0], dtype=np.float32).tobytes()}\n\n        res = await decoded_r.ft().search(query, query_params=query_params)\n        if is_resp2_connection(decoded_r):\n            assert res.total == 2\n            assert \"doc0\" == res.docs[0].id\n        else:\n            assert res[\"total_results\"] == 2\n            assert \"doc0\" == res[\"results\"][0][\"id\"]\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.1.224\")\n    async def test_async_svs_vamana_vector_types(self, decoded_r: redis.Redis):\n        # Test FLOAT16\n        await decoded_r.ft(\"idx16\").create_index(\n            (\n                VectorField(\n                    \"v16\",\n                    \"SVS-VAMANA\",\n                    {\"TYPE\": \"FLOAT16\", \"DIM\": 4, \"DISTANCE_METRIC\": \"L2\"},\n                ),\n            )\n        )\n\n        vectors = [[1.5, 2.5, 3.5, 4.5], [2.5, 3.5, 4.5, 5.5], [3.5, 4.5, 5.5, 6.5]]\n\n        for i, vec in enumerate(vectors):\n            await decoded_r.hset(\n                f\"doc16_{i}\", \"v16\", np.array(vec, dtype=np.float16).tobytes()\n            )\n\n        query = Query(\"*=>[KNN 2 @v16 $vec as score]\").no_content()\n        query_params = {\"vec\": np.array(vectors[0], dtype=np.float16).tobytes()}\n\n        res = await decoded_r.ft(\"idx16\").search(query, query_params=query_params)\n        if is_resp2_connection(decoded_r):\n            assert res.total == 2\n            assert \"doc16_0\" == res.docs[0].id\n        else:\n            assert res[\"total_results\"] == 2\n            assert \"doc16_0\" == res[\"results\"][0][\"id\"]\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.1.224\")\n    async def test_async_svs_vamana_compression(self, decoded_r: redis.Redis):\n        await decoded_r.ft().create_index(\n            (\n                VectorField(\n                    \"v\",\n                    \"SVS-VAMANA\",\n                    {\n                        \"TYPE\": \"FLOAT32\",\n                        \"DIM\": 8,\n                        \"DISTANCE_METRIC\": \"L2\",\n                        \"COMPRESSION\": \"LVQ8\",\n                        \"TRAINING_THRESHOLD\": 1024,\n                    },\n                ),\n            )\n        )\n\n        vectors = []\n        for i in range(20):\n            vec = [float(i + j) for j in range(8)]\n            vectors.append(vec)\n            await decoded_r.hset(\n                f\"doc{i}\", \"v\", np.array(vec, dtype=np.float32).tobytes()\n            )\n\n        query = Query(\"*=>[KNN 5 @v $vec as score]\").no_content()\n        query_params = {\"vec\": np.array(vectors[0], dtype=np.float32).tobytes()}\n\n        res = await decoded_r.ft().search(query, query_params=query_params)\n        if is_resp2_connection(decoded_r):\n            assert res.total == 5\n            assert \"doc0\" == res.docs[0].id\n        else:\n            assert res[\"total_results\"] == 5\n            assert \"doc0\" == res[\"results\"][0][\"id\"]\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.1.224\")\n    async def test_async_svs_vamana_build_parameters(self, decoded_r: redis.Redis):\n        await decoded_r.ft().create_index(\n            (\n                VectorField(\n                    \"v\",\n                    \"SVS-VAMANA\",\n                    {\n                        \"TYPE\": \"FLOAT32\",\n                        \"DIM\": 6,\n                        \"DISTANCE_METRIC\": \"COSINE\",\n                        \"CONSTRUCTION_WINDOW_SIZE\": 300,\n                        \"GRAPH_MAX_DEGREE\": 64,\n                        \"SEARCH_WINDOW_SIZE\": 20,\n                        \"EPSILON\": 0.05,\n                    },\n                ),\n            )\n        )\n\n        vectors = []\n        for i in range(15):\n            vec = [float(i + j) for j in range(6)]\n            vectors.append(vec)\n            await decoded_r.hset(\n                f\"doc{i}\", \"v\", np.array(vec, dtype=np.float32).tobytes()\n            )\n\n        query = Query(\"*=>[KNN 3 @v $vec as score]\").no_content()\n        query_params = {\"vec\": np.array(vectors[0], dtype=np.float32).tobytes()}\n\n        res = await decoded_r.ft().search(query, query_params=query_params)\n        if is_resp2_connection(decoded_r):\n            assert res.total == 3\n            assert \"doc0\" == res.docs[0].id\n        else:\n            assert res[\"total_results\"] == 3\n            assert \"doc0\" == res[\"results\"][0][\"id\"]\n\n\nclass TestHybridSearch(AsyncSearchTestsBase):\n    async def _create_hybrid_search_index(self, decoded_r: redis.Redis, dim=4):\n        await decoded_r.ft().create_index(\n            (\n                TextField(\"description\"),\n                NumericField(\"price\"),\n                TagField(\"color\"),\n                TagField(\"item_type\"),\n                NumericField(\"size\"),\n                VectorField(\n                    \"embedding\",\n                    \"FLAT\",\n                    {\n                        \"TYPE\": \"FLOAT32\",\n                        \"DIM\": dim,\n                        \"DISTANCE_METRIC\": \"L2\",\n                    },\n                ),\n                VectorField(\n                    \"embedding-hnsw\",\n                    \"HNSW\",\n                    {\n                        \"TYPE\": \"FLOAT32\",\n                        \"DIM\": dim,\n                        \"DISTANCE_METRIC\": \"L2\",\n                    },\n                ),\n            ),\n            definition=IndexDefinition(prefix=[\"item:\"]),\n        )\n        await AsyncSearchTestsBase.waitForIndex(decoded_r, \"idx\")\n\n    @staticmethod\n    def _generate_random_vector(dim):\n        return [random.random() for _ in range(dim)]\n\n    @staticmethod\n    def _generate_random_str_data(dim):\n        chars = \"abcdefgh12345678\"\n        return \"\".join(random.choice(chars) for _ in range(dim))\n\n    @staticmethod\n    async def _add_data_for_hybrid_search(\n        client: redis.Redis,\n        items_sets=1,\n        randomize_data=False,\n        dim_for_random_data=4,\n        use_random_str_data=False,\n    ):\n        if randomize_data or use_random_str_data:\n            generate_data_func = (\n                TestHybridSearch._generate_random_str_data\n                if use_random_str_data\n                else TestHybridSearch._generate_random_vector\n            )\n\n            dim_for_random_data = (\n                dim_for_random_data * 4 if use_random_str_data else dim_for_random_data\n            )\n\n            items = [\n                (generate_data_func(dim_for_random_data), \"red shoes\"),\n                (generate_data_func(dim_for_random_data), \"green shoes with red laces\"),\n                (generate_data_func(dim_for_random_data), \"red dress\"),\n                (generate_data_func(dim_for_random_data), \"orange dress\"),\n                (generate_data_func(dim_for_random_data), \"black shoes\"),\n            ]\n        else:\n            items = [\n                ([1.0, 2.0, 7.0, 8.0], \"red shoes\"),\n                ([1.0, 4.0, 7.0, 8.0], \"green shoes with red laces\"),\n                ([1.0, 2.0, 6.0, 5.0], \"red dress\"),\n                ([2.0, 3.0, 6.0, 5.0], \"orange dress\"),\n                ([5.0, 6.0, 7.0, 8.0], \"black shoes\"),\n            ]\n        items = items * items_sets\n        pipeline = client.pipeline()\n        for i, vec in enumerate(items):\n            vec, description = vec\n            mapping = {\n                \"description\": description,\n                \"embedding\": np.array(vec, dtype=np.float32).tobytes()\n                if not use_random_str_data\n                else vec,\n                \"embedding-hnsw\": np.array(vec, dtype=np.float32).tobytes()\n                if not use_random_str_data\n                else vec,\n                \"price\": 15 + i % 4,\n                \"color\": description.split(\" \")[0],\n                \"item_type\": description.split(\" \")[1],\n                \"size\": 10 + i % 3,\n            }\n            pipeline.hset(f\"item:{i}\", mapping=mapping)\n        await pipeline.execute()  # Execute all at once\n\n    @staticmethod\n    def _convert_dict_values_to_str(list_of_dicts):\n        res = []\n        for d in list_of_dicts:\n            res_dict = {}\n            for k, v in d.items():\n                if isinstance(v, list):\n                    res_dict[k] = [safe_str(x) for x in v]\n                else:\n                    res_dict[k] = safe_str(v)\n            res.append(res_dict)\n        return res\n\n    @staticmethod\n    def compare_list_of_dicts(actual, expected):\n        assert len(actual) == len(expected), (\n            f\"List of dicts length mismatch: {len(actual)} != {len(expected)}. \"\n            f\"Full dicts: actual:{actual}; expected:{expected}\"\n        )\n        for expected_dict_item in expected:\n            found = False\n            for actual_dict_item in actual:\n                if actual_dict_item == expected_dict_item:\n                    found = True\n                    break\n            if not found:\n                assert False, (\n                    f\"Dict {expected_dict_item} not found in actual list of dicts: {actual}. \"\n                    f\"All expected:{expected}\"\n                )\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_basic_hybrid_search(self, decoded_r):\n        # Create index and add data\n        await self._create_hybrid_search_index(decoded_r)\n        await self._add_data_for_hybrid_search(decoded_r, items_sets=5)\n\n        # set search query\n        search_query = HybridSearchQuery(\"@color:{red} @color:{green}\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding\",\n            vector_data=\"$vec\",\n        )\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        res = await decoded_r.ft().hybrid_search(\n            query=hybrid_query,\n            params_substitution={\n                \"vec\": np.array([-100, -200, -200, -300], dtype=np.float32).tobytes()\n            },\n        )\n\n        # the default results count limit is 10\n        if is_resp2_connection(decoded_r):\n            assert res.total_results == 10\n            assert len(res.results) == 10\n            assert res.warnings == []\n            assert res.execution_time > 0\n            assert all(isinstance(res.results[i][\"__score\"], bytes) for i in range(10))\n            assert all(isinstance(res.results[i][\"__key\"], bytes) for i in range(10))\n        else:\n            assert res[\"total_results\"] == 10\n            assert len(res[\"results\"]) == 10\n            assert res[\"warnings\"] == []\n            assert res[\"execution_time\"] > 0\n            assert all(isinstance(res[\"results\"][i][\"__score\"], str) for i in range(10))\n            assert all(isinstance(res[\"results\"][i][\"__key\"], str) for i in range(10))\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_hybrid_search_query_with_scorer(self, decoded_r):\n        # Create index and add data\n        await self._create_hybrid_search_index(decoded_r)\n        await self._add_data_for_hybrid_search(decoded_r, items_sets=10)\n\n        # set search query\n        search_query = HybridSearchQuery(\"shoes\")\n        search_query.scorer(\"TFIDF\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding\",\n            vector_data=\"$vec\",\n        )\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        combine_method = CombineResultsMethod(\n            CombinationMethods.LINEAR, ALPHA=1, BETA=0\n        )\n\n        postprocessing_config = HybridPostProcessingConfig()\n        postprocessing_config.load(\n            \"@description\", \"@color\", \"@price\", \"@size\", \"@__score\", \"@__item\"\n        )\n        postprocessing_config.limit(0, 2)\n\n        res = await decoded_r.ft().hybrid_search(\n            query=hybrid_query,\n            combine_method=combine_method,\n            post_processing=postprocessing_config,\n            params_substitution={\n                \"vec\": np.array([1, 2, 2, 3], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n\n        expected_results_tfidf = [\n            {\n                \"description\": b\"red shoes\",\n                \"color\": b\"red\",\n                \"price\": b\"15\",\n                \"size\": b\"10\",\n                \"__score\": b\"2\",\n            },\n            {\n                \"description\": b\"green shoes with red laces\",\n                \"color\": b\"green\",\n                \"price\": b\"16\",\n                \"size\": b\"11\",\n                \"__score\": b\"2\",\n            },\n        ]\n\n        if is_resp2_connection(decoded_r):\n            assert res.total_results >= 2\n            assert len(res.results) == 2\n            assert res.results == expected_results_tfidf\n            assert res.warnings == []\n        else:\n            assert res[\"total_results\"] >= 2\n            assert len(res[\"results\"]) == 2\n            assert res[\"results\"] == self._convert_dict_values_to_str(\n                expected_results_tfidf\n            )\n            assert res[\"warnings\"] == []\n\n        search_query.scorer(\"BM25\")\n        res = await decoded_r.ft().hybrid_search(\n            query=hybrid_query,\n            combine_method=combine_method,\n            post_processing=postprocessing_config,\n            params_substitution={\n                \"vec\": np.array([1, 2, 2, 3], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n        expected_results_bm25 = [\n            {\n                \"description\": b\"red shoes\",\n                \"color\": b\"red\",\n                \"price\": b\"15\",\n                \"size\": b\"10\",\n                \"__score\": b\"0.657894719299\",\n            },\n            {\n                \"description\": b\"green shoes with red laces\",\n                \"color\": b\"green\",\n                \"price\": b\"16\",\n                \"size\": b\"11\",\n                \"__score\": b\"0.657894719299\",\n            },\n        ]\n        if is_resp2_connection(decoded_r):\n            assert res.total_results >= 2\n            assert len(res.results) == 2\n            assert res.results == expected_results_bm25\n            assert res.warnings == []\n        else:\n            assert res[\"total_results\"] >= 2\n            assert len(res[\"results\"]) == 2\n            assert res[\"results\"] == self._convert_dict_values_to_str(\n                expected_results_bm25\n            )\n            assert res[\"warnings\"] == []\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_hybrid_search_query_with_vsim_filter(self, decoded_r):\n        # Create index and add data\n        await self._create_hybrid_search_index(decoded_r)\n        await self._add_data_for_hybrid_search(\n            decoded_r, items_sets=5, use_random_str_data=True\n        )\n\n        search_query = HybridSearchQuery(\"@color:{missing}\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding\",\n            vector_data=\"$vec\",\n        )\n        vsim_query.filter(HybridFilter(\"@price:[15 16] @size:[10 11]\"))\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        postprocessing_config = HybridPostProcessingConfig()\n        postprocessing_config.load(\"@price\", \"@size\")\n\n        res = await decoded_r.ft().hybrid_search(\n            query=hybrid_query,\n            post_processing=postprocessing_config,\n            params_substitution={\"vec\": \"abcd1234efgh5678\"},\n            timeout=10,\n        )\n        if is_resp2_connection(decoded_r):\n            assert len(res.results) > 0\n            assert res.warnings == []\n            for item in res.results:\n                assert item[\"price\"] in [b\"15\", b\"16\"]\n                assert item[\"size\"] in [b\"10\", b\"11\"]\n        else:\n            assert len(res[\"results\"]) > 0\n            assert res[\"warnings\"] == []\n            for item in res[\"results\"]:\n                assert item[\"price\"] in [\"15\", \"16\"]\n                assert item[\"size\"] in [\"10\", \"11\"]\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_hybrid_search_query_with_vsim_knn(self, decoded_r):\n        # Create index and add data\n        await self._create_hybrid_search_index(decoded_r)\n        await self._add_data_for_hybrid_search(decoded_r, items_sets=10)\n\n        # set search query\n        # this query won't have results, so we will be able to validate vsim results\n        search_query = HybridSearchQuery(\"@color:{none}\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding\",\n            vector_data=\"$vec\",\n        )\n\n        vsim_query.vsim_method_params(VectorSearchMethods.KNN, K=3)\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        postprocessing_config = HybridPostProcessingConfig()\n\n        res = await decoded_r.ft().hybrid_search(\n            query=hybrid_query,\n            post_processing=postprocessing_config,\n            params_substitution={\n                \"vec\": np.array([1, 2, 2, 3], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n        expected_results = [\n            {\"__key\": b\"item:2\", \"__score\": b\"0.016393442623\"},\n            {\"__key\": b\"item:7\", \"__score\": b\"0.0161290322581\"},\n            {\"__key\": b\"item:12\", \"__score\": b\"0.015873015873\"},\n        ]\n        if is_resp2_connection(decoded_r):\n            assert res.total_results == 3  # KNN top-k value\n            assert len(res.results) == 3\n            assert res.results == expected_results\n            assert res.warnings == []\n            assert res.execution_time > 0\n        else:\n            assert res[\"total_results\"] == 3  # KNN top-k value\n            assert len(res[\"results\"]) == 3\n            assert res[\"results\"] == self._convert_dict_values_to_str(expected_results)\n            assert res[\"warnings\"] == []\n            assert res[\"execution_time\"] > 0\n\n        vsim_query_with_hnsw = HybridVsimQuery(\n            vector_field_name=\"@embedding-hnsw\",\n            vector_data=\"$vec\",\n        )\n        vsim_query_with_hnsw.vsim_method_params(\n            VectorSearchMethods.KNN, K=3, EF_RUNTIME=1\n        )\n        hybrid_query_with_hnsw = HybridQuery(search_query, vsim_query_with_hnsw)\n\n        res2 = await decoded_r.ft().hybrid_search(\n            query=hybrid_query_with_hnsw,\n            params_substitution={\n                \"vec\": np.array([1, 2, 2, 3], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n\n        expected_results2 = [\n            {\"__key\": b\"item:12\", \"__score\": b\"0.016393442623\"},\n            {\"__key\": b\"item:22\", \"__score\": b\"0.0161290322581\"},\n            {\"__key\": b\"item:27\", \"__score\": b\"0.015873015873\"},\n        ]\n        if is_resp2_connection(decoded_r):\n            assert res2.total_results == 3  # KNN top-k value\n            assert len(res2.results) == 3\n            assert res2.results == expected_results2\n            assert res2.warnings == []\n            assert res2.execution_time > 0\n        else:\n            assert res2[\"total_results\"] == 3  # KNN top-k value\n            assert len(res2[\"results\"]) == 3\n            assert res2[\"results\"] == self._convert_dict_values_to_str(\n                expected_results2\n            )\n            assert res2[\"warnings\"] == []\n            assert res2[\"execution_time\"] > 0\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_hybrid_search_query_with_vsim_range(self, decoded_r):\n        # Create index and add data\n        await self._create_hybrid_search_index(decoded_r)\n        await self._add_data_for_hybrid_search(decoded_r, items_sets=10)\n\n        # set search query\n        # this query won't have results, so we will be able to validate vsim results\n        search_query = HybridSearchQuery(\"@color:{none}\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding\",\n            vector_data=\"$vec\",\n        )\n\n        vsim_query.vsim_method_params(VectorSearchMethods.RANGE, RADIUS=2)\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        postprocessing_config = HybridPostProcessingConfig()\n        postprocessing_config.limit(0, 3)\n\n        res = await decoded_r.ft().hybrid_search(\n            query=hybrid_query,\n            post_processing=postprocessing_config,\n            params_substitution={\n                \"vec\": np.array([1, 2, 7, 6], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n\n        expected_results = [\n            {\"__key\": b\"item:2\", \"__score\": b\"0.016393442623\"},\n            {\"__key\": b\"item:7\", \"__score\": b\"0.0161290322581\"},\n            {\"__key\": b\"item:12\", \"__score\": b\"0.015873015873\"},\n        ]\n        if is_resp2_connection(decoded_r):\n            assert res.total_results >= 3  # at least 3 results\n            assert len(res.results) == 3\n            assert res.results == expected_results\n            assert res.warnings == []\n            assert res.execution_time > 0\n        else:\n            assert res[\"total_results\"] >= 3\n            assert len(res[\"results\"]) == 3\n            assert res[\"results\"] == self._convert_dict_values_to_str(expected_results)\n            assert res[\"warnings\"] == []\n            assert res[\"execution_time\"] > 0\n\n        vsim_query_with_hnsw = HybridVsimQuery(\n            vector_field_name=\"@embedding-hnsw\",\n            vector_data=\"$vec\",\n        )\n\n        vsim_query_with_hnsw.vsim_method_params(\n            VectorSearchMethods.RANGE, RADIUS=2, EPSILON=0.5\n        )\n\n        hybrid_query_with_hnsw = HybridQuery(search_query, vsim_query_with_hnsw)\n\n        res = await decoded_r.ft().hybrid_search(\n            query=hybrid_query_with_hnsw,\n            post_processing=postprocessing_config,\n            params_substitution={\n                \"vec\": np.array([1, 2, 7, 6], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n\n        expected_results_hnsw = [\n            {\"__key\": b\"item:27\", \"__score\": b\"0.016393442623\"},\n            {\"__key\": b\"item:12\", \"__score\": b\"0.0161290322581\"},\n            {\"__key\": b\"item:22\", \"__score\": b\"0.015873015873\"},\n        ]\n\n        if is_resp2_connection(decoded_r):\n            assert res.total_results >= 3\n            assert len(res.results) == 3\n            assert res.results == expected_results_hnsw\n            assert res.warnings == []\n            assert res.execution_time > 0\n        else:\n            assert res[\"total_results\"] >= 3\n            assert len(res[\"results\"]) == 3\n            assert res[\"results\"] == self._convert_dict_values_to_str(\n                expected_results_hnsw\n            )\n            assert res[\"warnings\"] == []\n            assert res[\"execution_time\"] > 0\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_hybrid_search_query_with_combine_all_score_aliases(self, decoded_r):\n        # Create index and add data\n        await self._create_hybrid_search_index(decoded_r)\n        await self._add_data_for_hybrid_search(\n            decoded_r, items_sets=1, use_random_str_data=True\n        )\n\n        search_query = HybridSearchQuery(\"shoes\")\n        search_query.yield_score_as(\"search_score\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding-hnsw\",\n            vector_data=\"$vec\",\n            vsim_search_method=VectorSearchMethods.KNN,\n            vsim_search_method_params={\"K\": 3, \"EF_RUNTIME\": 1},\n            yield_score_as=\"vsim_score\",\n        )\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        combine_method = CombineResultsMethod(\n            CombinationMethods.LINEAR,\n            ALPHA=0.5,\n            BETA=0.5,\n            YIELD_SCORE_AS=\"combined_score\",\n        )\n\n        res = await decoded_r.ft().hybrid_search(\n            query=hybrid_query,\n            combine_method=combine_method,\n            params_substitution={\"vec\": \"abcd1234efgh5678\"},\n            timeout=10,\n        )\n\n        if is_resp2_connection(decoded_r):\n            assert len(res.results) > 0\n            assert res.warnings == []\n            for item in res.results:\n                assert item[\"combined_score\"] is not None\n                assert \"__score\" not in item\n                if item[\"__key\"] in [b\"item:0\", b\"item:1\", b\"item:4\"]:\n                    assert item[\"search_score\"] is not None\n                else:\n                    assert \"search_score\" not in item\n                if item[\"__key\"] in [b\"item:0\", b\"item:1\", b\"item:2\"]:\n                    assert item[\"vsim_score\"] is not None\n                else:\n                    assert \"vsim_score\" not in item\n\n        else:\n            assert len(res[\"results\"]) > 0\n            assert res[\"warnings\"] == []\n            for item in res[\"results\"]:\n                assert item[\"combined_score\"] is not None\n                assert \"__score\" not in item\n                if item[\"__key\"] in [\"item:0\", \"item:1\", \"item:4\"]:\n                    assert item[\"search_score\"] is not None\n                else:\n                    assert \"search_score\" not in item\n                if item[\"__key\"] in [\"item:0\", \"item:1\", \"item:2\"]:\n                    assert item[\"vsim_score\"] is not None\n                else:\n                    assert \"vsim_score\" not in item\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_hybrid_search_query_with_combine(self, decoded_r):\n        # Create index and add data\n        await self._create_hybrid_search_index(decoded_r)\n        await self._add_data_for_hybrid_search(decoded_r, items_sets=10)\n\n        # set search query\n        search_query = HybridSearchQuery(\"@color:{red}\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding\",\n            vector_data=\"$vec\",\n        )\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        combine_method_linear = CombineResultsMethod(\n            CombinationMethods.LINEAR, ALPHA=0.5, BETA=0.5\n        )\n\n        postprocessing_config = HybridPostProcessingConfig()\n        postprocessing_config.limit(0, 3)\n\n        res = await decoded_r.ft().hybrid_search(\n            query=hybrid_query,\n            combine_method=combine_method_linear,\n            post_processing=postprocessing_config,\n            params_substitution={\n                \"vec\": np.array([1, 2, 7, 6], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n\n        expected_results = [\n            {\"__key\": b\"item:2\", \"__score\": b\"0.166666666667\"},\n            {\"__key\": b\"item:7\", \"__score\": b\"0.166666666667\"},\n            {\"__key\": b\"item:12\", \"__score\": b\"0.166666666667\"},\n        ]\n        if is_resp2_connection(decoded_r):\n            assert res.total_results >= 3\n            assert len(res.results) == 3\n            assert res.results == expected_results\n            assert res.warnings == []\n            assert res.execution_time > 0\n        else:\n            assert res[\"total_results\"] >= 3\n            assert len(res[\"results\"]) == 3\n            assert res[\"results\"] == self._convert_dict_values_to_str(expected_results)\n            assert res[\"warnings\"] == []\n            assert res[\"execution_time\"] > 0\n\n        # combine with RRF and WINDOW + CONSTANT\n        combine_method_rrf = CombineResultsMethod(\n            CombinationMethods.RRF, WINDOW=3, CONSTANT=0.5\n        )\n        res = await decoded_r.ft().hybrid_search(\n            query=hybrid_query,\n            combine_method=combine_method_rrf,\n            post_processing=postprocessing_config,\n            params_substitution={\n                \"vec\": np.array([1, 2, 7, 6], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n\n        expected_results = [\n            {\"__key\": b\"item:2\", \"__score\": b\"1.06666666667\"},\n            {\"__key\": b\"item:0\", \"__score\": b\"0.666666666667\"},\n            {\"__key\": b\"item:7\", \"__score\": b\"0.4\"},\n        ]\n        if is_resp2_connection(decoded_r):\n            assert res.total_results >= 3\n            assert len(res.results) == 3\n            assert res.results == expected_results\n            assert res.warnings == []\n            assert res.execution_time > 0\n        else:\n            assert res[\"total_results\"] >= 3\n            assert len(res[\"results\"]) == 3\n            assert res[\"results\"] == self._convert_dict_values_to_str(expected_results)\n            assert res[\"warnings\"] == []\n            assert res[\"execution_time\"] > 0\n\n        # combine with RRF, not all possible params provided\n        combine_method_rrf_2 = CombineResultsMethod(CombinationMethods.RRF, WINDOW=3)\n        res = await decoded_r.ft().hybrid_search(\n            query=hybrid_query,\n            combine_method=combine_method_rrf_2,\n            post_processing=postprocessing_config,\n            params_substitution={\n                \"vec\": np.array([1, 2, 7, 6], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n\n        expected_results = [\n            {\"__key\": b\"item:2\", \"__score\": b\"0.032522474881\"},\n            {\"__key\": b\"item:0\", \"__score\": b\"0.016393442623\"},\n            {\"__key\": b\"item:7\", \"__score\": b\"0.0161290322581\"},\n        ]\n        if is_resp2_connection(decoded_r):\n            assert res.total_results >= 3\n            assert len(res.results) == 3\n            assert res.results == expected_results\n            assert res.warnings == []\n            assert res.execution_time > 0\n        else:\n            assert res[\"total_results\"] >= 3\n            assert len(res[\"results\"]) == 3\n            assert res[\"results\"] == self._convert_dict_values_to_str(expected_results)\n            assert res[\"warnings\"] == []\n            assert res[\"execution_time\"] > 0\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_hybrid_search_query_with_load(self, decoded_r):\n        # Create index and add data\n        await self._create_hybrid_search_index(decoded_r)\n        await self._add_data_for_hybrid_search(decoded_r, items_sets=10)\n\n        # set search query\n        search_query = HybridSearchQuery(\"@color:{red|green|black}\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding\",\n            vector_data=\"$vec\",\n        )\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        combine_method = CombineResultsMethod(\n            CombinationMethods.LINEAR, ALPHA=0.5, BETA=0.5\n        )\n\n        postprocessing_config = HybridPostProcessingConfig()\n        postprocessing_config.load(\n            \"@description\", \"@color\", \"@price\", \"@size\", \"@__key AS item_key\"\n        )\n        postprocessing_config.limit(0, 1)\n\n        res = await decoded_r.ft().hybrid_search(\n            query=hybrid_query,\n            combine_method=combine_method,\n            post_processing=postprocessing_config,\n            params_substitution={\n                \"vec\": np.array([1, 2, 7, 6], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n\n        expected_results = [\n            {\n                \"description\": b\"red dress\",\n                \"color\": b\"red\",\n                \"price\": b\"17\",\n                \"size\": b\"12\",\n                \"item_key\": b\"item:2\",\n            }\n        ]\n        if is_resp2_connection(decoded_r):\n            assert res.total_results >= 1\n            assert len(res.results) == 1\n            self.compare_list_of_dicts(res.results, expected_results)\n            assert res.warnings == []\n            assert res.execution_time > 0\n        else:\n            assert res[\"total_results\"] >= 1\n            assert len(res[\"results\"]) == 1\n            self.compare_list_of_dicts(\n                res[\"results\"], self._convert_dict_values_to_str(expected_results)\n            )\n            assert res[\"warnings\"] == []\n            assert res[\"execution_time\"] > 0\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_hybrid_search_query_with_load_and_apply(self, decoded_r):\n        # Create index and add data\n        await self._create_hybrid_search_index(decoded_r)\n        await self._add_data_for_hybrid_search(decoded_r, items_sets=10)\n\n        # set search query\n        search_query = HybridSearchQuery(\"@color:{red}\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding\",\n            vector_data=\"$vec\",\n        )\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        postprocessing_config = HybridPostProcessingConfig()\n        postprocessing_config.load(\"@color\", \"@price\", \"@size\")\n        postprocessing_config.apply(\n            price_discount=\"@price - (@price * 0.1)\",\n            tax_discount=\"@price_discount * 0.2\",\n        )\n        postprocessing_config.limit(0, 3)\n\n        res = await decoded_r.ft().hybrid_search(\n            query=hybrid_query,\n            post_processing=postprocessing_config,\n            params_substitution={\n                \"vec\": np.array([1, 2, 7, 6], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n\n        expected_results = [\n            {\n                \"color\": b\"red\",\n                \"price\": b\"15\",\n                \"size\": b\"10\",\n                \"price_discount\": b\"13.5\",\n                \"tax_discount\": b\"2.7\",\n            },\n            {\n                \"color\": b\"red\",\n                \"price\": b\"17\",\n                \"size\": b\"12\",\n                \"price_discount\": b\"15.3\",\n                \"tax_discount\": b\"3.06\",\n            },\n            {\n                \"color\": b\"red\",\n                \"price\": b\"18\",\n                \"size\": b\"11\",\n                \"price_discount\": b\"16.2\",\n                \"tax_discount\": b\"3.24\",\n            },\n        ]\n        if is_resp2_connection(decoded_r):\n            assert len(res.results) == 3\n            self.compare_list_of_dicts(res.results, expected_results)\n            assert res.warnings == []\n            assert res.execution_time > 0\n        else:\n            assert len(res[\"results\"]) == 3\n            self.compare_list_of_dicts(\n                res[\"results\"], self._convert_dict_values_to_str(expected_results)\n            )\n            assert res[\"warnings\"] == []\n            assert res[\"execution_time\"] > 0\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_hybrid_search_query_with_load_and_filter(self, decoded_r):\n        # Create index and add data\n        await self._create_hybrid_search_index(decoded_r)\n        await self._add_data_for_hybrid_search(decoded_r, items_sets=10)\n\n        # set search query\n        search_query = HybridSearchQuery(\"@color:{red|green|black}\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding\",\n            vector_data=\"$vec\",\n        )\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        postprocessing_config = HybridPostProcessingConfig()\n        postprocessing_config.load(\"@description\", \"@color\", \"@price\", \"@size\")\n        # for the postprocessing filter we need to filter on the loaded fields\n        # expecting all of them to be interpreted as strings - the initial filed types\n        # are not preserved\n        postprocessing_config.filter(HybridFilter('@price==\"15\"'))\n        postprocessing_config.limit(0, 3)\n\n        res = await decoded_r.ft().hybrid_search(\n            query=hybrid_query,\n            post_processing=postprocessing_config,\n            params_substitution={\n                \"vec\": np.array([1, 2, 7, 6], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n\n        if is_resp2_connection(decoded_r):\n            assert len(res.results) == 3\n            for item in res.results:\n                assert item[\"price\"] == b\"15\"\n            assert res.warnings == []\n            assert res.execution_time > 0\n        else:\n            assert len(res[\"results\"]) == 3\n            for item in res[\"results\"]:\n                assert item[\"price\"] == \"15\"\n            assert res[\"warnings\"] == []\n            assert res[\"execution_time\"] > 0\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_hybrid_search_query_with_load_apply_and_params(self, decoded_r):\n        # Create index and add data\n        await self._create_hybrid_search_index(decoded_r)\n        await self._add_data_for_hybrid_search(\n            decoded_r, items_sets=5, use_random_str_data=True\n        )\n\n        # set search query\n        search_query = HybridSearchQuery(\"@color:{$color_criteria}\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding\",\n            vector_data=\"$vector\",\n        )\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        postprocessing_config = HybridPostProcessingConfig()\n        postprocessing_config.load(\"@description\", \"@color\", \"@price\")\n        postprocessing_config.apply(price_discount=\"@price - (@price * 0.1)\")\n        postprocessing_config.limit(0, 3)\n\n        params_substitution = {\n            \"vector\": \"abcd1234abcd5678\",\n            \"color_criteria\": \"red\",\n        }\n\n        res = await decoded_r.ft().hybrid_search(\n            query=hybrid_query,\n            post_processing=postprocessing_config,\n            params_substitution=params_substitution,\n            timeout=10,\n        )\n\n        expected_results = [\n            {\n                \"description\": b\"red shoes\",\n                \"color\": b\"red\",\n                \"price\": b\"15\",\n                \"price_discount\": b\"13.5\",\n            },\n            {\n                \"description\": b\"red dress\",\n                \"color\": b\"red\",\n                \"price\": b\"17\",\n                \"price_discount\": b\"15.3\",\n            },\n            {\n                \"description\": b\"red shoes\",\n                \"color\": b\"red\",\n                \"price\": b\"16\",\n                \"price_discount\": b\"14.4\",\n            },\n        ]\n        if is_resp2_connection(decoded_r):\n            assert len(res.results) == 3\n            assert res.results == expected_results\n            assert res.warnings == []\n            assert res.execution_time > 0\n        else:\n            assert len(res[\"results\"]) == 3\n            assert res[\"results\"] == self._convert_dict_values_to_str(expected_results)\n            assert res[\"warnings\"] == []\n            assert res[\"execution_time\"] > 0\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_hybrid_search_query_with_limit(self, decoded_r):\n        # Create index and add data\n        await self._create_hybrid_search_index(decoded_r)\n        await self._add_data_for_hybrid_search(decoded_r, items_sets=10)\n\n        # set search query\n        search_query = HybridSearchQuery(\"@color:{red}\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding\",\n            vector_data=\"$vec\",\n        )\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        postprocessing_config = HybridPostProcessingConfig()\n        postprocessing_config.limit(0, 3)\n\n        res = await decoded_r.ft().hybrid_search(\n            query=hybrid_query,\n            post_processing=postprocessing_config,\n            params_substitution={\n                \"vec\": np.array([1, 2, 7, 6], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n\n        if is_resp2_connection(decoded_r):\n            assert len(res.results) == 3\n            assert res.warnings == []\n        else:\n            assert len(res[\"results\"]) == 3\n            assert res[\"warnings\"] == []\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_hybrid_search_query_with_load_apply_and_sortby(self, decoded_r):\n        # Create index and add data\n        await self._create_hybrid_search_index(decoded_r)\n        await self._add_data_for_hybrid_search(decoded_r, items_sets=1)\n\n        # set search query\n        search_query = HybridSearchQuery(\"@color:{red|green}\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding\",\n            vector_data=\"$vec\",\n        )\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        postprocessing_config = HybridPostProcessingConfig()\n        postprocessing_config.load(\"@color\", \"@price\")\n        postprocessing_config.apply(price_discount=\"@price - (@price * 0.1)\")\n        postprocessing_config.sort_by(\n            SortbyField(\"@price_discount\", asc=False), SortbyField(\"@color\", asc=True)\n        )\n        postprocessing_config.limit(0, 5)\n\n        res = await decoded_r.ft().hybrid_search(\n            query=hybrid_query,\n            post_processing=postprocessing_config,\n            params_substitution={\n                \"vec\": np.array([1, 2, 7, 6], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n\n        expected_results = [\n            {\"color\": b\"orange\", \"price\": b\"18\", \"price_discount\": b\"16.2\"},\n            {\"color\": b\"red\", \"price\": b\"17\", \"price_discount\": b\"15.3\"},\n            {\"color\": b\"green\", \"price\": b\"16\", \"price_discount\": b\"14.4\"},\n            {\"color\": b\"black\", \"price\": b\"15\", \"price_discount\": b\"13.5\"},\n            {\"color\": b\"red\", \"price\": b\"15\", \"price_discount\": b\"13.5\"},\n        ]\n        if is_resp2_connection(decoded_r):\n            assert res.total_results >= 5\n            assert len(res.results) == 5\n            # the order here should match because of the sort\n            assert res.results == expected_results\n            assert res.warnings == []\n            assert res.execution_time > 0\n        else:\n            assert res[\"total_results\"] >= 5\n            assert len(res[\"results\"]) == 5\n            # the order here should match because of the sort\n            assert res[\"results\"] == self._convert_dict_values_to_str(expected_results)\n            assert res[\"warnings\"] == []\n            assert res[\"execution_time\"] > 0\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_hybrid_search_query_with_timeout(self, decoded_r):\n        dim = 128\n        # Create index and add data\n        await self._create_hybrid_search_index(decoded_r, dim=dim)\n        await self._add_data_for_hybrid_search(\n            decoded_r,\n            items_sets=5000,\n            dim_for_random_data=dim,\n            use_random_str_data=True,\n        )\n\n        # set search query\n        search_query = HybridSearchQuery(\"*\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding-hnsw\",\n            vector_data=\"$vec\",\n        )\n        vsim_query.vsim_method_params(VectorSearchMethods.KNN, K=1000)\n        vsim_query.filter(\n            HybridFilter(\n                \"((@price:[15 16] @size:[10 11]) | (@price:[13 15] @size:[11 12])) @description:(shoes) -@description:(green)\"\n            )\n        )\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        combine_method = CombineResultsMethod(CombinationMethods.RRF, WINDOW=1000)\n\n        timeout = 5000  # 5 second timeout\n        res = await decoded_r.ft().hybrid_search(\n            query=hybrid_query,\n            combine_method=combine_method,\n            params_substitution={\"vec\": \"abcd\" * dim},\n            timeout=timeout,\n        )\n\n        if is_resp2_connection(decoded_r):\n            assert len(res.results) > 0\n            assert res.warnings == []\n            assert res.execution_time > 0 and res.execution_time < timeout\n        else:\n            assert len(res[\"results\"]) > 0\n            assert res[\"warnings\"] == []\n            assert res[\"execution_time\"] > 0 and res[\"execution_time\"] < timeout\n\n        res = await decoded_r.ft().hybrid_search(\n            query=hybrid_query,\n            params_substitution={\"vec\": \"abcd\" * dim},\n            timeout=1,\n        )  # 1 ms timeout\n        if is_resp2_connection(decoded_r):\n            assert (\n                b\"Timeout limit was reached (VSIM)\" in res.warnings\n                or b\"Timeout limit was reached (SEARCH)\" in res.warnings\n            )\n        else:\n            assert (\n                \"Timeout limit was reached (VSIM)\" in res[\"warnings\"]\n                or \"Timeout limit was reached (SEARCH)\" in res[\"warnings\"]\n            )\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_hybrid_search_query_with_load_and_groupby(self, decoded_r):\n        # Create index and add data\n        await self._create_hybrid_search_index(decoded_r)\n        await self._add_data_for_hybrid_search(decoded_r, items_sets=10)\n\n        # set search query\n        search_query = HybridSearchQuery(\"@color:{red|green}\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding\",\n            vector_data=\"$vec\",\n        )\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        postprocessing_config = HybridPostProcessingConfig()\n        postprocessing_config.load(\"@color\", \"@price\", \"@size\", \"@item_type\")\n        postprocessing_config.limit(0, 4)\n\n        postprocessing_config.group_by(\n            [\"@price\"],\n            reducers.count_distinct(\"@color\").alias(\"colors_count\"),\n        )\n\n        postprocessing_config.sort_by(SortbyField(\"@price\", asc=True))\n\n        res = await decoded_r.ft().hybrid_search(\n            query=hybrid_query,\n            post_processing=postprocessing_config,\n            params_substitution={\n                \"vec\": np.array([1, 2, 7, 6], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n\n        expected_results = [\n            {\"price\": b\"15\", \"colors_count\": b\"2\"},\n            {\"price\": b\"16\", \"colors_count\": b\"2\"},\n            {\"price\": b\"17\", \"colors_count\": b\"2\"},\n            {\"price\": b\"18\", \"colors_count\": b\"2\"},\n        ]\n\n        if is_resp2_connection(decoded_r):\n            assert len(res.results) == 4\n            assert res.results == expected_results\n            assert res.warnings == []\n        else:\n            assert len(res[\"results\"]) == 4\n            assert res[\"results\"] == self._convert_dict_values_to_str(expected_results)\n            assert res[\"warnings\"] == []\n\n        postprocessing_config = HybridPostProcessingConfig()\n        postprocessing_config.load(\"@color\", \"@price\", \"@size\", \"@item_type\")\n        postprocessing_config.limit(0, 6)\n        postprocessing_config.sort_by(\n            SortbyField(\"@price\", asc=True),\n            SortbyField(\"@item_type\", asc=True),\n        )\n\n        postprocessing_config.group_by(\n            [\"@price\", \"@item_type\"],\n            reducers.count_distinct(\"@color\").alias(\"unique_colors_count\"),\n        )\n\n        res = await decoded_r.ft().hybrid_search(\n            query=hybrid_query,\n            post_processing=postprocessing_config,\n            params_substitution={\n                \"vec\": np.array([1, 2, 7, 6], dtype=np.float32).tobytes()\n            },\n            timeout=1000,\n        )\n\n        expected_results = [\n            {\"price\": b\"15\", \"item_type\": b\"dress\", \"unique_colors_count\": b\"1\"},\n            {\"price\": b\"15\", \"item_type\": b\"shoes\", \"unique_colors_count\": b\"2\"},\n            {\"price\": b\"16\", \"item_type\": b\"dress\", \"unique_colors_count\": b\"1\"},\n            {\"price\": b\"16\", \"item_type\": b\"shoes\", \"unique_colors_count\": b\"2\"},\n            {\"price\": b\"17\", \"item_type\": b\"dress\", \"unique_colors_count\": b\"1\"},\n            {\"price\": b\"17\", \"item_type\": b\"shoes\", \"unique_colors_count\": b\"2\"},\n        ]\n        if is_resp2_connection(decoded_r):\n            assert len(res.results) == 6\n            assert res.results == expected_results\n            assert res.warnings == []\n        else:\n            assert len(res[\"results\"]) == 6\n            assert res[\"results\"] == self._convert_dict_values_to_str(expected_results)\n            assert res[\"warnings\"] == []\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    async def test_hybrid_search_query_with_cursor(self, decoded_r):\n        # Create index and add data\n        await self._create_hybrid_search_index(decoded_r)\n        await self._add_data_for_hybrid_search(decoded_r, items_sets=10)\n\n        # set search query\n        search_query = HybridSearchQuery(\"@color:{red|green}\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding\",\n            vector_data=\"$vec\",\n        )\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        res = await decoded_r.ft().hybrid_search(\n            query=hybrid_query,\n            cursor=HybridCursorQuery(count=5, max_idle=100),\n            params_substitution={\n                \"vec\": np.array([1, 2, 7, 6], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n        if is_resp2_connection(decoded_r):\n            assert isinstance(res, HybridCursorResult)\n            assert res.search_cursor_id > 0\n            assert res.vsim_cursor_id > 0\n            search_cursor = aggregations.Cursor(res.search_cursor_id)\n            vsim_cursor = aggregations.Cursor(res.vsim_cursor_id)\n        else:\n            assert res[\"SEARCH\"] > 0\n            assert res[\"VSIM\"] > 0\n            search_cursor = aggregations.Cursor(res[\"SEARCH\"])\n            vsim_cursor = aggregations.Cursor(res[\"VSIM\"])\n\n        search_res_from_cursor = await decoded_r.ft().aggregate(query=search_cursor)\n        if is_resp2_connection(decoded_r):\n            assert len(search_res_from_cursor.rows) == 5\n        else:\n            assert len(search_res_from_cursor[0][\"results\"]) == 5\n\n        vsim_res_from_cursor = await decoded_r.ft().aggregate(query=vsim_cursor)\n        if is_resp2_connection(decoded_r):\n            assert len(vsim_res_from_cursor.rows) == 5\n        else:\n            assert len(vsim_res_from_cursor[0][\"results\"]) == 5\n"
  },
  {
    "path": "tests/test_asyncio/test_sentinel.py",
    "content": "import socket\nfrom unittest import mock\n\nimport pytest\nimport pytest_asyncio\nfrom redis.asyncio.client import StrictRedis\n\nimport redis.asyncio.sentinel\nfrom redis import exceptions\nfrom redis.asyncio.sentinel import (\n    MasterNotFoundError,\n    Sentinel,\n    SentinelConnectionPool,\n    SlaveNotFoundError,\n)\nfrom tests.conftest import is_resp2_connection\n\n\n@pytest_asyncio.fixture(scope=\"module\", loop_scope=\"module\")\ndef master_ip(master_host):\n    yield socket.gethostbyname(master_host[0])\n\n\nclass SentinelTestClient:\n    def __init__(self, cluster, id):\n        self.cluster = cluster\n        self.id = id\n\n    async def sentinel_masters(self):\n        self.cluster.connection_error_if_down(self)\n        self.cluster.timeout_if_down(self)\n        return {self.cluster.service_name: self.cluster.master}\n\n    async def sentinel_slaves(self, master_name):\n        self.cluster.connection_error_if_down(self)\n        self.cluster.timeout_if_down(self)\n        if master_name != self.cluster.service_name:\n            return []\n        return self.cluster.slaves\n\n    async def execute_command(self, *args, **kwargs):\n        # wrapper purely to validate the calls don't explode\n        from redis.asyncio.client import bool_ok\n\n        return bool_ok\n\n\nclass SentinelTestCluster:\n    def __init__(self, service_name=\"mymaster\", ip=\"127.0.0.1\", port=6379):\n        self.clients = {}\n        self.master = {\n            \"ip\": ip,\n            \"port\": port,\n            \"is_master\": True,\n            \"is_sdown\": False,\n            \"is_odown\": False,\n            \"num-other-sentinels\": 0,\n        }\n        self.service_name = service_name\n        self.slaves = []\n        self.nodes_down = set()\n        self.nodes_timeout = set()\n\n    def connection_error_if_down(self, node):\n        if node.id in self.nodes_down:\n            raise exceptions.ConnectionError\n\n    def timeout_if_down(self, node):\n        if node.id in self.nodes_timeout:\n            raise exceptions.TimeoutError\n\n    def client(self, host, port, **kwargs):\n        return SentinelTestClient(self, (host, port))\n\n\n@pytest_asyncio.fixture()\nasync def cluster(master_ip):\n    cluster = SentinelTestCluster(ip=master_ip)\n    saved_Redis = redis.asyncio.sentinel.Redis\n    redis.asyncio.sentinel.Redis = cluster.client\n    yield cluster\n    redis.asyncio.sentinel.Redis = saved_Redis\n\n\n@pytest_asyncio.fixture()\ndef sentinel(request, cluster):\n    return Sentinel([(\"foo\", 26379), (\"bar\", 26379)])\n\n\n@pytest.fixture()\nasync def deployed_sentinel(request):\n    sentinel_ips = request.config.getoption(\"--sentinels\")\n    sentinel_endpoints = [\n        (ip.strip(), int(port.strip()))\n        for ip, port in (endpoint.split(\":\") for endpoint in sentinel_ips.split(\",\"))\n    ]\n    kwargs = {}\n    decode_responses = True\n\n    sentinel_kwargs = {\"decode_responses\": decode_responses}\n    force_master_ip = \"localhost\"\n\n    protocol = request.config.getoption(\"--protocol\", 2)\n\n    sentinel = Sentinel(\n        sentinel_endpoints,\n        force_master_ip=force_master_ip,\n        sentinel_kwargs=sentinel_kwargs,\n        socket_timeout=0.1,\n        protocol=protocol,\n        decode_responses=decode_responses,\n        **kwargs,\n    )\n    yield sentinel\n    for s in sentinel.sentinels:\n        await s.close()\n\n\n@pytest.mark.onlynoncluster\nasync def test_discover_master(sentinel, master_ip):\n    address = await sentinel.discover_master(\"mymaster\")\n    assert address == (master_ip, 6379)\n\n\n@pytest.mark.onlynoncluster\nasync def test_discover_master_error(sentinel):\n    with pytest.raises(MasterNotFoundError):\n        await sentinel.discover_master(\"xxx\")\n\n\n@pytest.mark.onlynoncluster\nasync def test_discover_master_sentinel_down(cluster, sentinel, master_ip):\n    # Put first sentinel 'foo' down\n    cluster.nodes_down.add((\"foo\", 26379))\n    address = await sentinel.discover_master(\"mymaster\")\n    assert address == (master_ip, 6379)\n    # 'bar' is now first sentinel\n    assert sentinel.sentinels[0].id == (\"bar\", 26379)\n\n\n@pytest.mark.onlynoncluster\nasync def test_discover_master_sentinel_timeout(cluster, sentinel, master_ip):\n    # Put first sentinel 'foo' down\n    cluster.nodes_timeout.add((\"foo\", 26379))\n    address = await sentinel.discover_master(\"mymaster\")\n    assert address == (master_ip, 6379)\n    # 'bar' is now first sentinel\n    assert sentinel.sentinels[0].id == (\"bar\", 26379)\n\n\n@pytest.mark.onlynoncluster\nasync def test_master_min_other_sentinels(cluster, master_ip):\n    sentinel = Sentinel([(\"foo\", 26379)], min_other_sentinels=1)\n    # min_other_sentinels\n    with pytest.raises(MasterNotFoundError):\n        await sentinel.discover_master(\"mymaster\")\n    cluster.master[\"num-other-sentinels\"] = 2\n    address = await sentinel.discover_master(\"mymaster\")\n    assert address == (master_ip, 6379)\n\n\n@pytest.mark.onlynoncluster\nasync def test_master_odown(cluster, sentinel):\n    cluster.master[\"is_odown\"] = True\n    with pytest.raises(MasterNotFoundError):\n        await sentinel.discover_master(\"mymaster\")\n\n\n@pytest.mark.onlynoncluster\nasync def test_master_sdown(cluster, sentinel):\n    cluster.master[\"is_sdown\"] = True\n    with pytest.raises(MasterNotFoundError):\n        await sentinel.discover_master(\"mymaster\")\n\n\n@pytest.mark.onlynoncluster\nasync def test_discover_slaves(cluster, sentinel):\n    assert await sentinel.discover_slaves(\"mymaster\") == []\n\n    cluster.slaves = [\n        {\"ip\": \"slave0\", \"port\": 1234, \"is_odown\": False, \"is_sdown\": False},\n        {\"ip\": \"slave1\", \"port\": 1234, \"is_odown\": False, \"is_sdown\": False},\n    ]\n    assert await sentinel.discover_slaves(\"mymaster\") == [\n        (\"slave0\", 1234),\n        (\"slave1\", 1234),\n    ]\n\n    # slave0 -> ODOWN\n    cluster.slaves[0][\"is_odown\"] = True\n    assert await sentinel.discover_slaves(\"mymaster\") == [(\"slave1\", 1234)]\n\n    # slave1 -> SDOWN\n    cluster.slaves[1][\"is_sdown\"] = True\n    assert await sentinel.discover_slaves(\"mymaster\") == []\n\n    cluster.slaves[0][\"is_odown\"] = False\n    cluster.slaves[1][\"is_sdown\"] = False\n\n    # node0 -> DOWN\n    cluster.nodes_down.add((\"foo\", 26379))\n    assert await sentinel.discover_slaves(\"mymaster\") == [\n        (\"slave0\", 1234),\n        (\"slave1\", 1234),\n    ]\n    cluster.nodes_down.clear()\n\n    # node0 -> TIMEOUT\n    cluster.nodes_timeout.add((\"foo\", 26379))\n    assert await sentinel.discover_slaves(\"mymaster\") == [\n        (\"slave0\", 1234),\n        (\"slave1\", 1234),\n    ]\n\n\n@pytest.mark.onlynoncluster\nasync def test_master_for(cluster, sentinel, master_ip):\n    async with sentinel.master_for(\"mymaster\", db=9) as master:\n        assert await master.ping()\n        assert master.connection_pool.master_address == (master_ip, 6379)\n\n    # Use internal connection check\n    async with sentinel.master_for(\"mymaster\", db=9, check_connection=True) as master:\n        assert await master.ping()\n\n\n@pytest.mark.onlynoncluster\nasync def test_slave_for(cluster, sentinel):\n    cluster.slaves = [\n        {\"ip\": \"127.0.0.1\", \"port\": 6379, \"is_odown\": False, \"is_sdown\": False}\n    ]\n    async with sentinel.slave_for(\"mymaster\", db=9) as slave:\n        assert await slave.ping()\n\n\n@pytest.mark.onlynoncluster\nasync def test_slave_for_slave_not_found_error(cluster, sentinel):\n    cluster.master[\"is_odown\"] = True\n    async with sentinel.slave_for(\"mymaster\", db=9) as slave:\n        with pytest.raises(SlaveNotFoundError):\n            await slave.ping()\n\n\n@pytest.mark.onlynoncluster\nasync def test_slave_round_robin(cluster, sentinel, master_ip):\n    cluster.slaves = [\n        {\"ip\": \"slave0\", \"port\": 6379, \"is_odown\": False, \"is_sdown\": False},\n        {\"ip\": \"slave1\", \"port\": 6379, \"is_odown\": False, \"is_sdown\": False},\n    ]\n    pool = SentinelConnectionPool(\"mymaster\", sentinel)\n    rotator = pool.rotate_slaves()\n    assert await rotator.__anext__() in ((\"slave0\", 6379), (\"slave1\", 6379))\n    assert await rotator.__anext__() in ((\"slave0\", 6379), (\"slave1\", 6379))\n    # Fallback to master\n    assert await rotator.__anext__() == (master_ip, 6379)\n    with pytest.raises(SlaveNotFoundError):\n        await rotator.__anext__()\n\n\n@pytest.mark.onlynoncluster\nasync def test_ckquorum(sentinel):\n    resp = await sentinel.sentinel_ckquorum(\"mymaster\")\n    assert resp is True\n\n\n@pytest.mark.onlynoncluster\nasync def test_flushconfig(sentinel):\n    resp = await sentinel.sentinel_flushconfig()\n    assert resp is True\n\n\n@pytest.mark.onlynoncluster\nasync def test_reset(cluster, sentinel):\n    cluster.master[\"is_odown\"] = True\n    resp = await sentinel.sentinel_reset(\"mymaster\")\n    assert resp is True\n\n\n@pytest.mark.onlynoncluster\n@pytest.mark.parametrize(\"method_name\", [\"master_for\", \"slave_for\"])\nasync def test_auto_close_pool(cluster, sentinel, method_name):\n    \"\"\"\n    Check that the connection pool created by the sentinel client is\n    automatically closed\n    \"\"\"\n\n    method = getattr(sentinel, method_name)\n    client = method(\"mymaster\", db=9)\n    pool = client.connection_pool\n    assert client.auto_close_connection_pool is True\n    calls = 0\n\n    async def mock_disconnect():\n        nonlocal calls\n        calls += 1\n\n    with mock.patch.object(pool, \"disconnect\", mock_disconnect):\n        await client.aclose()\n\n    assert calls == 1\n    await pool.disconnect()\n\n\n@pytest.mark.onlynoncluster\nasync def test_repr_correctly_represents_connection_object(sentinel):\n    pool = SentinelConnectionPool(\"mymaster\", sentinel)\n    connection = await pool.get_connection()\n\n    assert (\n        str(connection)\n        == \"<redis.asyncio.sentinel.SentinelManagedConnection,host=127.0.0.1,port=6379)>\"  # noqa: E501\n    )\n    assert connection.connection_pool == pool\n    await pool.release(connection)\n\n    del pool\n\n    assert (\n        str(connection)\n        == \"<redis.asyncio.sentinel.SentinelManagedConnection,host=127.0.0.1,port=6379)>\"  # noqa: E501\n    )\n\n\n# Tests against real sentinel instances\n@pytest.mark.onlynoncluster\nasync def test_get_sentinels(deployed_sentinel):\n    resps = await deployed_sentinel.sentinel_sentinels(\n        \"redis-py-test\", return_responses=True\n    )\n\n    # validate that the original command response is returned\n    assert isinstance(resps, list)\n\n    # validate that the command has been executed against all sentinels\n    # each response from each sentinel is returned\n    assert len(resps) > 1\n\n    # validate default behavior\n    resps = await deployed_sentinel.sentinel_sentinels(\"redis-py-test\")\n    assert isinstance(resps, bool)\n\n\n@pytest.mark.onlynoncluster\nasync def test_get_master_addr_by_name(deployed_sentinel):\n    resps = await deployed_sentinel.sentinel_get_master_addr_by_name(\n        \"redis-py-test\",\n        return_responses=True,\n    )\n\n    # validate that the original command response is returned\n    assert isinstance(resps, list)\n\n    # validate that the command has been executed just once\n    # when executed once, only one response element is returned\n    assert len(resps) == 1\n\n    assert isinstance(resps[0], tuple)\n\n    # validate default behavior\n    resps = await deployed_sentinel.sentinel_get_master_addr_by_name(\"redis-py-test\")\n    assert isinstance(resps, bool)\n\n\n@pytest.mark.onlynoncluster\nasync def test_redis_master_usage(deployed_sentinel):\n    r = await deployed_sentinel.master_for(\"redis-py-test\", db=0)\n    await r.set(\"foo\", \"bar\")\n    assert (await r.get(\"foo\")) == \"bar\"\n\n\n@pytest.mark.onlynoncluster\nasync def test_sentinel_commands_with_strict_redis_client(request):\n    sentinel_ips = request.config.getoption(\"--sentinels\")\n    sentinel_host, sentinel_port = sentinel_ips.split(\",\")[0].split(\":\")\n    protocol = request.config.getoption(\"--protocol\", 2)\n\n    client = StrictRedis(\n        host=sentinel_host, port=sentinel_port, decode_responses=True, protocol=protocol\n    )\n    # skipping commands that change the state of the sentinel setup\n    assert isinstance(\n        await client.sentinel_get_master_addr_by_name(\"redis-py-test\"), tuple\n    )\n    assert isinstance(await client.sentinel_master(\"redis-py-test\"), dict)\n    if is_resp2_connection(client):\n        assert isinstance(await client.sentinel_masters(), dict)\n    else:\n        masters = await client.sentinel_masters()\n        assert isinstance(masters, list)\n        for master in masters:\n            assert isinstance(master, dict)\n\n    assert isinstance(await client.sentinel_sentinels(\"redis-py-test\"), list)\n    assert isinstance(await client.sentinel_slaves(\"redis-py-test\"), list)\n\n    assert isinstance(await client.sentinel_ckquorum(\"redis-py-test\"), bool)\n\n    await client.close()\n"
  },
  {
    "path": "tests/test_asyncio/test_sentinel_managed_connection.py",
    "content": "import socket\nfrom unittest import mock\n\nimport pytest\nfrom redis.asyncio.retry import Retry\nfrom redis.asyncio.sentinel import SentinelManagedConnection\nfrom redis.backoff import NoBackoff\n\npytestmark = pytest.mark.asyncio\n\n\nasync def test_connect_retry_on_timeout_error(connect_args):\n    \"\"\"Test that the _connect function is retried in case of a timeout\"\"\"\n    connection_pool = mock.AsyncMock()\n    connection_pool.get_master_address = mock.AsyncMock(\n        return_value=(connect_args[\"host\"], connect_args[\"port\"])\n    )\n    conn = SentinelManagedConnection(\n        retry_on_timeout=True,\n        retry=Retry(NoBackoff(), 3),\n        connection_pool=connection_pool,\n    )\n    origin_connect = conn._connect\n    conn._connect = mock.AsyncMock()\n\n    async def mock_connect():\n        # connect only on the last retry\n        if conn._connect.call_count <= 2:\n            raise socket.timeout\n        else:\n            return await origin_connect()\n\n    conn._connect.side_effect = mock_connect\n    await conn.connect()\n    assert conn._connect.call_count == 3\n    assert connection_pool.get_master_address.call_count == 3\n    await conn.disconnect()\n"
  },
  {
    "path": "tests/test_asyncio/test_ssl.py",
    "content": "import ssl\r\nimport unittest.mock\r\nfrom urllib.parse import urlparse\r\nimport pytest\r\nimport pytest_asyncio\r\nimport redis.asyncio as redis\r\nfrom tests.conftest import skip_if_server_version_lt\r\nfrom tests.ssl_utils import get_tls_certificates, CertificateType, CN_USERNAME\r\n\r\n# Skip test or not based on cryptography installation\r\ntry:\r\n    import cryptography  # noqa\r\n\r\n    skip_if_cryptography = pytest.mark.skipif(False, reason=\"\")\r\n    skip_if_nocryptography = pytest.mark.skipif(False, reason=\"\")\r\nexcept ImportError:\r\n    skip_if_cryptography = pytest.mark.skipif(True, reason=\"cryptography not installed\")\r\n    skip_if_nocryptography = pytest.mark.skipif(\r\n        True, reason=\"cryptography not installed\"\r\n    )\r\n\r\n\r\n@pytest.mark.ssl\r\nclass TestSSL:\r\n    \"\"\"Tests for SSL connections in asyncio.\"\"\"\r\n\r\n    @pytest_asyncio.fixture()\r\n    async def _get_client(self, request):\r\n        ssl_url = request.config.option.redis_ssl_url\r\n        p = urlparse(ssl_url)[1].split(\":\")\r\n        client = redis.Redis(host=p[0], port=p[1], ssl=True)\r\n        yield client\r\n        await client.aclose()\r\n\r\n    async def test_ssl_with_invalid_cert(self, _get_client):\r\n        \"\"\"Test SSL connection with invalid certificate.\"\"\"\r\n        pass\r\n\r\n    async def test_cert_reqs_none_with_check_hostname(self, request):\r\n        \"\"\"Test that when ssl_cert_reqs=none is used with ssl_check_hostname=True,\r\n        the connection is created successfully with check_hostname internally set to False\"\"\"\r\n        ssl_url = request.config.option.redis_ssl_url\r\n        parsed_url = urlparse(ssl_url)\r\n        r = redis.Redis(\r\n            host=parsed_url.hostname,\r\n            port=parsed_url.port,\r\n            ssl=True,\r\n            ssl_cert_reqs=\"none\",\r\n            # Check that ssl_check_hostname is ignored, when ssl_cert_reqs=none\r\n            ssl_check_hostname=True,\r\n        )\r\n        try:\r\n            # Connection should be successful\r\n            assert await r.ping()\r\n            # check_hostname should have been automatically set to False\r\n            assert r.connection_pool.connection_class == redis.SSLConnection\r\n            conn = r.connection_pool.make_connection()\r\n            assert conn.check_hostname is False\r\n        finally:\r\n            await r.aclose()\r\n\r\n    async def test_ssl_flags_applied_to_context(self, request):\r\n        \"\"\"\r\n        Test that ssl_include_verify_flags and ssl_exclude_verify_flags\r\n        are properly applied to the SSL context\r\n        \"\"\"\r\n        ssl_url = request.config.option.redis_ssl_url\r\n        parsed_url = urlparse(ssl_url)\r\n\r\n        # Test with specific SSL verify flags\r\n        ssl_include_verify_flags = [\r\n            ssl.VerifyFlags.VERIFY_CRL_CHECK_LEAF,  # Disable strict verification\r\n            ssl.VerifyFlags.VERIFY_CRL_CHECK_CHAIN,  # Enable partial chain\r\n        ]\r\n\r\n        ssl_exclude_verify_flags = [\r\n            ssl.VerifyFlags.VERIFY_X509_STRICT,  # Disable trusted first\r\n        ]\r\n\r\n        r = redis.Redis(\r\n            host=parsed_url.hostname,\r\n            port=parsed_url.port,\r\n            ssl=True,\r\n            ssl_cert_reqs=\"none\",\r\n            ssl_include_verify_flags=ssl_include_verify_flags,\r\n            ssl_exclude_verify_flags=ssl_exclude_verify_flags,\r\n        )\r\n\r\n        try:\r\n            # Get the connection to trigger SSL context creation\r\n            conn = r.connection_pool.make_connection()\r\n            assert isinstance(conn, redis.SSLConnection)\r\n\r\n            # Verify the flags were processed by checking they're stored in connection\r\n            assert conn.include_verify_flags is not None\r\n            assert len(conn.include_verify_flags) == 2\r\n\r\n            assert conn.exclude_verify_flags is not None\r\n            assert len(conn.exclude_verify_flags) == 1\r\n\r\n            # Check each flag individually\r\n            for flag in ssl_include_verify_flags:\r\n                assert flag in conn.include_verify_flags, (\r\n                    f\"Flag {flag} not found in stored ssl_include_verify_flags\"\r\n                )\r\n            for flag in ssl_exclude_verify_flags:\r\n                assert flag in conn.exclude_verify_flags, (\r\n                    f\"Flag {flag} not found in stored ssl_exclude_verify_flags\"\r\n                )\r\n\r\n            # Test the actual SSL context created by the connection's RedisSSLContext\r\n            # We need to mock the ssl.create_default_context to capture the context\r\n            captured_context = None\r\n            original_create_default_context = ssl.create_default_context\r\n\r\n            def capture_context_create_default():\r\n                nonlocal captured_context\r\n                captured_context = original_create_default_context()\r\n                return captured_context\r\n\r\n            with unittest.mock.patch(\r\n                \"ssl.create_default_context\", capture_context_create_default\r\n            ):\r\n                # Trigger SSL context creation by calling get() on the RedisSSLContext\r\n                ssl_context = conn.ssl_context.get()\r\n\r\n                # Validate that we captured a context and it has the correct flags applied\r\n                assert captured_context is not None, \"SSL context was not captured\"\r\n                assert ssl_context is captured_context, (\r\n                    \"Returned context should be the captured one\"\r\n                )\r\n\r\n                # Verify that VERIFY_X509_STRICT was disabled (bit cleared)\r\n                assert not (\r\n                    captured_context.verify_flags & ssl.VerifyFlags.VERIFY_X509_STRICT\r\n                ), \"VERIFY_X509_STRICT should be disabled but is enabled\"\r\n\r\n                # Verify that VERIFY_CRL_CHECK_CHAIN was enabled (bit set)\r\n                assert (\r\n                    captured_context.verify_flags\r\n                    & ssl.VerifyFlags.VERIFY_CRL_CHECK_CHAIN\r\n                ), \"VERIFY_CRL_CHECK_CHAIN should be enabled but is disabled\"\r\n\r\n        finally:\r\n            await r.aclose()\r\n\r\n    async def test_ssl_ca_path_parameter(self, request):\r\n        \"\"\"Test that ssl_ca_path parameter is properly passed to SSLConnection\"\"\"\r\n        ssl_url = request.config.option.redis_ssl_url\r\n        parsed_url = urlparse(ssl_url)\r\n\r\n        # Test with a mock ca_path directory\r\n        test_ca_path = \"/tmp/test_ca_certs\"\r\n\r\n        r = redis.Redis(\r\n            host=parsed_url.hostname,\r\n            port=parsed_url.port,\r\n            ssl=True,\r\n            ssl_cert_reqs=\"none\",\r\n            ssl_ca_path=test_ca_path,\r\n        )\r\n\r\n        try:\r\n            # Get the connection to verify ssl_ca_path is passed through\r\n            conn = r.connection_pool.make_connection()\r\n            assert isinstance(conn, redis.SSLConnection)\r\n\r\n            # Verify the ca_path is stored in the SSL context\r\n            assert conn.ssl_context.ca_path == test_ca_path\r\n        finally:\r\n            await r.aclose()\r\n\r\n    async def test_ssl_password_parameter(self, request):\r\n        \"\"\"Test that ssl_password parameter is properly passed to SSLConnection\"\"\"\r\n        ssl_url = request.config.option.redis_ssl_url\r\n        parsed_url = urlparse(ssl_url)\r\n\r\n        # Test with a mock password for encrypted private key\r\n        test_password = \"test_key_password\"\r\n\r\n        r = redis.Redis(\r\n            host=parsed_url.hostname,\r\n            port=parsed_url.port,\r\n            ssl=True,\r\n            ssl_cert_reqs=\"none\",\r\n            ssl_password=test_password,\r\n        )\r\n\r\n        try:\r\n            # Get the connection to verify ssl_password is passed through\r\n            conn = r.connection_pool.make_connection()\r\n            assert isinstance(conn, redis.SSLConnection)\r\n\r\n            # Verify the password is stored in the SSL context\r\n            assert conn.ssl_context.password == test_password\r\n        finally:\r\n            await r.aclose()\r\n\r\n    @skip_if_server_version_lt(\"8.5.0\")\r\n    async def test_ssl_authenticate_with_client_cert(self, request, r):\r\n        \"\"\"Test that when client certificate is used for authentication,\r\n        the connection is created successfully\"\"\"\r\n\r\n        try:\r\n            # Non SSL client, to setup ACL\r\n            assert await r.acl_setuser(\r\n                CN_USERNAME,\r\n                enabled=True,\r\n                reset=True,\r\n                passwords=[\"+clientpass\"],\r\n                keys=[\"*\"],\r\n                commands=[\"+acl\"],\r\n            )\r\n        finally:\r\n            await r.close()\r\n\r\n        ssl_url = request.config.option.redis_ssl_url\r\n        p = urlparse(ssl_url)[1].split(\":\")\r\n        client_cn_cert, client_cn_key, ca_cert = get_tls_certificates(\r\n            request.session.config.REDIS_INFO[\"tls_cert_subdir\"],\r\n            CertificateType.client_cn,\r\n        )\r\n        r = redis.Redis(\r\n            host=p[0],\r\n            port=p[1],\r\n            ssl=True,\r\n            ssl_certfile=client_cn_cert,\r\n            ssl_keyfile=client_cn_key,\r\n            ssl_cert_reqs=\"required\",\r\n            ssl_ca_certs=ca_cert,\r\n        )\r\n        try:\r\n            assert await r.acl_whoami() == CN_USERNAME\r\n        finally:\r\n            await r.close()\r\n"
  },
  {
    "path": "tests/test_asyncio/test_timeseries.py",
    "content": "import time\nfrom time import sleep\n\nimport pytest\nimport pytest_asyncio\nimport redis.asyncio as redis\nfrom tests.conftest import (\n    assert_resp_response,\n    is_resp2_connection,\n    skip_if_server_version_gte,\n    skip_if_server_version_lt,\n    skip_ifmodversion_lt,\n)\n\n\n@pytest_asyncio.fixture()\nasync def decoded_r(create_redis, stack_url):\n    return await create_redis(decode_responses=True, url=stack_url)\n\n\n@pytest.mark.redismod\nasync def test_create(decoded_r: redis.Redis):\n    assert await decoded_r.ts().create(1)\n    assert await decoded_r.ts().create(2, retention_msecs=5)\n    assert await decoded_r.ts().create(3, labels={\"Redis\": \"Labs\"})\n    assert await decoded_r.ts().create(4, retention_msecs=20, labels={\"Time\": \"Series\"})\n    info = await decoded_r.ts().info(4)\n    assert_resp_response(\n        decoded_r, 20, info.get(\"retention_msecs\"), info.get(\"retentionTime\")\n    )\n    assert \"Series\" == info[\"labels\"][\"Time\"]\n\n    # Test for a chunk size of 128 Bytes\n    assert await decoded_r.ts().create(\"time-serie-1\", chunk_size=128)\n    info = await decoded_r.ts().info(\"time-serie-1\")\n    assert_resp_response(decoded_r, 128, info.get(\"chunk_size\"), info.get(\"chunkSize\"))\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.4.0\", \"timeseries\")\nasync def test_create_duplicate_policy(decoded_r: redis.Redis):\n    # Test for duplicate policy\n    for duplicate_policy in [\"block\", \"last\", \"first\", \"min\", \"max\"]:\n        ts_name = f\"time-serie-ooo-{duplicate_policy}\"\n        assert await decoded_r.ts().create(ts_name, duplicate_policy=duplicate_policy)\n        info = await decoded_r.ts().info(ts_name)\n        assert_resp_response(\n            decoded_r,\n            duplicate_policy,\n            info.get(\"duplicate_policy\"),\n            info.get(\"duplicatePolicy\"),\n        )\n\n\n@pytest.mark.redismod\nasync def test_alter(decoded_r: redis.Redis):\n    assert await decoded_r.ts().create(1)\n    res = await decoded_r.ts().info(1)\n    assert_resp_response(\n        decoded_r, 0, res.get(\"retention_msecs\"), res.get(\"retentionTime\")\n    )\n    assert await decoded_r.ts().alter(1, retention_msecs=10)\n    res = await decoded_r.ts().info(1)\n    assert {} == (await decoded_r.ts().info(1))[\"labels\"]\n    info = await decoded_r.ts().info(1)\n    assert_resp_response(\n        decoded_r, 10, info.get(\"retention_msecs\"), info.get(\"retentionTime\")\n    )\n    assert await decoded_r.ts().alter(1, labels={\"Time\": \"Series\"})\n    res = await decoded_r.ts().info(1)\n    assert \"Series\" == (await decoded_r.ts().info(1))[\"labels\"][\"Time\"]\n    info = await decoded_r.ts().info(1)\n    assert_resp_response(\n        decoded_r, 10, info.get(\"retention_msecs\"), info.get(\"retentionTime\")\n    )\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.4.0\", \"timeseries\")\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_alter_duplicate_policy(decoded_r: redis.Redis):\n    assert await decoded_r.ts().create(1)\n    info = await decoded_r.ts().info(1)\n    assert_resp_response(\n        decoded_r, \"block\", info.get(\"duplicate_policy\"), info.get(\"duplicatePolicy\")\n    )\n    assert await decoded_r.ts().alter(1, duplicate_policy=\"min\")\n    info = await decoded_r.ts().info(1)\n    assert_resp_response(\n        decoded_r, \"min\", info.get(\"duplicate_policy\"), info.get(\"duplicatePolicy\")\n    )\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.4.0\", \"timeseries\")\n@skip_if_server_version_gte(\"7.9.0\")\nasync def test_alter_duplicate_policy_prior_redis_8(decoded_r: redis.Redis):\n    assert await decoded_r.ts().create(1)\n    info = await decoded_r.ts().info(1)\n    assert_resp_response(\n        decoded_r, None, info.get(\"duplicate_policy\"), info.get(\"duplicatePolicy\")\n    )\n    assert await decoded_r.ts().alter(1, duplicate_policy=\"min\")\n    info = await decoded_r.ts().info(1)\n    assert_resp_response(\n        decoded_r, \"min\", info.get(\"duplicate_policy\"), info.get(\"duplicatePolicy\")\n    )\n\n\n@pytest.mark.redismod\nasync def test_add(decoded_r: redis.Redis):\n    assert 1 == await decoded_r.ts().add(1, 1, 1)\n    assert 2 == await decoded_r.ts().add(2, 2, 3, retention_msecs=10)\n    assert 3 == await decoded_r.ts().add(3, 3, 2, labels={\"Redis\": \"Labs\"})\n    assert 4 == await decoded_r.ts().add(\n        4, 4, 2, retention_msecs=10, labels={\"Redis\": \"Labs\", \"Time\": \"Series\"}\n    )\n    res = await decoded_r.ts().add(5, \"*\", 1)\n    assert abs(time.time() - round(float(res) / 1000)) < 1.0\n\n    info = await decoded_r.ts().info(4)\n    assert_resp_response(\n        decoded_r, 10, info.get(\"retention_msecs\"), info.get(\"retentionTime\")\n    )\n    assert \"Labs\" == info[\"labels\"][\"Redis\"]\n\n    # Test for a chunk size of 128 Bytes on TS.ADD\n    assert await decoded_r.ts().add(\"time-serie-1\", 1, 10.0, chunk_size=128)\n    info = await decoded_r.ts().info(\"time-serie-1\")\n    assert_resp_response(decoded_r, 128, info.get(\"chunk_size\"), info.get(\"chunkSize\"))\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.4.0\", \"timeseries\")\nasync def test_add_duplicate_policy(decoded_r: redis.Redis):\n    # Test for duplicate policy BLOCK\n    assert 1 == await decoded_r.ts().add(\"time-serie-add-ooo-block\", 1, 5.0)\n    with pytest.raises(Exception):\n        await decoded_r.ts().add(\n            \"time-serie-add-ooo-block\", 1, 5.0, on_duplicate=\"block\"\n        )\n\n    # Test for duplicate policy LAST\n    assert 1 == await decoded_r.ts().add(\"time-serie-add-ooo-last\", 1, 5.0)\n    assert 1 == await decoded_r.ts().add(\n        \"time-serie-add-ooo-last\", 1, 10.0, on_duplicate=\"last\"\n    )\n    res = await decoded_r.ts().get(\"time-serie-add-ooo-last\")\n    assert 10.0 == res[1]\n\n    # Test for duplicate policy FIRST\n    assert 1 == await decoded_r.ts().add(\"time-serie-add-ooo-first\", 1, 5.0)\n    assert 1 == await decoded_r.ts().add(\n        \"time-serie-add-ooo-first\", 1, 10.0, on_duplicate=\"first\"\n    )\n    res = await decoded_r.ts().get(\"time-serie-add-ooo-first\")\n    assert 5.0 == res[1]\n\n    # Test for duplicate policy MAX\n    assert 1 == await decoded_r.ts().add(\"time-serie-add-ooo-max\", 1, 5.0)\n    assert 1 == await decoded_r.ts().add(\n        \"time-serie-add-ooo-max\", 1, 10.0, on_duplicate=\"max\"\n    )\n    res = await decoded_r.ts().get(\"time-serie-add-ooo-max\")\n    assert 10.0 == res[1]\n\n    # Test for duplicate policy MIN\n    assert 1 == await decoded_r.ts().add(\"time-serie-add-ooo-min\", 1, 5.0)\n    assert 1 == await decoded_r.ts().add(\n        \"time-serie-add-ooo-min\", 1, 10.0, on_duplicate=\"min\"\n    )\n    res = await decoded_r.ts().get(\"time-serie-add-ooo-min\")\n    assert 5.0 == res[1]\n\n\n@pytest.mark.redismod\nasync def test_madd(decoded_r: redis.Redis):\n    await decoded_r.ts().create(\"a\")\n    assert [1, 2, 3] == await decoded_r.ts().madd(\n        [(\"a\", 1, 5), (\"a\", 2, 10), (\"a\", 3, 15)]\n    )\n\n\n@pytest.mark.redismod\nasync def test_incrby_decrby(decoded_r: redis.Redis):\n    for _ in range(100):\n        assert await decoded_r.ts().incrby(1, 1)\n        sleep(0.001)\n    assert 100 == (await decoded_r.ts().get(1))[1]\n    for _ in range(100):\n        assert await decoded_r.ts().decrby(1, 1)\n        sleep(0.001)\n    assert 0 == (await decoded_r.ts().get(1))[1]\n\n    assert await decoded_r.ts().incrby(2, 1.5, timestamp=5)\n    assert_resp_response(decoded_r, await decoded_r.ts().get(2), (5, 1.5), [5, 1.5])\n    assert await decoded_r.ts().incrby(2, 2.25, timestamp=7)\n    assert_resp_response(decoded_r, await decoded_r.ts().get(2), (7, 3.75), [7, 3.75])\n    assert await decoded_r.ts().decrby(2, 1.5, timestamp=15)\n    assert_resp_response(decoded_r, await decoded_r.ts().get(2), (15, 2.25), [15, 2.25])\n\n    # Test for a chunk size of 128 Bytes on TS.INCRBY\n    assert await decoded_r.ts().incrby(\"time-serie-1\", 10, chunk_size=128)\n    info = await decoded_r.ts().info(\"time-serie-1\")\n    assert_resp_response(decoded_r, 128, info.get(\"chunk_size\"), info.get(\"chunkSize\"))\n\n    # Test for a chunk size of 128 Bytes on TS.DECRBY\n    assert await decoded_r.ts().decrby(\"time-serie-2\", 10, chunk_size=128)\n    info = await decoded_r.ts().info(\"time-serie-2\")\n    assert_resp_response(decoded_r, 128, info.get(\"chunk_size\"), info.get(\"chunkSize\"))\n\n\n@pytest.mark.redismod\nasync def test_create_and_delete_rule(decoded_r: redis.Redis):\n    # test rule creation\n    time = 100\n    await decoded_r.ts().create(1)\n    await decoded_r.ts().create(2)\n    await decoded_r.ts().createrule(1, 2, \"avg\", 100)\n    for i in range(50):\n        await decoded_r.ts().add(1, time + i * 2, 1)\n        await decoded_r.ts().add(1, time + i * 2 + 1, 2)\n    await decoded_r.ts().add(1, time * 2, 1.5)\n    assert round((await decoded_r.ts().get(2))[1], 5) == 1.5\n    info = await decoded_r.ts().info(1)\n    if is_resp2_connection(decoded_r):\n        assert info.rules[0][1] == 100\n    else:\n        assert info[\"rules\"][\"2\"][0] == 100\n\n    # test rule deletion\n    await decoded_r.ts().deleterule(1, 2)\n    info = await decoded_r.ts().info(1)\n    assert not info[\"rules\"]\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.10.0\", \"timeseries\")\nasync def test_del_range(decoded_r: redis.Redis):\n    try:\n        await decoded_r.ts().delete(\"test\", 0, 100)\n    except Exception as e:\n        assert e.__str__() != \"\"\n\n    for i in range(100):\n        await decoded_r.ts().add(1, i, i % 7)\n    assert 22 == await decoded_r.ts().delete(1, 0, 21)\n    assert [] == await decoded_r.ts().range(1, 0, 21)\n    assert_resp_response(\n        decoded_r, await decoded_r.ts().range(1, 22, 22), [(22, 1.0)], [[22, 1.0]]\n    )\n\n\n@pytest.mark.redismod\nasync def test_range(decoded_r: redis.Redis):\n    for i in range(100):\n        await decoded_r.ts().add(1, i, i % 7)\n    assert 100 == len(await decoded_r.ts().range(1, 0, 200))\n    for i in range(100):\n        await decoded_r.ts().add(1, i + 200, i % 7)\n    assert 200 == len(await decoded_r.ts().range(1, 0, 500))\n    # last sample isn't returned\n    assert 20 == len(\n        await decoded_r.ts().range(\n            1, 0, 500, aggregation_type=\"avg\", bucket_size_msec=10\n        )\n    )\n    assert 10 == len(await decoded_r.ts().range(1, 0, 500, count=10))\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.10.0\", \"timeseries\")\nasync def test_range_advanced(decoded_r: redis.Redis):\n    for i in range(100):\n        await decoded_r.ts().add(1, i, i % 7)\n        await decoded_r.ts().add(1, i + 200, i % 7)\n\n    assert 2 == len(\n        await decoded_r.ts().range(\n            1,\n            0,\n            500,\n            filter_by_ts=[i for i in range(10, 20)],\n            filter_by_min_value=1,\n            filter_by_max_value=2,\n        )\n    )\n    res = await decoded_r.ts().range(\n        1, 0, 10, aggregation_type=\"count\", bucket_size_msec=10, align=\"+\"\n    )\n    assert_resp_response(decoded_r, res, [(0, 10.0), (10, 1.0)], [[0, 10.0], [10, 1.0]])\n    res = await decoded_r.ts().range(\n        1, 0, 10, aggregation_type=\"count\", bucket_size_msec=10, align=5\n    )\n    assert_resp_response(decoded_r, res, [(0, 5.0), (5, 6.0)], [[0, 5.0], [5, 6.0]])\n    res = await decoded_r.ts().range(\n        1, 0, 10, aggregation_type=\"twa\", bucket_size_msec=10\n    )\n    assert_resp_response(decoded_r, res, [(0, 2.55), (10, 3.0)], [[0, 2.55], [10, 3.0]])\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.10.0\", \"timeseries\")\nasync def test_rev_range(decoded_r: redis.Redis):\n    for i in range(100):\n        await decoded_r.ts().add(1, i, i % 7)\n    assert 100 == len(await decoded_r.ts().range(1, 0, 200))\n    for i in range(100):\n        await decoded_r.ts().add(1, i + 200, i % 7)\n    assert 200 == len(await decoded_r.ts().range(1, 0, 500))\n    # first sample isn't returned\n    assert 20 == len(\n        await decoded_r.ts().revrange(\n            1, 0, 500, aggregation_type=\"avg\", bucket_size_msec=10\n        )\n    )\n    assert 10 == len(await decoded_r.ts().revrange(1, 0, 500, count=10))\n    assert 2 == len(\n        await decoded_r.ts().revrange(\n            1,\n            0,\n            500,\n            filter_by_ts=[i for i in range(10, 20)],\n            filter_by_min_value=1,\n            filter_by_max_value=2,\n        )\n    )\n    assert_resp_response(\n        decoded_r,\n        await decoded_r.ts().revrange(\n            1, 0, 10, aggregation_type=\"count\", bucket_size_msec=10, align=\"+\"\n        ),\n        [(10, 1.0), (0, 10.0)],\n        [[10, 1.0], [0, 10.0]],\n    )\n    assert_resp_response(\n        decoded_r,\n        await decoded_r.ts().revrange(\n            1, 0, 10, aggregation_type=\"count\", bucket_size_msec=10, align=1\n        ),\n        [(1, 10.0), (0, 1.0)],\n        [[1, 10.0], [0, 1.0]],\n    )\n\n\n@pytest.mark.onlynoncluster\n@pytest.mark.redismod\nasync def test_multi_range(decoded_r: redis.Redis):\n    await decoded_r.ts().create(1, labels={\"Test\": \"This\", \"team\": \"ny\"})\n    await decoded_r.ts().create(\n        2, labels={\"Test\": \"This\", \"Taste\": \"That\", \"team\": \"sf\"}\n    )\n    for i in range(100):\n        await decoded_r.ts().add(1, i, i % 7)\n        await decoded_r.ts().add(2, i, i % 11)\n\n    res = await decoded_r.ts().mrange(0, 200, filters=[\"Test=This\"])\n    assert 2 == len(res)\n    if is_resp2_connection(decoded_r):\n        assert 100 == len(res[0][\"1\"][1])\n\n        res = await decoded_r.ts().mrange(0, 200, filters=[\"Test=This\"], count=10)\n        assert 10 == len(res[0][\"1\"][1])\n\n        for i in range(100):\n            await decoded_r.ts().add(1, i + 200, i % 7)\n        res = await decoded_r.ts().mrange(\n            0, 500, filters=[\"Test=This\"], aggregation_type=\"avg\", bucket_size_msec=10\n        )\n        assert 2 == len(res)\n        assert 20 == len(res[0][\"1\"][1])\n\n        # test withlabels\n        assert {} == res[0][\"1\"][0]\n        res = await decoded_r.ts().mrange(\n            0, 200, filters=[\"Test=This\"], with_labels=True\n        )\n        assert {\"Test\": \"This\", \"team\": \"ny\"} == res[0][\"1\"][0]\n    else:\n        assert 100 == len(res[\"1\"][2])\n\n        res = await decoded_r.ts().mrange(0, 200, filters=[\"Test=This\"], count=10)\n        assert 10 == len(res[\"1\"][2])\n\n        for i in range(100):\n            await decoded_r.ts().add(1, i + 200, i % 7)\n        res = await decoded_r.ts().mrange(\n            0, 500, filters=[\"Test=This\"], aggregation_type=\"avg\", bucket_size_msec=10\n        )\n        assert 2 == len(res)\n        assert 20 == len(res[\"1\"][2])\n\n        # test withlabels\n        assert {} == res[\"1\"][0]\n        res = await decoded_r.ts().mrange(\n            0, 200, filters=[\"Test=This\"], with_labels=True\n        )\n        assert {\"Test\": \"This\", \"team\": \"ny\"} == res[\"1\"][0]\n\n\n@pytest.mark.onlynoncluster\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.10.0\", \"timeseries\")\nasync def test_multi_range_advanced(decoded_r: redis.Redis):\n    await decoded_r.ts().create(1, labels={\"Test\": \"This\", \"team\": \"ny\"})\n    await decoded_r.ts().create(\n        2, labels={\"Test\": \"This\", \"Taste\": \"That\", \"team\": \"sf\"}\n    )\n    for i in range(100):\n        await decoded_r.ts().add(1, i, i % 7)\n        await decoded_r.ts().add(2, i, i % 11)\n\n    # test with selected labels\n    res = await decoded_r.ts().mrange(\n        0, 200, filters=[\"Test=This\"], select_labels=[\"team\"]\n    )\n    if is_resp2_connection(decoded_r):\n        assert {\"team\": \"ny\"} == res[0][\"1\"][0]\n        assert {\"team\": \"sf\"} == res[1][\"2\"][0]\n\n        # test with filterby\n        res = await decoded_r.ts().mrange(\n            0,\n            200,\n            filters=[\"Test=This\"],\n            filter_by_ts=[i for i in range(10, 20)],\n            filter_by_min_value=1,\n            filter_by_max_value=2,\n        )\n        assert [(15, 1.0), (16, 2.0)] == res[0][\"1\"][1]\n\n        # test groupby\n        res = await decoded_r.ts().mrange(\n            0, 3, filters=[\"Test=This\"], groupby=\"Test\", reduce=\"sum\"\n        )\n        assert [(0, 0.0), (1, 2.0), (2, 4.0), (3, 6.0)] == res[0][\"Test=This\"][1]\n        res = await decoded_r.ts().mrange(\n            0, 3, filters=[\"Test=This\"], groupby=\"Test\", reduce=\"max\"\n        )\n        assert [(0, 0.0), (1, 1.0), (2, 2.0), (3, 3.0)] == res[0][\"Test=This\"][1]\n        res = await decoded_r.ts().mrange(\n            0, 3, filters=[\"Test=This\"], groupby=\"team\", reduce=\"min\"\n        )\n        assert 2 == len(res)\n        assert [(0, 0.0), (1, 1.0), (2, 2.0), (3, 3.0)] == res[0][\"team=ny\"][1]\n        assert [(0, 0.0), (1, 1.0), (2, 2.0), (3, 3.0)] == res[1][\"team=sf\"][1]\n\n        # test align\n        res = await decoded_r.ts().mrange(\n            0,\n            10,\n            filters=[\"team=ny\"],\n            aggregation_type=\"count\",\n            bucket_size_msec=10,\n            align=\"-\",\n        )\n        assert [(0, 10.0), (10, 1.0)] == res[0][\"1\"][1]\n        res = await decoded_r.ts().mrange(\n            0,\n            10,\n            filters=[\"team=ny\"],\n            aggregation_type=\"count\",\n            bucket_size_msec=10,\n            align=5,\n        )\n        assert [(0, 5.0), (5, 6.0)] == res[0][\"1\"][1]\n    else:\n        assert {\"team\": \"ny\"} == res[\"1\"][0]\n        assert {\"team\": \"sf\"} == res[\"2\"][0]\n\n        # test with filterby\n        res = await decoded_r.ts().mrange(\n            0,\n            200,\n            filters=[\"Test=This\"],\n            filter_by_ts=[i for i in range(10, 20)],\n            filter_by_min_value=1,\n            filter_by_max_value=2,\n        )\n        assert [[15, 1.0], [16, 2.0]] == res[\"1\"][2]\n\n        # test groupby\n        res = await decoded_r.ts().mrange(\n            0, 3, filters=[\"Test=This\"], groupby=\"Test\", reduce=\"sum\"\n        )\n        assert [[0, 0.0], [1, 2.0], [2, 4.0], [3, 6.0]] == res[\"Test=This\"][3]\n        res = await decoded_r.ts().mrange(\n            0, 3, filters=[\"Test=This\"], groupby=\"Test\", reduce=\"max\"\n        )\n        assert [[0, 0.0], [1, 1.0], [2, 2.0], [3, 3.0]] == res[\"Test=This\"][3]\n        res = await decoded_r.ts().mrange(\n            0, 3, filters=[\"Test=This\"], groupby=\"team\", reduce=\"min\"\n        )\n        assert 2 == len(res)\n        assert [[0, 0.0], [1, 1.0], [2, 2.0], [3, 3.0]] == res[\"team=ny\"][3]\n        assert [[0, 0.0], [1, 1.0], [2, 2.0], [3, 3.0]] == res[\"team=sf\"][3]\n\n        # test align\n        res = await decoded_r.ts().mrange(\n            0,\n            10,\n            filters=[\"team=ny\"],\n            aggregation_type=\"count\",\n            bucket_size_msec=10,\n            align=\"-\",\n        )\n        assert [[0, 10.0], [10, 1.0]] == res[\"1\"][2]\n        res = await decoded_r.ts().mrange(\n            0,\n            10,\n            filters=[\"team=ny\"],\n            aggregation_type=\"count\",\n            bucket_size_msec=10,\n            align=5,\n        )\n        assert [[0, 5.0], [5, 6.0]] == res[\"1\"][2]\n\n\n@pytest.mark.onlynoncluster\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.10.0\", \"timeseries\")\nasync def test_multi_reverse_range(decoded_r: redis.Redis):\n    await decoded_r.ts().create(1, labels={\"Test\": \"This\", \"team\": \"ny\"})\n    await decoded_r.ts().create(\n        2, labels={\"Test\": \"This\", \"Taste\": \"That\", \"team\": \"sf\"}\n    )\n    for i in range(100):\n        await decoded_r.ts().add(1, i, i % 7)\n        await decoded_r.ts().add(2, i, i % 11)\n\n    res = await decoded_r.ts().mrange(0, 200, filters=[\"Test=This\"])\n    assert 2 == len(res)\n    if is_resp2_connection(decoded_r):\n        assert 100 == len(res[0][\"1\"][1])\n\n        res = await decoded_r.ts().mrange(0, 200, filters=[\"Test=This\"], count=10)\n        assert 10 == len(res[0][\"1\"][1])\n\n        for i in range(100):\n            await decoded_r.ts().add(1, i + 200, i % 7)\n        res = await decoded_r.ts().mrevrange(\n            0, 500, filters=[\"Test=This\"], aggregation_type=\"avg\", bucket_size_msec=10\n        )\n        assert 2 == len(res)\n        assert 20 == len(res[0][\"1\"][1])\n        assert {} == res[0][\"1\"][0]\n\n        # test withlabels\n        res = await decoded_r.ts().mrevrange(\n            0, 200, filters=[\"Test=This\"], with_labels=True\n        )\n        assert {\"Test\": \"This\", \"team\": \"ny\"} == res[0][\"1\"][0]\n\n        # test with selected labels\n        res = await decoded_r.ts().mrevrange(\n            0, 200, filters=[\"Test=This\"], select_labels=[\"team\"]\n        )\n        assert {\"team\": \"ny\"} == res[0][\"1\"][0]\n        assert {\"team\": \"sf\"} == res[1][\"2\"][0]\n\n        # test filterby\n        res = await decoded_r.ts().mrevrange(\n            0,\n            200,\n            filters=[\"Test=This\"],\n            filter_by_ts=[i for i in range(10, 20)],\n            filter_by_min_value=1,\n            filter_by_max_value=2,\n        )\n        assert [(16, 2.0), (15, 1.0)] == res[0][\"1\"][1]\n\n        # test groupby\n        res = await decoded_r.ts().mrevrange(\n            0, 3, filters=[\"Test=This\"], groupby=\"Test\", reduce=\"sum\"\n        )\n        assert [(3, 6.0), (2, 4.0), (1, 2.0), (0, 0.0)] == res[0][\"Test=This\"][1]\n        res = await decoded_r.ts().mrevrange(\n            0, 3, filters=[\"Test=This\"], groupby=\"Test\", reduce=\"max\"\n        )\n        assert [(3, 3.0), (2, 2.0), (1, 1.0), (0, 0.0)] == res[0][\"Test=This\"][1]\n        res = await decoded_r.ts().mrevrange(\n            0, 3, filters=[\"Test=This\"], groupby=\"team\", reduce=\"min\"\n        )\n        assert 2 == len(res)\n        assert [(3, 3.0), (2, 2.0), (1, 1.0), (0, 0.0)] == res[0][\"team=ny\"][1]\n        assert [(3, 3.0), (2, 2.0), (1, 1.0), (0, 0.0)] == res[1][\"team=sf\"][1]\n\n        # test align\n        res = await decoded_r.ts().mrevrange(\n            0,\n            10,\n            filters=[\"team=ny\"],\n            aggregation_type=\"count\",\n            bucket_size_msec=10,\n            align=\"-\",\n        )\n        assert [(10, 1.0), (0, 10.0)] == res[0][\"1\"][1]\n        res = await decoded_r.ts().mrevrange(\n            0,\n            10,\n            filters=[\"team=ny\"],\n            aggregation_type=\"count\",\n            bucket_size_msec=10,\n            align=1,\n        )\n        assert [(1, 10.0), (0, 1.0)] == res[0][\"1\"][1]\n    else:\n        assert 100 == len(res[\"1\"][2])\n\n        res = await decoded_r.ts().mrange(0, 200, filters=[\"Test=This\"], count=10)\n        assert 10 == len(res[\"1\"][2])\n\n        for i in range(100):\n            await decoded_r.ts().add(1, i + 200, i % 7)\n        res = await decoded_r.ts().mrevrange(\n            0, 500, filters=[\"Test=This\"], aggregation_type=\"avg\", bucket_size_msec=10\n        )\n        assert 2 == len(res)\n        assert 20 == len(res[\"1\"][2])\n        assert {} == res[\"1\"][0]\n\n        # test withlabels\n        res = await decoded_r.ts().mrevrange(\n            0, 200, filters=[\"Test=This\"], with_labels=True\n        )\n        assert {\"Test\": \"This\", \"team\": \"ny\"} == res[\"1\"][0]\n\n        # test with selected labels\n        res = await decoded_r.ts().mrevrange(\n            0, 200, filters=[\"Test=This\"], select_labels=[\"team\"]\n        )\n        assert {\"team\": \"ny\"} == res[\"1\"][0]\n        assert {\"team\": \"sf\"} == res[\"2\"][0]\n\n        # test filterby\n        res = await decoded_r.ts().mrevrange(\n            0,\n            200,\n            filters=[\"Test=This\"],\n            filter_by_ts=[i for i in range(10, 20)],\n            filter_by_min_value=1,\n            filter_by_max_value=2,\n        )\n        assert [[16, 2.0], [15, 1.0]] == res[\"1\"][2]\n\n        # test groupby\n        res = await decoded_r.ts().mrevrange(\n            0, 3, filters=[\"Test=This\"], groupby=\"Test\", reduce=\"sum\"\n        )\n        assert [[3, 6.0], [2, 4.0], [1, 2.0], [0, 0.0]] == res[\"Test=This\"][3]\n        res = await decoded_r.ts().mrevrange(\n            0, 3, filters=[\"Test=This\"], groupby=\"Test\", reduce=\"max\"\n        )\n        assert [[3, 3.0], [2, 2.0], [1, 1.0], [0, 0.0]] == res[\"Test=This\"][3]\n        res = await decoded_r.ts().mrevrange(\n            0, 3, filters=[\"Test=This\"], groupby=\"team\", reduce=\"min\"\n        )\n        assert 2 == len(res)\n        assert [[3, 3.0], [2, 2.0], [1, 1.0], [0, 0.0]] == res[\"team=ny\"][3]\n        assert [[3, 3.0], [2, 2.0], [1, 1.0], [0, 0.0]] == res[\"team=sf\"][3]\n\n        # test align\n        res = await decoded_r.ts().mrevrange(\n            0,\n            10,\n            filters=[\"team=ny\"],\n            aggregation_type=\"count\",\n            bucket_size_msec=10,\n            align=\"-\",\n        )\n        assert [[10, 1.0], [0, 10.0]] == res[\"1\"][2]\n        res = await decoded_r.ts().mrevrange(\n            0,\n            10,\n            filters=[\"team=ny\"],\n            aggregation_type=\"count\",\n            bucket_size_msec=10,\n            align=1,\n        )\n        assert [[1, 10.0], [0, 1.0]] == res[\"1\"][2]\n\n\n@pytest.mark.redismod\nasync def test_get(decoded_r: redis.Redis):\n    name = \"test\"\n    await decoded_r.ts().create(name)\n    assert not await decoded_r.ts().get(name)\n    await decoded_r.ts().add(name, 2, 3)\n    assert 2 == (await decoded_r.ts().get(name))[0]\n    await decoded_r.ts().add(name, 3, 4)\n    assert 4 == (await decoded_r.ts().get(name))[1]\n\n\n@pytest.mark.onlynoncluster\n@pytest.mark.redismod\nasync def test_mget(decoded_r: redis.Redis):\n    await decoded_r.ts().create(1, labels={\"Test\": \"This\"})\n    await decoded_r.ts().create(2, labels={\"Test\": \"This\", \"Taste\": \"That\"})\n    act_res = await decoded_r.ts().mget([\"Test=This\"])\n    exp_res = [{\"1\": [{}, None, None]}, {\"2\": [{}, None, None]}]\n    exp_res_resp3 = {\"1\": [{}, []], \"2\": [{}, []]}\n    assert_resp_response(decoded_r, act_res, exp_res, exp_res_resp3)\n    await decoded_r.ts().add(1, \"*\", 15)\n    await decoded_r.ts().add(2, \"*\", 25)\n    res = await decoded_r.ts().mget([\"Test=This\"])\n    if is_resp2_connection(decoded_r):\n        assert 15 == res[0][\"1\"][2]\n        assert 25 == res[1][\"2\"][2]\n    else:\n        assert 15 == res[\"1\"][1][1]\n        assert 25 == res[\"2\"][1][1]\n    res = await decoded_r.ts().mget([\"Taste=That\"])\n    if is_resp2_connection(decoded_r):\n        assert 25 == res[0][\"2\"][2]\n    else:\n        assert 25 == res[\"2\"][1][1]\n\n    # test with_labels\n    if is_resp2_connection(decoded_r):\n        assert {} == res[0][\"2\"][0]\n    else:\n        assert {} == res[\"2\"][0]\n    res = await decoded_r.ts().mget([\"Taste=That\"], with_labels=True)\n    if is_resp2_connection(decoded_r):\n        assert {\"Taste\": \"That\", \"Test\": \"This\"} == res[0][\"2\"][0]\n    else:\n        assert {\"Taste\": \"That\", \"Test\": \"This\"} == res[\"2\"][0]\n\n\n@pytest.mark.redismod\nasync def test_info(decoded_r: redis.Redis):\n    await decoded_r.ts().create(\n        1, retention_msecs=5, labels={\"currentLabel\": \"currentData\"}\n    )\n    info = await decoded_r.ts().info(1)\n    assert_resp_response(\n        decoded_r, 5, info.get(\"retention_msecs\"), info.get(\"retentionTime\")\n    )\n    assert info[\"labels\"][\"currentLabel\"] == \"currentData\"\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.4.0\", \"timeseries\")\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_info_duplicate_policy(decoded_r: redis.Redis):\n    await decoded_r.ts().create(\n        1, retention_msecs=5, labels={\"currentLabel\": \"currentData\"}\n    )\n    info = await decoded_r.ts().info(1)\n    assert_resp_response(\n        decoded_r, \"block\", info.get(\"duplicate_policy\"), info.get(\"duplicatePolicy\")\n    )\n\n    await decoded_r.ts().create(\"time-serie-2\", duplicate_policy=\"min\")\n    info = await decoded_r.ts().info(\"time-serie-2\")\n    assert_resp_response(\n        decoded_r, \"min\", info.get(\"duplicate_policy\"), info.get(\"duplicatePolicy\")\n    )\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.4.0\", \"timeseries\")\n@skip_if_server_version_gte(\"7.9.0\")\nasync def test_info_duplicate_policy_prior_redis_8(decoded_r: redis.Redis):\n    await decoded_r.ts().create(\n        1, retention_msecs=5, labels={\"currentLabel\": \"currentData\"}\n    )\n    info = await decoded_r.ts().info(1)\n    assert_resp_response(\n        decoded_r, None, info.get(\"duplicate_policy\"), info.get(\"duplicatePolicy\")\n    )\n\n    await decoded_r.ts().create(\"time-serie-2\", duplicate_policy=\"min\")\n    info = await decoded_r.ts().info(\"time-serie-2\")\n    assert_resp_response(\n        decoded_r, \"min\", info.get(\"duplicate_policy\"), info.get(\"duplicatePolicy\")\n    )\n\n\n@pytest.mark.onlynoncluster\n@pytest.mark.redismod\nasync def test_query_index(decoded_r: redis.Redis):\n    await decoded_r.ts().create(1, labels={\"Test\": \"This\"})\n    await decoded_r.ts().create(2, labels={\"Test\": \"This\", \"Taste\": \"That\"})\n    assert 2 == len(await decoded_r.ts().queryindex([\"Test=This\"]))\n    assert 1 == len(await decoded_r.ts().queryindex([\"Taste=That\"]))\n    assert_resp_response(\n        decoded_r, await decoded_r.ts().queryindex([\"Taste=That\"]), [2], [\"2\"]\n    )\n\n\n@pytest.mark.redismod\nasync def test_uncompressed(decoded_r: redis.Redis):\n    await decoded_r.ts().create(\"compressed\")\n    await decoded_r.ts().create(\"uncompressed\", uncompressed=True)\n    for i in range(1000):\n        await decoded_r.ts().add(\"compressed\", i, i)\n        await decoded_r.ts().add(\"uncompressed\", i, i)\n    compressed_info = await decoded_r.ts().info(\"compressed\")\n    uncompressed_info = await decoded_r.ts().info(\"uncompressed\")\n    if is_resp2_connection(decoded_r):\n        assert compressed_info.memory_usage != uncompressed_info.memory_usage\n    else:\n        assert compressed_info[\"memoryUsage\"] != uncompressed_info[\"memoryUsage\"]\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.12.0\", \"timeseries\")\nasync def test_create_with_insertion_filters(decoded_r: redis.Redis):\n    await decoded_r.ts().create(\n        \"time-series-1\",\n        duplicate_policy=\"last\",\n        ignore_max_time_diff=5,\n        ignore_max_val_diff=10.0,\n    )\n    assert 1000 == await decoded_r.ts().add(\"time-series-1\", 1000, 1.0)\n    assert 1010 == await decoded_r.ts().add(\"time-series-1\", 1010, 11.0)\n    assert 1010 == await decoded_r.ts().add(\"time-series-1\", 1013, 10.0)\n    assert 1020 == await decoded_r.ts().add(\"time-series-1\", 1020, 11.5)\n    assert 1021 == await decoded_r.ts().add(\"time-series-1\", 1021, 22.0)\n\n    data_points = await decoded_r.ts().range(\"time-series-1\", \"-\", \"+\")\n    assert_resp_response(\n        decoded_r,\n        data_points,\n        [(1000, 1.0), (1010, 11.0), (1020, 11.5), (1021, 22.0)],\n        [[1000, 1.0], [1010, 11.0], [1020, 11.5], [1021, 22.0]],\n    )\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.12.0\", \"timeseries\")\nasync def test_alter_with_insertion_filters(decoded_r: redis.Redis):\n    assert 1000 == await decoded_r.ts().add(\"time-series-1\", 1000, 1.0)\n    assert 1010 == await decoded_r.ts().add(\"time-series-1\", 1010, 11.0)\n    assert 1013 == await decoded_r.ts().add(\"time-series-1\", 1013, 10.0)\n\n    await decoded_r.ts().alter(\n        \"time-series-1\",\n        duplicate_policy=\"last\",\n        ignore_max_time_diff=5,\n        ignore_max_val_diff=10.0,\n    )\n\n    assert 1013 == await decoded_r.ts().add(\"time-series-1\", 1015, 11.5)\n\n    data_points = await decoded_r.ts().range(\"time-series-1\", \"-\", \"+\")\n    assert_resp_response(\n        decoded_r,\n        data_points,\n        [(1000, 1.0), (1010, 11.0), (1013, 10.0)],\n        [[1000, 1.0], [1010, 11.0], [1013, 10.0]],\n    )\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.12.0\", \"timeseries\")\nasync def test_add_with_insertion_filters(decoded_r: redis.Redis):\n    assert 1000 == await decoded_r.ts().add(\n        \"time-series-1\",\n        1000,\n        1.0,\n        duplicate_policy=\"last\",\n        ignore_max_time_diff=5,\n        ignore_max_val_diff=10.0,\n    )\n\n    assert 1000 == await decoded_r.ts().add(\"time-series-1\", 1004, 3.0)\n\n    data_points = await decoded_r.ts().range(\"time-series-1\", \"-\", \"+\")\n    assert_resp_response(decoded_r, data_points, [(1000, 1.0)], [[1000, 1.0]])\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.12.0\", \"timeseries\")\nasync def test_incrby_with_insertion_filters(decoded_r: redis.Redis):\n    assert 1000 == await decoded_r.ts().incrby(\n        \"time-series-1\",\n        1.0,\n        timestamp=1000,\n        duplicate_policy=\"last\",\n        ignore_max_time_diff=5,\n        ignore_max_val_diff=10.0,\n    )\n\n    assert 1000 == await decoded_r.ts().incrby(\"time-series-1\", 3.0, timestamp=1000)\n\n    data_points = await decoded_r.ts().range(\"time-series-1\", \"-\", \"+\")\n    assert_resp_response(decoded_r, data_points, [(1000, 1.0)], [[1000, 1.0]])\n\n    assert 1000 == await decoded_r.ts().incrby(\"time-series-1\", 10.1, timestamp=1000)\n\n    data_points = await decoded_r.ts().range(\"time-series-1\", \"-\", \"+\")\n    assert_resp_response(decoded_r, data_points, [(1000, 11.1)], [[1000, 11.1]])\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.12.0\", \"timeseries\")\nasync def test_decrby_with_insertion_filters(decoded_r: redis.Redis):\n    assert 1000 == await decoded_r.ts().decrby(\n        \"time-series-1\",\n        1.0,\n        timestamp=1000,\n        duplicate_policy=\"last\",\n        ignore_max_time_diff=5,\n        ignore_max_val_diff=10.0,\n    )\n\n    assert 1000 == await decoded_r.ts().decrby(\"time-series-1\", 3.0, timestamp=1000)\n\n    data_points = await decoded_r.ts().range(\"time-series-1\", \"-\", \"+\")\n    assert_resp_response(decoded_r, data_points, [(1000, -1.0)], [[1000, -1.0]])\n\n    assert 1000 == await decoded_r.ts().decrby(\"time-series-1\", 10.1, timestamp=1000)\n\n    data_points = await decoded_r.ts().range(\"time-series-1\", \"-\", \"+\")\n    assert_resp_response(decoded_r, data_points, [(1000, -11.1)], [[1000, -11.1]])\n\n\n@pytest.mark.redismod\n@skip_if_server_version_lt(\"8.5.0\")\nasync def test_range_with_count_nan_count_all_aggregators(decoded_r: redis.Redis):\n    await decoded_r.ts().create(\n        \"temperature:2:32\",\n    )\n\n    # Fill with values\n    assert await decoded_r.ts().add(\"temperature:2:32\", 1000, \"NaN\") == 1000\n    assert await decoded_r.ts().add(\"temperature:2:32\", 1003, 25) == 1003\n    assert await decoded_r.ts().add(\"temperature:2:32\", 1005, \"NaN\") == 1005\n    assert await decoded_r.ts().add(\"temperature:2:32\", 1006, \"NaN\") == 1006\n\n    # Ensure we count only NaN values\n    data_points = await decoded_r.ts().range(\n        \"temperature:2:32\",\n        1000,\n        1006,\n        aggregation_type=\"countNan\",\n        bucket_size_msec=1000,\n    )\n    assert_resp_response(\n        decoded_r,\n        data_points,\n        [(1000, 3)],\n        [[1000, 3]],\n    )\n\n    # Ensure we count ALL values\n    data_points = await decoded_r.ts().range(\n        \"temperature:2:32\",\n        1000,\n        1006,\n        aggregation_type=\"countAll\",\n        bucket_size_msec=1000,\n    )\n    assert_resp_response(\n        decoded_r,\n        data_points,\n        [(1000, 4)],\n        [[1000, 4]],\n    )\n\n\n@pytest.mark.redismod\n@skip_if_server_version_lt(\"8.5.0\")\nasync def test_rev_range_with_count_nan_count_all_aggregators(decoded_r: redis.Redis):\n    await decoded_r.ts().create(\n        \"temperature:2:32\",\n    )\n\n    # Fill with values\n    assert await decoded_r.ts().add(\"temperature:2:32\", 1000, \"NaN\") == 1000\n    assert await decoded_r.ts().add(\"temperature:2:32\", 1003, 25) == 1003\n    assert await decoded_r.ts().add(\"temperature:2:32\", 1005, \"NaN\") == 1005\n    assert await decoded_r.ts().add(\"temperature:2:32\", 1006, \"NaN\") == 1006\n\n    # Ensure we count only NaN values\n    data_points = await decoded_r.ts().revrange(\n        \"temperature:2:32\",\n        1000,\n        1006,\n        aggregation_type=\"countNan\",\n        bucket_size_msec=1000,\n    )\n    assert_resp_response(\n        decoded_r,\n        data_points,\n        [(1000, 3)],\n        [[1000, 3]],\n    )\n\n    # Ensure we count ALL values\n    data_points = await decoded_r.ts().revrange(\n        \"temperature:2:32\",\n        1000,\n        1006,\n        aggregation_type=\"countAll\",\n        bucket_size_msec=1000,\n    )\n    assert_resp_response(\n        decoded_r,\n        data_points,\n        [(1000, 4)],\n        [[1000, 4]],\n    )\n\n\n@pytest.mark.redismod\n@skip_if_server_version_lt(\"8.5.0\")\nasync def test_mrange_with_count_nan_count_all_aggregators(decoded_r: redis.Redis):\n    await decoded_r.ts().create(\n        \"temperature:A\",\n        labels={\"type\": \"temperature\", \"name\": \"A\"},\n    )\n    await decoded_r.ts().create(\n        \"temperature:B\",\n        labels={\"type\": \"temperature\", \"name\": \"B\"},\n    )\n\n    # Fill with values\n    assert await decoded_r.ts().madd(\n        [(\"temperature:A\", 1000, \"NaN\"), (\"temperature:A\", 1001, 27)]\n    )\n    assert await decoded_r.ts().madd(\n        [(\"temperature:B\", 1000, \"NaN\"), (\"temperature:B\", 1001, 28)]\n    )\n\n    # Ensure we count only NaN values\n    data_points = await decoded_r.ts().mrange(\n        1000,\n        1001,\n        aggregation_type=\"countNan\",\n        bucket_size_msec=1000,\n        filters=[\"type=temperature\"],\n    )\n    assert_resp_response(\n        decoded_r,\n        data_points,\n        [\n            {\"temperature:A\": [{}, [(1000, 1.0)]]},\n            {\"temperature:B\": [{}, [(1000, 1.0)]]},\n        ],\n        {\n            \"temperature:A\": [{}, {\"aggregators\": [\"countnan\"]}, [[1000, 1.0]]],\n            \"temperature:B\": [{}, {\"aggregators\": [\"countnan\"]}, [[1000, 1.0]]],\n        },\n    )\n\n    # Ensure we count ALL values\n    data_points = await decoded_r.ts().mrange(\n        1000,\n        1001,\n        aggregation_type=\"countAll\",\n        bucket_size_msec=1000,\n        filters=[\"type=temperature\"],\n    )\n    assert_resp_response(\n        decoded_r,\n        data_points,\n        [\n            {\"temperature:A\": [{}, [(1000, 2.0)]]},\n            {\"temperature:B\": [{}, [(1000, 2.0)]]},\n        ],\n        {\n            \"temperature:A\": [{}, {\"aggregators\": [\"countall\"]}, [[1000, 2.0]]],\n            \"temperature:B\": [{}, {\"aggregators\": [\"countall\"]}, [[1000, 2.0]]],\n        },\n    )\n\n\n@pytest.mark.redismod\n@skip_if_server_version_lt(\"8.5.0\")\nasync def test_mrevrange_with_count_nan_count_all_aggregators(decoded_r: redis.Redis):\n    await decoded_r.ts().create(\n        \"temperature:A\",\n        labels={\"type\": \"temperature\", \"name\": \"A\"},\n    )\n    await decoded_r.ts().create(\n        \"temperature:B\",\n        labels={\"type\": \"temperature\", \"name\": \"B\"},\n    )\n\n    # Fill with values\n    assert await decoded_r.ts().madd(\n        [(\"temperature:A\", 1000, \"NaN\"), (\"temperature:A\", 1001, 27)]\n    )\n    assert await decoded_r.ts().madd(\n        [(\"temperature:B\", 1000, \"NaN\"), (\"temperature:B\", 1001, 28)]\n    )\n\n    # Ensure we count only NaN values\n    data_points = await decoded_r.ts().mrevrange(\n        1000,\n        1001,\n        aggregation_type=\"countNan\",\n        bucket_size_msec=1000,\n        filters=[\"type=temperature\"],\n    )\n    assert_resp_response(\n        decoded_r,\n        data_points,\n        [\n            {\"temperature:A\": [{}, [(1000, 1.0)]]},\n            {\"temperature:B\": [{}, [(1000, 1.0)]]},\n        ],\n        {\n            \"temperature:A\": [{}, {\"aggregators\": [\"countnan\"]}, [[1000, 1.0]]],\n            \"temperature:B\": [{}, {\"aggregators\": [\"countnan\"]}, [[1000, 1.0]]],\n        },\n    )\n\n    # Ensure we count ALL values\n    data_points = await decoded_r.ts().mrevrange(\n        1000,\n        1001,\n        aggregation_type=\"countAll\",\n        bucket_size_msec=1000,\n        filters=[\"type=temperature\"],\n    )\n    assert_resp_response(\n        decoded_r,\n        data_points,\n        [\n            {\"temperature:A\": [{}, [(1000, 2.0)]]},\n            {\"temperature:B\": [{}, [(1000, 2.0)]]},\n        ],\n        {\n            \"temperature:A\": [{}, {\"aggregators\": [\"countall\"]}, [[1000, 2.0]]],\n            \"temperature:B\": [{}, {\"aggregators\": [\"countall\"]}, [[1000, 2.0]]],\n        },\n    )\n"
  },
  {
    "path": "tests/test_asyncio/test_usage_counter.py",
    "content": "import asyncio\n\nimport pytest\n\n\n@pytest.mark.asyncio\nasync def test_usage_counter(r):\n    async def dummy_task():\n        async with r:\n            await asyncio.sleep(0.01)\n\n    tasks = [dummy_task() for _ in range(20)]\n    await asyncio.gather(*tasks)\n\n    # After all tasks have completed, the usage counter should be back to zero.\n    assert r._usage_counter == 0\n"
  },
  {
    "path": "tests/test_asyncio/test_utils.py",
    "content": "from datetime import datetime\nimport warnings\nimport pytest\nimport redis\nfrom redis.utils import (\n    deprecated_function,\n    deprecated_args,\n    experimental_method,\n    experimental_args,\n)\n\n\nasync def redis_server_time(client: redis.Redis):\n    seconds, milliseconds = await client.time()\n    timestamp = float(f\"{seconds}.{milliseconds}\")\n    return datetime.fromtimestamp(timestamp)\n\n\n# Async tests for deprecated_function decorator\nclass TestDeprecatedFunctionAsync:\n    @pytest.mark.asyncio\n    async def test_async_function_warns(self):\n        @deprecated_function(reason=\"use new_async_func\", version=\"2.0.0\")\n        async def old_async_func():\n            return \"async_result\"\n\n        with warnings.catch_warnings(record=True) as w:\n            warnings.simplefilter(\"always\")\n            result = await old_async_func()\n            assert result == \"async_result\"\n            assert len(w) == 1\n            assert issubclass(w[0].category, DeprecationWarning)\n            assert \"old_async_func\" in str(w[0].message)\n\n    @pytest.mark.asyncio\n    async def test_async_preserves_function_metadata(self):\n        @deprecated_function()\n        async def async_documented_func():\n            \"\"\"This is the async docstring.\"\"\"\n            pass\n\n        assert async_documented_func.__name__ == \"async_documented_func\"\n        assert async_documented_func.__doc__ == \"This is the async docstring.\"\n\n\n# Async tests for deprecated_args decorator\nclass TestDeprecatedArgsAsync:\n    @pytest.mark.asyncio\n    async def test_async_function_warns_on_deprecated_arg(self):\n        @deprecated_args(args_to_warn=[\"old_param\"], reason=\"use new_param\")\n        async def async_func_with_args(new_param=None, old_param=None):\n            return new_param or old_param\n\n        with warnings.catch_warnings(record=True) as w:\n            warnings.simplefilter(\"always\")\n            result = await async_func_with_args(old_param=\"async_value\")\n            assert result == \"async_value\"\n            assert len(w) == 1\n            assert issubclass(w[0].category, DeprecationWarning)\n            assert \"old_param\" in str(w[0].message)\n\n    @pytest.mark.asyncio\n    async def test_async_function_no_warning_on_allowed_arg(self):\n        @deprecated_args(args_to_warn=[\"*\"], allowed_args=[\"allowed_param\"])\n        async def async_func_with_allowed(allowed_param=None):\n            return allowed_param\n\n        with warnings.catch_warnings(record=True) as w:\n            warnings.simplefilter(\"always\")\n            result = await async_func_with_allowed(allowed_param=\"async_value\")\n            assert result == \"async_value\"\n            assert len(w) == 0\n\n    @pytest.mark.asyncio\n    async def test_async_wildcard_warns_all_args(self):\n        @deprecated_args(args_to_warn=[\"*\"])\n        async def async_func_all_deprecated(param1=None, param2=None):\n            return (param1, param2)\n\n        with warnings.catch_warnings(record=True) as w:\n            warnings.simplefilter(\"always\")\n            result = await async_func_all_deprecated(param1=\"a\", param2=\"b\")\n            assert result == (\"a\", \"b\")\n            assert len(w) == 1\n            assert \"param1\" in str(w[0].message) or \"param2\" in str(w[0].message)\n\n\n# Async tests for experimental_method decorator\nclass TestExperimentalMethodAsync:\n    @pytest.mark.asyncio\n    async def test_async_function_warns(self):\n        @experimental_method()\n        async def async_experimental_func():\n            return \"async_experimental_result\"\n\n        with warnings.catch_warnings(record=True) as w:\n            warnings.simplefilter(\"always\")\n            result = await async_experimental_func()\n            assert result == \"async_experimental_result\"\n            assert len(w) == 1\n            assert issubclass(w[0].category, UserWarning)\n            assert \"async_experimental_func\" in str(w[0].message)\n\n    @pytest.mark.asyncio\n    async def test_async_preserves_function_metadata(self):\n        @experimental_method()\n        async def async_experimental_documented():\n            \"\"\"Experimental async docstring.\"\"\"\n            pass\n\n        assert async_experimental_documented.__name__ == \"async_experimental_documented\"\n        assert async_experimental_documented.__doc__ == \"Experimental async docstring.\"\n\n\n# Async tests for experimental_args decorator\nclass TestExperimentalArgsAsync:\n    @pytest.mark.asyncio\n    async def test_async_function_warns_on_experimental_arg(self):\n        @experimental_args(args_to_warn=[\"beta_param\"])\n        async def async_func_with_experimental(stable_param=None, beta_param=None):\n            return stable_param or beta_param\n\n        with warnings.catch_warnings(record=True) as w:\n            warnings.simplefilter(\"always\")\n            result = await async_func_with_experimental(beta_param=\"async_beta\")\n            assert result == \"async_beta\"\n            assert len(w) == 1\n            assert issubclass(w[0].category, UserWarning)\n            assert \"beta_param\" in str(w[0].message)\n\n    @pytest.mark.asyncio\n    async def test_async_no_warning_when_no_args_provided(self):\n        @experimental_args(args_to_warn=[\"beta_param\"])\n        async def async_func_no_args():\n            return \"no_args\"\n\n        with warnings.catch_warnings(record=True) as w:\n            warnings.simplefilter(\"always\")\n            result = await async_func_no_args()\n            assert result == \"no_args\"\n            assert len(w) == 0\n"
  },
  {
    "path": "tests/test_asyncio/test_vsets.py",
    "content": "import json\nimport random\nimport numpy as np\nimport pytest\nimport pytest_asyncio\nimport redis\nfrom redis.commands.vectorset.commands import QuantizationOptions\n\nfrom tests.conftest import (\n    skip_if_server_version_lt,\n)\n\n\n@pytest_asyncio.fixture()\nasync def d_client(create_redis, redis_url):\n    return await create_redis(url=redis_url, decode_responses=True)\n\n\n@pytest_asyncio.fixture()\nasync def client(create_redis, redis_url):\n    return await create_redis(url=redis_url, decode_responses=False)\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_add_elem_with_values(d_client):\n    float_array = [1, 4.32, 0.11]\n    resp = await d_client.vset().vadd(\"myset\", float_array, \"elem1\")\n    assert resp == 1\n\n    emb = await d_client.vset().vemb(\"myset\", \"elem1\")\n    assert _validate_quantization(float_array, emb, tolerance=0.1)\n\n    with pytest.raises(redis.DataError):\n        await d_client.vset().vadd(\"myset_invalid_data\", None, \"elem1\")\n\n    with pytest.raises(redis.DataError):\n        await d_client.vset().vadd(\"myset_invalid_data\", [12, 45], None, reduce_dim=3)\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_add_elem_with_vector(d_client):\n    float_array = [1, 4.32, 0.11]\n    # Convert the list of floats to a byte array in fp32 format\n    byte_array = _to_fp32_blob_array(float_array)\n    resp = await d_client.vset().vadd(\"myset\", byte_array, \"elem1\")\n    assert resp == 1\n\n    emb = await d_client.vset().vemb(\"myset\", \"elem1\")\n    assert _validate_quantization(float_array, emb, tolerance=0.1)\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_add_elem_reduced_dim(d_client):\n    float_array = [1, 4.32, 0.11, 0.5, 0.9]\n    resp = await d_client.vset().vadd(\"myset\", float_array, \"elem1\", reduce_dim=3)\n    assert resp == 1\n\n    dim = await d_client.vset().vdim(\"myset\")\n    assert dim == 3\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_add_elem_cas(d_client):\n    float_array = [1, 4.32, 0.11, 0.5, 0.9]\n    resp = await d_client.vset().vadd(\n        \"myset\", vector=float_array, element=\"elem1\", cas=True\n    )\n    assert resp == 1\n\n    emb = await d_client.vset().vemb(\"myset\", \"elem1\")\n    assert _validate_quantization(float_array, emb, tolerance=0.1)\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_add_elem_no_quant(d_client):\n    float_array = [1, 4.32, 0.11, 0.5, 0.9]\n    resp = await d_client.vset().vadd(\n        \"myset\",\n        vector=float_array,\n        element=\"elem1\",\n        quantization=QuantizationOptions.NOQUANT,\n    )\n    assert resp == 1\n\n    emb = await d_client.vset().vemb(\"myset\", \"elem1\")\n    assert _validate_quantization(float_array, emb, tolerance=0.0)\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_add_elem_bin_quant(d_client):\n    float_array = [1, 4.32, 0.0, 0.05, -2.9]\n    resp = await d_client.vset().vadd(\n        \"myset\",\n        vector=float_array,\n        element=\"elem1\",\n        quantization=QuantizationOptions.BIN,\n    )\n    assert resp == 1\n\n    emb = await d_client.vset().vemb(\"myset\", \"elem1\")\n    expected_array = [1, 1, -1, 1, -1]\n    assert _validate_quantization(expected_array, emb, tolerance=0.0)\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_add_elem_q8_quant(d_client):\n    float_array = [1, 4.32, 10.0, -21, -2.9]\n    resp = await d_client.vset().vadd(\n        \"myset\",\n        vector=float_array,\n        element=\"elem1\",\n        quantization=QuantizationOptions.BIN,\n    )\n    assert resp == 1\n\n    emb = await d_client.vset().vemb(\"myset\", \"elem1\")\n    expected_array = [1, 1, 1, -1, -1]\n    assert _validate_quantization(expected_array, emb, tolerance=0.0)\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_add_elem_ef(d_client):\n    await d_client.vset().vadd(\"myset\", vector=[5, 55, 65, -20, 30], element=\"elem1\")\n    await d_client.vset().vadd(\n        \"myset\", vector=[-40, -40.32, 10.0, -4, 2.9], element=\"elem2\"\n    )\n\n    float_array = [1, 4.32, 10.0, -21, -2.9]\n    resp = await d_client.vset().vadd(\"myset\", float_array, \"elem3\", ef=1)\n    assert resp == 1\n\n    emb = await d_client.vset().vemb(\"myset\", \"elem3\")\n    assert _validate_quantization(float_array, emb, tolerance=0.1)\n\n    sim = await d_client.vset().vsim(\"myset\", input=\"elem3\", with_scores=True)\n    assert len(sim) == 3\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_add_elem_with_attr(d_client):\n    float_array = [1, 4.32, 10.0, -21, -2.9]\n    attrs_dict = {\"key1\": \"value1\", \"key2\": \"value2\"}\n    resp = await d_client.vset().vadd(\n        \"myset\",\n        vector=float_array,\n        element=\"elem3\",\n        attributes=attrs_dict,\n    )\n    assert resp == 1\n\n    emb = await d_client.vset().vemb(\"myset\", \"elem3\")\n    assert _validate_quantization(float_array, emb, tolerance=0.1)\n\n    attr_saved = await d_client.vset().vgetattr(\"myset\", \"elem3\")\n    assert attr_saved == attrs_dict\n\n    resp = await d_client.vset().vadd(\n        \"myset\",\n        vector=float_array,\n        element=\"elem4\",\n        attributes={},\n    )\n    assert resp == 1\n\n    emb = await d_client.vset().vemb(\"myset\", \"elem4\")\n    assert _validate_quantization(float_array, emb, tolerance=0.1)\n\n    attr_saved = await d_client.vset().vgetattr(\"myset\", \"elem4\")\n    assert attr_saved is None\n\n    resp = await d_client.vset().vadd(\n        \"myset\",\n        vector=float_array,\n        element=\"elem5\",\n        attributes=json.dumps(attrs_dict),\n    )\n    assert resp == 1\n\n    emb = await d_client.vset().vemb(\"myset\", \"elem5\")\n    assert _validate_quantization(float_array, emb, tolerance=0.1)\n\n    attr_saved = await d_client.vset().vgetattr(\"myset\", \"elem5\")\n    assert attr_saved == attrs_dict\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_add_elem_with_numlinks(d_client):\n    elements_count = 100\n    vector_dim = 10\n    for i in range(elements_count):\n        float_array = [random.randint(0, 10) for x in range(vector_dim)]\n        await d_client.vset().vadd(\n            \"myset\",\n            float_array,\n            f\"elem{i}\",\n            numlinks=8,\n        )\n\n    float_array = [1, 4.32, 0.11, 0.5, 0.9, 0.1, 0.2, 0.3, 0.4, 0.5]\n    resp = await d_client.vset().vadd(\"myset\", float_array, \"elem_numlinks\", numlinks=8)\n    assert resp == 1\n\n    emb = await d_client.vset().vemb(\"myset\", \"elem_numlinks\")\n    assert _validate_quantization(float_array, emb, tolerance=0.5)\n\n    numlinks_all_layers = await d_client.vset().vlinks(\"myset\", \"elem_numlinks\")\n    for neighbours_list_for_layer in numlinks_all_layers:\n        assert len(neighbours_list_for_layer) <= 8\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_vsim_count(d_client):\n    elements_count = 30\n    vector_dim = 800\n    for i in range(elements_count):\n        float_array = [random.uniform(0, 10) for x in range(vector_dim)]\n        await d_client.vset().vadd(\n            \"myset\",\n            float_array,\n            f\"elem{i}\",\n            numlinks=64,\n        )\n\n    vsim = await d_client.vset().vsim(\"myset\", input=\"elem1\")\n    assert len(vsim) == 10\n    assert isinstance(vsim, list)\n    assert isinstance(vsim[0], str)\n\n    vsim = await d_client.vset().vsim(\"myset\", input=\"elem1\", count=5)\n    assert len(vsim) == 5\n    assert isinstance(vsim, list)\n    assert isinstance(vsim[0], str)\n\n    vsim = await d_client.vset().vsim(\"myset\", input=\"elem1\", count=50)\n    assert len(vsim) == 30\n    assert isinstance(vsim, list)\n    assert isinstance(vsim[0], str)\n\n    vsim = await d_client.vset().vsim(\"myset\", input=\"elem1\", count=15)\n    assert len(vsim) == 15\n    assert isinstance(vsim, list)\n    assert isinstance(vsim[0], str)\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_vsim_with_scores(d_client):\n    elements_count = 20\n    vector_dim = 50\n    for i in range(elements_count):\n        float_array = [random.uniform(0, 10) for x in range(vector_dim)]\n        await d_client.vset().vadd(\n            \"myset\",\n            float_array,\n            f\"elem{i}\",\n            numlinks=64,\n        )\n\n    vsim = await d_client.vset().vsim(\"myset\", input=\"elem1\", with_scores=True)\n    assert len(vsim) == 10\n    assert isinstance(vsim, dict)\n    assert isinstance(vsim[\"elem1\"], float)\n    assert 0 <= vsim[\"elem1\"] <= 1\n\n\n@skip_if_server_version_lt(\"8.2.0\")\nasync def test_vsim_with_attribs_attribs_set(d_client):\n    elements_count = 5\n    vector_dim = 10\n    attrs_dict = {\"key1\": \"value1\", \"key2\": \"value2\"}\n    for i in range(elements_count):\n        float_array = [random.uniform(0, 5) for x in range(vector_dim)]\n        await d_client.vset().vadd(\n            \"myset\",\n            float_array,\n            f\"elem{i}\",\n            numlinks=64,\n            attributes=attrs_dict if i % 2 == 0 else None,\n        )\n\n    vsim = await d_client.vset().vsim(\"myset\", input=\"elem1\", with_attribs=True)\n    assert len(vsim) == 5\n    assert isinstance(vsim, dict)\n    assert vsim[\"elem1\"] is None\n    assert vsim[\"elem2\"] == attrs_dict\n\n\n@skip_if_server_version_lt(\"8.2.0\")\nasync def test_vsim_with_scores_and_attribs_attribs_set(d_client):\n    elements_count = 5\n    vector_dim = 10\n    attrs_dict = {\"key1\": \"value1\", \"key2\": \"value2\"}\n    for i in range(elements_count):\n        float_array = [random.uniform(0, 5) for x in range(vector_dim)]\n        await d_client.vset().vadd(\n            \"myset\",\n            float_array,\n            f\"elem{i}\",\n            numlinks=64,\n            attributes=attrs_dict if i % 2 == 0 else None,\n        )\n\n    vsim = await d_client.vset().vsim(\n        \"myset\", input=\"elem1\", with_scores=True, with_attribs=True\n    )\n    assert len(vsim) == 5\n    assert isinstance(vsim, dict)\n    assert isinstance(vsim[\"elem1\"], dict)\n    assert \"score\" in vsim[\"elem1\"]\n    assert \"attributes\" in vsim[\"elem1\"]\n    assert isinstance(vsim[\"elem1\"][\"score\"], float)\n    assert vsim[\"elem1\"][\"attributes\"] is None\n\n    assert isinstance(vsim[\"elem2\"], dict)\n    assert \"score\" in vsim[\"elem2\"]\n    assert \"attributes\" in vsim[\"elem2\"]\n    assert isinstance(vsim[\"elem2\"][\"score\"], float)\n    assert vsim[\"elem2\"][\"attributes\"] == attrs_dict\n\n\n@skip_if_server_version_lt(\"8.2.0\")\nasync def test_vsim_with_attribs_attribs_not_set(d_client):\n    elements_count = 20\n    vector_dim = 50\n    for i in range(elements_count):\n        float_array = [random.uniform(0, 10) for x in range(vector_dim)]\n        await d_client.vset().vadd(\n            \"myset\",\n            float_array,\n            f\"elem{i}\",\n            numlinks=64,\n        )\n\n    vsim = await d_client.vset().vsim(\"myset\", input=\"elem1\", with_attribs=True)\n    assert len(vsim) == 10\n    assert isinstance(vsim, dict)\n    assert vsim[\"elem1\"] is None\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_vsim_with_different_vector_input_types(d_client):\n    elements_count = 10\n    vector_dim = 5\n    for i in range(elements_count):\n        float_array = [random.uniform(0, 10) for x in range(vector_dim)]\n        attributes = {\"index\": i, \"elem_name\": f\"elem_{i}\"}\n        await d_client.vset().vadd(\n            \"myset\",\n            float_array,\n            f\"elem_{i}\",\n            numlinks=4,\n            attributes=attributes,\n        )\n    sim = await d_client.vset().vsim(\"myset\", input=\"elem_1\")\n    assert len(sim) == 10\n    assert isinstance(sim, list)\n\n    float_array = [1, 4.32, 0.0, 0.05, -2.9]\n    sim_to_float_array = await d_client.vset().vsim(\"myset\", input=float_array)\n    assert len(sim_to_float_array) == 10\n    assert isinstance(sim_to_float_array, list)\n\n    fp32_vector = _to_fp32_blob_array(float_array)\n    sim_to_fp32_vector = await d_client.vset().vsim(\"myset\", input=fp32_vector)\n    assert len(sim_to_fp32_vector) == 10\n    assert isinstance(sim_to_fp32_vector, list)\n    assert sim_to_float_array == sim_to_fp32_vector\n\n    with pytest.raises(redis.DataError):\n        await d_client.vset().vsim(\"myset\", input=None)\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_vsim_unexisting(d_client):\n    float_array = [1, 4.32, 0.11, 0.5, 0.9]\n    await d_client.vset().vadd(\"myset\", vector=float_array, element=\"elem1\", cas=True)\n\n    with pytest.raises(redis.ResponseError):\n        await d_client.vset().vsim(\"myset\", input=\"elem_not_existing\")\n\n    sim = await d_client.vset().vsim(\"myset_not_existing\", input=\"elem1\")\n    assert sim == []\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_vsim_with_filter(d_client):\n    elements_count = 50\n    vector_dim = 800\n    for i in range(elements_count):\n        float_array = [random.uniform(0, 10) for x in range(vector_dim)]\n        attributes = {\"index\": i, \"elem_name\": f\"elem_{i}\"}\n        await d_client.vset().vadd(\n            \"myset\",\n            float_array,\n            f\"elem_{i}\",\n            numlinks=4,\n            attributes=attributes,\n        )\n    float_array = [-random.uniform(10, 20) for x in range(vector_dim)]\n    attributes = {\"index\": elements_count, \"elem_name\": \"elem_special\"}\n    await d_client.vset().vadd(\n        \"myset\",\n        float_array,\n        \"elem_special\",\n        numlinks=4,\n        attributes=attributes,\n    )\n    sim = await d_client.vset().vsim(\"myset\", input=\"elem_1\", filter=\".index > 10\")\n    assert len(sim) == 10\n    assert isinstance(sim, list)\n    for elem in sim:\n        assert int(elem.split(\"_\")[1]) > 10\n\n    sim = await d_client.vset().vsim(\n        \"myset\",\n        input=\"elem_1\",\n        filter=\".index > 10 and .index < 15 and .elem_name in ['elem_12', 'elem_17']\",\n    )\n    assert len(sim) == 1\n    assert isinstance(sim, list)\n    assert sim[0] == \"elem_12\"\n\n    sim = await d_client.vset().vsim(\n        \"myset\",\n        input=\"elem_1\",\n        filter=\".index > 25 and .elem_name in ['elem_12', 'elem_17', 'elem_19']\",\n        ef=100,\n    )\n    assert len(sim) == 0\n    assert isinstance(sim, list)\n\n    sim = await d_client.vset().vsim(\n        \"myset\",\n        input=\"elem_1\",\n        filter=\".index > 28 and .elem_name in ['elem_12', 'elem_17', 'elem_special']\",\n        filter_ef=1,\n    )\n    assert len(sim) == 0, (\n        f\"Expected 0 results, but got {len(sim)} with filter_ef=1, sim: {sim}\"\n    )\n    assert isinstance(sim, list)\n\n    sim = await d_client.vset().vsim(\n        \"myset\",\n        input=\"elem_1\",\n        filter=\".index > 28 and .elem_name in ['elem_12', 'elem_17', 'elem_special']\",\n        filter_ef=500,\n    )\n    assert len(sim) == 1\n    assert isinstance(sim, list)\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_vsim_truth_no_thread_enabled(d_client):\n    elements_count = 100\n    vector_dim = 50\n    for i in range(1, elements_count + 1):\n        float_array = [i * vector_dim for _ in range(vector_dim)]\n        await d_client.vset().vadd(\"myset\", float_array, f\"elem_{i}\")\n\n    await d_client.vset().vadd(\"myset\", [-22 for _ in range(vector_dim)], \"elem_man_2\")\n\n    sim_without_truth = await d_client.vset().vsim(\n        \"myset\", input=\"elem_man_2\", count=30, with_scores=True\n    )\n    sim_truth = await d_client.vset().vsim(\n        \"myset\", input=\"elem_man_2\", count=30, with_scores=True, truth=True\n    )\n\n    assert len(sim_without_truth) == 30\n    assert len(sim_truth) == 30\n\n    assert isinstance(sim_without_truth, dict)\n    assert isinstance(sim_truth, dict)\n\n    sim_no_thread = await d_client.vset().vsim(\n        \"myset\", input=\"elem_man_2\", with_scores=True, no_thread=True\n    )\n\n    assert len(sim_no_thread) == 10\n    assert isinstance(sim_no_thread, dict)\n\n\n@skip_if_server_version_lt(\"8.2.0\")\nasync def test_vsim_epsilon(d_client):\n    await d_client.vset().vadd(\"myset\", [2, 1, 1], \"a\")\n    await d_client.vset().vadd(\"myset\", [2, 0, 1], \"b\")\n    await d_client.vset().vadd(\"myset\", [2, 0, 0], \"c\")\n    await d_client.vset().vadd(\"myset\", [2, 0, 2], \"d\")\n    await d_client.vset().vadd(\"myset\", [-2, -1, -1], \"e\")\n\n    res1 = await d_client.vset().vsim(\"myset\", [2, 1, 1])\n    assert 5 == len(res1)\n\n    res2 = await d_client.vset().vsim(\"myset\", [2, 1, 1], epsilon=0.5)\n    assert 4 == len(res2)\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_vdim(d_client):\n    float_array = [1, 4.32, 0.11, 0.5, 0.9, 0.1, 0.2]\n    await d_client.vset().vadd(\"myset\", float_array, \"elem1\")\n\n    dim = await d_client.vset().vdim(\"myset\")\n    assert dim == len(float_array)\n\n    await d_client.vset().vadd(\"myset_reduced\", float_array, \"elem1\", reduce_dim=4)\n    reduced_dim = await d_client.vset().vdim(\"myset_reduced\")\n    assert reduced_dim == 4\n\n    with pytest.raises(redis.ResponseError):\n        await d_client.vset().vdim(\"myset_unexisting\")\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_vcard(d_client):\n    n = 20\n    for i in range(n):\n        float_array = [random.uniform(0, 10) for x in range(1, 8)]\n        await d_client.vset().vadd(\"myset\", float_array, f\"elem{i}\")\n\n    card = await d_client.vset().vcard(\"myset\")\n    assert card == n\n\n    with pytest.raises(redis.ResponseError):\n        await d_client.vset().vdim(\"myset_unexisting\")\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_vrem(d_client):\n    n = 3\n    for i in range(n):\n        float_array = [random.uniform(0, 10) for x in range(1, 8)]\n        await d_client.vset().vadd(\"myset\", float_array, f\"elem{i}\")\n\n    resp = await d_client.vset().vrem(\"myset\", \"elem2\")\n    assert resp == 1\n\n    card = await d_client.vset().vcard(\"myset\")\n    assert card == n - 1\n\n    resp = await d_client.vset().vrem(\"myset\", \"elem2\")\n    assert resp == 0\n\n    card = await d_client.vset().vcard(\"myset\")\n    assert card == n - 1\n\n    resp = await d_client.vset().vrem(\"myset_unexisting\", \"elem1\")\n    assert resp == 0\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_vemb_bin_quantization(d_client):\n    e = [1, 4.32, 0.0, 0.05, -2.9]\n    await d_client.vset().vadd(\n        \"myset\",\n        e,\n        \"elem\",\n        quantization=QuantizationOptions.BIN,\n    )\n    emb_no_quant = await d_client.vset().vemb(\"myset\", \"elem\")\n    assert emb_no_quant == [1, 1, -1, 1, -1]\n\n    emb_no_quant_raw = await d_client.vset().vemb(\"myset\", \"elem\", raw=True)\n    assert emb_no_quant_raw[\"quantization\"] == \"bin\"\n    assert isinstance(emb_no_quant_raw[\"raw\"], bytes)\n    assert isinstance(emb_no_quant_raw[\"l2\"], float)\n    assert \"range\" not in emb_no_quant_raw\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_vemb_q8_quantization(d_client):\n    e = [1, 10.32, 0.0, 2.05, -12.5]\n    await d_client.vset().vadd(\"myset\", e, \"elem\", quantization=QuantizationOptions.Q8)\n\n    emb_q8_quant = await d_client.vset().vemb(\"myset\", \"elem\")\n    assert _validate_quantization(e, emb_q8_quant, tolerance=0.1)\n\n    emb_q8_quant_raw = await d_client.vset().vemb(\"myset\", \"elem\", raw=True)\n    assert emb_q8_quant_raw[\"quantization\"] == \"int8\"\n    assert isinstance(emb_q8_quant_raw[\"raw\"], bytes)\n    assert isinstance(emb_q8_quant_raw[\"l2\"], float)\n    assert isinstance(emb_q8_quant_raw[\"range\"], float)\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_vemb_no_quantization(d_client):\n    e = [1, 10.32, 0.0, 2.05, -12.5]\n    await d_client.vset().vadd(\n        \"myset\", e, \"elem\", quantization=QuantizationOptions.NOQUANT\n    )\n\n    emb_no_quant = await d_client.vset().vemb(\"myset\", \"elem\")\n    assert _validate_quantization(e, emb_no_quant, tolerance=0.1)\n\n    emb_no_quant_raw = await d_client.vset().vemb(\"myset\", \"elem\", raw=True)\n    assert emb_no_quant_raw[\"quantization\"] == \"f32\"\n    assert isinstance(emb_no_quant_raw[\"raw\"], bytes)\n    assert isinstance(emb_no_quant_raw[\"l2\"], float)\n    assert \"range\" not in emb_no_quant_raw\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_vemb_default_quantization(d_client):\n    e = [1, 5.32, 0.0, 0.25, -5]\n    await d_client.vset().vadd(\"myset\", vector=e, element=\"elem\")\n\n    emb_default_quant = await d_client.vset().vemb(\"myset\", \"elem\")\n    assert _validate_quantization(e, emb_default_quant, tolerance=0.1)\n\n    emb_default_quant_raw = await d_client.vset().vemb(\"myset\", \"elem\", raw=True)\n    assert emb_default_quant_raw[\"quantization\"] == \"int8\"\n    assert isinstance(emb_default_quant_raw[\"raw\"], bytes)\n    assert isinstance(emb_default_quant_raw[\"l2\"], float)\n    assert isinstance(emb_default_quant_raw[\"range\"], float)\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_vemb_fp32_quantization(d_client):\n    float_array_fp32 = [1, 4.32, 0.11]\n    # Convert the list of floats to a byte array in fp32 format\n    byte_array = _to_fp32_blob_array(float_array_fp32)\n    await d_client.vset().vadd(\"myset\", byte_array, \"elem\")\n\n    emb_fp32_quant = await d_client.vset().vemb(\"myset\", \"elem\")\n    assert _validate_quantization(float_array_fp32, emb_fp32_quant, tolerance=0.1)\n\n    emb_fp32_quant_raw = await d_client.vset().vemb(\"myset\", \"elem\", raw=True)\n    assert emb_fp32_quant_raw[\"quantization\"] == \"int8\"\n    assert isinstance(emb_fp32_quant_raw[\"raw\"], bytes)\n    assert isinstance(emb_fp32_quant_raw[\"l2\"], float)\n    assert isinstance(emb_fp32_quant_raw[\"range\"], float)\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_vemb_unexisting(d_client):\n    emb_not_existing = await d_client.vset().vemb(\"not_existing\", \"elem\")\n    assert emb_not_existing is None\n\n    e = [1, 5.32, 0.0, 0.25, -5]\n    await d_client.vset().vadd(\"myset\", vector=e, element=\"elem\")\n    emb_elem_not_existing = await d_client.vset().vemb(\"myset\", \"not_existing\")\n    assert emb_elem_not_existing is None\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_vlinks(d_client):\n    elements_count = 100\n    vector_dim = 800\n    for i in range(elements_count):\n        float_array = [random.uniform(0, 10) for x in range(vector_dim)]\n        await d_client.vset().vadd(\n            \"myset\",\n            float_array,\n            f\"elem{i}\",\n            numlinks=8,\n        )\n\n    element_links_all_layers = await d_client.vset().vlinks(\"myset\", \"elem1\")\n    assert len(element_links_all_layers) >= 1\n    for neighbours_list_for_layer in element_links_all_layers:\n        assert isinstance(neighbours_list_for_layer, list)\n        for neighbour in neighbours_list_for_layer:\n            assert isinstance(neighbour, str)\n\n    elem_links_all_layers_with_scores = await d_client.vset().vlinks(\n        \"myset\", \"elem1\", with_scores=True\n    )\n    assert len(elem_links_all_layers_with_scores) >= 1\n    for neighbours_dict_for_layer in elem_links_all_layers_with_scores:\n        assert isinstance(neighbours_dict_for_layer, dict)\n        for neighbour_key, score_value in neighbours_dict_for_layer.items():\n            assert isinstance(neighbour_key, str)\n            assert isinstance(score_value, float)\n\n    float_array = [0.75, 0.25, 0.5, 0.1, 0.9]\n    await d_client.vset().vadd(\"myset_one_elem_only\", float_array, \"elem1\")\n    elem_no_neighbours_with_scores = await d_client.vset().vlinks(\n        \"myset_one_elem_only\", \"elem1\", with_scores=True\n    )\n    assert len(elem_no_neighbours_with_scores) >= 1\n    for neighbours_dict_for_layer in elem_no_neighbours_with_scores:\n        assert isinstance(neighbours_dict_for_layer, dict)\n        assert len(neighbours_dict_for_layer) == 0\n\n    elem_no_neighbours_no_scores = await d_client.vset().vlinks(\n        \"myset_one_elem_only\", \"elem1\"\n    )\n    assert len(elem_no_neighbours_no_scores) >= 1\n    for neighbours_list_for_layer in elem_no_neighbours_no_scores:\n        assert isinstance(neighbours_list_for_layer, list)\n        assert len(neighbours_list_for_layer) == 0\n\n    unexisting_element_links = await d_client.vset().vlinks(\"myset\", \"unexisting_elem\")\n    assert unexisting_element_links is None\n\n    unexisting_vset_links = await d_client.vset().vlinks(\"myset_unexisting\", \"elem1\")\n    assert unexisting_vset_links is None\n\n    unexisting_element_links = await d_client.vset().vlinks(\n        \"myset\", \"unexisting_elem\", with_scores=True\n    )\n    assert unexisting_element_links is None\n\n    unexisting_vset_links = await d_client.vset().vlinks(\n        \"myset_unexisting\", \"elem1\", with_scores=True\n    )\n    assert unexisting_vset_links is None\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_vinfo(d_client):\n    elements_count = 100\n    vector_dim = 800\n    for i in range(elements_count):\n        float_array = [random.uniform(0, 10) for x in range(vector_dim)]\n        await d_client.vset().vadd(\n            \"myset\",\n            float_array,\n            f\"elem{i}\",\n            numlinks=8,\n            quantization=QuantizationOptions.BIN,\n        )\n\n    vset_info = await d_client.vset().vinfo(\"myset\")\n    assert vset_info[\"quant-type\"] == \"bin\"\n    assert vset_info[\"vector-dim\"] == vector_dim\n    assert vset_info[\"size\"] == elements_count\n    assert vset_info[\"max-level\"] > 0\n    assert vset_info[\"hnsw-max-node-uid\"] == elements_count\n\n    unexisting_vset_info = await d_client.vset().vinfo(\"myset_unexisting\")\n    assert unexisting_vset_info is None\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_vset_vget_attributes(d_client):\n    float_array = [1, 4.32, 0.11]\n    attributes = {\"key1\": \"value1\", \"key2\": \"value2\"}\n\n    # validate vgetattrs when no attributes are set with vadd\n    resp = await d_client.vset().vadd(\"myset\", float_array, \"elem1\")\n    assert resp == 1\n\n    attrs = await d_client.vset().vgetattr(\"myset\", \"elem1\")\n    assert attrs is None\n\n    # validate vgetattrs when attributes are set with vadd\n    resp = await d_client.vset().vadd(\n        \"myset_with_attrs\", float_array, \"elem1\", attributes=attributes\n    )\n    assert resp == 1\n\n    attrs = await d_client.vset().vgetattr(\"myset_with_attrs\", \"elem1\")\n    assert attrs == attributes\n\n    # Set attributes and get attributes\n    resp = await d_client.vset().vsetattr(\"myset\", \"elem1\", attributes)\n    assert resp == 1\n    attr_saved = await d_client.vset().vgetattr(\"myset\", \"elem1\")\n    assert attr_saved == attributes\n\n    # Set attributes to None\n    resp = await d_client.vset().vsetattr(\"myset\", \"elem1\", None)\n    assert resp == 1\n    attr_saved = await d_client.vset().vgetattr(\"myset\", \"elem1\")\n    assert attr_saved is None\n\n    # Set attributes to empty dict\n    resp = await d_client.vset().vsetattr(\"myset\", \"elem1\", {})\n    assert resp == 1\n    attr_saved = await d_client.vset().vgetattr(\"myset\", \"elem1\")\n    assert attr_saved is None\n\n    # Set attributes provided as string\n    resp = await d_client.vset().vsetattr(\"myset\", \"elem1\", json.dumps(attributes))\n    assert resp == 1\n    attr_saved = await d_client.vset().vgetattr(\"myset\", \"elem1\")\n    assert attr_saved == attributes\n\n    # Set attributes to unexisting element\n    resp = await d_client.vset().vsetattr(\"myset\", \"elem2\", attributes)\n    assert resp == 0\n    attr_saved = await d_client.vset().vgetattr(\"myset\", \"elem2\")\n    assert attr_saved is None\n\n    # Set attributes to unexisting vset\n    resp = await d_client.vset().vsetattr(\"myset_unexisting\", \"elem1\", attributes)\n    assert resp == 0\n    attr_saved = await d_client.vset().vgetattr(\"myset_unexisting\", \"elem1\")\n    assert attr_saved is None\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_vrandmember(d_client):\n    elements = [\"elem1\", \"elem2\", \"elem3\"]\n    for elem in elements:\n        float_array = [random.uniform(0, 10) for x in range(1, 8)]\n        await d_client.vset().vadd(\"myset\", float_array, element=elem)\n\n    random_member = await d_client.vset().vrandmember(\"myset\")\n    assert random_member in elements\n\n    members_list = await d_client.vset().vrandmember(\"myset\", count=2)\n    assert len(members_list) == 2\n    assert all(member in elements for member in members_list)\n\n    # Test with count greater than the number of elements\n    members_list = await d_client.vset().vrandmember(\"myset\", count=10)\n    assert len(members_list) == len(elements)\n    assert all(member in elements for member in members_list)\n\n    # Test with negative count\n    members_list = await d_client.vset().vrandmember(\"myset\", count=-2)\n    assert len(members_list) == 2\n    assert all(member in elements for member in members_list)\n\n    # Test with count equal to the number of elements\n    members_list = await d_client.vset().vrandmember(\"myset\", count=len(elements))\n    assert len(members_list) == len(elements)\n    assert all(member in elements for member in members_list)\n\n    # Test with count equal to 0\n    members_list = await d_client.vset().vrandmember(\"myset\", count=0)\n    assert members_list == []\n\n    # Test with count equal to 1\n    members_list = await d_client.vset().vrandmember(\"myset\", count=1)\n    assert len(members_list) == 1\n    assert members_list[0] in elements\n\n    # Test with count equal to -1\n    members_list = await d_client.vset().vrandmember(\"myset\", count=-1)\n    assert len(members_list) == 1\n    assert members_list[0] in elements\n\n    # Test with unexisting vset & without count\n    members_list = await d_client.vset().vrandmember(\"myset_unexisting\")\n    assert members_list is None\n\n    # Test with unexisting vset & count\n    members_list = await d_client.vset().vrandmember(\"myset_unexisting\", count=5)\n    assert members_list == []\n\n\n@skip_if_server_version_lt(\"8.2.0\")\nasync def test_8_2_new_vset_features_without_decoding_responces(client):\n    # test vadd\n    elements = [\"elem1\", \"elem2\", \"elem3\"]\n    attrs_dict = {\"key1\": \"value1\", \"key2\": \"value2\"}\n    for elem in elements:\n        float_array = [random.uniform(0.5, 10) for x in range(0, 8)]\n        resp = await client.vset().vadd(\n            \"myset\", float_array, element=elem, attributes=attrs_dict\n        )\n        assert resp == 1\n\n    # test vsim with attributes\n    vsim_with_attribs = await client.vset().vsim(\n        \"myset\", input=\"elem1\", with_attribs=True\n    )\n    assert len(vsim_with_attribs) == 3\n    assert isinstance(vsim_with_attribs, dict)\n    assert isinstance(vsim_with_attribs[b\"elem1\"], dict)\n    assert vsim_with_attribs[b\"elem1\"] == attrs_dict\n\n    # test vsim with score and attributes\n    vsim_with_scores_and_attribs = await client.vset().vsim(\n        \"myset\", input=\"elem1\", with_scores=True, with_attribs=True\n    )\n    assert len(vsim_with_scores_and_attribs) == 3\n    assert isinstance(vsim_with_scores_and_attribs, dict)\n    assert isinstance(vsim_with_scores_and_attribs[b\"elem1\"], dict)\n    assert \"score\" in vsim_with_scores_and_attribs[b\"elem1\"]\n    assert \"attributes\" in vsim_with_scores_and_attribs[b\"elem1\"]\n    assert isinstance(vsim_with_scores_and_attribs[b\"elem1\"][\"score\"], float)\n    assert isinstance(vsim_with_scores_and_attribs[b\"elem1\"][\"attributes\"], dict)\n    assert vsim_with_scores_and_attribs[b\"elem1\"][\"attributes\"] == attrs_dict\n\n\n@skip_if_server_version_lt(\"7.9.0\")\nasync def test_vset_commands_without_decoding_responces(client):\n    # test vadd\n    elements = [\"elem1\", \"elem2\", \"elem3\"]\n    attrs_dict = {\"key1\": \"value1\", \"key2\": \"value2\"}\n    for elem in elements:\n        float_array = [random.uniform(0.5, 10) for x in range(0, 8)]\n        resp = await client.vset().vadd(\n            \"myset\", float_array, element=elem, attributes=attrs_dict\n        )\n        assert resp == 1\n\n    # test vemb\n    emb = await client.vset().vemb(\"myset\", \"elem1\")\n    assert len(emb) == 8\n    assert isinstance(emb, list)\n    assert all(isinstance(x, float) for x in emb)\n\n    emb_raw = await client.vset().vemb(\"myset\", \"elem1\", raw=True)\n    assert emb_raw[\"quantization\"] == b\"int8\"\n    assert isinstance(emb_raw[\"raw\"], bytes)\n    assert isinstance(emb_raw[\"l2\"], float)\n    assert isinstance(emb_raw[\"range\"], float)\n\n    # test vsim\n    vsim = await client.vset().vsim(\"myset\", input=\"elem1\")\n    assert len(vsim) == 3\n    assert isinstance(vsim, list)\n    assert isinstance(vsim[0], bytes)\n\n    # test vsim with scores\n    vsim_with_scores = await client.vset().vsim(\n        \"myset\", input=\"elem1\", with_scores=True\n    )\n    assert len(vsim_with_scores) == 3\n    assert isinstance(vsim_with_scores, dict)\n    assert isinstance(vsim_with_scores[b\"elem1\"], float)\n\n    # test vlinks - no scores\n    element_links_all_layers = await client.vset().vlinks(\"myset\", \"elem1\")\n    assert len(element_links_all_layers) >= 1\n    for neighbours_list_for_layer in element_links_all_layers:\n        assert isinstance(neighbours_list_for_layer, list)\n        for neighbour in neighbours_list_for_layer:\n            assert isinstance(neighbour, bytes)\n    # test vlinks with scores\n    elem_links_all_layers_with_scores = await client.vset().vlinks(\n        \"myset\", \"elem1\", with_scores=True\n    )\n    assert len(elem_links_all_layers_with_scores) >= 1\n    for neighbours_dict_for_layer in elem_links_all_layers_with_scores:\n        assert isinstance(neighbours_dict_for_layer, dict)\n        for neighbour_key, score_value in neighbours_dict_for_layer.items():\n            assert isinstance(neighbour_key, bytes)\n            assert isinstance(score_value, float)\n\n    # test vinfo\n    vset_info = await client.vset().vinfo(\"myset\")\n    assert vset_info[b\"quant-type\"] == b\"int8\"\n    assert vset_info[b\"vector-dim\"] == 8\n    assert vset_info[b\"size\"] == len(elements)\n    assert vset_info[b\"max-level\"] >= 0\n    assert vset_info[b\"hnsw-max-node-uid\"] == len(elements)\n\n    # test vgetattr\n    attributes = {\"key1\": \"value1\", \"key2\": \"value2\"}\n    await client.vset().vsetattr(\"myset\", \"elem1\", attributes)\n    attrs = await client.vset().vgetattr(\"myset\", \"elem1\")\n    assert attrs == attributes\n\n    # test vrandmember\n    random_member = await client.vset().vrandmember(\"myset\")\n    assert isinstance(random_member, bytes)\n    assert random_member.decode(\"utf-8\") in elements\n\n    members_list = await client.vset().vrandmember(\"myset\", count=2)\n    assert len(members_list) == 2\n    assert all(member.decode(\"utf-8\") in elements for member in members_list)\n\n\ndef _to_fp32_blob_array(float_array):\n    \"\"\"\n    Convert a list of floats to a byte array in fp32 format.\n    \"\"\"\n    # Convert the list of floats to a NumPy array with dtype np.float32\n    arr = np.array(float_array, dtype=np.float32)\n    # Convert the NumPy array to a byte array\n    byte_array = arr.tobytes()\n    return byte_array\n\n\ndef _validate_quantization(original, quantized, tolerance=0.1):\n    original = np.array(original, dtype=np.float32)\n    quantized = np.array(quantized, dtype=np.float32)\n\n    max_diff = np.max(np.abs(original - quantized))\n    if max_diff > tolerance:\n        return False\n    else:\n        return True\n\n\n@skip_if_server_version_lt(\"8.4.0\")\nasync def test_vrange_basic(d_client):\n    \"\"\"Test basic VRANGE functionality with lexicographical ordering.\"\"\"\n    # Add elements with different names\n    elements = [\"apple\", \"banana\", \"cherry\", \"date\", \"elderberry\"]\n    for elem in elements:\n        await d_client.vset().vadd(\"myset\", [1.0, 2.0, 3.0], elem)\n\n    # Test full range\n    result = await d_client.vset().vrange(\"myset\", \"-\", \"+\")\n    assert result == elements\n    assert len(result) == 5\n\n    # Test inclusive range\n    result = await d_client.vset().vrange(\"myset\", \"[banana\", \"[date\")\n    assert result == [\"banana\", \"cherry\", \"date\"]\n\n    # Test exclusive range\n    result = await d_client.vset().vrange(\"myset\", \"(banana\", \"(date\")\n    assert result == [\"cherry\"]\n\n\n@skip_if_server_version_lt(\"8.4.0\")\nasync def test_vrange_with_count(d_client):\n    \"\"\"Test VRANGE with count parameter.\"\"\"\n    # Add elements\n    elements = [\"a\", \"b\", \"c\", \"d\", \"e\", \"f\", \"g\"]\n    for elem in elements:\n        await d_client.vset().vadd(\"myset\", [1.0, 2.0], elem)\n\n    # Test with positive count\n    result = await d_client.vset().vrange(\"myset\", \"-\", \"+\", count=3)\n    assert len(result) == 3\n    assert result == [\"a\", \"b\", \"c\"]\n\n    # Test with count larger than set size\n    result = await d_client.vset().vrange(\"myset\", \"-\", \"+\", count=100)\n    assert len(result) == 7\n    assert result == elements\n\n    # Test with count = 0\n    result = await d_client.vset().vrange(\"myset\", \"-\", \"+\", count=0)\n    assert result == []\n\n    # Test with negative count (should return all)\n    result = await d_client.vset().vrange(\"myset\", \"-\", \"+\", count=-1)\n    assert len(result) == 7\n    assert result == elements\n\n\n@skip_if_server_version_lt(\"8.4.0\")\nasync def test_vrange_iteration(d_client):\n    \"\"\"Test VRANGE for stateless iteration.\"\"\"\n    # Add elements\n    elements = [f\"elem{i:03d}\" for i in range(20)]\n    for elem in elements:\n        await d_client.vset().vadd(\"myset\", [1.0, 2.0], elem)\n\n    # Iterate through all elements, 5 at a time\n    all_results = []\n    start = \"-\"\n    while True:\n        result = await d_client.vset().vrange(\"myset\", start, \"+\", count=5)\n        if not result:\n            break\n        all_results.extend(result)\n        # Continue from the last element (exclusive)\n        start = f\"({result[-1]}\"\n\n    assert len(all_results) == 20\n    assert all_results == elements\n\n\n@skip_if_server_version_lt(\"8.4.0\")\nasync def test_vrange_empty_key(d_client):\n    \"\"\"Test VRANGE on non-existent key.\"\"\"\n    result = await d_client.vset().vrange(\"nonexistent\", \"-\", \"+\")\n    assert result == []\n\n    result = await d_client.vset().vrange(\"nonexistent\", \"[a\", \"[z\", count=10)\n    assert result == []\n\n\n@skip_if_server_version_lt(\"8.4.0\")\nasync def test_vrange_special_characters(d_client):\n    \"\"\"Test VRANGE with elements containing special characters.\"\"\"\n    # Add elements with special characters\n    elements = [\"a:1\", \"a:2\", \"b:1\", \"b:2\", \"c:1\"]\n    for elem in elements:\n        await d_client.vset().vadd(\"myset\", [1.0, 2.0], elem)\n\n    # Test range with prefix\n    result = await d_client.vset().vrange(\"myset\", \"[a:\", \"[a:9\")\n    assert result == [\"a:1\", \"a:2\"]\n\n    result = await d_client.vset().vrange(\"myset\", \"[b:\", \"[b:9\")\n    assert result == [\"b:1\", \"b:2\"]\n\n\n@skip_if_server_version_lt(\"8.4.0\")\nasync def test_vrange_single_element(d_client):\n    \"\"\"Test VRANGE with a single element.\"\"\"\n    await d_client.vset().vadd(\"myset\", [1.0, 2.0], \"single\")\n\n    result = await d_client.vset().vrange(\"myset\", \"-\", \"+\")\n    assert result == [\"single\"]\n\n    result = await d_client.vset().vrange(\"myset\", \"[single\", \"[single\")\n    assert result == [\"single\"]\n\n    result = await d_client.vset().vrange(\"myset\", \"(single\", \"+\")\n    assert result == []\n\n\n@skip_if_server_version_lt(\"8.4.0\")\nasync def test_vrange_lexicographical_order(d_client):\n    \"\"\"Test that VRANGE returns elements in correct lexicographical order.\"\"\"\n    # Add elements in random order\n    elements = [\"zebra\", \"apple\", \"mango\", \"banana\", \"cherry\"]\n    for elem in elements:\n        await d_client.vset().vadd(\"myset\", [1.0, 2.0], elem)\n\n    # Should return in sorted order\n    result = await d_client.vset().vrange(\"myset\", \"-\", \"+\")\n    expected = sorted(elements)\n    assert result == expected\n\n\n@skip_if_server_version_lt(\"8.4.0\")\nasync def test_vrange_numeric_strings(d_client):\n    \"\"\"Test VRANGE with numeric string elements.\"\"\"\n    # Add numeric strings (lexicographical order, not numeric)\n    elements = [\"1\", \"10\", \"2\", \"20\", \"3\"]\n    for elem in elements:\n        await d_client.vset().vadd(\"myset\", [1.0, 2.0], elem)\n\n    # Lexicographical order: \"1\", \"10\", \"2\", \"20\", \"3\"\n    result = await d_client.vset().vrange(\"myset\", \"-\", \"+\")\n    expected = sorted(elements)  # [\"1\", \"10\", \"2\", \"20\", \"3\"]\n    assert result == expected\n\n    # Range from \"1\" to \"2\" (inclusive)\n    result = await d_client.vset().vrange(\"myset\", \"[1\", \"[2\")\n    assert result == [\"1\", \"10\", \"2\"]\n"
  },
  {
    "path": "tests/test_asyncio/testdata/jsontestdata.py",
    "content": "nested_large_key = r\"\"\"\n{\n  \"jkra\": [\n    154,\n    4472,\n    [\n      8567,\n      false,\n      363.84,\n      5276,\n      \"ha\",\n      \"rizkzs\",\n      93\n    ],\n    false\n  ],\n  \"hh\": 20.77,\n  \"mr\": 973.217,\n  \"ihbe\": [\n    68,\n    [\n      true,\n      {\n        \"lqe\": [\n          486.363,\n          [\n            true,\n            {\n              \"mp\": {\n                \"ory\": \"rj\",\n                \"qnl\": \"tyfrju\",\n                \"hf\": None\n              },\n              \"uooc\": 7418,\n              \"xela\": 20,\n              \"bt\": 7014,\n              \"ia\": 547,\n              \"szec\": 68.73\n            },\n            None\n          ],\n          3622,\n          \"iwk\",\n          None\n        ],\n        \"fepi\": 19.954,\n        \"ivu\": {\n          \"rmnd\": 65.539,\n          \"bk\": 98,\n          \"nc\": \"bdg\",\n          \"dlb\": {\n            \"hw\": {\n              \"upzz\": [\n                true,\n                {\n                  \"nwb\": [\n                    4259.47\n                  ],\n                  \"nbt\": \"yl\"\n                },\n                false,\n                false,\n                65,\n                [\n                  [\n                    [],\n                    629.149,\n                    \"lvynqh\",\n                    \"hsk\",\n                    [],\n                    2011.932,\n                    true,\n                    []\n                  ],\n                  None,\n                  \"ymbc\",\n                  None\n                ],\n                \"aj\",\n                97.425,\n                \"hc\",\n                58\n              ]\n            },\n            \"jq\": true,\n            \"bi\": 3333,\n            \"hmf\": \"pl\",\n            \"mrbj\": [\n              true,\n              false\n            ]\n          }\n        },\n        \"hfj\": \"lwk\",\n        \"utdl\": \"aku\",\n        \"alqb\": [\n          74,\n          534.389,\n          7235,\n          [\n            None,\n            false,\n            None\n          ]\n        ]\n      },\n      None,\n      {\n        \"lbrx\": {\n          \"vm\": \"ubdrbb\"\n        },\n        \"tie\": \"iok\",\n        \"br\": \"ojro\"\n      },\n      70.558,\n      [\n        {\n          \"mmo\": None,\n          \"dryu\": None\n        }\n      ]\n    ],\n    true,\n    None,\n    false,\n    {\n      \"jqun\": 98,\n      \"ivhq\": [\n        [\n          [\n            675.936,\n            [\n              520.15,\n              1587.4,\n              false\n            ],\n            \"jt\",\n            true,\n            {\n              \"bn\": None,\n              \"ygn\": \"cve\",\n              \"zhh\": true,\n              \"aak\": 9165,\n              \"skx\": true,\n              \"qqsk\": 662.28\n            },\n            {\n              \"eio\": 9933.6,\n              \"agl\": None,\n              \"pf\": false,\n              \"kv\": 5099.631,\n              \"no\": None,\n              \"shly\": 58\n            },\n            [\n              None,\n              [\n                \"uiundu\",\n                726.652,\n                false,\n                94.92,\n                259.62,\n                {\n                  \"ntqu\": None,\n                  \"frv\": None,\n                  \"rvop\": \"upefj\",\n                  \"jvdp\": {\n                    \"nhx\": [],\n                    \"bxnu\": {},\n                    \"gs\": None,\n                    \"mqho\": None,\n                    \"xp\": 65,\n                    \"ujj\": {}\n                  },\n                  \"ts\": false,\n                  \"kyuk\": [\n                    false,\n                    58,\n                    {},\n                    \"khqqif\"\n                  ]\n                },\n                167,\n                true,\n                \"bhlej\",\n                53\n              ],\n              64,\n              {\n                \"eans\": \"wgzfo\",\n                \"zfgb\": 431.67,\n                \"udy\": [\n                  {\n                    \"gnt\": [],\n                    \"zeve\": {}\n                  },\n                  {\n                    \"pg\": {},\n                    \"vsuc\": {},\n                    \"dw\": 19,\n                    \"ffo\": \"uwsh\",\n                    \"spk\": \"pjdyam\",\n                    \"mc\": [],\n                    \"wunb\": {},\n                    \"qcze\": 2271.15,\n                    \"mcqx\": None\n                  },\n                  \"qob\"\n                ],\n                \"wo\": \"zy\"\n              },\n              {\n                \"dok\": None,\n                \"ygk\": None,\n                \"afdw\": [\n                  7848,\n                  \"ah\",\n                  None\n                ],\n                \"foobar\": 3.141592,\n                \"wnuo\": {\n                  \"zpvi\": {\n                    \"stw\": true,\n                    \"bq\": {},\n                    \"zord\": true,\n                    \"omne\": 3061.73,\n                    \"bnwm\": \"wuuyy\",\n                    \"tuv\": 7053,\n                    \"lepv\": None,\n                    \"xap\": 94.26\n                  },\n                  \"nuv\": false,\n                  \"hhza\": 539.615,\n                  \"rqw\": {\n                    \"dk\": 2305,\n                    \"wibo\": 7512.9,\n                    \"ytbc\": 153,\n                    \"pokp\": None,\n                    \"whzd\": None,\n                    \"judg\": [],\n                    \"zh\": None\n                  },\n                  \"bcnu\": \"ji\",\n                  \"yhqu\": None,\n                  \"gwc\": true,\n                  \"smp\": {\n                    \"fxpl\": 75,\n                    \"gc\": [],\n                    \"vx\": 9352.895,\n                    \"fbzf\": 4138.27,\n                    \"tiaq\": 354.306,\n                    \"kmfb\": {},\n                    \"fxhy\": [],\n                    \"af\": 94.46,\n                    \"wg\": {},\n                    \"fb\": None\n                  }\n                },\n                \"zvym\": 2921,\n                \"hhlh\": [\n                  45,\n                  214.345\n                ],\n                \"vv\": \"gqjoz\"\n              },\n              [\n                \"uxlu\",\n                None,\n                \"utl\",\n                64,\n                [\n                  2695\n                ],\n                [\n                  false,\n                  None,\n                  [\n                    \"cfcrl\",\n                    [],\n                    [],\n                    562,\n                    1654.9,\n                    {},\n                    None,\n                    \"sqzud\",\n                    934.6\n                  ],\n                  {\n                    \"hk\": true,\n                    \"ed\": \"lodube\",\n                    \"ye\": \"ziwddj\",\n                    \"ps\": None,\n                    \"ir\": {},\n                    \"heh\": false\n                  },\n                  true,\n                  719,\n                  50.56,\n                  [\n                    99,\n                    6409,\n                    None,\n                    4886,\n                    \"esdtkt\",\n                    {},\n                    None\n                  ],\n                  [\n                    false,\n                    \"bkzqw\"\n                  ]\n                ],\n                None,\n                6357\n              ],\n              {\n                \"asvv\": 22.873,\n                \"vqm\": {\n                  \"drmv\": 68.12,\n                  \"tmf\": 140.495,\n                  \"le\": None,\n                  \"sanf\": [\n                    true,\n                    [],\n                    \"vyawd\",\n                    false,\n                    76.496,\n                    [],\n                    \"sdfpr\",\n                    33.16,\n                    \"nrxy\",\n                    \"antje\"\n                  ],\n                  \"yrkh\": 662.426,\n                  \"vxj\": true,\n                  \"sn\": 314.382,\n                  \"eorg\": None\n                },\n                \"bavq\": [\n                  21.18,\n                  8742.66,\n                  {\n                    \"eq\": \"urnd\"\n                  },\n                  56.63,\n                  \"fw\",\n                  [\n                    {},\n                    \"pjtr\",\n                    None,\n                    \"apyemk\",\n                    [],\n                    [],\n                    false,\n                    {}\n                  ],\n                  {\n                    \"ho\": None,\n                    \"ir\": 124,\n                    \"oevp\": 159,\n                    \"xdrv\": 6705,\n                    \"ff\": [],\n                    \"sx\": false\n                  },\n                  true,\n                  None,\n                  true\n                ],\n                \"zw\": \"qjqaap\",\n                \"hr\": {\n                  \"xz\": 32,\n                  \"mj\": 8235.32,\n                  \"yrtv\": None,\n                  \"jcz\": \"vnemxe\",\n                  \"ywai\": [\n                    None,\n                    564,\n                    false,\n                    \"vbr\",\n                    54.741\n                  ],\n                  \"vw\": 82,\n                  \"wn\": true,\n                  \"pav\": true\n                },\n                \"vxa\": 881\n              },\n              \"bgt\",\n              \"vuzk\",\n              857\n            ]\n          ]\n        ],\n        None,\n        None,\n        {\n          \"xyzl\": \"nvfff\"\n        },\n        true,\n        13\n      ],\n      \"npd\": None,\n      \"ha\": [\n        [\n          \"du\",\n          [\n            980,\n            {\n              \"zdhd\": [\n                129.986,\n                [\n                  \"liehns\",\n                  453,\n                  {\n                    \"fuq\": false,\n                    \"dxpn\": {},\n                    \"hmpx\": 49,\n                    \"zb\": \"gbpt\",\n                    \"vdqc\": None,\n                    \"ysjg\": false,\n                    \"gug\": 7990.66\n                  },\n                  \"evek\",\n                  [\n                    {}\n                  ],\n                  \"dfywcu\",\n                  9686,\n                  None\n                ]\n              ],\n              \"gpi\": {\n                \"gt\": {\n                  \"qe\": 7460,\n                  \"nh\": \"nrn\",\n                  \"czj\": 66.609,\n                  \"jwd\": true,\n                  \"rb\": \"azwwe\",\n                  \"fj\": {\n                    \"csn\": true,\n                    \"foobar\": 1.61803398875,\n                    \"hm\": \"efsgw\",\n                    \"zn\": \"vbpizt\",\n                    \"tjo\": 138.15,\n                    \"teo\": {},\n                    \"hecf\": [],\n                    \"ls\": false\n                  }\n                },\n                \"xlc\": 7916,\n                \"jqst\": 48.166,\n                \"zj\": \"ivctu\"\n              },\n              \"jl\": 369.27,\n              \"mxkx\": None,\n              \"sh\": [\n                true,\n                373,\n                false,\n                \"sdis\",\n                6217,\n                {\n                  \"ernm\": None,\n                  \"srbo\": 90.798,\n                  \"py\": 677,\n                  \"jgrq\": None,\n                  \"zujl\": None,\n                  \"odsm\": {\n                    \"pfrd\": None,\n                    \"kwz\": \"kfvjzb\",\n                    \"ptkp\": false,\n                    \"pu\": None,\n                    \"xty\": None,\n                    \"ntx\": [],\n                    \"nq\": 48.19,\n                    \"lpyx\": []\n                  },\n                  \"ff\": None,\n                  \"rvi\": [\n                    \"ych\",\n                    {},\n                    72,\n                    9379,\n                    7897.383,\n                    true,\n                    {},\n                    999.751,\n                    false\n                  ]\n                },\n                true\n              ],\n              \"ghe\": [\n                24,\n                {\n                  \"lpr\": true,\n                  \"qrs\": true\n                },\n                true,\n                false,\n                7951.94,\n                true,\n                2690.54,\n                [\n                  93,\n                  None,\n                  None,\n                  \"rlz\",\n                  true,\n                  \"ky\",\n                  true\n                ]\n              ],\n              \"vet\": false,\n              \"olle\": None\n            },\n            \"jzm\",\n            true\n          ],\n          None,\n          None,\n          19.17,\n          7145,\n          \"ipsmk\"\n        ],\n        false,\n        {\n          \"du\": 6550.959,\n          \"sps\": 8783.62,\n          \"nblr\": {\n            \"dko\": 9856.616,\n            \"lz\": {\n              \"phng\": \"dj\"\n            },\n            \"zeu\": 766,\n            \"tn\": \"dkr\"\n          },\n          \"xa\": \"trdw\",\n          \"gn\": 9875.687,\n          \"dl\": None,\n          \"vuql\": None\n        },\n        {\n          \"qpjo\": None,\n          \"das\": {\n            \"or\": {\n              \"xfy\": None,\n              \"xwvs\": 4181.86,\n              \"yj\": 206.325,\n              \"bsr\": [\n                \"qrtsh\"\n              ],\n              \"wndm\": {\n                \"ve\": 56,\n                \"jyqa\": true,\n                \"ca\": None\n              },\n              \"rpd\": 9906,\n              \"ea\": \"dvzcyt\"\n            },\n            \"xwnn\": 9272,\n            \"rpx\": \"zpr\",\n            \"srzg\": {\n              \"beo\": 325.6,\n              \"sq\": None,\n              \"yf\": None,\n              \"nu\": [\n                377,\n                \"qda\",\n                true\n              ],\n              \"sfz\": \"zjk\"\n            },\n            \"kh\": \"xnpj\",\n            \"rk\": None,\n            \"hzhn\": [\n              None\n            ],\n            \"uio\": 6249.12,\n            \"nxrv\": 1931.635,\n            \"pd\": None\n          },\n          \"pxlc\": true,\n          \"mjer\": false,\n          \"hdev\": \"msr\",\n          \"er\": None\n        },\n        \"ug\",\n        None,\n        \"yrfoix\",\n        503.89,\n        563\n      ],\n      \"tcy\": 300,\n      \"me\": 459.17,\n      \"tm\": [\n        134.761,\n        \"jcoels\",\n        None\n      ],\n      \"iig\": 945.57,\n      \"ad\": \"be\"\n    },\n    \"ltpdm\",\n    None,\n    14.53\n  ],\n  \"xi\": \"gxzzs\",\n  \"zfpw\": 1564.87,\n  \"ow\": None,\n  \"tm\": [\n    46,\n    876.85\n  ],\n  \"xejv\": None\n}\n\"\"\"  # noqa\n"
  },
  {
    "path": "tests/test_asyncio/testdata/titles.csv",
    "content": "bhoj shala,1\nradhika balakrishnan,1\nltm,1\nsterlite energy,1\ntroll doll,11\nsonnontio,1\nnickelodeon netherlands kids choice awards,1\njamaica national basketball team,5\nclan mackenzie,1\nsecure attention key,3\ntemplate talk indo pakistani war of 1971,1\nhassan firouzabadi,2\ncarter alan,1\nalan levy,1\ntim severin,2\nfaux pas derived from chinese pronunciation,1\njruby,3\ntobias nielsén,1\navro 571 buffalo,1\ntreasury stock,17\nשלום,10\noxygen 19,1\nntru,4\ntennis racquet,1\nplace of birth,4\ncouncil of canadians,1\nurshu,1\namerican hotel,1\ndow corning corporation,3\nlanguage based learning disability,3\nmeri aashiqui tum se hi,30\nspecificity,9\nedward l hedden,1\npelli chesukundam,2\nof love and shadows,4\nfort san felipe,2\namerican express gold card dress of lizzy gardiner,4\njovian,5\nkitashinagawa station,1\nradhi jaidi,1\ncordelia scaife may,2\nminor earth major sky,1\nbunty lawless stakes,1\nhigh capacity color barcode,3\nlyla lerrol,1\ncrawford roberts,1\ncollin balester,1\nugo crousillat,1\nom prakash chautala,3\nizzy hoyland,1\nthe poet,2\ndaryl sabara,6\naromatic acid,2\nreina sofia,1\nswierczek masovian voivodeship,1\nhousing segregation in the united states,2\nkaren maser,1\nscaptia beyonceae,2\nkitakyushu city,1\nhtc desire 610,4\ndostoevsky,3\nportal victorian era,1\nbose–einstein correlations,3\nralph hodgson,1\nracquet club,2\nwalter camp man of the year,1\naustralian movies,1\nk04he,1\naustralia–india relations,2\njohn william howard thompson,1\npro cathedral,1\npaddyfield pipit,2\nbook finance,1\nford maverick,10\nslurve,4\nmnozil brass,2\nfiesta 9 1/8 inch square luncheon plate sunflower,1\nkorsi,1\ndraft 140th operations group,2\ncamp,29\nseries acceleration,1\naljouf,1\ndemocratic party of new mexico,2\nunited kingdom general election debates 2010,2\nmadura strait,2\nback examination,1\nborgata,2\nil ritorno di tobia,3\novaphagous,1\nmotörhead,9\nhellmaster,1\nrichard keynes,1\ncryogenic treatment,3\nmonte porzio,1\ntransliteration of arabic,1\nanti catholic,2\na very merry pooh year,2\nsuffixes in hebrew,3\nbarr body,16\nalaska constitution,1\njuan garrido,1\nyi lijun,1\nwawa inc,2\nendre kelemen,1\nl brands,18\nlr44,1\ncoat of arms of the nagorno karabakh republic,1\nantonino fernandez,1\nsalisbury roller girls,1\nzayat,2\nian meadows,2\nsemigalia,1\nkhloe and lamar,2\nholding,1\nlarchmont edgewater,1\ndynamic parcel distribution,6\nseaworld,30\nassistant secretary of war,1\ndigital currency,14\nmazomanie wisconsin,1\nsujatha rangarajan,8\nstreet child,1\nanna sheehan,1\nviolence jack,2\nsanti solari,1\ntemplate talk texas in the civil war,1\ncolorss foundation,1\nfaucaria,1\nalfred gardyne de chastelain,2\ntramp,1\ncannington ontario,2\npenguinone,1\ncardiac arrest,2\nsumman grouper,1\ncyndis list,1\ncbs,2\nsalminus brasiliensis,2\nkodiak bear,26\ncinemascore,9\nphragmidium,1\ncity of vultures,1\nlawrence g romo,1\nchandni chowk to china,1\nscarp retreat,1\nrosses point,1\ncarretera de cádiz,1\nchamunda,8\nbattle of stalingrad,1\nwho came first,2\nsalome,5\nportuguese historical museum,3\nwestfield sarasota square,1\nmuehrckes nails,3\nkennebec north carolina,1\namerican classical league,1\nhow do you like them apples,1\nmark halperin,20\ncirco,1\nturner classic movies,2\naustralian rules football in sweden,1\nhousehold silver,3\nfrank baird,1\nescape from east berlin,2\na village romeo and juliet,1\nwally nesbitt,6\njoseph renzulli,2\nspalding gray,1\ndangaria kandha,1\npms asterisk,2\nopenal,1\nromy haag,1\nmh message handling system,4\npioneer 4,4\nhmcs stettler,1\ngangsta,10\nmajor third,4\njoan osbourn,1\nmount columbia,2\nactive galactic nucleus,14\nrobert clary,8\neva pracht,1\nion implantation,5\nrydell poepon,4\nballer blockin,2\nenfield chase railway station,1\nserge aurier,13\nflorin vlaicu,1\nvan diemens land,9\nkrishnapur bagalkot,1\noleksandr zinchenko,96\ncollaborations,2\nhecla,2\namber marshall,7\ninácio henrique de gouveia,1\nbronze age korea,1\nslc punk,5\nryan jack,2\nclathrus ruber,6\nangel of death,4\nvalentines park,1\nextra pyramidal,1\nkiami davael,1\noleg i shuplyak,1\nnidum,2\nfriendship of salem,2\nbèze,3\narnold weinstock,1\nable,1\ns d ugamchand,1\nthe omega glory,2\nami james,3\ndenmark at the 1968 summer olympics,1\nkill me again,1\nrichmond town square,1\nguy domville,1\njessica simpson,1\nkinship care,1\nbrugge railway station,2\nunobtainium,16\ncarl johan bernadotte,3\nacacia concinna,5\nepinomis,1\ninterlachen country club,1\ncompromise tariff,1\nfairchild jk,1\ndog trainer,1\nbrian dabul,1\ncai yong,1\njezebel,7\naugarten porcelain,1\nsummerslam 1992,1\nion andoni goikoetxea,2\ndominican church vienna,1\niffhs worlds best club coach,2\nuruguayan presidential election 2009,2\nsaving the queen,1\nun cadavre,1\nhistory of the jews in france,4\nwbyg,1\ncharles de brosses,2\nhuman weapon,2\nhaunted castle,3\naustin maestro,1\nsearch for extra terrestrial intelligence,1\nsuwon,9\ncost per impression,1\nosney lock,1\nmarkus eriksson,1\ncultural depictions of tony blair,2\nerich kempka,3\npornogrind,5\nchekhov,1\nmarilinda garcia,2\nhard drive,1\nsmall arms,9\nexploration of north america,8\ninternational korfball federation,1\nphotographic lens design,4\nk hari prasad,1\nlebanese forces,3\ngreece at the 2004 summer olympics,1\nlets trim our hair in accordance with the socialist lifestyle,2\nbattle of cassinga,5\ndonald and the wheel,1\nvti transmission,1\ngille chlerig earl of mar,1\nheart of atlanta motel inc v united states,6\noh yeah,3\ncarol decker,5\nprajakta shukre,4\nprofiling,17\nthukima,1\nthe great waldo search,1\nnick vincent,2\nthe decision of the appeals jury is final and can only be overruled by a decision of the executive committee 2e,1\ncivilization board game,1\nerasmus+,1\neden phillpotts,1\nunleash the beast,1\nvaroujan hakhbandian,1\nfermats last theorem,1\nconan the indomitable,1\nvagrant records,1\nhouse of villehardouin,1\nzoneyesha ulatha,1\nashur bel nisheshu,1\nten wijngaerde,2\nlgi homes,1\namerican nietzsche a history of an icon and his ideas,1\neuropean magpie,3\npablo soto,1\nterminiello v chicago,1\nvladimir cosma,2\nbattle of yunnan burma road,1\nophirodexia,1\nthudar,1\nnorthern irish,2\nbohemond of tarente,1\nanita moorjani,5\nserra do gerês,1\nfort horsted,1\nmetre gauge,2\nstage show,3\ncommon flexor sheath of hand,2\nconall corc,1\narray slicing,6\nschüfftan process,1\nanmol malik,3\nout cold,2\nantiknock,2\nmoss force,1\npaul medhurst,1\nsomonauk illinois,1\ngeorge crum,11\nbaby talk,6\ndaniel mann,4\nvacuum flask,10\nprostitution in the republic of ireland,5\nbutch jones,7\nfeminism in ukraine,1\nst marys church kilmore county wexford,1\nsonny emory,1\nsatsuma han,1\nelben,1\nthe best of the rippingtons,3\nm3p,1\nboat sharing,1\niisco,1\nhoftoren,1\ncannabis in the united kingdom,6\ntemplate talk germany districts saxony anhalt,1\njean baptiste dutrou bornier,1\nteylers museum,1\nsimons problem,2\ngerardus huysmans,1\npupillary distance,5\njane lowe,1\npalais de justice brussels,1\nhillsdale free will baptist college,1\nraf wattisham,2\nparnataara,1\njensen beach campus of the florida institute of technology,1\nscottish gypsy and traveller groups,3\ncliffs shaft mine museum,3\nroaring forties,4\nwhere in time is carmen sandiego?,2\nperfect field,1\nrob schamberger,1\nlcd soundsystem,10\nalan rathbone,26\nsetup,1\ngliding over all,4\ndastur,1\nflensburger brauerei,3\nberkeley global campus at richmond bay,1\nkanakapura,1\nmineworkers union of namibia,1\ntokneneng,3\nmapuche textiles,3\nperanakan beaded slippers,1\ngoodra,2\nkanab ut,1\nthe gold act 1968,4\ngrey langur,1\nprocol harum,5\nchris alexander,1\nft walton beach metropolitan area,3\ndimensionless quantity,16\nthe science of mind,1\nalfons schone,1\neuparthenos nubilis,1\nbatrachotoxin,5\nfabric live 22,1\nmchenry boatwright,1\nlangney sports club,1\nakela jones,1\nlookout,2\nmatsuo tsurayaba,2\ngeneral jackson,3\nhair removal,14\nafrican party for the independence of cape verde,4\nreplica trick,1\nbromfenac,2\nmake someone happy,1\nsam pancake,1\ndenys finch hatton,10\nlatin rhythm albums,1\nmain bronchus,1\ncampidoglio,4\ncathaoirleach,1\nemress justina,1\nsulzbach hesse,1\nnoncicatricial alopecia,1\nsylvan place,4\nstalag i c,1\nleague of extraordinary gentlemen,1\nsergey korolyov,2\nserbian presidential election 1997,1\nbarnes lake millers lake michigan,1\nchristmas island health centre,1\ndayton ballet,2\ngilles fauconnier,1\nharald svergja,1\njoanna newsom discography,2\nastro xi yue hd,1\ncode sharing,3\ndreamcast vmu,1\narmand emmanuel du plessis duc de richelieu,1\necole supérieure des arts du cirque,2\ngerry mulligan,12\nkaaka kaaka,1\nmexico at the 2012 summer olympics,4\nbar wizards,2\nchristmas is almost here again,2\nsterling heights michigan,4\ngaultheria procumbens,3\neben etzebeth,8\nviktorija Čmilytė,1\nlos angeles county california,39\nfamily entertainment,2\nquantum well,9\nelton,1\nallan frewin jones,1\ndaniela ruah,32\ngkd legend,1\ncoffman–graham algorithm,1\nsanta clara durango,1\nbrian protheroe,3\ncrawler transporter,10\nlakshman,3\nfes el bali,2\nmary a krupsak,1\nirish rugby football union,5\nneuropsychiatry,2\njosé pirela,1\nbonaire status referendum 2015,1\nit,2\nplayhouse in the park,1\nalexander yakovlev,7\nold bear,1\ngraph tool,2\nmerseyside west,1\nromanian armies in the battle of stalingrad,1\ndark they were and golden eyed,1\naidan obrien,8\ntown and davis,1\nsuum cuique,3\ngerman american day,2\nnorthampton county pennsylvania,3\ncandidates of the south australian state election 2010,1\nvenator marginatus,2\nk60an,1\ntemplate talk campaignbox seven years war european,1\nmaravi,1\nflaithbertach ua néill,1\njunction ohio,1\ndave walter,1\nlondon transport board,1\ntuyuka,1\nthe moodys,3\nnoel,3\neugen richter,1\ncowanshannock township armstrong county pennsylvania,1\npre columbian gold museum,1\nlac demosson,1\nlincosamides,9\nthe vegas connection,1\nstephen e harris,1\nalkali feldspar,2\nbrant hansen,1\ndraft carnatic music stub,4\nthe chemicals between us,1\nblood and bravery,1\nsan diego flash,3\ncovert channel,5\nernest w adams,1\nhills brothers coffee,1\ncosmic background explorer,4\ninternational union of pure and applied physics,2\nvladimir kramnik,21\nhinterland,2\ntinker bell and the legend of the neverbeast,5\nophisops jerdonii,1\nfine gold,1\nnet explosive quantity,3\nmiss colorado teen usa,3\nroyal philharmonic orchestra discography,1\nelyazid maddour,1\nmatthew kelly,2\ntemplating language,1\njapan campaign,2\nbarack obama on mass surveillance,2\nthomas r donahue,1\nold right,4\nspencer kimball,1\ngolden kela awards,1\nblinn college,3\nw k simms,1\nquinto romano,1\nrichard mulrooney,1\nmr backup z64,1\nmonetization of us in kind food aid,1\nalex chilton,2\npropaganda in the peoples republic of china,4\njiří skalák,8\nm5 stuart tank,1\ntemplate talk ap defensive players of the year,1\ncrisis,2\nazuchi momoyama period,1\ncare and maintenance,2\na$ap mob,3\nnear field communication,111\nhips hips hooray,1\npromotional cd,1\nandean hairy armadillo,1\ntrigueros del valle,1\nelmwood illinois,1\ncantonment florida,2\nmargo t oge,1\nnational park service,36\nmonongalia county ballpark,3\nbakemonogatari,6\nfelicia michaels,1\ninstitute of oriental studies of the russian academy of sciences,2\neconomy of eritrea,2\nvincenzo chiarenza,1\nmicroelectronics,4\nfresno state bulldogs mens basketball,1\nmaotou,1\nblokely,1\nduplicati,3\ngoud,2\nniki reiser,1\nedward leonard ellington,1\njaswant singh of marwar,1\nbiharsharif,1\ndynasty /trackback/,1\nmachrihanish,4\njay steinberg,1\npeter luger steak house,3\npalookaville,1\nferrari grand prix results,2\nbankruptcy discharge,2\nmike mccue,2\nnuestra belleza méxico 2013,2\nalex neal bullen,1\ngus macdonald baron macdonald of tradeston,2\nflorida circuit court,1\nhaarp,2\nv pudur block,1\ngrocer,1\nshmuel hanavi,1\nisaqueena falls,2\njean moulin university,1\nfinal fantasy collection,1\ntemplate talk american frontier,1\nchex quest,4\nmuslim students association,2\nmarco pique,1\njinja safari,1\nthe collection,9\nurban districts of germany,5\nrajiv chilaka,1\nzion,2\nvf 32,1\nunited states commission on civil rights,2\nzazam,1\nbarnettas,4\nrebecca blasband,1\nlincoln village,1\nfilm soundtracks,1\nangus t jones,77\nsnuppy,3\nw/indexphp,30\nfile talk american world war ii senior military officials 1945jpeg,1\nworship leader,1\nein qiniya,1\nbuxton maine,1\nmatt dewitt,1\nbéla bollobás,3\nearlysville union church,1\nbae/mcdonnell douglas harrier ii gr9,1\ncalifornian condor,2\nprogressive enhancement,15\nits not my time,4\necw on tnn,2\nihop,36\naeronautical chart,1\nclique width,1\nfuengirola,8\narchicebus achilles,2\ncomparison of alcopops,1\ncarla anderson hills,1\nroanoke county virginia,2\njaílson alves dos santos,1\nrameses revenge,1\nkaycee stroh,5\nles experts,1\nniels skousen,1\napollo hoax theories,1\nmercedes w204,2\nenhanced mitigation experience toolkit,15\nbert barnes,1\nserializability,6\nten plagues of egypt,1\njoe l brown,1\ncategory talk high importance chicago bears articles,1\nstephen caffrey,3\neuropean border surveillance system,2\nachytonix,1\nm2 machine gun,1\ngurieli,1\nkunefe,1\nm33 helmet,3\nlittle carmine,1\nsmush,3\njosé horacio gómez,1\nproduct recall,1\negger,1\nwisconsin highway 55,1\nharbledown,1\nlow copy repeats,1\ncurt gentry,1\nunited colors of benetton,1\nadiabatic shear band,2\npea galaxy,1\nwhere are you now,1\ndils,1\nsurprise s1,1\nsenate oceans caucus,2\nwindsor new hampshire,1\na hawk and a hacksaw,1\ni love it loud,2\nmilbcom,1\nold world vulture,7\ncamara v municipal court of city and county of san francisco,1\nski dubai,1\nst cyprians school,2\naibo,1\nticker symbol,2\nhendrik houthakker,1\nshivering,5\njacob arminius,1\nmowming,1\npanjiva,2\nnamco libble rabble,5\nrudolph bing,1\nsindhi cap,2\nlogician,1\nford xa falcon,2\nthe sunny side up show,1\nhelen adams,2\nkharchin,1\nbrittany maynard,13\nkim kyu jong,1\nmessier 103,3\nleon boiler,1\nthe rapeman,1\ntwa flight 3,4\nleading ladies,1\ndelta octantis,2\nqatari nationality law,1\nlionel cripps,1\njosé daniel carreño,1\ncrypsotidia longicosta,1\npolish falcons,1\nhighlands north gauteng,1\nthe florida channel,1\noreste barale,1\nghazi of iraq,2\ncharles grandison finney,4\nahmet ali,1\nabbeytown,1\ncaribou,3\nbig two,2\nalien,14\naslantaş dam,3\ntheme of the traitor and the hero,1\nvladimir solovyov,1\nlaguna ojo de liebre,1\nclive barton,1\nebrahim daoud nonoo,1\nrichard goodwin keats,2\nback to the who tour 51,1\nentertainmentwise,1\nja preston,1\njohn astin,19\nstrict function,1\ncam ranh international airport,2\ngary pearson,1\nsven väth,8\ntoad,6\njohnny pace,1\nhunt stockwell,1\nrolando schiavi,1\nclaudia grassl,1\noxford nova scotia,1\nmaryland sheep and wool festival,1\nconquest of bread,1\nerevan,1\ncomparison of islamic and jewish dietary laws,11\nsheila burnford,1\nestevan payan,1\nocean butterflies international,7\nthe royal winnipeg rifles,1\ngreen goblin in other media,2\nvideo gaming in japan,8\nchurch of the guanche people,4\ngustav hartlaub,2\nian mcgeechan,4\nhammer and sickle,17\nkonkiep river,1\nceri richards,1\ndecentralized,2\ndepth psychology,3\ncentennial parkway,1\nyugoslav monitor vardar,1\nbattle of bobbili,2\nmagnus iii of sweden,1\nengland c national football team,2\nthuraakunu,1\nbab el ehr,1\nkoi,1\ncully wilson,1\nmoney laundering,1\nstirling western australia,1\njennifer dinoia,1\neureka street,1\nmessage / call my name,1\nmake in maharashtra,4\nhuckleberry creek patrol cabin,1\nalmost famous,5\ntruck nuts,4\nvocus communications,1\ngikwik,1\nbattle of bataan,4\nconfluence pennsylvania,2\nislander 23,1\nmv skorpios ii,1\nsingle wire earth return,1\npolitics of odisha,1\ncrédit du nord,3\npiper methysticum,2\ncoble,2\nkathleen a mattea,1\ncoachella valley music and arts festival,50\ntooniverse,1\nspofforth castle,1\narabian knight,2\ntwo airlines policy,1\nhinduja group,17\nswagg alabama,1\nportuguese profanity,1\nloomis gang,2\nnina veselova,2\naegyrcitherium,1\nbees in paradise,1\nbéládys anomaly,3\nbadalte rishtey,1\nfirst bank fc,1\ncystoseira,1\nred book of endangered languages,1\nrose,6\nterry mcgurrin,3\njason hawke,1\npeter chernin,1\ntu 204,1\nthe man who walked alone,1\ntool grade steel,1\nwrist spin,1\none step forward two steps back,1\ntheodor boveri,1\nheunginjimun,1\nfama–french three factor model,34\nbilly whitehurst,1\nrip it up,4\nred lorry yellow lorry,4\nnao tōyama,8\ngeneral macarthur,1\nrabi oscillation,2\ndevín,1\nolympus e 420,1\nhydra entertainment,1\nchris cheney,3\nrio all suite hotel and casino,3\nthe death gate cycle,2\nfatima,1\nkamomioya shrine,1\nfive nights at freddys 3,14\nthe broom of the system,3\nrobert blincoe,1\nhistory of wells fargo,9\npinocytosis,4\nleaf phoenix,1\nwxmw,2\ntommy henriksen,13\ngeri halliwell discography,2\nblade runneri have seen things you would not believe,1\nmadhwa brahmins,1\ni/o ventures,1\nedorisi master ekhosuehi,2\njunior orange bowl,1\nkhit,2\nsue jones,1\nimmortalized,35\ncity building series,4\nquran translation,1\nunited states consulate,1\ndose response relationship,1\ncaitriona,1\ncolocolo,21\nmedea class destroyer,1\nvaastav,1\netc1,1\njohn altoon,2\nthylacine,113\ncycling at the 1924 summer olympics,1\nmargaret nagle,1\nsuperpower,57\ngülşen,1\nanthems to the welkin at dusk,4\nyerevan united fc,1\nthe family fang,14\ndomain,4\nhigh speed rail in india,14\ntrifolium pratense,7\nflorida mountains,2\nnational city corp,5\nlength of us participation in major wars,2\nacacia acanthoclada,1\noffas dyke path,2\nenduro,7\nhoward center,1\nlittlebits,4\nplácido domingo jr,1\nhookdale illinois,1\nthe love language,1\ncupids arrows,1\ndc talk,7\nmaesopsis eminii,1\nhere comes goodbye,1\nfreddie foreman,5\nmarvel comics publishers,1\nconsolidated city–county,5\ncountess marianne bernadotte of wisborg,1\nlos angeles baptist high school,1\nmaglalatik,1\ndeo,2\nmeilichiu,1\nwade coleman,1\nmonster soul,2\njulion alvarez,2\nplatinum 166,1\nshark week,12\nhossbach memorandum,4\njack c massey,3\nardore,1\nphilosopher king,5\ndynamic random access memory,5\nbronze age in southeastern europe,1\ntamil films of 2012,1\nnathalie cely,1\nitalian capital,1\noptic tract,3\nshakti kumar,1\nwho killed bruce lee,1\nparlement of brittany,3\nsan juan national historic site,2\nlivewell,2\ntemplate talk om,1\nal bell,2\npzl w 3 sokół,8\ndurrës rail station,3\ndavid stubbs,1\npharmacon,3\nrailfan,7\ncomics by country,2\ncullen baker,1\nmaximum subarray problem,19\noutlaws and angels,1\nparadise falls,2\nmathias pogba,28\ndonella meadows,4\njohn leconte,2\nswaziland national football team,7\ngabriele detti,2\nif ever youre in my arms again,1\nchristian basso,1\nhelen shapiro,7\ntaisha abelar,1\nfluid dynamics,1\nernest wilberforce,1\nkocaeli university,2\nbritish m class submarine,1\nmodern woodmen of america,1\nlas posadas,3\nfederal budget of germany,2\nliberation front of chad,1\nsandomierz,5\nap italian language and culture,1\nmanuel gonzález,1\ngeorgian military road,2\nclear creek county colorado,1\nmatt clark,2\ntest tube,18\nak 47,1\ndiège,1\nlondon school of economics+,1\nmichael york,14\nhalf eagle,6\nstrike force,1\ntype 054 frigate,2\nsino indian relations,7\nfern,3\nlouvencourt,1\nghb receptor,2\nchondrolaryngoplasty,2\nandrew lewer,1\nross king,1\ncolpix records,1\noctober 28,1\ntatsunori hara,1\nrossana lópez león,1\nhaskell texas,3\ntower subway,2\nwaspstrumental,1\ntemplate talk nba anniversary teams,1\ngeorge leo leech,1\nstill nothing moves you,1\nblood cancer,3\nbuffy lynne williams,1\ndpgc u know what im throwin up,1\ndaniel nadler,1\nkhalifa sankaré,2\nhomo genus,1\ngarðar thór cortes,3\nveyyil,1\nmatt dodge,1\nhipponix subrufus,1\nanostraca,1\nhartshill park,1\npurple acid phosphatases,1\naustromyrtus dulcis,1\nshamirpet lake,1\nfavila of asturias,2\nacute gastroenteritis,1\ndalton cache pleasant camp border crossing,1\nurobilinogen,13\nss kawartha park,1\nprofessional chess association,1\nspecies extinction,1\ngapa hele bi sata,1\nphyllis lyon and del martin,1\nuk–us extradition treaty of 2003,1\na woman killed with kindness,1\nhow bizarre,1\nnorm augustine,1\ngeil,1\nvolleyball at the 2015 southeast asian games,2\njim ottaviani,1\nchekmagushevskiy district,1\ninformation search process,2\nqueer,63\nwilliam pidgeon,1\namelia adamo,1\nnato ouvrage \"g\",1\ntamsin beaumont,1\neconomy of syria,13\ndouglas dc 8 20,1\ntama and friends,4\npringles,22\nkannada grammar,7\nlotoja,1\npeony,1\nbmmi,1\neurovision song contest 1992,11\ncerro blanco metro station,1\nsherlock the riddle of the crown jewels,4\ndorsa cato,1\nnkg2d,8\nspecific heat,6\nnokia 6310i,2\ntergum,2\nbahai temple,1\ndal segno,5\nleigh chapman,2\ntupolev tu 144,60\nflight of ideas,1\nrita montaner,1\nvivien a schmidt,1\nbattle of the treasury islands,2\nthree kinds of evil destination,1\nrichlite,1\nmedinilla,2\ntimeline of aids,1\ncolin renfrew baron renfrew of kaimsthorn,2\nhélène rollès,1\npedro winter,1\nsabine free state,1\nbrzeg,1\npalisades park,1\ngas gangrene,11\ndotyk,2\ndaniela kix,1\ncanna,16\nproperty list,9\njohn hamburg,1\ndunk island,5\nalbreda,1\nscammed yankees,1\nwireball,3\njunior 4,1\nabsolutely anything,15\nlinux operating system,1\nsolsbury hill,15\nnotopholia,1\nscottish heraldry,2\ntemplate talk paper data storage media,1\ncategory talk religion in ancient sparta,1\ncategory talk cancer deaths in puerto rico,1\nmid michigan community college,2\ntvb anniversary awards,1\nfrederick taylor gates,1\nomoiyari yosan,3\njournal of the physical society of japan,1\nkings in the corner,2\nnungua,1\namerika,4\npacific marine environmental laboratory,1\nthe thought exchange,1\nitalian bee,5\nroma in spain,1\nsirinart,1\ncrandon wisconsin,1\nshubnikov–de haas effect,6\nportrait of maria portinari,4\ncolin mcmanus,1\nuniversal personal telecommunications,1\nroyal docks,4\nbrecon and radnorshire,3\neilema caledonica,1\nchalon sur saône,8\ntoyota grand hiace,1\nsophorose,1\nsemirefined 2bwax,1\nmechanics institute chess club,1\nthe culture high,2\ndont wake me up,1\ntranscaucasian mole vole,1\nharry zvi tabor,1\nvhs assault rifle,1\nplaying possum,2\nomar minaya,2\nprivate university,1\nyuki togashi,3\nski free,2\nsay no more,1\ndiving at the 1999 summer universiade,1\narmando sosa peña,1\ntimur tekkal,1\njura elektroapparate,1\npornographic magazine,1\ntukur yusuf buratai,1\nkeep on moving,1\nlaboulbeniomycetes,1\nchiropractor solve problems,1\nmark s allen,3\ncommittees of the european parliament,4\nblondie,7\nveblungsnes,1\nbank vault,10\nsmiling irish eyes,1\nrobert kalina,2\npolarization ellipse,2\nhuntingdon priory,1\nenergy in the united kingdom,34\nhamble,1\nraja sikander zaman,1\nperigea hippia,1\ncollege of liberal arts and sciences,1\nbootblock,1\nnato reporting names,2\nthe serpentwar saga,1\nreformed churches in the netherlands,1\ncollaborative document review,4\ncombat mission beyond overlord,3\nvlra,2\npat st john,1\noceanid,5\nitapetinga,1\ninsane championship wrestling,9\nnathaniel gorham,1\nestadio metropolitano de fútbol de lara,2\nwilliam of saint amour,2\nnew york drama critics circle award,1\nalliant rq 6 outrider,2\nilsan,1\ntop model po russki,1\nwoolens,1\nrutledge minnesota,1\njoigny coach crash,2\nzhou enlai the last perfect revolutionary,1\nthe theoretical minimum,1\narrow security,1\njohn shelton wilder,2\njasdf,2\nkatie may,2\namerican jewish military history project,1\nbusiness professionals of america,1\nquestioned document examination,5\nmotorola a760,1\namerican steel & wire,1\nlouis armstrong at the crescendo vol 1,1\nedward vernon,3\nmaria taipaleenmäki,1\nmargical history tour,2\njar jar,1\naustralian oxford dictionary,2\nrevenue service,2\nodoardo farnese hereditary prince of parma,1\nweekend in new england,1\nlaurence harbor new jersey,2\naramark tower,1\nstealers wheel,1\ncephalon,1\ndawnguard,1\nsaintsbury,2\nsaint fuscien,1\nryoko kuninaka,1\nfarm to market road 1535,1\nalan kennedy,2\nesteban casagolda,1\nshin angyo onshi,1\nwilliam gowland,1\neastern religions,6\nkenny lala,1\nalphonso davies,1\ntadamasa hayashi,1\nmeet the parents,2\ncalvinist church,1\nristorante paradiso,1\njose joaquim champalimaud,1\nolis,1\nmill hill school,2\nlockroy,1\nbattle of princeton,10\ncent,8\nbrough superior ss80,1\nras al khaima club,3\nwashington international university,3\nbradley kasal,2\nmiguel Ángel varvello,1\noxygen permeability,1\nfemoral circumflex artery,1\ngolden sun dark dawn,4\npusarla sindhu,1\ntoyota winglet,1\nwind profiler,1\nmontefiore medical center,2\ntemplate talk guitar hero series,3\nlittle leaf linden,1\nramana,4\nislam in the czech republic,2\nmanuel vitorino,1\njoseph radetzky von radetz,3\nfrancois damiens,1\nparasite fighter,1\nfriday night at st andrews,3\nhurbazum,1\nhaidhausen,1\npetabox,2\nsalmonella enteritidis,2\nmatthew r denver,1\nde la salle,1\nanti terrorism act 2015,6\nbrugsen,1\nmountain times,1\ncolumbia basin project,1\ncommon wallaroo,2\nclepsis brunneograpta,1\nred hot + dance,1\nmao fumei,1\ndark shrew,1\ncoach,8\ncome saturday morning,1\naanmai thavarael,1\nhellenia,1\ndonate life america,2\nplot of beauty and the beast toronto musical,1\nbirths in 1243,3\nmain page/wiki/portal technology,8\ncambridgeshire archives and local studies,1\nbig pines california,1\npegasus in popular culture,4\nbaron glendonbrook,1\nyour face sounds familiar,5\nboom tube,2\nrichard gough,8\nthe new beginning in niigata,3\namerican academy of health physics,1\nplain,9\ntushino airfield,1\nking george v coronation medal,1\ngeologic overpressure,1\nseille,1\ncalorimeter,25\nfrench civil service,1\ndavid l paterson,1\nchinese gunboat chung shan,2\nrhizobium inoculants,1\nwizard,4\nbaghestan,1\npaustian house,2\nellen pompeo,55\ndamien williams,1\ntomoe tamiyasu,1\nacute epithelial keratitis,1\ncasey abrams,8\nmendozite,1\nkantian ethics,2\nmcclure syndicate,1\ntokyo metro,6\ncuisine of guinea bissau,1\nmossberg 500,18\nmollie gillen,1\nabove and beyond party,1\njoey carbone,1\nfaulkner state community college,1\ntetsuya ishikawa,1\nelectric flag,3\nmeet the feebles,2\nkplm,1\nwhen we were twenty one,1\nhorus bird,2\nyouth in revolt,8\nspongebob squarepants revenge of the flying dutchman,3\nehow,5\nnikos xydakis,2\nziprasidone,19\nulsan airport,1\nflechtingen,1\ndave christian,3\ndelaware national guard,1\nskaria thomas,1\niraca,1\nkkhi,2\nswimming at the 2015 world aquatics championships – mens 1500 metre freestyle,2\ncrossing lines,37\njohn du cane,1\ni8,1\nbauer pottery,1\naffinity sutton,4\nlotus 119,1\nuss arleigh burke,1\npalmar interossei,2\nnofx discography,4\nbwia west indies airways,3\ngopala ii,1\nnorth fork correctional facility,1\nszeged 2011,1\nmilligram per cent,2\nhalas and batchelor,1\nwhat the day owes the night,1\nsighișoara medieval festival,5\nscarning railway station,1\ncambridge hospital,1\namnesia labyrinth,2\ncokie roberts,7\nsavings identity,3\npravia,1\nmcgrath,4\npakistan boy scouts association,1\ndan carpenter,2\nmarikina–infanta highway,2\ngenetic analysis,2\ntemplate talk ohio state university,1\nthomas chamberlain,4\nmoe book,1\ncoyote waits,1\nblack protestant,1\nneetu singh,19\nmahmoud sarsak,1\ncasa loma,28\nbedivere,8\nboundary park,2\ndanger danger,14\njennifer coolidge,49\npop ya collar,1\ncollaboration with the axis powers during world war ii,10\ngreenskeepers,1\nthe dukes children,1\nalaska off road warriors,1\ntwenty five satang coin,1\ntemplate talk private equity investors,2\namerican red cross,24\njason shepherd,1\ngeorgetown college,2\nocean countess,1\nammonium magnesium phosphate,1\ncommunity supported agriculture,5\nphilosophy of suicide,4\nyard ramp,2\ncaptain germany,1\nbob klapisch,1\ni will never let you down,2\nfebruary 11,6\nron dennis,13\nrancid,16\nthe mall blackburn,1\nsouth high school,6\ncharles allen culberson,1\norganizational behavior,66\nautomatic route selection,1\nuss the sullivans,9\nyo no creo en los hombres,1\njanet,1\nserena armstrong jones viscountess linley,3\nlouisiana–lafayette ragin cajuns mens basketball,1\nflower films,1\nmichelle ellsworth,1\nnorbertine rite,2\nspanish mump,1\nshah jahan,67\nfraser coast region,1\nmatt cornwell,1\nnra,1\ncrested butte mountain resort,1\ncollege football playoff national championship,2\ncraig heaney,4\ndevil weed,1\nsatsuki sho,1\njordaan brown,1\nlittle annie,4\nthiha htet aung,1\nthe disreputable history of frankie landau banks,1\nmickey lewis,1\neldar nizamutdinov,1\nm1825 forage cap,1\nantonina makarova,1\nmopani district municipality,2\nal jahra sc,1\nchaim topol,4\ntum saath ho jab apne,1\npiff the magic dragon,7\nimagining argentina,1\nni 62,1\nphys rev lett,1\nthe peoples political party,1\ncasoto,1\npopular movement of the revolution,4\nhuntingtown maryland,1\nla bohème,33\nkhirbat al jawfa,1\nlycksele zoo,1\ndeveti krug,2\ncuba at the 2000 summer olympics,2\nrose wilson,7\nsammy lee,2\ndave sheridan,10\nuniversal records,2\nantiquities trade,3\nshoveller,1\ntapered integration,1\nparker pen company,4\nmushahid hussain syed,1\nnynehead,1\ncounter reformation,2\nnhl on nbc,11\nronny rosenthal,2\narsenie todiraş,3\nlobster random,1\nhalliburton,37\ngordon county georgia,1\nbelle isle florida,3\nmolly stanton,3\ngreen crombec,1\ngeodesist,2\nabd al rahman al sufi,4\ndemography of japan,26\nlive xxx tv,5\nnaihanchi,1\ncofinite,1\nmsnbot,5\nclausard,1\nmimidae,1\nwind direction,15\nirrational winding of a torus,1\ntursiops truncatus,1\ntrustee,1\nlumacaftor/ivacaftor,2\nbalancing lake,2\nshoe trees,1\ncycling at the 1928 summer olympics – mens team pursuit,1\ncalponia harrisonfordi,1\nhindu rate of growth,1\ndee gordon,7\npassion white flag,2\nfrog skin,1\nrudolf eucken,2\nbayantal govisümber,1\nchristopher a iannella,1\nrobert myers,1\njames simons,1\nmeng xuenong,1\nabayomi olonisakin,1\nmilton wynants,1\ncincinnatus powell,1\natomic bomb band,1\nhopfield network,12\njet pocket top must,1\nthe state of the world,1\nwelf i duke of bavaria,2\namerican civil liberties union v national security agency,3\nelizabeth fedde,1\nlibrarything,2\nkim fletcher,1\ntracy island,2\npraise song for the day,1\nsuperstar,7\newen spencer,1\nback striped weasel,1\ncs concordia chiajna,1\nbruce curry,1\nmalificent,1\ndr b r ambedkar university,2\nriver plate,1\ndesha county arkansas,1\nharare declaration,2\npatrick dehornoy,1\npaul alan cox,2\nauckland mounted rifles regiment,1\nmikoyan gurevich dis,3\ncorn exchange manchester,2\nsharpshooter,1\nthe new york times manga best sellers of 2013,1\nmax perutz,2\nandrei makolov,1\ninazuma eleven saikyō gundan Ōga shūrai,2\ntatra 816,1\nashwin sanghi,8\npipestone township michigan,1\ncraig shoemaker,1\ndavid bateson,1\nlew lehr,1\ncrewe to manchester line,2\nsamurai champloo,36\ntali ploskov,2\njanet sobel,3\nkabe station,1\nrippon,1\nalexander iii equestrian,1\nlouban,2\nthe twelfth night,1\ndelaware state forest,1\nthe amazing race china 3,1\nbrillouins theorem,1\nextreme north,3\nsuper frelon,1\ngeorge watsons,1\nmungo park,1\nworkin together,3\nboy,12\nbrownsville toros,1\nkim lim,1\nfutsal,63\nmotoring taxation in the united kingdom,1\naccelerator physics codes,1\narytenoid cartilage,3\nthe price of beauty,3\nlife on the murder scene,2\nhydrophysa psyllalis,1\njürgen brandt,2\neconomic history association,2\nthe sandwich girl,1\nheber macmahon,1\nvolume 1 sound magic,2\nsan francisco–oakland–hayward ca metropolitan statistical area,9\nharriet green,7\ntarnawa kolonia,1\neur1 movement certificate,20\nanna nolan,2\ngulf of gökova,1\nhavertown,2\norlando scandrick,4\ndoug owston correctional centre,1\nasterionella,4\nespostoa,1\nranked voting system,10\ncommercial law,39\nkirk,1\nmongolian cuisine,8\nturfanosuchus,1\narthur anderson,4\nsven olof lindholm,1\nbatherton,1\ndimetrodon,1\npianos become the teeth,1\nunited kingdom in the eurovision song contest 1976,1\nmedieval,11\nit bites,1\nion television,8\nseaboard system railroad,3\nsayan mountains,3\nmusaffah,1\ncharles de foucauld,3\nurgh a music war,1\ntranslit,1\namerican revolutionary war/article from the 1911 encyclopedia part 1,1\nuss mauna kea,1\npowder burn,1\nbald faced hornet,9\nproducer of the year,1\nthe most wanted man,1\nclear history,8\nmikael lilius,1\nclass invariant,4\nforever michael,3\ngoofing off,3\ntower viewer,3\nclaudiu marin,1\nnicolas cage,1\nwaol,2\ns10 nbc respirator,2\neducation outreach,1\ngyeongsan,2\ntemplate talk saints2008draftpicks,1\nbotaurus,1\nfrancis harper,1\nmauritanian general election 1971,1\nkirsty roper,2\nnon steroidal anti inflammatory drug,17\nnearchus of elea,2\nresistance to antiviral drugs,1\nraghavendra rajkumar,5\ntemplate talk cc sa/sandbox,1\nwashington gubernatorial election 2012,2\npaul lovens,1\nexpress freighters australia,2\nbunny bleu,2\nosaka prefecture,2\nfederal reserve bank of boston,4\nhacı ahmet,1\nunderground chapter 1,10\nfilippo simeoni,2\nthe wonderful wizard of oz,3\nsailing away,1\navelino gomez memorial award,1\nbadger,65\nhongkou football stadium,3\nbenjamin f cheatham,2\nfair isaac,2\nkwab,1\nal hank aaron award,3\ngender in dutch grammar,1\nidiom neutral,2\nda lata,1\ntuu languages,1\nderivations are used,1\nclete patterson,1\ndanish folklore,4\nandroid app //orgwikipedia/http/enmwikipediaorg/wiki/westfield academy,1\ntoto,8\nea,1\nvictory bond tour,1\ncredai,2\nhérin,1\nst james louisiana,1\nnecrolestes,2\ncable knit,1\nsaunderstown,1\nus route 52 in ohio,1\nsailors rest tennessee,1\nadlai stevenson i,6\nmiscibility,13\nhelp footnotes,13\nmurrell belanger,1\nnew holland pennsylvania,5\nhaldanodon,1\nfeminine psychology,2\nriot city wrestling,1\nmobile content management system,2\nzinio,1\ncentral differencing scheme,2\nenoch,2\nusp florence admax,1\nmaester aemon,7\nnorman \"lechero\" st john,1\nice racing,1\ntiger cub economies,6\nklaipėda region,12\nwu qian,8\nmalayalam films of 1987,1\nestadio nuevo la victoria,1\nnanotoxicology,2\nhot revolver,1\nnives ivankovic,1\nglen edward rogers,5\nepicene,3\neochaid ailtlethan,1\njudiciary of finland,1\nen jersey,1\nstatc,1\natta kim,1\nmizi research,2\nacs applied materials & interfaces,1\nthank god youre here,9\nloneliness,8\nh e b plus,2\ncorella bohol,1\nmoney in the bank,59\ngolden circle air t bird,1\nflash forward,1\ncategory talk philippine television series by network,1\ndfmda,1\nthe road to wellville,8\nernst tüscher,1\ncommission,14\nabdul rahman bin faisal,6\noversea chinese banking corporation,7\nray malavasi,1\nal qadisiyah fc,4\nanisfield wolf book award,1\njacques van rees,1\njakki tha motamouth,1\nscoop,1\npiti,2\ncarlos reyes,1\nv o chidambaram pillai,6\ndiamonds sparkle,1\nthe great transformation,5\ncardston alberta temple,1\nla vendetta,1\nmiyota nagano,1\nnational shrine of st elizabeth ann seton,2\nchaotic,1\nbreastfeeding and hiv,1\nfriedemann schulz von thun,1\nmukhammas,2\nfishbowl worldwide media,1\nmohamed amin,3\njohn densmore,10\nsuryadevara nayaks,1\nmetal gear solid peace walker,12\nché café,2\nold growth,1\nlake view cemetery,1\nkonigsberg class cruiser,1\ncourts of law,1\nnova scotia peninsula,3\njairam ramesh,4\nportal kerala/introduction,1\nedinburgh 50 000 – the final push,1\nludachristmas,3\nmotion blur,1\ndeliberative process privilege,2\nbubblegram,1\nsimon breach grenade,2\ntess henley,1\ngojinjo daiko,1\ncommon support aircraft,2\nzelda rubinstein,9\nyolanda kakabadse,1\namerican studio woodturning movement,1\nrichard carpenter,67\nvehicle door,3\ntransmission system operator,9\nchrista campbell,9\nmarolles en brie,1\nkorsholma castle,1\nmurder of annie le,3\nkims,1\nzionist union,8\nportal current events/june 2004,2\nmarination,8\ncap haïtien international airport,2\nfujima kansai,1\nvampire weekend discography,3\nmoncton coliseum,2\nwing chair,1\nel laco,2\ncastle fraser,1\ntemplate talk greek political parties,1\nsociety finch,1\nchief executive officer,4\nbattle of bloody run,3\ncoat of arms of tunisia,2\nnishi kawaguchi station,1\ncolonoscopy,30\nvic tayback,5\nlonnie mack discography,3\nyusuf salman yusuf,2\nmarco simone,4\nsaint just,1\nelizabeth taylor filmography,6\nhaglöfs,2\nyunis al astal,1\ndaymond john,36\nbedd y cawr hillfort,1\ndurjoy datta,1\nwealtheow,1\naaron mceneff,1\nculture in berlin,1\ntemple of saturn,6\nnermin zolotić,1\nthe darwin awards,1\npatricio pérez,1\nchris levine,1\nmisanthropic,1\ndragster,2\neldar,19\nchrzanowo gmina szelków,1\nzimmerberg base tunnel,6\njakob schaffner,1\ncalifornia gubernatorial recall election 2003,1\ntommy moe,1\nbikrami calendar,1\nmama said,11\nhellenic armed forces,8\ncandy box,3\nmonstervision,3\nkachin independent army,1\npro choice,1\ntshiluba language,1\ntrucial states,9\ncollana,1\nbest music video short form,1\npokémon +giratina+and+the+sky+warrior,1\netteldorf,1\nacademic grading in chile,2\nland and liberty,3\naustralian bureau of meteorology,1\ncheoin gu,1\nwilliam henry green,1\newsd,2\ngate of hell,1\nsioux falls regional airport,3\nnevelj zsenit,1\nbevo lebourveau,1\nranjana ami ar asbona,1\nshaun fleming,1\njean antoine siméon fort,1\nsports book,1\nvedran smailović,3\nsimple harmonic motion,29\nwikipedia talk wikiproject film/archive 16,1\nprincess jasmine,13\ngreat bustard,5\nallred unit,1\ncheng san,1\nmini paceman,1\nflavoprotein,2\nstorage wars canada,3\nuniversity rowing,2\ncategory talk wikiproject saskatchewan communities,1\nthe washington sun,1\nrotary dial,6\nhailar district,1\nassistant secretary of the air force,2\nthe décoration for the yellow house,5\nchris mclennan,1\nthe cincinnati kid,4\neducation in the republic of ireland,15\nsteve brodie,2\ncountry club of detroit,1\nwazner,1\nportal spain,4\nsenna,3\nwilliam j bernd house,1\nbalaji baji rao,8\nworth dying for,1\ncool ruler,1\nturn your lights down low,2\nmavroudis bougaidis,1\nnational registry emergency medical technician,1\njames young,8\neyewire,1\ndark matters twisted but true/,1\njosé pascual monzo,1\ngerman election 1928,2\nlinton vassell,1\nconvention on the participation of foreigners in public life at local level,1\nthorium fuel cycle,5\nhoneybaby honeybaby,1\ngolestan palace,3\nlombok international airport,11\nmainichi daily news,1\nk&p,1\nliberal network for latin america,1\ncádiz memorial,1\ngrupo corripio,1\nelie and earlsferry,1\nisidore geoffroy saint hilaire,1\nal salmiya sc,2\npiano sonata hob xvi/33,1\ne f bleiler,1\nnational register of historic places listings in york county virginia,3\ngupta empire,2\ngerman immigration to the united states,1\nthrough gates of splendor,2\niap,1\nlove takes wing,1\ntours de merle,1\naleksey zelensky,1\npaul almond,2\nboston cambridge quincy ma nh metropolitan statistical area,1\nkomiks presents dragonna,1\nprincess victoire of france,1\nalan pownall,3\ntilak nagar,2\nlg life sciences co ltd,8\nbefore their eyes,1\nlabor right,5\nmichiko to hatchin,1\nsusan p graber,1\nxii,1\nhanswulf,1\nsymbol rate,17\nmyo18b,2\nrowing at the 2010 asian games – mens coxed eight,1\ncaspar weinberger jr,2\nbettle juice,1\nbattle of the morannon,7\ndarlington county south carolina,1\nmayfield pennsylvania,1\nruwerrupt de mad,1\nluthfi assyaukanie,1\nfiat panda,30\nwickiup reservoir,1\ntanabe–sugano diagram,6\nalexander sacher masoch prize,1\nintracellular transport,1\nchurch of the val de grâce,1\njebel ad dair,1\nrosalind e krauss,6\ncross origin resource sharing,97\nreadiness to sacrifice,1\ncreel terrazas family,1\nphase portrait,9\nsubepithelial connective tissue graft,1\nlake malawi,18\nphillips & drew,1\nernst vom rath,2\ninfinitus,1\ngeneva convention for the amelioration of the condition of the wounded and sick in armies in the field,2\nworld heritage,1\ndole whip,8\nleveling effect,1\nbioship,3\nvanilloids,2\nsuperionic conductor,1\nbasil bernstein,7\narmin b cremers,2\nszlichtyngowa,1\nbeixinqiao station,1\nunited states presidential election in utah 1980,1\nwatson v united states,3\nwillie mcgill,1\nmelle belgium,1\nal majmaah,1\nmesolimbic dopamine pathway,1\nsix flags new england,5\nacp,2\ngeostrategy,2\noriginal folk blues,1\nwentworth military academy,1\nbromodichloromethane,3\ndoublet,4\ntawfiq al rabiah,1\nsergej jakirović,1\nmako surgical corp,3\nempire of lies,1\nold southwest,1\nbay of arguin,1\nbringing up buddy,1\nmustapha hadji,7\nraymond kopa,7\nevil horde,1\nkettering england,1\nextravaganza,1\nchristian labour party,2\njoice mujuru,6\nv,15\nle père,4\nmy fathers dragon,2\ncumulus cloud,32\nfantasy on themes from mozarts figaro and don giovanni,1\npostpone indefinitely,1\nextreme point,1\niraq–israel relations,1\nhenry le scrope 3rd baron scrope of masham,1\nrating beer,1\nclaude alvin villee jr,2\nclackamas town center,2\nroope latvala,4\nrichard bethell 1st baron westbury,1\nryan gosling,1\nyelina salas,1\namicus,1\ncecilia bowes lyon countess of strathmore and kinghorne,6\nprogramming style,9\nnow and then,9\nsomethingawful,1\nnuka hiva campaign,1\nbostongurka,2\njorge luis ochoa vázquez,1\nphilip burton,1\nrainbow fish,7\nroad kill,5\nchristiane frenette,2\nas if,1\npaul ricard,1\nroberto dañino,1\nshoyu,1\njakarta,96\ndean keith simonton,1\nmastocytosis,19\nhiroko yakushimaru,3\nproblem of other minds,2\njaunutis,1\ntfp deficiency,1\naccess atlantech edutainment,1\nkristian thulesen dahl,1\nwilliam wei,1\nandy san dimas,10\nkempten/allgäu,1\naugustus caesar,9\nconrad janis,1\ntugaya lanao del sur,1\nsecond generation antipsychotics,1\nanema e core,2\nsucking the 70s,1\nthe czars,2\nvakulabharanam,1\nf double sharp,3\nprymnesin,1\ndick bavetta,2\nbilly jones,3\ncolumbine,4\nfile talk joseph bidenjpg,1\nmandelbrot set,79\nconstant elasticity of variance model,2\nmorris method,1\nal shamal stadium,5\nhes alright,1\nmadurai massacre,1\nphilip kwon,2\nchristadelphians,7\nthis man is dangerous,2\nkiowa creek community church,1\npier paolo vergerio,1\norder of the most holy annunciation,2\njohn plender,1\nvallée de joux,2\ngraysby,1\nludwig minkus,3\npotato aphid,1\nbánh bột chiên,1\nwilhelmstraße,1\nfee waybill,1\ndesigned to sell,1\nironfall invasion,2\nlieutenant governor of the isle of man,1\nthird reading,2\neleanor roosevelt high school,1\nsu zhe,1\nheat conductivity,1\nsi satchanalai national park,1\netale space,1\nfaq,24\nlow carbohydrate diet,1\ndifferentiation of integrals,1\nkarl fogel,2\ntom chapman,3\njames gamble rogers,2\njeff rector,1\nburkut,9\njoe robinson,1\nturtle flambeau flowage,1\nmoves like jagger,3\nturbaco,1\noghuz turk,2\nlatent human error,5\nsquare number,17\nrugby football league championship third division,2\naltoona pennsylvania,23\ncircus tent,1\nsatirical novel,1\nclaoxylon,1\nbarbaros class frigate,4\noyer and terminer,2\ntelephone numbers in the bahamas,1\nthomas c krajeski,2\nmv glenachulish,1\nsports broadcasting contracts in australia,3\ncar audio,1\nted lewis,2\neric bogosian/robotstxt,2\nfurman university japanese garden,1\njed clampett,2\nflintstone,2\nc of tranquility,2\nrutali,2\nberkhamsted place,1\nwissam ben yedder,13\nnt5e,1\nerol onaran,1\nallium amplectens,1\nthe three musketeers,2\nnorth eastern alberta junior b hockey league,1\ndoggie daddy,1\nlauma,1\nthe love racket,1\neta hoffman,1\nryans four,3\nomerta – city of gangsters,1\nhumberview secondary school,2\nparels,1\nthe descent,1\nevgenia linetskaya,1\nmanhunt international 1994,1\namerican society of animal science,1\namerican samoa national rugby union team,1\nfaster faster,1\nall creatures great and small,1\nmama said knock you out,9\nrozhdestveno memorial estate,2\nwizard of odd,1\nlugalbanda,4\nbeardsley minnesota,1\nthe rogue prince,10\nuss escambia,1\nstormy weather,3\ncouleurs sur paris,1\nmadrigal,4\ncolin tibbett,1\nlemelson–mit prize,2\nphonetical singing,1\nglucophage,3\nsuetonius,10\nungra,1\nblack and white minstrel,1\nwoolwich west by election 1975,1\ntrolleybuses in wellington,2\njason macdonald,3\nussr state prize,2\nrobert m anderson,1\nkichijōji,1\napache kid wilderness,1\nsneaky pete,8\nedward knight,1\nfabiano santacroce,1\nhemendra kumar ray,1\nsweat therapy,1\nstewart onan,2\nisrael–turkey relations,1\nnatalie krill,5\nclinoporus biporosus,1\nkosmos 2470,2\nvladislav sendecki,1\nhealthcare in madagascar,1\ntemplate talk 2010 european ryder cup team,1\nrichard lyons,1\ntransfer of undertakings regs 2006,3\nimage processor,3\nalvin wyckoff,1\nkōbō abe,1\nkettle valley rail trail,1\nmy baby just cares for me,3\nu28,1\nwestern australia police,10\nscincidae,1\npartitionism,1\nglenmorangie distillery tour,1\nriver cave,1\nszilárd tóth,1\ni dont want nobody to give me nothing,1\ncity,67\nannabel dover,2\nplacebo discography,8\nshowbiz,8\nsolio ranch,1\nloan,191\nmorgan james,10\ninternational federation of film critics,3\nthe frankenstones,2\npastor bonus,1\nbilly purvis,1\nthe gunfighters,1\nsandefjord,2\nohio wine,2\nfor the love of a man,1\ndrifters,10\nilhéus,1\nbikini frankenstein,1\nsubterranean homesick alien,1\nchemical nomenclature,17\ngreat wicomico river,1\ningrid caven,1\njapanese destroyer takanami,1\nnosler partition,1\nwagaman northern territory,1\nslovak presidential election 2019,1\nfuggerei,12\nal hibah,1\nirish war of independence,2\njoan smallwood,1\nanthony j celebrezze jr,1\nmercedes benz m130 engine,2\nphineas and ferb,2\nbelgium womens national football team,3\nreynevan,1\njoe,1\nalan wilson,1\nepha3,1\nbelarus national handball team,1\nphaedra,14\nmove,2\namateur rocketry,3\nepizootic hemorrhagic disease,5\nprague derby,4\nbasilica of st thérèse lisieux,1\npompeianus,1\nsolved game,3\ntramacet,19\nessar energy,3\nlumbar stenosis,1\npart,24\nhải vân tunnel,1\nvsm group,3\nwalter hooper,2\nconsumer needs,1\nbell helicopter,18\nlaunde abbey,2\nramune,10\ndeclarations of war during world war ii,1\nsaint laurent de la salanque,1\nbalkenbrij,1\nbalgheim,1\nout of the box,13\ncappella,1\nnational pharmaceutical pricing authority,4\nfriend and foe,1\nnew democracy,1\neastern phoebe,2\nisipum of geumgwan gaya,1\ntel quel,1\ntraveler,12\nsuperbeast,1\noddsac,1\nzamora spain,1\ndeclaration of state sovereignty of the russian soviet federative socialist republic,1\nchumash painted cave state historic park california,3\nzentiva,1\nbritish rail class 88,5\nwest indies cricket board,3\npauli jørgensen,1\npunisher kills the marvel universe,7\nwilliam de percy,1\nvehicle production group,4\nuc irvine anteaters mens volleyball,2\ndong sik yoon,1\nhyæna,2\ncanadian industries limited,1\nmr ii,1\njim muhwezi,1\ncitizen jane,2\nnight and day concert,1\ndouble precision floating point format,2\nherbal liqueurs,1\nthe fixed period,5\npip/taz,1\nlesser caucasus,2\nuragasmanhandiya,2\nalternative words for british,2\nkhuzaima qutbuddin,1\nhelmut balderis,2\nwesley r edens,1\nscott sassa,4\nmutant mudds,3\neast krotz springs louisiana,1\nleonard frey,3\ncounting sort,15\nleandro gonzález pírez,2\nshula marks,1\nsierville,1\ncalifornia commission on teacher credentialing,1\nraymond loewy,10\nbeevor foundry,1\ndog snapper,2\nhitman contracts,5\neduard herzog,1\nwittard nemesis of ragnarok,1\ncape may light,1\nal saunders,3\ndistant earth,2\nbeam of light,2\narent we all?,1\nveridicality,1\nprivate enterprise,3\nrambhadracharya,3\ndps,5\nbeckdorf,1\nrúaidhrí de valera,1\nvivian bang,3\nsugar pine,1\nvn parameswaran pillai,1\nhenry ross perot sr,1\nthe arcadian,1\nthe record,6\ng turner howard iii,1\noleksandr usyk,12\nmumbai suburban district,5\nvicente dutra,1\npaean,1\nscottish piping society of london,1\ningot,11\nalex obrien,6\nautonomous counties of china,1\nkaleorid,1\nremix & repent,3\ngender performativity,7\ngodheadsilo,1\ntonsilloliths,1\nla dawri,1\nkiran more,3\nbillboard music award for woman of the year,1\ntahitian ukulele,1\nbuick lacrosse,14\ndraft helen milner jury sent home for the night,2\nhistory of japanese cuisine,6\ntime tunnel,1\nalbert odyssey 2,1\noysters rockefeller,4\njim mahon,1\nevolutionary invasion analysis,1\nsunk cost fallacy,3\nuniversidad de manila,1\nmorgan crucible,1\nsouthern miss golden eagles football,2\nhoratio alger,13\nbiological psychopathology,1\nhollywood,115\nproduct manager,21\nthomas burgh 3rd baron burgh,1\nstan hack,1\npeloponesian war,1\nrepublic of china presidential election 2004,2\nsanitarium,4\ngrowthgate,1\nsamuel e anderson,1\nbobo faulkner,1\nkaffebrenneriet,1\nmonponsett pond seaplane base,1\npowers of horror,3\nviburnum burkwoodii,1\nnew suez canal,5\ngerardo ortíz,2\njaphia life,1\npaul pastur,1\nfuller craft museum,1\nnomal valley,1\ninaugural address,1\nsaint Étienne du vigan,1\nlip ribbon microphone,2\nmary cheney,2\npiebald,6\nkadambas,1\ntransportation in omaha,7\nbefore the league,1\nfeltham and heston by election 2011,1\naboriginal music of canada,3\ndnssec,6\nsshtunnels,1\nrobin benway,1\nswimming at the 1968 summer olympics – mens 4 x 200 metre freestyle relay,1\ncommission internationale permanente pour lepreuve des armes à feu portatives,3\ndeath rock,1\nhugo junkers,6\ngmt,3\nkeanu reeves,2\nbeverly kansas,1\ncharlotte blair parker,1\nkids,5\nweight bench,1\nkiasmos,8\nbasque country autonomous basketball team,1\ngideon toury,2\ngugak/,1\ntexass 32nd congressional district,2\nhave you ever been lonely,1\ntake the weather with you,1\nchukchi,1\nthe magicians wife,1\njuan manuel bordeu,1\nport gaverne,1\nmusic for films iii,1\nnorthern edo masquerades,1\nhang gliding,15\nmarine corps logistics base barstow,2\ncentury iii mall,1\npeter tarlow,1\nthermal hall effect,1\ndavid ogden stiers,18\nwebmonkey,1\nfive cereals,2\nosceola washington,1\nclover virginia,2\nsphinginae,2\nstuart brace,1\nal di meola discography,7\nsunflowers,1\nhasty generalization,4\npolish athletic association,1\nthe purge 3,2\nbitetti combat mma 4,1\nhiroko nagata,2\nmona seilitz,1\nmixed member proportional representation,7\nrancho temecula,2\nsinai,1\nnorrmalmstorg robbery,5\nsilesian walls,1\nfloyd stahl,1\ngary becker,1\nknowledge engineering,5\nport of mobile,1\nluckiest girl alive,2\nilya rabinovich,1\nbridge,3\nel general,3\ncornerstone schools,1\ngozmo,1\ncharles courtney curran,1\nbroker,32\nus senate committee on banking housing and urban affairs,2\nretroversion of the sovereignty to the people,1\ngiorgi baramidze,1\nlars grael,1\nabdul qadir,3\npgrep,2\ncategory talk seasons in danish womens football,1\nmalus sieversii,1\ngod squad,4\ncategory of acts,1\nmelkote,1\nlinda langston,1\nsherry romanado,1\nmontana sky,8\nhistory of burkina faso,1\niso 639 kxu,1\nlos angeles fire department museum and memorial,1\nrecognize,1\nder bewegte mann,6\ndavy pröpper,1\noutline of vehicles,2\ngesta francorum,1\nsidney w pink,1\nronald pierce,1\nmartin munkácsi,1\nnord noreg,1\naccounting rate of return,7\nurwerk,1\nalbert gallo,1\nantennaria dioica,3\ntransport in sudan,2\nfladry,1\ncumayeri,1\nbennington college,11\npêro de alenquer,2\nsixth man,1\nwilliam i of aquitaine,1\nradisson diamond,1\nbelgian united nations command,1\nvenus genetrix,1\nsayesha saigal,14\ninverse dynamics,2\nnational constitutional assembly,1\nhoney bear,4\ncertosa di pavia,2\nselective breeding,31\nlet your conscience be your guide,1\nhan hyun jun,1\nclosed loop,8\ntemplate talk golf major championships master,1\ntwin oaks community virginia,1\nred flag,3\nhousing authority of new orleans,2\njoice heth,4\ntoñito,1\nivan pavlov,2\nmadanapalle,4\nptat,1\nrenger van der zande,1\nanaerobic metabolism,2\npatrick osullivan,1\nshirakoya okuma,1\npermian high school,9\nthomas h ford,1\nsouthfield high school,1\nreligion in kuwait,2\nnathrop colorado,1\nhefner hugh m,1\nwhitney bashor,1\npope shenouda iii of alexandria,7\nthomas henderson,1\ntokka and rahzar,13\nwindows thumbnail cache,3\nconsumer council for water,1\nsake bombs and happy endings,1\nlothlÃ³rien,1\nthe space bar,4\nsakuma rail park,1\noas albay,3\ndan frankel,1\ncliff hillegass,1\niron sky,12\npentile matrix family,1\noregon system,1\ncalifornia sea lion,7\njeanneau,2\nmeadowhall interchange,1\nlille catholic university,1\nnuñomoral,1\nvending machine,30\nxarelto,1\njonbenét ramsey,3\nprogresso castelmaggiore,1\ntacticity,6\nwing arms,1\ngag,2\nhank greenberg,8\ngarda síochána,14\npuggy,1\np sainath,1\nthe year of living dangerously,9\narmy reserve components overseas training ribbon,1\nhmas nestor,1\njohn beckwith,1\nflorida constitution,2\nyonne,3\nbenoît richaud,1\nmamilla pool,2\ngerald bull,14\ndavid halberstam,12\nmy fair son,2\nncaa division iii womens golf championships,1\nanniela,1\nking county,1\nkamil jankovský,1\nsynaptic,3\nrab,6\nswitched mode regulator,1\nhistory of biochemistry,1\nhalaf,2\nhenry colley,1\nco postcode area,3\nsocial finance uk,1\ncercospora,2\nthe dao,1\nunité radicale,2\nshinji hashimoto,3\ntommy remengesau,3\nisobel gowdie,2\nmys prasad,9\nnational palace museum of korea,1\nbasílica del salvador,2\nno stone unturned,2\nwalton group,1\nforamen ovale,1\nslavic neopaganism,1\niowa county wisconsin,3\nmelodi grand prix junior,1\njarndyce and jarndyce,3\ntalagunda,1\nnicholas of autrecourt,1\nsubstitution box,3\nthe power of the daleks,1\nreal gas,6\nedward w hincks,1\nkangxi dictionary,5\nnatural world,1\nh h asquith,21\nfrancis steegmuller,1\nsasha roiz,3\nmedia manipulation,1\nlooking for comedy in the muslim world,2\nbytown,4\nprevisualization,1\nrita ora discography,11\nkiersey oklahoma,1\nhenry greville 3rd earl of warwick,1\ndraft,4\nphenolate,1\ni believe,1\nvirologist,1\nrelief in abstract,1\neastern medical college,1\npurveyance,2\nascending to infinity,2\nsportstime ohio,2\nchurch of wells,1\nivory joe hunter,1\nwayne mcgregor,2\nluna 17,4\nviscount portman,2\nwikipedia talk wikipedia signpost/2009 07 27/technology report,1\nnegramaro,1\nbarking owl,2\ni need you,2\nbrockway mountain drive,1\ntemplate talk albatros aircraft,1\nfuture shock,11\nchina national highway 317,1\nlaurent gbagbo,7\nplum pudding model,18\nleague of the rural people of finland,1\ndundees rising,1\nnikon f55,1\nolympic deaths,5\ngemma jones,19\nhafsa bint al hajj al rukuniyya,1\npersonal child health record,1\nlogic in computer science,11\nbhyve,3\nhothouse,1\nlog house,6\nlibrary of celsus,2\nthe lizzie bennet diaries,1\nleave this town the b sides ep,1\nestimated time of arrival,8\nchariotry in ancient egypt,2\namerican precision museum,1\ndimos moutsis,1\nscriptlet,1\nsomething in the wind,1\nsharka blue,1\ntime on the cross the economics of american negro slavery,1\ntomislav kiš,1\nkhalid islambouli,7\nbankruptcy abuse prevention and consumer protection act,7\ngračanica bosnia and herzegovina,2\njungs theory of neurosis,5\nmgm animation,1\nsoviet support for iran during the iran–iraq war,3\nnative american,1\ntemplate talk nigeria squad 1994 fifa world cup,1\nnorwegian lutheran church,4\nadia barnes,1\ncoatings,1\nmehdi hajizadeh,1\nthe dead matter cemetery gates,1\nfuzzy little creatures,1\nwaje,7\nanji,1\nheinz haber,1\nturkish albums chart,1\nsebastian steinberg,1\nprice fixing cases,2\nbellator 48,1\nedgar r champlin,1\notto hermann leopold heckmann,1\nbishops stortford fc,4\nstern–volmer relationship,6\nmorgan quitno,2\nfive star general,1\niso 13406 2,1\nblack prince,11\nleopard kung fu,1\nfelix wong,5\nmary claire king,6\nalvar lidell,1\nplayonline,1\ninfantry branch,1\nandrew pattison,1\njohn turmel,1\nkent,74\nedwin palmer hoyt,1\ncaptivity narratives,1\njaguar xj220,1\nhms tanatside,2\nnew faces,2\nedward levy lawson 1st baron burnham,1\nsamuel woodfill,3\njewish partisans,9\nabandonware,16\nearly islamic philosophy,2\nsleeper cell,5\nmedia of africa,2\nsan andreas,3\nluxuria,2\negon hostovský,3\npelagibacteraceae,1\nmartin william currie,1\nborescope,21\nnarratives of islamic origins the beginnings of islamic historical writing,1\nlecompton constitution,2\naxé bahia,2\npaul goodman,1\ntemplate talk washington nationals roster navbox,1\na saucerful of secrets,2\ndavid carol macdonnell mather,1\nportal buddhism,3\nflorestópolis,1\nalecs+golf+ab,1\nbank alfalah,1\nfrank pellegrino,3\nloutre,1\nerp4it,2\nmonument to joe louis,2\nwitch trial of nogaredo,1\nsabrina santiago,2\nno night so long,3\nhelena carter,1\nrenya mutaguchi,3\nyo yogi,4\nbolivarian alliance for the americas,3\ncooper boone,1\nuss iowa,24\nmitsuo iso,2\ncranberry,1\nbatrachotomus,1\nrichard lester,5\nbermudo pérez de traba,1\nrosser reeves ruby,1\ntelecommunications in morocco,4\ni a richards,1\nnidhal guessoum,1\nlilliefors test,6\nthe silenced,5\nmambilla plateau,1\nsociology of health and illness,3\ntereza chlebovská,2\nbismoll,3\nkim suna,1\nscream of the demon lover,1\njoan van ark,7\nintended nationally determined contributions,6\ndietary supplement,16\nlast chance mining museum,1\nsavoia marchetti s65,1\nif i can dream,1\nmaharet and mekare,4\nnea anchialos national airport,2\namerican journal of digestive diseases,1\nchance,2\nlockheed f 94c starfire,1\nthe game game,1\nkuzey güney,3\nsemmering base tunnel,1\nthree mile island,1\nevaluation function,1\nrobert mckee,4\ncarmelo soria,1\nmoneta nova,1\npīnyīn,1\ninternational submarine band,3\nelections in the bahamas,5\npowell alabama,1\nkmgv,1\ncharles stuart duke of kendal,2\necho and narcissus,7\ntrencrom hill,1\nashwini dutt,1\nthe herzegovina museum,1\nliverpool fc–manchester united fc rivalry,12\nkerber,1\nflakpanzer 38,8\ndemographics of bihar,2\nrico reeds,1\nvandenberg afb space launch complex 3,1\nwiesendangen,1\nlamm,1\nallen doyle,2\nanusree,5\nbroad spectrum,1\nbay middleton,2\nconnect savannah,1\nhistory of immigration to canada,22\nwaco fm,3\nnakano takeko,1\nmurnau am staffelsee,2\nminarchy,1\nhaymans dwarf epauletted fruit bat,1\nbrachyglottis repanda,1\nassociative,1\nmississippi aerial river transit,1\nstefano siragusa,2\ngregor the overlander,3\nmarine raider,1\npogorzans,1\nsportcity,2\ngarancahua creek,1\nvincent dimartino,3\nninja,2\nnatural history museum of bern,1\nrevolutionary catalonia,4\nchiayi,1\nalix strachey,3\nlooe island,1\ncollege football usa 96,1\noff peak return,1\nminsk 1 airport,1\nevangelical lutheran church in burma,2\nriemann–roch theorem,1\nthe comic strip,2\nvladimir istomin,1\namerica again,2\nbrown treecreeper,1\namerican high school,1\npowerglide,2\noolitic limestone,1\ndaz1,1\njarrow vikings,1\npierre philippe thomire,1\ndorothy cadman,1\ngaston palewski,3\ntwin river bridges,1\nim yours,1\nambrose dudley 3rd earl of warwick,3\nssim,2\noriginal hits,1\ncosmonaut,9\nspecial educational needs and disability act 2001,4\nwill you speak this word,1\nhistory of wolverhampton wanderers fc,1\ndon lawrence,1\ntokyo metropolitan museum of photography,1\norduspor,1\njohn lukacs,3\npatrice collazo,1\nlords resistance army insurgency,5\nronald \"slim\" williams,5\ndrivin for linemen 200,1\nnicolò da ponte,1\nbucky pope,1\newing miles brown,2\nugly kid joe,28\namerican flight 11,1\nlouzouer,1\ndistrict hospital agra,1\njessica jane applegate,1\nsexuality educators,1\nserie a scandal of 2006,1\nat war with reality,1\nstephen wiltshire,13\nvechigen switzerland,1\nrikki clarke,3\nrayakottai,1\npermanent magnet electric motor,1\nqazi imdadul haq,1\nplywood,49\nntr telugu desam party,1\nskin lightening,1\nroyal natal national park,1\nuss mcdougal,2\nqueen of the sun,1\nkaranjachromene,1\non 90,1\nenrique márquez,1\nsiegfried and roy,1\ncity manager,6\nwrdg,1\nwhy i am not a christian,3\nprotein coding region,1\nroyal bank of queensland gympie,1\nbritish invasions of the river plate,2\nyasufumi nakanoue,1\nmagnetic man,1\nkickback,3\ntillandsia subg allardtia,1\nnorth american nr 349,1\nedict of amboise,1\nst andrew square edinburgh,2\nflag of washington,2\ntimeless,2\nnew york state route 125,3\nfudge,3\nsingle entry bookkeeping system,5\nrefractive surgery,8\nbi monthly,1\npark high school stanmore,1\nnorton anthology of english literature,1\nmichael wines,1\ngaff rig,1\nkosmos 1793,1\nmajor facilitator superfamily,2\ntalpur dynasty,1\nbyron bradfute,1\nquercitello,1\nrcmp national protective security program,1\nann kobayashi,1\nrecurring saturday night live characters and sketches,3\nabraham hill,1\nnagapattinam district,4\npidgeon,3\nmycalessos,1\ntechnical university of hamburg,1\nelectric shock&ei=ahp0tbk0emvo gbe v2bbw&sa=x&oi=translate&ct=result&resnum=2&ved=0ceaq7gewaq&prev=/search?q=electric+shock&hl=da&biw=1024&bih=618&prmd=ivns,2\naim 54 phoenix,18\nundercut,5\ngokhale memorial girls college,1\ndigital penetration,19\ncentre for peace studies tromsø,1\nrichie williams,1\nwalloon region,1\nalbany city hall,2\nmaxine carr,4\nanglosphere,18\neffect of world war i on children in the united states,1\njosh bell,1\ngerman thaya,1\nbrian murphy,3\nmarguerite countess of blessington,1\nleak,1\nbubble point,5\ninternational federation of human rights,1\nclubcorp,2\ngreater philadelphia,1\ndaniel albright,1\nmacas,1\nroses,4\nwoleu ntem,1\nshades of blue,1\nsay aah,2\ncurtiss sbc,1\nion andone,1\nfirstborn,1\nmarringarr language,2\nann e todd,1\nnative american day,4\nstand my ground,1\nbavington,1\nclassification of indigenous peoples of the americas,2\nalways,6\nleola south dakota,1\npsycilicibin,2\nroy rogers,1\nmarmalade,1\nnational prize of the gdr,1\nshilp guru,1\nm2 e 50,1\njorge majfud,2\ncutter and bone,1\nwilliam steeves,1\nlisa swerling,2\ngrace quigley,5\ntelecommunications in yemen,1\nrarotonga international airport,7\ncycling at the 2010 central american and caribbean games,2\nmazda b3000,1\nhanwencun,1\nadurfrazgird,1\nivan ivanov vano,1\nyhwh,1\nqarshi,4\noshibori,2\nuppada,1\niain clough,1\npainted desert,7\ntugzip,1\nmy little pony fighting is magic,143\npantheon,2\nchinese people in zambia,1\nyves saint laurent,3\ntexas helicopter m79t jet wasp ii,1\nforever reign,1\ncharlotte crosby,32\nealdormen,9\ncopper phosphate,2\nmean absolute difference,5\nhôtel de soubise,5\njosh rees,2\nnon commissioned officer,70\ngb jones,1\nim feeling you,2\nbook of shadows,9\nbrain trauma,1\nsulpitius verulanus,1\nvikranth,5\nspace adaptation syndrome,6\nunited states presidential election in hawaii 1988,1\njoe garner,4\nriver suir bridge,2\nthe beach boys medley,1\njoyce castle,1\nchristophe wargnier,1\nik people,2\nsketch show,1\nbuena vista police department,1\nfile talk layzie bone clevelandjpg,1\ngillian osullivan,3\nprince albert of saxe coburg and gotha,2\nberean academy,1\nmotorcraft quality parts 500,1\nfrederick law olmsted,21\nborn this way,9\nsterling virginia,4\nif wishes were horses beggars would ride,1\nsection mark,1\ntapi,1\nnavy cross,1\nhousekeeper,1\ngian battista marino,1\nplaná,1\nchiromantes haematocheir,1\ncolonial life & accident insurance company,4\naduana building,2\nkim johnston ulrich,1\nberkelium 254,1\nm&t bank corp,2\nsit up,1\nsheknows,1\nphantom lady,1\nbruce kamsinky,1\ncommercial drive,1\nchinese people in the netherlands,1\nsylvia young theatre school,4\ninfluenza a virus subtype h2n3,1\ndracut,2\nnate webster,1\nvila velebita,1\nuaz patriot,4\ndemocratic unification party,1\nalexander slidell mackenzie,1\nportland mulino airport,1\nfirst person shooter,2\nthe temporary widow,1\nterry austin,1\nthe foremans treachery,1\nhms blenheim,1\nsodium dichloro s triazinetrione,1\nkurt becher,1\ncumberland gap tn,1\nnewton cotes,1\ndaphne guinness,6\ninternal tide,1\ngod and gender in hinduism,2\nhowlin for you,1\nstellarator,14\ncavea,3\nfaye ginsburg,1\nlady cop,3\ntemplate talk yugoslavia squad 1986 fiba world championship,1\nsolidarity economy,1\nsecond presidency of carlos andrés pérez,1\nbora bora,71\nxfs,1\nchristina bonde,1\nagriculture in australia,20\nscenic drive,1\nrichard mantell,1\nmotordrome,1\nbroadview hawks,1\nmisty,2\ninternational bank of commerce,2\nistanbul sapphire,5\nchangkat keruing,1\nthe hotel inspector unseen,1\ntharwa australian capital territory,2\nstrauss,2\nshock film,1\nulick burke 1st marquess of clanricarde,2\nvalencia cathedral,5\nkay bojesen,1\npalogneux,1\ntexas beltway 8,1\njackie walorski,7\ncapital punishment in montana,1\nbyte pair encoding,2\nupper deerfield township new jersey,2\nlucca comics & games,1\nlee chae young,1\nczar alexander ii,1\nkool ad,6\nleopold van limburg stirum,1\njohn dunn,1\npoliceman,2\nwhat dreams may come,3\ngrant ginder,1\nchieverfueil,2\nlong island express,1\nmalmö sweden,2\nsong for my father,1\nsee saw,2\njean jacques françois le barbier,5\ndo rag,11\ndsb bank,2\ndavical,6\ncervical cap,1\ngershon yankelewitz,1\nthe last hurrah,4\ncategory talk educational institutions established in 1906,1\ntour pleyel,1\nleón klimovsky,1\nphyoe phyoe aung,1\nphil sawyer,2\nandroid app //orgwikipedia/http/enmwikipediaorg/wiki/swiftkey,1\ndeontological,3\njuan dixon,12\nrobert pine,4\nalexander tilloch galt,2\ncommon tailorbird,12\nderailed,7\nmike campbell,3\nterminator 2 3 d battle across time,3\ntechnische universität münchen,4\nbaloana,1\nechis leucogaster,1\nlahore pigeon,1\nwilliam de beauchamp 9th earl of warwick,2\nerin go bragh,14\neconomics u$a,1\nvillafranca montes de oca,1\npope eusebius,2\nmartin kruskal,1\nfélix de blochausen,1\njeff jacoby,1\nmark krein,2\ntravis wester,2\nfort louis de la louisiane,1\nweddingwire,2\nping,54\ndon swayze,8\nsteve hamilton,3\nrhenish,1\nwinrar,3\nbirths in 1561,4\ncopyright law of the netherlands,2\nfloodland,9\ntamil nadu tourism development corporation,1\ndolls house,1\nchkrootkit,1\nsearch for the hero,1\navenal,1\ntini,2\npatamona,1\naspendos international opera and ballet festival,2\nfelix cora jr,5\nyellow cardinal,2\nantony jay,1\nconda,1\na tramp shining,1\nwilliam miller,1\nholomictic lake,2\ngrowler,2\nthe violence of summer,1\nmeerschaum,3\ncd138,1\nkarl friedrich may,1\nhistory of iraq,2\nhenry ford,139\nrumwold,1\nbeatrice di tenda,1\nblaze,1\nnick corfield,1\nwalt longmire,5\neleazar maccabeus,1\nbusiness edition,1\nkarl oyston,4\ngypsy beats and balkan bangers,1\nfa premier league 2004 05,1\nagawan radar bomb scoring site,1\nthe hall of the dead,1\ncombat training centre,1\nmoroccan portuguese conflicts,2\npokipsy,1\nminor characters in csi crime scene investigation,1\nmiguel molina,1\nbuckypaper,2\nmagazine,4\nforget about it,2\nmarco schällibaum,1\nr d smith,1\nnfl playoff results,2\nfour score,1\ncentenary bank,2\nlondon borough of camden,12\nbhumij,1\ncounter reformation/trackback/,1\nbilly volek,1\ncover song,1\nawang bay,1\ndouglas fitzgerald dowd,3\narchitecture of ancient greece,5\nny1,2\nacademy award for best visual effects,3\nhistory of the mbta,2\ntriangle group,1\ncharles r fenwick,1\nberenice i of egypt,1\nwindow detector,1\ncorruption perception index,1\nleffrinckoucke,1\nlee anna clark,1\nburndy,2\ninset day,2\namerican association of motor vehicle administrators,1\nckm matrix,1\nangiopoietin 1,1\nsteven marsh,1\nopen reading frame,27\ntelesystems,1\npastoral poetry,1\nwest wycombe park,2\nlithium,7\nnogales international airport,1\nwajków,1\nsls 1,1\ntrillo,2\nmax s,1\nverndale,1\nyes sir i can boogie,1\nblog spam,10\ndaniel veyt,1\nwilliam brown,3\ntakami yoshimoto,1\njosh greenberg,4\ngeoffrey heyworth 1st baron heyworth,1\nmedeina,3\nanja steinlechner,1\nriviera beach florida,2\ngerris wilkinson,1\nnorth american lutheran church,1\npaul dillett,11\nproto euphratean language,1\nbest selling books,2\npumpellyite,1\nbusiness objects,1\nfodor,2\nxanadu,3\nlondon river,1\ndraft juan de orduña,2\nbarriemore barlow,3\njew harp,1\nbirmingham,1\ntitus davis,1\nmarch 2012 gaza–israel clashes,1\nenergy demand management,2\naquarium of the americas,3\ntto,1\nl h c tippett,1\noptical fiber,88\nonești,2\nstanley ntagali,1\nprussian blue,1\nbill kovach,2\nhip pointer,3\nalessandra amoroso,4\nfleet racing,1\nnavy maryland rivalry,1\ncornering force,1\nthe mighty quest for epic loot,5\nkatalyst,2\nthe beef seeds,1\nshack out on 101,1\naircraft carrier operations,1\noverseas province,2\ninstitute of state and law,1\nlight truck,5\nplastics in the construction industry,2\nlittle zizou,2\ncongenic,2\nadriaen van utrecht,1\nbrian mcgrath,3\nparvati,1\njason gwynne,1\nkphp,1\nmiryusif mirbabayev,1\nkōriyama castle,3\nthe making of a legend gone with the wind,2\nshot traps,1\nawa tag team championship,1\nlittlebourne,2\nfranchot tone,4\njohn dudley 2nd earl of warwick,2\nmass spec,1\nfinal fantasy vi,44\ngerry ellis,1\nadon olam,3\nman 24310,1\np n okeke ojiudu,1\nunqi,1\nsnom,1\nbruce bagemihl,1\ncategory talk animals described in 1932,1\nmetalist oblast sports complex,1\ncolley harman scotland,1\nsuka,1\nanita sarkeesian,81\nkazakhstan national under 17 football team,1\nym,2\nmatt barnes,1\ntour phare,1\nbellus–claisen rearrangement,2\nturkey at the 2012 summer olympics,1\nirréversible,32\numbilical nonseverance,1\nwood stave,1\nindian pentecostal church of god,1\ncamponotus nearcticus,3\njohn tesh,13\nsyncline,4\nskins,50\nkelsey manitoba,1\nalkayida,2\npolyglotism,17\nforensic statistics,2\nram vilas sharma,8\npearl jam,71\ndj max fever,1\nislamic view of miracles,5\nkds,1\nalabama cavefish,1\njohanna drucker,1\ntom wolk,4\nrottenburg,2\ngoshen connecticut,2\nmaker media,1\nmorphett street adelaide,1\nkeystone hotel,1\nbaseball hall of fame balloting 2005,1\ngongzhuling south railway station,1\nss charles bulfinch,1\nsig mkmo,1\ncartman finds love,2\nembassy of syria in washington dc,1\ncharles prince of wales,175\nteachings of the prophet joseph smith,1\ncharles iv,1\nalethea steven,1\ntype i rifle,2\na peter bailey,1\nbrain cancer,1\neric l clay,2\njett bandy,1\nmoro rebellion,9\neustachów,1\navianca el salvador,2\ndont stop the party,4\nreciprocal function,1\ndagmar damková,1\nhautmont,1\npenguin english dictionary,2\nwaddie mitchell,1\ntechnician fourth grade,3\nhot girls in love,1\ncritérium du dauphiné,59\nlove song,2\nroger ii,2\nwhitbread book award,1\nthomas colepeper 2nd baron colepeper,2\na king and no king,1\nbig fish & begonia,5\nmayville new york,2\nmolecularity,1\ned romero,1\none watt initiative,3\njeremy hellickson,2\nwilliam morgan,1\ngiammario piscitella,1\neastern lesser bamboo lemur,1\npadre abad district,1\ndon brodie,1\nfacts on the ground,1\nundeniable evolution and the science of creation,1\njohn of giscala,1\nbryce harper,45\ngabriela irimia,1\nempire earth mobile,1\nthe queen vic,1\nhelen rowland,1\nmixed nuts,5\nmalacosteus niger,2\ngeorge r r martin/a song of ice and fire,1\nbrock osweiler,11\ntough,1\noutline of agriculture,4\nsea wolf,1\nmo vaughn,4\nthe brood of erys,1\ncomposite unit training exercise,1\nisabella acres,4\nthe jersey,5\ncoal creek bridge,1\nhabana libre,1\nnicole pulliam,1\njohn shortland,1\ndaniel pollen,1\nmagic kit,1\nbaruch adonai l&,1\na daughters a daughter,2\nlaughlin nevada,11\ntubercule,1\nlouis laurie,1\ninternet boom,3\nconversion of paul,1\ncomparison of software calculators,1\nchoctaw freedmen,2\njosh eady,1\nhôpital charles lemoyne,2\nu mobile,2\njohn tomlinson,1\nbaré esporte clube,2\ntuğçe güder,2\nhighams park railway station,4\nnewport east,1\nclothing industry,6\nscott rosenberg,6\nmy 5 wives,2\nmatt godfrey,1\nport ellen,2\nwinecoff hotel fire,1\nfide world chess championship 2005,2\nlara piper,1\nthe little mermaid,1\nfoxmail,6\npenn lyon homes,1\nstockholm opera,1\namerican journal of theology,1\nbernard gorcey,3\nrodger collins,1\nclarkeulia sepiaria,1\nkorean era name,3\nmelide ticino,1\nunknown to no one,1\nasilinae,1\nscânteia train accident,1\nparti de la liberté et de la justice sociale,1\nfalkland islands sovereignty dispute,13\ncastile,10\nfrench battleship flandre,1\nnils taube,1\nanisa haghdadi,1\nwilliam tell told again,2\nmagister,3\nzgc 7,1\nnational agricultural cooperative marketing federation of india,3\nles bingaman,1\nchebfun,1\nportal current events/august 2014,2\neparchy of oradea mare,1\ntempo and mode in evolution,2\nseili,1\nboniface,3\nsupportersvereniging ajax,1\nsupport team,1\nlactometer,1\ntwice as sweet,1\nspruce pine mining district,2\nbanknotes of the east african shilling,1\ncerebral cortex,3\ntagalogs,1\ngerman diaspora,8\ngrammelot,1\nmax a,1\ncategory talk vienna culture,1\ncheung kong graduate school of business,1\nthree certainties,1\nmultani,3\nbarry callebaut,15\njoanne mcneil,1\nz grill,4\ncommonwealth of australia constitution act 1900,1\nganzorigiin mandakhnaran,1\npeter h schultz,1\nea pga tour,3\nscars & memories,1\nexodus from lydda,1\nstates reorganisation act 1956,4\nguy brown,1\nhorsebridge,1\narthur mafokate,1\naldus manutius,5\namerican daylight,3\njean chaufourier,2\nedmond de caillou,1\nhms iron duke,9\ndispleased records,1\nquantum turing machine,3\nncert textbook controversies,2\ndracs,1\nbeyrouth governorate,1\nstaphylococcus caprae,1\ntankard,2\nsurfaid international,1\nhohenthurn,2\nmission x 41,1\nprofessional wrestling hall of fame,2\ngeorge mountbatten 4th marquess of milford haven,2\nathletics at the 2012 summer paralympics womens club throw f31 32/51,1\nknots and crosses,1\nedge vector,1\nphilippe arthuys,1\nbaron raglan,1\nodell beckham jr,3\nelfriede geiringer,1\nhyflux,1\nauthor level metrics,2\nieee fellow,1\npori brigade,3\npolyphenol antioxidant,1\nthe brothers,8\nkakaji Ōita,1\nshyam srinivasan,2\nshahid kapoor,88\nchuckie williams,1\ncolonial,4\nroman spain,1\nconvolvulus pluricaulis,1\nwilliam j burns international detective agency,1\naccessibility for ontarians with disabilities act 2005,1\nlinguist,1\nagonist,2\nxiaozi,1\nholker hall,1\nnovatium,1\nalois jirásek,1\nlesser crested tern,1\nnames of european cities in different languages z,1\nhydrogen cooled turbogenerator,2\nindian airlines flight 257,1\nunited states attorney for the northern district of indiana,1\nthis is us,11\ntransaction capabilities application part,1\nculiacán,6\nhash based message authentication code,65\nheinz murach,1\ndual citizen,2\nzhizn’ za tsarya,1\ngabriel taborin technical school foundation inc,1\ndeaths in july 1999,1\naponi vi arizona,1\namish in the city,2\ngoodbye cruel world,1\nst augustine grass,10\nmoesi,1\nviolette leduc,3\nmethyl formate,9\nyou walk away,1\nthe traveler,1\nbond,89\nmoa cuba,3\nhebrew medicine,1\nwomen in the russian and soviet military,2\nhelp log,2\ncuillin,5\nback fire,14\nsalesrepresentativesbiz,1\nhogsnort rupert,1\ndwarf minke whale,1\nembassy of albania ottawa,1\ncotai water jet,1\nst lucie county florida,8\nwesselman,1\namerican indian art,1\nrichard arkless,1\ntrolleybuses in bergen,1\nvama buzăului,1\nfar east movement,9\nthrees a crowd,1\ninsane,3\nlinux technology center,4\npatty duke,24\nsmuckers,1\nkapalua,1\namf futsal world cup,5\numes chandra college,1\njnanappana,2\nbar bar bar,1\nberetta m951,2\nlibertarian anarchism,1\nfart proudly,4\npeyton place,5\nphase detection autofocus,1\ncavalry in the american civil war,9\nclass stratification,1\nbattle of cockpit point,1\nregiment van heutsz,2\nana rivas logan,1\nnenya,1\nwestland wah 64 apache,1\nroslyn harbor new york,3\naugust wilhelm von hofmann,1\nprofessional baseball,2\ndouglas feith,1\npogrom,21\naušra kėdainiai,1\npseudopeptidoglycan,4\narquà petrarca,1\nwayampi,1\nconservative government 1866 1868,1\nworld naked bike ride,28\nfruitvale oil field,2\nshuttle buran,1\nrobert c pruyn,1\ntotem,1\nmegalotheca,1\nnkechi egbe,1\njames p comeford,1\nheavens memo pad,7\ncauca valley,1\njungfraujoch railway station,2\nseo in guk,24\nbold for delphi,1\nmultiple frames interface,1\nzhenli ye gon,6\nkyabram victoria,1\ntwo stars for peace solution,1\ncouette flow,9\nnew formalism,2\ntemplate talk 1930s comedy film stub,1\ntemplate talk scream,1\njoona toivio,4\niaaf silver label road race,1\nsuper bowl xxviii,5\ni aint never,1\npaul little racing,1\njacobite rising of 1715,3\nkatherine archuleta,1\nprogrammable logic device,12\nfootsteps of our fathers,2\nonce upon a tour,1\ntauck,1\nbudapest memorandum on security assurances,5\nprostitution in chad,2\nbebedouro,2\nvice,2\nmadredeus,1\np diddy,1\nprincess alice of the united kingdom,20\njerry hairston jr,1\nneo noir,3\nself evaluation motives,1\nrelativity the special and the general theory,2\nthe sign of four,3\nkevin deyoung,1\nrobin long,1\nmokshaa helsa,1\nnagaon,1\naniceto esquivel sáenz,1\nsda,2\ngerman battlecruiser gneisenau,1\nassisted reproductive technology,12\ncmmg,1\nvision of you,1\nkeshia chanté discography,1\nbiofuel in the united kingdom,1\nkatinka ingabogovinanana,1\nhutt valley,1\ngarwol dong,1\ntunceli province,3\nedwin bickerstaff,1\nhalloween 3 awesomeland,1\ncanadian records in track and field,1\nubisoft são paulo,1\nmidstream,16\njethro tull,4\nchildhoods end,55\nss rohilla,1\nlagranges four square theorem,6\nbucky pizzarelli,3\njannik bandowski,80\nguðni Ágústsson,1\nmultidimensional probability distribution,1\nbrno–tuřany airport,2\nbroughtonia,5\ncold hands warm heart,1\nsimone biles,32\nbf homes parañaque,2\nakaflieg köln ls11,3\nstreet fighter legacy,2\nbeautiful kisses,1\nfirst modern olympics,1\nmacbook air,1\ndublab,1\nsilent night deadly night,6\nearth defense force 2025,2\ngrant township carroll county iowa,1\ngary williams,1\nmalmö aviation,1\ngeographical pricing,2\nanaheim memorial medical center,1\nmary+mallon,1\nhenry a byroade,1\nwawasan 2020,4\neurovision dance contest,6\nlydia polgreen,1\npilsen kansas,1\ncolin sampson,1\nneelamegha perumal temple,1\njames bye,2\ncanadian federation of agriculture,1\nf w de klerk,34\nbob casey jr,3\nnorthport east,1\nelian gonzalez affair,1\naleksei bibik,1\nanthony dias blue,1\npyaar ke side effects,4\nfusako kitashirakawa,1\ncal robertson,4\nshandong national cultural heritage list,1\npolice story 3 super cop,5\nthe third ingredient,3\ndean horrix,1\npico el león,1\ncesar chavez street,1\nprospered,1\nchildren in cocoa production,5\ngervase helwys,1\nbinary digit,1\nkovai sarala,4\nmathematics and music,1\nmacroglossum,1\nf gary gray,21\nbroadsoft,2\ncachan,4\nbukkake,21\nchurch of st margaret of scotland,1\nchristopher cockerell,3\namsterdam oud zuid,1\ncounty of bogong,1\nintel mobile communications,1\nthe legend of white fang,1\nmillwright,19\nwill buckley,1\nbill jelen,2\ntemplate talk san francisco 49ers coach navbox,1\namalia garcía,1\nbecause he lives,1\nair charts,1\nstade edmond machtens,1\nhenry stommel,1\ndxgi,1\nmisr el makasa sc,1\nchad price,2\ncarl henning wijkmark,1\nacanthogorgiidae,1\ndiqduq,1\nprelog strain,2\ncrispin the cross of lead,4\navraham adan,2\nbarbershop arranging,1\nfree x tv,1\neric guillot,1\nkht,1\nnever a dull moment,1\nlwów school of mathematics,1\nsears centre,3\nchin state,6\nvan halen 2007 2008 tour,1\nrobert weinberg,3\nfierté montréal,2\nvince jack,1\nheikki kuula,1\narchitecture of the republic of macedonia,1\nglossary of education terms,1\naleksandra szwed,1\nmilitary history of europe,3\nexeter central railway station,1\nstaroselye,1\nlee thomas,7\nsaint peters square,2\nromanization of hispania,2\nfile talk dodecahedrongif,1\nsigned and sealed in blood,8\ncolleges of worcester consortium,1\ndistrict electoral divisions,1\ngalkot,1\nking África,3\nmonetary policy,57\nbrp ang pangulo,2\nbattle of mạo khê,1\nair tube,1\nruth ashton taylor,2\nkeith jensen,1\nheadland alabama,1\nwillie loomis,1\ninteractive data extraction and analysis,2\ngeorgetown city hall,2\nchuck es in love,2\nweeksville brooklyn,1\nanatoly sagalevich,2\nbrowett lindley & co,1\nbarnawartha victoria,1\npop,2\nblack balance,2\naceratorchis,1\nemmeline pethick lawrence baroness pethick lawrence,1\nosso buco,1\nherminie cadolle,2\ntelegram & gazette,2\nle van hieu,1\npine honey,2\nnexvax2,1\nleicester north railway station,1\njacqueline foster,1\nbill handel,3\nnizami street,1\nradke,1\nbob mulder,1\nambroise thomas,4\ncarles puigdemont i casamajó,1\ncallable bond,6\ntesco metro,2\nmohan dharia,1\ngreat hammerhead,12\nvinko coce,3\njohn mayne,1\ncobb cloverleaf,1\nuhlan,10\ngiulio migliaccio,1\nbelmont university,6\nrinucumab,1\nkearny high school,1\nchūgen,1\nstages,2\nboar%27s head carol,1\nknight of the bath,1\nayres thrush,7\nsing hallelujah,1\nthe tender land,2\nwholesale banking,1\njean jacques perrey,5\nmaxime bossis,2\nsherman records,1\nalan osório da costa silva,1\nfannie willis johnson house,1\nblacks equation,2\nlevinthals paradox,2\nthomas scully,2\nnecron,3\nuniversity of alberta school of business,5\nlake shetek,1\ntoby maduot,1\ngavriil golovkin,1\nsweetwater,3\natlantic revolutions,2\njaime reyes (comics,1\nkajang by election 2014,1\nmycotoxigenic,1\nsan marco altarpiece,2\nline impedance stabilization network,2\nsantiago hernández,1\njazzland,3\nhost–guest chemistry,4\ngiovanni florio,2\nst marylebone school,1\nacqua fragile,1\nthe horse whisperer,10\ndon francis,1\nmike molesevich,1\nbrad wright,1\nnorth melbourne football club,3\nbrady dragmire,1\nmargaret snowling,2\nwing chun terms,4\nmckey sullivan,1\nderek ford,1\ncache bus,1\nbernie grant arts centre,2\namata francisca,1\nsinha,2\nlarissa loukianenko,1\noceans apart&sa=u&ved=0ahukewjw4n6eqdblahun7gmkhxxebd8qfgg4mag&usg=afqjcnhhjagrbamjgaxc7rpsso4i9z jgw,1\nanemone heart,2\nalison mcinnes,1\njuan lindo,1\nmahesh bhupati,1\nbaháí faith in taiwan,5\ncinema impero,1\ntemplate talk rob thomas,1\nlikin,1\nscience & faith,1\nfort saint elmo,3\ndelhi kumar,6\njuha lallukka,1\nsituational sexual behavior,2\nmilligan indiana,1\nwilliam em lands,1\nkarl anselm duke of urach,2\nhérold goulon,1\nvedic mathematics,20\nmove to this,1\nkoussan,1\nfloored,1\nraghu nandan mandal,1\nangels gods secret agents,1\northogonal,2\nthe little house on the prairie,1\nchilean pintail,1\nguardian angel,2\nst leonard maryland,1\ngreen parties in the united kingdom,1\ntime to say goodbye,1\nalba michigan,2\nharbourfront centre,1\ncorner tube boiler,1\nconsensus government,1\nppru 1,1\ncorporate anniversary,4\nsazerac company,5\nkyle friend,1\nbmw k1100lt,1\npergola marche,1\ncommonwealth of kentucky,2\ntaiwan passport,2\nclare quilty,1\ndomenico caprioli,1\nfrank m hull,1\ncheng sui,2\nnazi board games,3\nspark bridge,1\nderrick thomas,6\nwunnumin 1,1\nemotion remixed +,4\nbrian howard dix,2\nbrigalow queensland,2\nburgi dynasty,1\napolonia supermercados,1\nbrandon lafell,2\none day,24\nnara period,9\ntemplate talk the land before time,1\nassyrians in iraq,1\ntrade union reform and employment rights act 1993,2\ntemplate talk evansville crimson giants seasons,1\nboys be smile / 目覚めた朝にはきみが隣に,2\nkapuloan sundha kecil,1\nhuman impact of internet use,1\nkolkata metro line 2,3\nsaint pardoux morterolles,1\ncarfin grotto,2\nsamuel johnson prize,3\nfrench royal family,1\nandroid app //orgwikipedia/http/enmwikipediaorg/wiki/victoria park,1\nmazda xedos 9,1\nmăiestrit,1\npetroleum economist,2\npenetration,2\nadrian rawlins,8\nplutonium 239,11\nculture of montreal,1\nbritish germans,2\nwarszawa wesoła railway station,1\nlorenzo di bonaventura,6\nmilitary ranks of estonia,1\nuss flint,8\narthur f defranzo,1\nsadeh,1\njammu and kashmir,3\nigor budan,2\ncharmila,2\nchoi,1\nmohammed ali khan walajah,1\nsourabh varma,1\nafter here through midland,1\nmartyn day,1\njustin larouche,1\nillinoiss 6th congressional district,4\njackson wy,1\ntyson apostol,4\nmitch morse,1\nrobert davila,1\ncanons regular of saint john cantius,1\ngiant girdled lizard,2\ncascade volcanoes,5\nfools day,1\ncordyline indivisa,1\npueraria,2\nswiss folklore,4\nmeretz,3\nunited states senate elections 1836 and 1837,1\nbaby i need your love/ easy come easy go,1\nbutrus al bustani,2\nthe lion the lamb the man,1\nrushikulya,1\nbrickworks,3\nalliance party of kenya,1\nludlow college,1\ninternationalism,11\nernest halliwell,1\nconstantine phipps 1st marquess of normanby,1\nkari ye bozorg,1\nsignal flow,4\ni beam,1\ndevils lake,1\nunion of artists of the ussr,2\nindex of saint kitts and nevis related articles,1\nethernet physical layer,18\ndimensional analysis,16\nanatomical directions,2\nsupreme court of guam,1\nsentul kuala lumpur,2\nducefixion,1\nred breasted merganser,4\nreservation,3\nin the land of blood and honey,9\nkate spade,2\nalbina airstrip,1\nkankakee,1\nservicelink,2\ncastilleja levisecta,1\ntonmeister,2\nchanda sahib,1\nlists of patriarchs archbishops and bishops,1\nmach zehnder modulator,1\ngiants causeway,79\nliteral,7\nuss gerald r ford,1\nmonster hunter portable 3rd,3\nbayern munich v norwich city,1\nbanking industry,1\nprankton united,1\nst elmo w acosta,1\nspeech disorder,9\nwelcome to my dna,1\nnouriel roubini,6\narthur kill,2\nbill grundy,7\njake gyllenhaal,1\nworld bowl 2000,1\nwnt7a,1\npink flamingo,2\ntridentine calendar,1\nray ratto,1\nf 88 voodoo,1\nsuper star,4\nondřej havelka,1\nsophia dorothea of celle,12\nclavulina tepurumenga,1\nvampire bats,4\nihsan,1\nocotea foetens,1\ngannett inc,1\nkemira,4\ngre–nal,2\nfarm bureau mutual,1\npete fox,1\nlet him have it,3\nbackwoods home magazine,6\nte reo maori remixes,1\nhussain andaryas,1\nbagun sumbrai,1\nthe westin paris – vendôme,4\nxochiquetzal,4\nplayers tour championship 2013/2014,1\npicnic,7\njosh elliott,5\nernak,3\ngracias,1\nk280ff,1\nbandaranaike–chelvanayakam pact,1\npatrick baert,1\nnausicaä of the valley of the wind,33\nal jurisich,1\ntwitter,230\nwindow,38\nthe power hour,1\nduplex worm,1\nsonam bajwa,16\nbaljit singh deo,1\nindian jews,1\noutline of madagascar,1\noutback 8,1\ndye fig,1\nbritish columbia recall and initiative referendum 1991,1\nfelipe suau,1\nnorth perry ohio,1\ngilbeys gin,1\nphilippe cavoret,1\nluděk pachman,1\nthe it girl,1\ndragonnades,1\nrick debruhl,2\nxpath 20,2\nsean mcnulty,1\nwilliam moser,1\ninternational centre for the settlement of investment disputes,1\nmendes napoli,2\ncanadian rugby championship,1\nbattle of maidstone,2\nboulevard theatre,2\nsnow sheep,3\npenalty corner,1\nmichael ricketts,5\ncrocodile,2\njob safety analysis,5\nduffy antigen,1\ncounties of virginia,1\na place to bury strangers,5\nsocialist workers’ party of iran,1\nwlw t,1\ncore autosport,1\nwest francia,10\nkaren kilgariff,2\npacific tsunami museum,1\nfirst avenue,1\ntroubadour,1\ngreat podil fire,1\nchilean presidential referendum 1988,1\npavol schmidt,1\nhandguard,1\ncrime without passion,1\ndio at donington uk live 1983 & 1987,1\noptic nerves,1\nwake forest school of medicine,1\nnew jersey jewish news,2\nluke boden,2\nchris hicky,1\nbeforu,2\nverch,1\nst roch,3\ncivitas,1\ntmrevolution,3\njamie spencer,1\nbond beam,1\nmegan fox,4\nbattle of bayan,1\njapan airlines flight 472,1\nyuen kay san,1\nthe friendly ghost,1\nrice,14\njack dellal,16\nlee ranaldo,9\nthe overlanders,1\nearl castle stewart,5\nfirst down,1\nrheum maximowiczii,1\nwashington state republican party,2\nostwald bas rhin,1\ntennessee open,1\nkenneth kister,1\nted kennedy,72\npreben elkjaer,1\nindia reynolds,2\nsantagata de goti,1\nhenrietta churchill 2nd duchess of marlborough,1\ncreteil,1\nntt data,3\nzoot allures,4\ntheatre of ancient greece,29\nbujinkan,6\nclube ferroviário da huíla,2\nnhn,4\nhp series 80,2\ninterstate 15,4\nmoszczanka,1\nlawnside school district,1\nvirunga mountains,5\nhallway,1\nserb peoples radical party,1\nfree dance,1\nmishawaka amphitheatre,1\ndeerhead kansas,1\nutopiayile rajavu,1\njohn w olver transit center,1\nfuta tooro,1\ndigoxigenin,5\nthomas schirrmacher,1\ntwipra kingdom,1\npulpwood,6\nthink blue linux,1\nraho city taxi,1\nfrederic remington art museum,1\nwajdi mouawad,1\nsemi automatic firearm,12\nphyllis chase,1\nmalden new york,1\nthe aetiology of hysteria,2\nmy maserati does 185,1\nfriedrich wilhelm von jagow,1\napne rang hazaar,1\nbór greater poland voivodeship,1\nindia rubber,2\nbring your daughter to the slaughter,4\nyasser radwan,1\nkuala ketil,1\nnotre dame de paris,1\nyuanjiang,1\nfengjuan,1\ntockenham,1\ntransnistrian presidential election 1991,1\ngautami,28\nprovidenciales airport,1\ndonald chumley,1\nmiddle finger,8\ncalke abbey,4\nthou shalt not kill,1\ntrail,7\nbattle of dunkirk,43\neyre yorke block,3\nmactan,3\namerican ninja warrior,2\nnevel papperman,1\nninja storm power rangers,1\nuss castle rock,1\nturcos,1\nphilippine sea frontier,1\nirom chanu sharmila,7\nfor the first time,2\nstian ringstad,1\ntréon,1\nhiro fujikake,1\nrenewable energy in norway,4\ndedh ishqiya,18\nleucothoe,2\necmo,2\nknfm,1\ngangnam gu,1\noadby town fc,1\nclamperl,2\nmummy cave,2\nkenneth d bailey,2\npeter freuchen,2\ndayanand bandodkar,2\nshawn crahan,16\nbarbara trentham,2\nuniversity of virginia school of nursing,1\nvöckla,1\nintuitive surgical inc,1\ncyncoed,4\njohn l stevens,1\ndaniel farabello,1\ntrent harmon,5\nferoze gandhi unchahar thermal power station,1\nsamuel powell,1\npan slavic,1\nswimming at the 1992 summer olympics – womens 4 × 100 metre freestyle relay,1\nhuman behaviour,2\nsiege of port royal,3\neridug,1\nlafee,1\nnorth bethesda trail,1\nscheveningen system,1\nspecial penn thing,1\npserimos,1\npravda vítězí,1\nwiki dankowska,1\ntranscript,13\nsecond inauguration of grover cleveland,1\nspent fuel,1\nertms regional,2\nfrederick scherger,1\nnivis,1\nherbert hugo menges,1\nkapitan sino,1\nsamson,34\nminae mizumura,2\ngro kvinlog,1\nchasing shadows,2\nd j fontana,1\nmassively multiplayer online game,27\ncapture of new orleans,8\nmeat puppet,1\namerican pet products manufacturers association,3\nvillardonnel,1\nsessile serrated adenoma,3\npatch products,1\nlodovico altieri,1\nportal,2\njake maskall,4\nthe shops at la cantera,8\nstage struck,5\nelizabeth m tamposi,2\ntaylor swift,22\nforum spam,9\nbarry cowdrill,3\npatagopteryx,2\nkorg ms 2000,1\nhmas dubbo,2\nss khaplang,2\nkevin kelly,1\npunk goes pop volume 5,3\nspurt,2\nbristol pound,5\nmilitary history of finland during world war ii,10\nlaguardia,1\njosé marcó del pont,1\nconditional expectation,18\nthe beat goes on,1\npatricia buckley ebrey,1\nali ibn yusuf,2\ncaristii,1\nwilliam l brandon,1\nfomite,5\nbarcelona el prat airport,7\nmattequartier,4\ninvading the sacred,1\njefferson station,3\nchibalo,1\nphil voyles,1\nramen,41\narchbishopric of athens,1\nrobert arnot,1\ndiethylhydroxylamine,2\nchristian vazquez,1\nservage hosting,1\nufo alien invasion,1\nblackburn railway station,3\nperformance metric,19\npencilings,1\nphosphoenolpyruvate,1\nunder lights,2\ndiego de la hoya,1\nfelipe caicedo,5\njimmy arguello,1\ncielo dalcamo,1\njan navrátil,1\nlinear pottery culture,9\nwbga,1\nk36dd,1\ndie hard 2,22\ncompanding,8\nthis is the modern world,10\ncosmology,26\ncraig borten,1\nred pelicans,1\nac gilbert,2\nfougasse,1\nleonardos robot,4\njohn of whithorn,2\ndavid prescott barrows,2\nhttp cookie,168\nemilia telese,6\nherăstrău park,2\nlauro villar,1\nearl of lincoln,1\nborn again,2\nmilan rufus,1\nweper,2\nlevitt bernstein,1\njean de thevenot,1\njill paton walsh,2\nleudal,1\nkyle mccafferty,1\npluralistic walkthrough,2\ngreetings to the new brunette,3\nangus maccoll,1\nloco live,2\npalm i705,1\nsaila laakkonen,1\nssta,1\nbuch,1\neduardo cunha,7\nmarie bouliard,1\nmystic society,2\nchu jus house,1\nboob tube,8\nil mestiere della vita,1\nhadley fraser,7\nmarek larwood,2\nimperial knight,2\nadbc,1\nhoudini,8\npatrice talon,3\niodamoeba,1\nlong march,26\nnyinba,1\nmaurice dunkley,1\nnew south wales state election 1874–75,1\njohn lee carroll,1\npoya bridge,1\ncategory talk military units and formations established in 2004,1\nthe family values tour 1999,2\nbrødrene hartmann,1\nmiomelon,1\njohn moran bailey,1\nsan juan archipelago,1\ncome as you are,7\nhypo niederösterreich,1\nsaturn vi,2\ncherokee county kansas,1\nmaher abu remeleh,1\nfile talk jb grace singlejpg,1\ncount paris,8\ntemplate talk anime and manga,1\nkntv,4\nganges river dolphin,4\njerry pacht,1\nrapid response,1\ncrunch bandicoot,1\nbig gay love,2\njohn mckay,1\nbareq,1\nnikon d2x,1\nintercontinental paris le grand hotel,1\noakland alternative high school,1\nekow eshun,1\njimmy fortune,1\namerican gladiator,2\nella sophia armitage,1\nunited we stand what more can i give,5\nmaruti suzuki celerio,1\ngeraldo rivera/trackback/,1\ndogs tobramycin contain a primary amine,1\nhot coffee mod,11\nshriners,25\nmora missouri,1\nseattle wa,1\nall star baseball 2003,1\ncomparison of android e book reader software,7\ncalling out loud,2\ninitiative 912,1\ncharles batchelor,2\nterry spraggan,2\nwallace thurman,2\nstefan smith,2\ngeorge holding,22\ninstitute of business administration sukkar,1\nstaten island new york,4\nvalency,1\nchintamani taluk,1\nmahatma gandhi,1\nco orbital,1\nepex spot,1\ntheodoric the great,3\nfk novi pazar,1\nzappas olympics,2\ngustav krupp von bohlen und halbach,1\nyasmany tomás,4\nnotre temps,1\ncats %,1\nintramolecular vibrational energy redistribution,1\ngraduate management admission test,49\nrobin fleming,1\ndaniel gadzhev,1\nachaean league,7\nthe four books,1\ntunica people,1\nmurray hurst,1\nhajipur,7\nwolfgang fischer,1\nbethel minnesota,2\nwincdemu,1\naleksandar luković,5\nzilog,6\nwill to live,1\npgc,1\ncaptain sky,1\neprobemide,1\ngunther plüschow,1\njackson laboratory,3\nss orontes,2\nbishop morlino,1\neldorado air force station,2\ntin oxide,1\njohn bell,2\najay banga,2\nnail polish remover induced contact dermatitis,1\nquinctia,1\na/n urm 25d signal generator,1\nthe art company,3\nseawind 300c,1\nhalf and half,7\nconstantia czirenberg,1\nhalifax county north carolina,4\ntunica vaginalis,9\nlife & times of michael k,2\nmethyl propionate,1\ncarla bley band,1\nus secret service,2\nmaría elena moyano,2\nlory meagher cup,9\nmalay sultanate,1\nthird lanark,1\nolivier dacourt,10\nangri,2\nukrainian catholic eparchy of saints peter and paul,1\nphosphinooxazolines,1\nallied health professions,24\nhydroxybenzoic acid,1\nsrinatha,3\nzone melting,5\nmiko,1\nrobert b downs,1\nresource management,3\nnew year tree,1\nagraw imazighen,1\ncatmando,8\npython ide,5\nrocky mount wilson roanoke rapids nc combined statistical area,1\nspanish crown,3\nianis zicu,1\nwilliam c hubbard,2\nislamic marital jurisprudence,5\nthe school of night,1\nkrdc,4\nel centro imperials,1\natiq uz zaman,1\nsliba zkha,1\nfile no mosquesvg,8\nherzegovinians,1\nparadise lost,1\nthe fairly oddparents,6\ncivic alliance,1\nanbu,3\nbroadcaster,2\nle bon,1\ncolumbus nebraska,4\ninuit people,1\nthe menace,6\nilya ilyich mechnikov,1\nalgonquin college,4\nseat córdoba wrc,1\neuropean route e30,6\nthree lakes florida,1\nk10de,1\nglyphonyx rhopalacanthus,1\nask rhod gilbert,1\nbolas criollas,1\ncounty borough of southport,1\nroll on mississippi,1\npulitzer prize for photography,7\nmark fisher,1\noakley g kelly,1\ntajikistani presidential election 1999,1\nthe relapse,4\nnabil bentaleb,8\napprentice,1\ndale brown,3\nstudebaker packard hawk series,1\nyu gi oh trading card game,14\nparalimni,2\ninstitut national polytechnique de toulouse,1\nto catch a spy,1\nhammer,4\nmount judi,2\nthomas posey,1\nmaxime baca,1\narthur susskind,1\nelkins constructors,2\nsiege of gaeta,1\npemex,1\nhenry o flipper award,1\nmccordsville indiana,1\ncarife,1\nprima donna,1\nproton,1\nhenry farrell,1\nrandall davidson,1\nhistory of georgia,11\nbeef tongue,4\nted spread,4\ndouglas xt 30,3\nheavenly mother,1\nmonte santangelo,1\nlothar matthaus,1\namerican party,2\ntire kingdom,1\nbastrop state park,3\njames maurice gavin,1\nblue bird all american,4\ntime and a word,10\nrunny babbit,1\nnordic regional airlines,6\nadvanced scientifics,2\nthe space traders,2\nmongol invasion of anatolia,1\nabu hayyan al gharnati,1\nlisa geoghan,3\nvalentia harbour railway station,1\nsilo,10\njimmy zhingchak,1\nglamma kid,1\nbonneville high school,1\nsecant line,5\nthe longshots,2\ncosta rican general election 1917,1\nan emotion away,1\nrawlins high school,1\ncold inflation pressure,4\nreceptionthe,2\ntom payne,8\ntb treatment,1\nhatikvah,8\nol yellow eyes is back,1\nvincent mroz,1\ntravis bickle,1\nqatar stars league 1985–86,1\nelectronic document management,1\norliska,1\ngáspár orbán,1\nsunabeda,1\ndonatus magnus,1\nlawrence e spivak,2\ncavalieri,1\naw kuchler,1\ncoat of arms of kuwait,1\nwallis–zieff–goldblatt syndrome,1\ndoug heffernan,3\ng3 battlecruiser,3\nimran abbas,1\nplymouth,1\ngould colorado,1\nin japan,1\ndelmar watson,1\nskygusty west virginia,1\nvesque sisters,1\nrushton triangular lodge,1\nitalic font,3\nwarner w hodgdon carolina 500,1\nblackamoors,5\nmagna cum laude,14\nfollow that horse,1\njean snella,1\nchris frith,1\nsoul power,2\nspare me the details,1\nymer xhaferi,1\nmurano glass,5\nmichel magras,1\nrashard and wallace go to white castle,1\nvenus figurines of malta,1\ndidnt we almost have it all,1\new,1\ndavid h koch institute for integrative cancer research,2\nblack coyote,1\npriob,2\npiera coppola,1\nbudhism,4\nsouth african class h1 4 8 2t,1\ndimitris papamichael+dimitris+papamixail,3\nsystem sensor,1\nfarragut class destroyer,1\nno down payment,1\nwilliam rogers,1\ndesperate choices to save my child,1\njoe launchbury,7\nqueen seondeok of silla,11\nadams county wisconsin,1\nbandhan bank,1\nx ray tubes,1\nsporadic group,1\nlozovaya,1\nmairead maguire,3\nroyal challengers bangalore in 2016,1\njanko of czarnków,1\nmarosormenyes,1\nthe deadly reclaim,1\nrick doblin,1\ngwen jorgensen,6\nshire of halls creek,1\ncarlton house,6\nurad bean,1\nbaton rouge louisiana,39\nkiel institute for the world economy,3\nthe satuc cup,1\nharlem division,1\nargonaut,2\nchoi jeongrye,2\noptical disc image,2\ngroesbeek canadian war cemetery,2\nrangpur india,1\nandroid n,72\ntjeld class patrol boat,1\ntogether for yes,2\ntender dracula,1\nshane nelson,1\npalazzo ducale urbino,1\nangels,4\ndouble centralizer theorem,1\nhomme,4\nworld heart federation,1\npatricia ja lee,4\na date with elvis,1\nsaints row,1\nlanzhou lamian,1\nsubcompact car,1\njojo discography,5\ngary,18\nglobal returnable asset identifier,1\naloysia weber,2\nemperor nero,2\nheavyweights,6\nhush records,1\nmewa textil service,2\nmichigan gubernatorial election 1986,1\nsolanine,9\nandré moritz,3\nforeign relations of china,12\nwilliam t anderson,3\nlindquist field,1\nbiggersdale hole,1\nmanayunk/norristown line,1\naliti,1\nbudhivanta,3\ntm forum,4\noff plan property,1\nwu xin the monster killer,4\naharon leib shteinman,1\nmark catano,1\nllanfihangel,1\natp–adp translocase,4\ntótkomlós,1\nnikita magaloff,1\nxo telescope,1\npseudomonas rhizosphaerae,1\npccooler,1\narcion therapeutics inc,8\noklahoma gubernatorial election 2010,1\nseed treatment,3\nconnecticut education network,1\ncompany85,1\nbryan molloy,1\nroupeiro,1\nwendt beach park,2\nentick v carrington,3\nfiremens auxiliary,1\nshotcrete,14\nsepharial,1\npoet laureate of virginia,1\nmusth,6\ndragon run state forest,3\nfocal point,10\npacific drilling,1\nintro,2\npriscus,1\nrokurō mochizuki,1\nbofur,2\ntiffany mount,1\nthanasis papazoglou,12\nlife is grand,1\nergersheim bas rhin,1\nmedical reserve corps,3\nanthony ashley cooper 2nd earl of shaftesbury,1\nuefa euro 2012 group a,32\namerica movil sab de cv,1\nchristopher cook,1\nvladimir makanin,1\nfile talk first battle of saratogausmaeduhistorygif,1\ndean foods,4\nlogical thinking,1\ntychonic system,1\nhand washing,17\nbioresonance therapy,4\ngünther burstyn,4\nreligion in the united kingdom,35\nbancroft ontario,2\nalberta enterprise group,1\nbelizean spanish,1\nminuscule 22,1\nhmga2,3\nsidama people,1\nshigeaki mori,2\nmoonstars,1\nhazard,24\nchilis,6\nrango,3\nkenichi itō,1\nisle of rum,1\nshortwood united fc,1\nbronx gangs,1\nheterometaboly,2\nbeagling,4\njurgen pommerenke,1\nrockin,1\nst maria maggiore,1\nphilipp reis,1\ntimeboxing,12\ntemplate talk tallahassee radio,1\naarti puri,2\njohn paul verree,2\nadam tomkins,1\nknoppers,1\nsven olov eriksson,1\nruth bowyer,1\nhöfðatorg tower 1,1\ncitywire,3\nhelen bosanquet,1\nulex europaeus,4\nrichard martyn,1\nhana sugisaki,2\nits all over now baby blue,6\nthe myths and legends of king arthur and the knights of the round table,2\ndooce,1\ngerman submarine u 9,1\ngeorge shearing,4\nbishop of winchester,3\nmaximilian karl lamoral odonnell,2\nhec edmundson,1\nmorgawr,3\nsovereign state,67\navignon—la mitis—matane—matapédia,1\nduramax v8 engine,12\nvilla rustica,2\ncarl dorsey,1\nclairol,6\nabruzzo,22\nmomsen lung,10\nm23 rebellion,2\nkira oreilly,1\nconstitutive relation,2\nbifrontal craniotomy,1\nbasilica of st nicholas amsterdam,2\nmarinus kraus,1\nmoog prodigy,2\nlucy hale,49\nlingiya,1\nidiopathic orbital inflammatory disease,3\nshaanxi youser group,1\napeirohedron,1\nprogram of all inclusive care for the elderly,2\ntv3 ghana,3\narnold schwarzenegger,338\nraquel carriedo tomás,1\ncincinnati playhouse in the park,2\ncolobomata,2\nstar craft 2,1\nyaaf,1\nfc santa clarita,1\nrelease me,3\nnotts county supporters trust,1\nwestchester airport,1\nslowhand at 70 – live at the royal albert hall,1\nbruce gray,2\nonly the good die young,1\nsewell thomas stadium,1\nkyle cook,1\nnorthwest passage,1\neurex airlines,1\nuss pierre,1\nfeitsui dam,1\nsales force,1\nobrien class destroyer,5\nsant longowal institute of engineering and technology,3\nunited states presidential election in oklahoma 1952,1\nedyta bartosiewicz,1\nmarquess of dorset,1\nwhiting wyoming,1\nakanda,1\njim brewster,1\nmozdok republic of north ossetia alania,1\nmaritime gendarmerie,2\nparesh patel,1\ncommunication art,1\nsanta anita handicap,2\ndahlia,44\nqikpad,1\npudhaiyal,3\noroshi,1\nioda,3\nwillis j gertsch,1\nscurvy grass,1\nbombing of rotterdam,2\ngagarin russia,1\ndynamic apnea without fins,1\nloess,14\nhans adolf krebs,4\nporęby stare,1\nkismat ki baazi,1\nmalcolm slesser,1\nblue crane route local municipality,1\njean michel basquiat,104\ncustoms trade partnership against terrorism,3\nlower cove newfoundland and labrador,1\naashiqui 2,6\nelliott lee,1\nedison electric light company,2\ni rigoberta menchú,1\nbattle of tennōji,2\ntransport workers union of america,1\nphysical review b,1\nway too far,1\nbreguet 941,1\nmanuel hegen,1\nthe blacklist,12\njohn dorahy,4\ncinderella sanyu,1\nluis castañeda lossio,1\nheadquarters of a military area,1\njbala people,2\npetrofac emirates,1\nins garuda,3\naustralia national rugby league team,2\nstate of emergency 2,3\nmexican sex comedy,2\nbaby anikha,1\nnotions,1\nandroid app //orgwikipedia/http/enmwikipediaorg/wiki/elasticity,1\nkissing you,2\nmontearagón,1\ngrzegorz proksa,3\nshook,1\nmay hegglin anomaly,1\nchrysler rb engine,2\ngmcsf,2\nblacksburg,1\nchris hollod,1\nthe new guy,1\nthulimbah queensland,1\nsust,1\nknight kadosh,2\ndetails,4\nnickel mining in new caledonia,3\neaster hotspot,1\nsurinamese interior war,1\nfield corn,2\nbolesław iii wrymouth,6\nlutwyche queensland,1\nmichael campbell,1\nmilitary ranks of turkey,3\nmícheal martin,1\nthe architects dream,2\njoel robert,1\nthomas smith,1\ninclusion probability,1\nfucked company,1\ngenderfluid,5\nlewisham by election 1891,1\nnet promoter,98\ndonald stewart,1\nxml base,2\nbhikhu parekh,4\nanthocharis cardamines,1\nvuosaari,1\ndemographics of burundi,1\ndst,1\ndavid ensor,2\nmount pavlof,1\nvince young,5\nst beunos ignatian spirituality centre,4\nezekiel 48,1\nlewis elliott chaze,1\ntemplate talk croatia squad 2012 mens european water polo championship,1\nthe voice of the philippines,4\nwhites ferry,1\ncananga odorata,9\nman of steel,2\njohn michael talbot,2\nsuperior oblique myokymia,2\nanisochilus,2\ne421,1\nmidnight rider,14\nmatrícula consular,1\nfirst nehru ministry,2\nchristopher mcculloch,2\nems chemie,12\ndominique martin,1\nuniversity club of washington dc,1\nnurse education,5\ntheyre coming to take me away ha haaa,1\nbill dauterive,4\nbelhar,1\nheel and toe,4\nuniversity of the arctic members,2\nmitava,1\nwjmx fm,1\nfather callahan,4\ndivine word academy of dagupan,1\nbogs,1\ndenny heck,2\nchurch of st james valletta,1\nfield cathedral of the polish army,1\nindian skimmer,1\nhistory of british airways,3\ninternational mobile subscriber identity,38\nsuzel roche,1\nsteven watt,1\nduke ellineton,1\nkirbys avalanche,4\n"
  },
  {
    "path": "tests/test_auth/__init__.py",
    "content": ""
  },
  {
    "path": "tests/test_auth/test_token.py",
    "content": "from datetime import datetime, timezone\n\nimport pytest\nfrom redis.auth.err import InvalidTokenSchemaErr\nfrom redis.auth.token import JWToken, SimpleToken\n\n\nclass TestToken:\n    def test_simple_token(self):\n        token = SimpleToken(\n            \"value\",\n            (datetime.now(timezone.utc).timestamp() * 1000) + 1000,\n            (datetime.now(timezone.utc).timestamp() * 1000),\n            {\"key\": \"value\"},\n        )\n\n        assert token.ttl() == pytest.approx(1000, 10)\n        assert token.is_expired() is False\n        assert token.try_get(\"key\") == \"value\"\n        assert token.get_value() == \"value\"\n        assert token.get_expires_at_ms() == pytest.approx(\n            (datetime.now(timezone.utc).timestamp() * 1000) + 100, 10\n        )\n        assert token.get_received_at_ms() == pytest.approx(\n            (datetime.now(timezone.utc).timestamp() * 1000), 10\n        )\n\n        token = SimpleToken(\n            \"value\",\n            -1,\n            (datetime.now(timezone.utc).timestamp() * 1000),\n            {\"key\": \"value\"},\n        )\n\n        assert token.ttl() == -1\n        assert token.is_expired() is False\n        assert token.get_expires_at_ms() == -1\n\n    def test_jwt_token(self):\n        jwt = pytest.importorskip(\"jwt\")\n\n        token = {\n            \"exp\": datetime.now(timezone.utc).timestamp() + 100,\n            \"iat\": datetime.now(timezone.utc).timestamp(),\n            \"key\": \"value\",\n        }\n        encoded = jwt.encode(token, \"secret\", algorithm=\"HS256\")\n        jwt_token = JWToken(encoded)\n\n        assert jwt_token.ttl() == pytest.approx(100000, 10)\n        assert jwt_token.is_expired() is False\n        assert jwt_token.try_get(\"key\") == \"value\"\n        assert jwt_token.get_value() == encoded\n        assert jwt_token.get_expires_at_ms() == pytest.approx(\n            (datetime.now(timezone.utc).timestamp() * 1000) + 100000, 10\n        )\n        assert jwt_token.get_received_at_ms() == pytest.approx(\n            (datetime.now(timezone.utc).timestamp() * 1000), 10\n        )\n\n        token = {\n            \"exp\": -1,\n            \"iat\": datetime.now(timezone.utc).timestamp(),\n            \"key\": \"value\",\n        }\n        encoded = jwt.encode(token, \"secret\", algorithm=\"HS256\")\n        jwt_token = JWToken(encoded)\n\n        assert jwt_token.ttl() == -1\n        assert jwt_token.is_expired() is False\n        assert jwt_token.get_expires_at_ms() == -1000\n\n        with pytest.raises(InvalidTokenSchemaErr):\n            token = {\"key\": \"value\"}\n            encoded = jwt.encode(token, \"secret\", algorithm=\"HS256\")\n            JWToken(encoded)\n"
  },
  {
    "path": "tests/test_auth/test_token_manager.py",
    "content": "import asyncio\nfrom datetime import datetime, timezone\nfrom time import sleep\nfrom unittest.mock import Mock\n\nimport pytest\nfrom redis.auth.err import RequestTokenErr, TokenRenewalErr\nfrom redis.auth.idp import IdentityProviderInterface\nfrom redis.auth.token import SimpleToken\nfrom redis.auth.token_manager import (\n    CredentialsListener,\n    RetryPolicy,\n    TokenManager,\n    TokenManagerConfig,\n)\n\n\nclass TestTokenManager:\n    @pytest.mark.parametrize(\n        \"exp_refresh_ratio\",\n        [\n            0.9,\n            0.28,\n        ],\n        ids=[\n            \"Refresh ratio = 0.9\",\n            \"Refresh ratio = 0.28\",\n        ],\n    )\n    def test_success_token_renewal(self, exp_refresh_ratio):\n        tokens = []\n        errors = []\n\n        # Use a function to generate fresh tokens at request time\n        # to avoid timing issues on slow CI runners\n        def generate_token():\n            now = datetime.now(timezone.utc).timestamp() * 1000\n            return SimpleToken(\"value\", now + 10000, now, {\"oid\": \"test\"})\n\n        mock_provider = Mock(spec=IdentityProviderInterface)\n        mock_provider.request_token.side_effect = (\n            lambda *args, **kwargs: generate_token()\n        )\n\n        def on_next(token):\n            nonlocal tokens\n            tokens.append(token)\n\n        def on_error(err):\n            nonlocal errors\n            errors.append(err)\n\n        mock_listener = Mock(spec=CredentialsListener)\n        mock_listener.on_next = on_next\n        mock_listener.on_error = on_error\n\n        retry_policy = RetryPolicy(1, 10)\n        config = TokenManagerConfig(exp_refresh_ratio, 0, 1000, retry_policy)\n        mgr = TokenManager(mock_provider, config)\n        mgr.start(mock_listener)\n        sleep(0.1)\n\n        assert len(errors) == 0, f\"Unexpected errors: {errors}\"\n        assert len(tokens) > 0\n\n    @pytest.mark.parametrize(\n        \"exp_refresh_ratio\",\n        [\n            (0.9),\n            (0.28),\n        ],\n        ids=[\n            \"Refresh ratio = 0.9\",\n            \"Refresh ratio = 0.28\",\n        ],\n    )\n    @pytest.mark.asyncio\n    async def test_async_success_token_renewal(self, exp_refresh_ratio):\n        tokens = []\n        mock_provider = Mock(spec=IdentityProviderInterface)\n        mock_provider.request_token.side_effect = [\n            SimpleToken(\n                \"value\",\n                (datetime.now(timezone.utc).timestamp() * 1000) + 100,\n                (datetime.now(timezone.utc).timestamp() * 1000),\n                {\"oid\": \"test\"},\n            ),\n            SimpleToken(\n                \"value\",\n                (datetime.now(timezone.utc).timestamp() * 1000) + 130,\n                (datetime.now(timezone.utc).timestamp() * 1000) + 30,\n                {\"oid\": \"test\"},\n            ),\n            SimpleToken(\n                \"value\",\n                (datetime.now(timezone.utc).timestamp() * 1000) + 160,\n                (datetime.now(timezone.utc).timestamp() * 1000) + 60,\n                {\"oid\": \"test\"},\n            ),\n            SimpleToken(\n                \"value\",\n                (datetime.now(timezone.utc).timestamp() * 1000) + 190,\n                (datetime.now(timezone.utc).timestamp() * 1000) + 90,\n                {\"oid\": \"test\"},\n            ),\n        ]\n\n        async def on_next(token):\n            nonlocal tokens\n            tokens.append(token)\n\n        mock_listener = Mock(spec=CredentialsListener)\n        mock_listener.on_next = on_next\n\n        retry_policy = RetryPolicy(1, 10)\n        config = TokenManagerConfig(exp_refresh_ratio, 0, 1000, retry_policy)\n        mgr = TokenManager(mock_provider, config)\n        await mgr.start_async(mock_listener, block_for_initial=True)\n        await asyncio.sleep(0.1)\n\n        assert len(tokens) > 0\n\n    @pytest.mark.parametrize(\n        \"block_for_initial,tokens_acquired\",\n        [\n            (True, 1),\n            (False, 0),\n        ],\n        ids=[\n            \"Block for initial, callback will triggered once\",\n            \"Non blocked, callback wont be triggered\",\n        ],\n    )\n    @pytest.mark.asyncio\n    async def test_async_request_token_blocking_behaviour(\n        self, block_for_initial, tokens_acquired\n    ):\n        tokens = []\n        mock_provider = Mock(spec=IdentityProviderInterface)\n        mock_provider.request_token.return_value = SimpleToken(\n            \"value\",\n            (datetime.now(timezone.utc).timestamp() * 1000) + 100,\n            (datetime.now(timezone.utc).timestamp() * 1000),\n            {\"oid\": \"test\"},\n        )\n\n        async def on_next(token):\n            nonlocal tokens\n            sleep(0.1)\n            tokens.append(token)\n\n        mock_listener = Mock(spec=CredentialsListener)\n        mock_listener.on_next = on_next\n\n        retry_policy = RetryPolicy(1, 10)\n        config = TokenManagerConfig(1, 0, 1000, retry_policy)\n        mgr = TokenManager(mock_provider, config)\n        await mgr.start_async(mock_listener, block_for_initial=block_for_initial)\n\n        assert len(tokens) == tokens_acquired\n\n    def test_token_renewal_with_skip_initial(self):\n        tokens = []\n        mock_provider = Mock(spec=IdentityProviderInterface)\n        mock_provider.request_token.side_effect = [\n            SimpleToken(\n                \"value\",\n                (datetime.now(timezone.utc).timestamp() * 1000) + 1000,\n                (datetime.now(timezone.utc).timestamp() * 1000),\n                {\"oid\": \"test\"},\n            ),\n            SimpleToken(\n                \"value\",\n                (datetime.now(timezone.utc).timestamp() * 1000) + 1500,\n                (datetime.now(timezone.utc).timestamp() * 1000),\n                {\"oid\": \"test\"},\n            ),\n        ]\n\n        def on_next(token):\n            nonlocal tokens\n            tokens.append(token)\n\n        mock_listener = Mock(spec=CredentialsListener)\n        mock_listener.on_next = on_next\n\n        retry_policy = RetryPolicy(3, 10)\n        config = TokenManagerConfig(0.5, 0, 1000, retry_policy)\n        mgr = TokenManager(mock_provider, config)\n        mgr.start(mock_listener, skip_initial=True)\n        assert len(tokens) == 0\n\n        sleep(0.6)\n\n        assert len(tokens) > 0\n\n    @pytest.mark.asyncio\n    async def test_async_token_renewal_with_skip_initial(self):\n        tokens = []\n        mock_provider = Mock(spec=IdentityProviderInterface)\n        mock_provider.request_token.side_effect = [\n            SimpleToken(\n                \"value\",\n                (datetime.now(timezone.utc).timestamp() * 1000) + 1000,\n                (datetime.now(timezone.utc).timestamp() * 1000),\n                {\"oid\": \"test\"},\n            ),\n            SimpleToken(\n                \"value\",\n                (datetime.now(timezone.utc).timestamp() * 1000) + 1200,\n                (datetime.now(timezone.utc).timestamp() * 1000),\n                {\"oid\": \"test\"},\n            ),\n            SimpleToken(\n                \"value\",\n                (datetime.now(timezone.utc).timestamp() * 1000) + 1400,\n                (datetime.now(timezone.utc).timestamp() * 1000),\n                {\"oid\": \"test\"},\n            ),\n        ]\n\n        async def on_next(token):\n            nonlocal tokens\n            tokens.append(token)\n\n        mock_listener = Mock(spec=CredentialsListener)\n        mock_listener.on_next = on_next\n\n        retry_policy = RetryPolicy(3, 10)\n        config = TokenManagerConfig(0.5, 0, 1000, retry_policy)\n        mgr = TokenManager(mock_provider, config)\n        await mgr.start_async(mock_listener, skip_initial=True)\n        assert len(tokens) == 0\n\n        await asyncio.sleep(0.6)\n        assert len(tokens) > 0\n\n    def test_success_token_renewal_with_retry(self):\n        tokens = []\n        errors = []\n        call_count = [0]\n\n        # Use a function to generate fresh tokens at request time\n        # to avoid timing issues on slow CI runners\n        def request_token_side_effect(*args, **kwargs):\n            call_count[0] += 1\n            if call_count[0] <= 2:\n                raise RequestTokenErr(\"Simulated failure\")\n            now = datetime.now(timezone.utc).timestamp() * 1000\n            return SimpleToken(\"value\", now + 10000, now, {\"oid\": \"test\"})\n\n        mock_provider = Mock(spec=IdentityProviderInterface)\n        mock_provider.request_token.side_effect = request_token_side_effect\n\n        def on_next(token):\n            nonlocal tokens\n            tokens.append(token)\n\n        def on_error(err):\n            nonlocal errors\n            errors.append(err)\n\n        mock_listener = Mock(spec=CredentialsListener)\n        mock_listener.on_next = on_next\n        mock_listener.on_error = on_error\n\n        retry_policy = RetryPolicy(3, 10)\n        config = TokenManagerConfig(1, 0, 1000, retry_policy)\n        mgr = TokenManager(mock_provider, config)\n        mgr.start(mock_listener)\n        # Should be less than a 0.1, or it will be flacky\n        # due to additional token renewal.\n        sleep(0.08)\n\n        assert len(errors) == 0, f\"Unexpected errors: {errors}\"\n        assert mock_provider.request_token.call_count > 0\n        assert len(tokens) > 0\n\n    @pytest.mark.asyncio\n    async def test_async_success_token_renewal_with_retry(self):\n        tokens = []\n        errors = []\n        call_count = [0]\n\n        # Use a function to generate fresh tokens at request time\n        # to avoid timing issues on slow CI runners\n        def request_token_side_effect(*args, **kwargs):\n            call_count[0] += 1\n            if call_count[0] <= 2:\n                raise RequestTokenErr(\"Simulated failure\")\n            now = datetime.now(timezone.utc).timestamp() * 1000\n            return SimpleToken(\"value\", now + 10000, now, {\"oid\": \"test\"})\n\n        mock_provider = Mock(spec=IdentityProviderInterface)\n        mock_provider.request_token.side_effect = request_token_side_effect\n\n        async def on_next(token):\n            nonlocal tokens\n            tokens.append(token)\n\n        async def on_error(err):\n            nonlocal errors\n            errors.append(err)\n\n        mock_listener = Mock(spec=CredentialsListener)\n        mock_listener.on_next = on_next\n        mock_listener.on_error = on_error\n\n        retry_policy = RetryPolicy(3, 10)\n        config = TokenManagerConfig(1, 0, 1000, retry_policy)\n        mgr = TokenManager(mock_provider, config)\n        await mgr.start_async(mock_listener, block_for_initial=True)\n        # Should be less than a 0.1, or it will be flacky\n        # due to additional token renewal.\n        await asyncio.sleep(0.08)\n\n        assert len(errors) == 0, f\"Unexpected errors: {errors}\"\n        assert mock_provider.request_token.call_count > 0\n        assert len(tokens) > 0\n\n    def test_no_token_renewal_on_process_complete(self):\n        tokens = []\n        mock_provider = Mock(spec=IdentityProviderInterface)\n        mock_provider.request_token.return_value = SimpleToken(\n            \"value\",\n            (datetime.now(timezone.utc).timestamp() * 1000) + 1000,\n            (datetime.now(timezone.utc).timestamp() * 1000),\n            {\"oid\": \"test\"},\n        )\n\n        def on_next(token):\n            nonlocal tokens\n            tokens.append(token)\n\n        mock_listener = Mock(spec=CredentialsListener)\n        mock_listener.on_next = on_next\n\n        retry_policy = RetryPolicy(1, 10)\n        config = TokenManagerConfig(0.9, 0, 1000, retry_policy)\n        mgr = TokenManager(mock_provider, config)\n        mgr.start(mock_listener)\n        sleep(0.2)\n\n        assert len(tokens) == 1\n\n    @pytest.mark.asyncio\n    async def test_async_no_token_renewal_on_process_complete(self):\n        tokens = []\n        mock_provider = Mock(spec=IdentityProviderInterface)\n        mock_provider.request_token.return_value = SimpleToken(\n            \"value\",\n            (datetime.now(timezone.utc).timestamp() * 1000) + 1000,\n            (datetime.now(timezone.utc).timestamp() * 1000),\n            {\"oid\": \"test\"},\n        )\n\n        async def on_next(token):\n            nonlocal tokens\n            tokens.append(token)\n\n        mock_listener = Mock(spec=CredentialsListener)\n        mock_listener.on_next = on_next\n\n        retry_policy = RetryPolicy(1, 10)\n        config = TokenManagerConfig(0.9, 0, 1000, retry_policy)\n        mgr = TokenManager(mock_provider, config)\n        await mgr.start_async(mock_listener, block_for_initial=True)\n        await asyncio.sleep(0.2)\n\n        assert len(tokens) == 1\n\n    def test_failed_token_renewal_with_retry(self):\n        tokens = []\n        exceptions = []\n\n        mock_provider = Mock(spec=IdentityProviderInterface)\n        mock_provider.request_token.side_effect = [\n            RequestTokenErr,\n            RequestTokenErr,\n            RequestTokenErr,\n            RequestTokenErr,\n        ]\n\n        def on_next(token):\n            nonlocal tokens\n            tokens.append(token)\n\n        def on_error(exception):\n            nonlocal exceptions\n            exceptions.append(exception)\n\n        mock_listener = Mock(spec=CredentialsListener)\n        mock_listener.on_next = on_next\n        mock_listener.on_error = on_error\n\n        retry_policy = RetryPolicy(3, 10)\n        config = TokenManagerConfig(1, 0, 1000, retry_policy)\n        mgr = TokenManager(mock_provider, config)\n        mgr.start(mock_listener)\n        sleep(0.1)\n\n        assert mock_provider.request_token.call_count == 4\n        assert len(tokens) == 0\n        assert len(exceptions) == 1\n\n    @pytest.mark.asyncio\n    async def test_async_failed_token_renewal_with_retry(self):\n        tokens = []\n        exceptions = []\n\n        mock_provider = Mock(spec=IdentityProviderInterface)\n        mock_provider.request_token.side_effect = [\n            RequestTokenErr,\n            RequestTokenErr,\n            RequestTokenErr,\n            RequestTokenErr,\n        ]\n\n        async def on_next(token):\n            nonlocal tokens\n            tokens.append(token)\n\n        async def on_error(exception):\n            nonlocal exceptions\n            exceptions.append(exception)\n\n        mock_listener = Mock(spec=CredentialsListener)\n        mock_listener.on_next = on_next\n        mock_listener.on_error = on_error\n\n        retry_policy = RetryPolicy(3, 10)\n        config = TokenManagerConfig(1, 0, 1000, retry_policy)\n        mgr = TokenManager(mock_provider, config)\n        await mgr.start_async(mock_listener, block_for_initial=True)\n        sleep(0.1)\n\n        assert mock_provider.request_token.call_count == 4\n        assert len(tokens) == 0\n        assert len(exceptions) == 1\n\n    def test_failed_renewal_on_expired_token(self):\n        errors = []\n        mock_provider = Mock(spec=IdentityProviderInterface)\n        mock_provider.request_token.return_value = SimpleToken(\n            \"value\",\n            (datetime.now(timezone.utc).timestamp() * 1000) - 100,\n            (datetime.now(timezone.utc).timestamp() * 1000) - 1000,\n            {\"oid\": \"test\"},\n        )\n\n        def on_error(error: TokenRenewalErr):\n            nonlocal errors\n            errors.append(error)\n\n        mock_listener = Mock(spec=CredentialsListener)\n        mock_listener.on_error = on_error\n\n        retry_policy = RetryPolicy(1, 10)\n        config = TokenManagerConfig(1, 0, 1000, retry_policy)\n        mgr = TokenManager(mock_provider, config)\n        mgr.start(mock_listener)\n\n        assert len(errors) == 1\n        assert isinstance(errors[0], TokenRenewalErr)\n        assert str(errors[0]) == \"Requested token is expired\"\n\n    @pytest.mark.asyncio\n    async def test_async_failed_renewal_on_expired_token(self):\n        errors = []\n        mock_provider = Mock(spec=IdentityProviderInterface)\n        mock_provider.request_token.return_value = SimpleToken(\n            \"value\",\n            (datetime.now(timezone.utc).timestamp() * 1000) - 100,\n            (datetime.now(timezone.utc).timestamp() * 1000) - 1000,\n            {\"oid\": \"test\"},\n        )\n\n        async def on_error(error: TokenRenewalErr):\n            nonlocal errors\n            errors.append(error)\n\n        mock_listener = Mock(spec=CredentialsListener)\n        mock_listener.on_error = on_error\n\n        retry_policy = RetryPolicy(1, 10)\n        config = TokenManagerConfig(1, 0, 1000, retry_policy)\n        mgr = TokenManager(mock_provider, config)\n        await mgr.start_async(mock_listener, block_for_initial=True)\n\n        assert len(errors) == 1\n        assert isinstance(errors[0], TokenRenewalErr)\n        assert str(errors[0]) == \"Requested token is expired\"\n\n    def test_failed_renewal_on_callback_error(self):\n        errors = []\n        mock_provider = Mock(spec=IdentityProviderInterface)\n        mock_provider.request_token.return_value = SimpleToken(\n            \"value\",\n            (datetime.now(timezone.utc).timestamp() * 1000) + 1000,\n            (datetime.now(timezone.utc).timestamp() * 1000),\n            {\"oid\": \"test\"},\n        )\n\n        def on_next(token):\n            raise Exception(\"Some exception\")\n\n        def on_error(error):\n            nonlocal errors\n            errors.append(error)\n\n        mock_listener = Mock(spec=CredentialsListener)\n        mock_listener.on_next = on_next\n        mock_listener.on_error = on_error\n\n        retry_policy = RetryPolicy(1, 10)\n        config = TokenManagerConfig(1, 0, 1000, retry_policy)\n        mgr = TokenManager(mock_provider, config)\n        mgr.start(mock_listener)\n\n        assert len(errors) == 1\n        assert isinstance(errors[0], TokenRenewalErr)\n        assert str(errors[0]) == \"Some exception\"\n\n    @pytest.mark.asyncio\n    async def test_async_failed_renewal_on_callback_error(self):\n        errors = []\n        mock_provider = Mock(spec=IdentityProviderInterface)\n        mock_provider.request_token.return_value = SimpleToken(\n            \"value\",\n            (datetime.now(timezone.utc).timestamp() * 1000) + 1000,\n            (datetime.now(timezone.utc).timestamp() * 1000),\n            {\"oid\": \"test\"},\n        )\n\n        async def on_next(token):\n            raise Exception(\"Some exception\")\n\n        async def on_error(error):\n            nonlocal errors\n            errors.append(error)\n\n        mock_listener = Mock(spec=CredentialsListener)\n        mock_listener.on_next = on_next\n        mock_listener.on_error = on_error\n\n        retry_policy = RetryPolicy(1, 10)\n        config = TokenManagerConfig(1, 0, 1000, retry_policy)\n        mgr = TokenManager(mock_provider, config)\n        await mgr.start_async(mock_listener, block_for_initial=True)\n\n        assert len(errors) == 1\n        assert isinstance(errors[0], TokenRenewalErr)\n        assert str(errors[0]) == \"Some exception\"\n"
  },
  {
    "path": "tests/test_background.py",
    "content": "import asyncio\nimport threading\nfrom time import sleep\n\nimport pytest\n\nfrom redis.background import BackgroundScheduler\n\n\nclass TestBackgroundScheduler:\n    def test_run_once(self):\n        execute_counter = 0\n        one = \"arg1\"\n        two = 9999\n\n        def callback(arg1: str, arg2: int):\n            nonlocal execute_counter\n            nonlocal one\n            nonlocal two\n\n            execute_counter += 1\n\n            assert arg1 == one\n            assert arg2 == two\n\n        scheduler = BackgroundScheduler()\n        scheduler.run_once(0.1, callback, one, two)\n        assert execute_counter == 0\n\n        sleep(0.15)\n\n        assert execute_counter == 1\n\n    @pytest.mark.parametrize(\n        \"interval,timeout,min_call_count\",\n        [\n            (0.012, 0.04, 2),  # At least 2 calls (was 3, but timing on CI can vary)\n            (0.035, 0.04, 1),\n            (0.045, 0.04, 0),\n        ],\n    )\n    def test_run_recurring(self, interval, timeout, min_call_count):\n        execute_counter = []\n        one = \"arg1\"\n        two = 9999\n\n        def callback(arg1: str, arg2: int):\n            nonlocal execute_counter\n            nonlocal one\n            nonlocal two\n\n            execute_counter.append(1)\n\n            assert arg1 == one\n            assert arg2 == two\n\n        scheduler = BackgroundScheduler()\n        scheduler.run_recurring(interval, callback, one, two)\n        assert len(execute_counter) == 0\n\n        sleep(timeout)\n\n        # Use >= instead of == to account for timing variations on CI runners\n        assert len(execute_counter) >= min_call_count\n\n    @pytest.mark.asyncio\n    @pytest.mark.parametrize(\n        \"interval,timeout,min_call_count\",\n        [\n            (0.012, 0.04, 2),  # At least 2 calls (was 3, but timing on CI can vary)\n            (0.035, 0.04, 1),\n            (0.045, 0.04, 0),\n        ],\n    )\n    async def test_run_recurring_async(self, interval, timeout, min_call_count):\n        execute_counter = []\n        one = \"arg1\"\n        two = 9999\n\n        async def callback(arg1: str, arg2: int):\n            nonlocal execute_counter\n            nonlocal one\n            nonlocal two\n\n            execute_counter.append(1)\n\n            assert arg1 == one\n            assert arg2 == two\n\n        scheduler = BackgroundScheduler()\n        await scheduler.run_recurring_async(interval, callback, one, two)\n        assert len(execute_counter) == 0\n\n        await asyncio.sleep(timeout)\n\n        # Use >= instead of == to account for timing variations on CI runners\n        assert len(execute_counter) >= min_call_count\n\n    @pytest.mark.parametrize(\n        \"interval,timeout,min_call_count\",\n        [\n            (0.012, 0.04, 2),  # At least 2 calls\n            (0.035, 0.04, 1),\n            (0.045, 0.04, 0),\n        ],\n    )\n    def test_run_recurring_coro(self, interval, timeout, min_call_count):\n        \"\"\"\n        Test that run_recurring_coro executes coroutines in a background thread.\n        This is used by sync MultiDBClient for async health checks.\n        \"\"\"\n        execute_counter = []\n        one = \"arg1\"\n        two = 9999\n\n        async def coro_callback(arg1: str, arg2: int):\n            nonlocal execute_counter\n            execute_counter.append(1)\n            assert arg1 == one\n            assert arg2 == two\n\n        scheduler = BackgroundScheduler()\n        scheduler.run_recurring_coro(interval, coro_callback, one, two)\n        assert len(execute_counter) == 0\n\n        sleep(timeout)\n\n        # Use >= instead of == to account for timing variations on CI runners\n        assert len(execute_counter) >= min_call_count\n        scheduler.stop()\n\n    def test_run_recurring_coro_runs_in_background_thread(self):\n        \"\"\"\n        Verify that run_recurring_coro executes in a separate thread,\n        not blocking the main thread.\n        \"\"\"\n        main_thread_id = threading.current_thread().ident\n        coro_thread_ids = []\n\n        async def coro_callback():\n            coro_thread_ids.append(threading.current_thread().ident)\n\n        scheduler = BackgroundScheduler()\n        scheduler.run_recurring_coro(0.01, coro_callback)\n\n        sleep(0.05)  # Wait for at least one execution\n\n        assert len(coro_thread_ids) >= 1\n        # Coroutine should run in a different thread than main\n        assert all(tid != main_thread_id for tid in coro_thread_ids)\n        scheduler.stop()\n\n    def test_run_recurring_coro_supports_concurrent_execution(self):\n        \"\"\"\n        Verify that multiple coroutines scheduled in the same background loop\n        can run concurrently (not blocking each other).\n        \"\"\"\n        execution_order = []\n        lock = threading.Lock()\n\n        async def slow_coro(name: str):\n            with lock:\n                execution_order.append(f\"{name}_start\")\n            await asyncio.sleep(0.02)  # Simulate async I/O\n            with lock:\n                execution_order.append(f\"{name}_end\")\n\n        async def fast_coro(name: str):\n            with lock:\n                execution_order.append(f\"{name}_start\")\n            await asyncio.sleep(0.005)\n            with lock:\n                execution_order.append(f\"{name}_end\")\n\n        scheduler = BackgroundScheduler()\n        # Schedule both coroutines - they share the same event loop\n        scheduler.run_recurring_coro(0.01, slow_coro, \"slow\")\n        scheduler.run_recurring_coro(0.01, fast_coro, \"fast\")\n\n        sleep(0.1)\n        scheduler.stop()\n\n        # Both coroutines should have executed\n        assert \"slow_start\" in execution_order\n        assert \"fast_start\" in execution_order\n\n    def test_run_recurring_coro_with_timeout(self):\n        \"\"\"\n        Test that asyncio.wait_for works correctly within run_recurring_coro,\n        which is critical for health check timeouts.\n        \"\"\"\n        results = []\n\n        async def slow_operation():\n            await asyncio.sleep(1.0)  # Takes 1 second\n            return \"completed\"\n\n        async def coro_with_timeout():\n            try:\n                result = await asyncio.wait_for(slow_operation(), timeout=0.01)\n                results.append((\"success\", result))\n            except asyncio.TimeoutError:\n                results.append((\"timeout\", None))\n\n        scheduler = BackgroundScheduler()\n        scheduler.run_recurring_coro(0.02, coro_with_timeout)\n\n        sleep(0.1)\n        scheduler.stop()\n\n        # Should have at least one timeout result\n        assert len(results) >= 1\n        assert all(r[0] == \"timeout\" for r in results)\n\n    def test_run_recurring_coro_stops_cleanly(self):\n        \"\"\"\n        Verify that stop() properly terminates the background event loop.\n        \"\"\"\n        execute_counter = []\n\n        async def coro_callback():\n            execute_counter.append(1)\n\n        scheduler = BackgroundScheduler()\n        scheduler.run_recurring_coro(0.01, coro_callback)\n\n        sleep(0.05)\n        count_before_stop = len(execute_counter)\n        assert count_before_stop >= 1\n\n        scheduler.stop()\n        sleep(0.05)\n\n        # No more executions after stop\n        count_after_stop = len(execute_counter)\n        # Allow for at most 1 more execution due to timing\n        assert count_after_stop <= count_before_stop + 1\n\n    def test_run_recurring_coro_exception_does_not_stop_scheduler(self):\n        \"\"\"\n        Verify that exceptions in coroutines don't crash the scheduler.\n        \"\"\"\n        success_count = []\n        error_count = []\n\n        async def flaky_coro():\n            if len(success_count) % 2 == 0:\n                error_count.append(1)\n                raise ValueError(\"Simulated error\")\n            success_count.append(1)\n\n        scheduler = BackgroundScheduler()\n        scheduler.run_recurring_coro(0.01, flaky_coro)\n\n        sleep(0.1)\n        scheduler.stop()\n\n        # Both successes and errors should have occurred\n        # (scheduler continues despite exceptions)\n        total_executions = len(success_count) + len(error_count)\n        assert total_executions >= 2\n\n    def test_run_recurring_coro_prevents_overlapping_executions(self):\n        \"\"\"\n        Verify that the next execution is scheduled only after the current one completes,\n        preventing overlapping runs when execution takes longer than the interval.\n        \"\"\"\n        execution_log = []\n        lock = threading.Lock()\n\n        async def slow_coro():\n            with lock:\n                execution_log.append((\"start\", len(execution_log)))\n            await asyncio.sleep(0.05)  # Takes 50ms, longer than 10ms interval\n            with lock:\n                execution_log.append((\"end\", len(execution_log)))\n\n        scheduler = BackgroundScheduler()\n        # Interval is 10ms but execution takes 50ms\n        scheduler.run_recurring_coro(0.01, slow_coro)\n\n        sleep(0.2)\n        scheduler.stop()\n\n        # Verify no overlapping: each \"start\" should be followed by \"end\" before next \"start\"\n        # With overlap prevention, pattern should be: start, end, start, end, ...\n        starts = [i for i, (event, _) in enumerate(execution_log) if event == \"start\"]\n        ends = [i for i, (event, _) in enumerate(execution_log) if event == \"end\"]\n\n        # Each start should have a corresponding end before the next start\n        for i, start_idx in enumerate(starts[:-1]):\n            # Find the end that follows this start\n            corresponding_ends = [e for e in ends if e > start_idx]\n            assert len(corresponding_ends) > 0, \"Start without corresponding end\"\n            # The end should come before the next start\n            next_start_idx = starts[i + 1]\n            assert corresponding_ends[0] < next_start_idx, (\n                \"Overlapping execution detected\"\n            )\n"
  },
  {
    "path": "tests/test_backoff.py",
    "content": "from unittest.mock import Mock\n\nimport pytest\n\nfrom redis.backoff import ExponentialWithJitterBackoff\n\n\ndef test_exponential_with_jitter_backoff(monkeypatch: pytest.MonkeyPatch) -> None:\n    mock_random = Mock(side_effect=[0.25, 0.5, 0.75, 1.0, 0.9])\n    monkeypatch.setattr(\"random.random\", mock_random)\n\n    bo = ExponentialWithJitterBackoff(cap=5, base=1)\n\n    assert bo.compute(0) == 0.25  # min(5, 0.25*2^0)\n    assert bo.compute(1) == 1.0  # min(5, 0.5*2^1)\n    assert bo.compute(2) == 3.0  # min(5, 0.75*2^2)\n    assert bo.compute(3) == 5.0  # min(5, 1*2^3)\n    assert bo.compute(4) == 5.0  # min(5, 0.9*2^4)\n"
  },
  {
    "path": "tests/test_bloom.py",
    "content": "from math import inf\n\nimport pytest\nimport redis.commands.bf\nfrom redis.exceptions import RedisError\n\nfrom .conftest import (\n    _get_client,\n    assert_resp_response,\n    is_resp2_connection,\n    skip_ifmodversion_lt,\n)\n\n\n@pytest.fixture()\ndef decoded_r(request, stack_url):\n    with _get_client(\n        redis.Redis, request, decode_responses=True, from_url=stack_url\n    ) as client:\n        yield client\n\n\ndef intlist(obj):\n    return [int(v) for v in obj]\n\n\n@pytest.fixture\ndef client(decoded_r):\n    assert isinstance(decoded_r.bf(), redis.commands.bf.BFBloom)\n    assert isinstance(decoded_r.cf(), redis.commands.bf.CFBloom)\n    assert isinstance(decoded_r.cms(), redis.commands.bf.CMSBloom)\n    assert isinstance(decoded_r.tdigest(), redis.commands.bf.TDigestBloom)\n    assert isinstance(decoded_r.topk(), redis.commands.bf.TOPKBloom)\n\n    decoded_r.flushdb()\n    return decoded_r\n\n\n@pytest.mark.redismod\ndef test_create(client):\n    \"\"\"Test CREATE/RESERVE calls\"\"\"\n    assert client.bf().create(\"bloom\", 0.01, 1000)\n    assert client.bf().create(\"bloom_e\", 0.01, 1000, expansion=1)\n    assert client.bf().create(\"bloom_ns\", 0.01, 1000, noScale=True)\n    assert client.cf().create(\"cuckoo\", 1000)\n    assert client.cf().create(\"cuckoo_e\", 1000, expansion=1)\n    assert client.cf().create(\"cuckoo_bs\", 1000, bucket_size=4)\n    assert client.cf().create(\"cuckoo_mi\", 1000, max_iterations=10)\n    assert client.cms().initbydim(\"cmsDim\", 100, 5)\n    assert client.cms().initbyprob(\"cmsProb\", 0.01, 0.01)\n    assert client.topk().reserve(\"topk\", 5, 100, 5, 0.9)\n\n\n@pytest.mark.redismod\ndef test_bf_reserve(client):\n    \"\"\"Testing BF.RESERVE\"\"\"\n    assert client.bf().reserve(\"bloom\", 0.01, 1000)\n    assert client.bf().reserve(\"bloom_e\", 0.01, 1000, expansion=1)\n    assert client.bf().reserve(\"bloom_ns\", 0.01, 1000, noScale=True)\n    assert client.cf().reserve(\"cuckoo\", 1000)\n    assert client.cf().reserve(\"cuckoo_e\", 1000, expansion=1)\n    assert client.cf().reserve(\"cuckoo_bs\", 1000, bucket_size=4)\n    assert client.cf().reserve(\"cuckoo_mi\", 1000, max_iterations=10)\n    assert client.cms().initbydim(\"cmsDim\", 100, 5)\n    assert client.cms().initbyprob(\"cmsProb\", 0.01, 0.01)\n    assert client.topk().reserve(\"topk\", 5, 100, 5, 0.9)\n\n\n@pytest.mark.experimental\n@pytest.mark.redismod\ndef test_tdigest_create(client):\n    assert client.tdigest().create(\"tDigest\", 100)\n\n\n@pytest.mark.redismod\ndef test_bf_add(client):\n    assert client.bf().create(\"bloom\", 0.01, 1000)\n    assert 1 == client.bf().add(\"bloom\", \"foo\")\n    assert 0 == client.bf().add(\"bloom\", \"foo\")\n    assert [0] == intlist(client.bf().madd(\"bloom\", \"foo\"))\n    assert [0, 1] == client.bf().madd(\"bloom\", \"foo\", \"bar\")\n    assert [0, 0, 1] == client.bf().madd(\"bloom\", \"foo\", \"bar\", \"baz\")\n    assert 1 == client.bf().exists(\"bloom\", \"foo\")\n    assert 0 == client.bf().exists(\"bloom\", \"noexist\")\n    assert [1, 0] == intlist(client.bf().mexists(\"bloom\", \"foo\", \"noexist\"))\n\n\n@pytest.mark.redismod\ndef test_bf_insert(client):\n    assert client.bf().create(\"bloom\", 0.01, 1000)\n    assert [1] == intlist(client.bf().insert(\"bloom\", [\"foo\"]))\n    assert [0, 1] == intlist(client.bf().insert(\"bloom\", [\"foo\", \"bar\"]))\n    assert [1] == intlist(client.bf().insert(\"captest\", [\"foo\"], capacity=10))\n    assert [1] == intlist(client.bf().insert(\"errtest\", [\"foo\"], error=0.01))\n    assert 1 == client.bf().exists(\"bloom\", \"foo\")\n    assert 0 == client.bf().exists(\"bloom\", \"noexist\")\n    assert [1, 0] == intlist(client.bf().mexists(\"bloom\", \"foo\", \"noexist\"))\n    info = client.bf().info(\"bloom\")\n    assert_resp_response(\n        client,\n        2,\n        info.get(\"insertedNum\"),\n        info.get(\"Number of items inserted\"),\n    )\n    assert_resp_response(\n        client,\n        1000,\n        info.get(\"capacity\"),\n        info.get(\"Capacity\"),\n    )\n    assert_resp_response(\n        client,\n        1,\n        info.get(\"filterNum\"),\n        info.get(\"Number of filters\"),\n    )\n\n\n@pytest.mark.redismod\ndef test_bf_scandump_and_loadchunk(client):\n    # Store a filter\n    client.bf().create(\"myBloom\", \"0.0001\", \"1000\")\n\n    # test is probabilistic and might fail. It is OK to change variables if\n    # certain to not break anything\n    def do_verify():\n        res = 0\n        for x in range(1000):\n            client.bf().add(\"myBloom\", x)\n            rv = client.bf().exists(\"myBloom\", x)\n            assert rv\n            rv = client.bf().exists(\"myBloom\", f\"nonexist_{x}\")\n            res += rv == x\n        assert res < 5\n\n    do_verify()\n    cmds = []\n\n    cur = client.bf().scandump(\"myBloom\", 0)\n    first = cur[0]\n    cmds.append(cur)\n\n    while True:\n        cur = client.bf().scandump(\"myBloom\", first)\n        first = cur[0]\n        if first == 0:\n            break\n        else:\n            cmds.append(cur)\n    prev_info = client.bf().execute_command(\"bf.debug\", \"myBloom\")\n\n    # Remove the filter\n    client.bf().client.delete(\"myBloom\")\n\n    # Now, load all the commands:\n    for cmd in cmds:\n        client.bf().loadchunk(\"myBloom\", *cmd)\n\n    cur_info = client.bf().execute_command(\"bf.debug\", \"myBloom\")\n    assert prev_info == cur_info\n    do_verify()\n\n    client.bf().client.delete(\"myBloom\")\n    client.bf().create(\"myBloom\", \"0.0001\", \"10000000\")\n\n\n@pytest.mark.redismod\ndef test_bf_info(client):\n    expansion = 4\n    # Store a filter\n    client.bf().create(\"nonscaling\", \"0.0001\", \"1000\", noScale=True)\n    info = client.bf().info(\"nonscaling\")\n    assert_resp_response(\n        client,\n        None,\n        info.get(\"expansionRate\"),\n        info.get(\"Expansion rate\"),\n    )\n\n    client.bf().create(\"expanding\", \"0.0001\", \"1000\", expansion=expansion)\n    info = client.bf().info(\"expanding\")\n    assert_resp_response(\n        client,\n        4,\n        info.get(\"expansionRate\"),\n        info.get(\"Expansion rate\"),\n    )\n\n    try:\n        # noScale mean no expansion\n        client.bf().create(\n            \"myBloom\", \"0.0001\", \"1000\", expansion=expansion, noScale=True\n        )\n        assert False\n    except RedisError:\n        assert True\n\n\n@pytest.mark.redismod\ndef test_bf_card(client):\n    # return 0 if the key does not exist\n    assert client.bf().card(\"not_exist\") == 0\n\n    # Store a filter\n    assert client.bf().add(\"bf1\", \"item_foo\") == 1\n    assert client.bf().card(\"bf1\") == 1\n\n    # Error when key is of a type other than Bloom filter.\n    with pytest.raises(redis.ResponseError):\n        client.set(\"setKey\", \"value\")\n        client.bf().card(\"setKey\")\n\n\n@pytest.mark.redismod\ndef test_cf_add_and_insert(client):\n    assert client.cf().create(\"cuckoo\", 1000)\n    assert client.cf().add(\"cuckoo\", \"filter\")\n    assert not client.cf().addnx(\"cuckoo\", \"filter\")\n    assert 1 == client.cf().addnx(\"cuckoo\", \"newItem\")\n    assert [1] == client.cf().insert(\"captest\", [\"foo\"])\n    assert [1] == client.cf().insert(\"captest\", [\"foo\"], capacity=1000)\n    assert [1] == client.cf().insertnx(\"captest\", [\"bar\"])\n    assert [1] == client.cf().insertnx(\"captest\", [\"food\"], nocreate=\"1\")\n    assert [0, 0, 1] == client.cf().insertnx(\"captest\", [\"foo\", \"bar\", \"baz\"])\n    assert [0] == client.cf().insertnx(\"captest\", [\"bar\"], capacity=1000)\n    assert [1] == client.cf().insert(\"empty1\", [\"foo\"], capacity=1000)\n    assert [1] == client.cf().insertnx(\"empty2\", [\"bar\"], capacity=1000)\n    info = client.cf().info(\"captest\")\n    assert_resp_response(\n        client, 5, info.get(\"insertedNum\"), info.get(\"Number of items inserted\")\n    )\n    assert_resp_response(\n        client, 0, info.get(\"deletedNum\"), info.get(\"Number of items deleted\")\n    )\n    assert_resp_response(\n        client, 1, info.get(\"filterNum\"), info.get(\"Number of filters\")\n    )\n\n\n@pytest.mark.redismod\ndef test_cf_exists_and_del(client):\n    assert client.cf().create(\"cuckoo\", 1000)\n    assert client.cf().add(\"cuckoo\", \"filter\")\n    assert client.cf().exists(\"cuckoo\", \"filter\")\n    assert not client.cf().exists(\"cuckoo\", \"notexist\")\n    assert [1, 0] == client.cf().mexists(\"cuckoo\", \"filter\", \"notexist\")\n    assert 1 == client.cf().count(\"cuckoo\", \"filter\")\n    assert 0 == client.cf().count(\"cuckoo\", \"notexist\")\n    assert client.cf().delete(\"cuckoo\", \"filter\")\n    assert 0 == client.cf().count(\"cuckoo\", \"filter\")\n\n\n@pytest.mark.redismod\ndef test_cms(client):\n    assert client.cms().initbydim(\"dim\", 1000, 5)\n    assert client.cms().initbyprob(\"prob\", 0.01, 0.01)\n    assert client.cms().incrby(\"dim\", [\"foo\"], [5])\n    assert [0] == client.cms().query(\"dim\", \"notexist\")\n    assert [5] == client.cms().query(\"dim\", \"foo\")\n    assert [10, 15] == client.cms().incrby(\"dim\", [\"foo\", \"bar\"], [5, 15])\n    assert [10, 15] == client.cms().query(\"dim\", \"foo\", \"bar\")\n    info = client.cms().info(\"dim\")\n    assert info[\"width\"]\n    assert 1000 == info[\"width\"]\n    assert 5 == info[\"depth\"]\n    assert 25 == info[\"count\"]\n\n\n@pytest.mark.onlynoncluster\n@pytest.mark.redismod\ndef test_cms_merge(client):\n    assert client.cms().initbydim(\"A\", 1000, 5)\n    assert client.cms().initbydim(\"B\", 1000, 5)\n    assert client.cms().initbydim(\"C\", 1000, 5)\n    assert client.cms().incrby(\"A\", [\"foo\", \"bar\", \"baz\"], [5, 3, 9])\n    assert client.cms().incrby(\"B\", [\"foo\", \"bar\", \"baz\"], [2, 3, 1])\n    assert [5, 3, 9] == client.cms().query(\"A\", \"foo\", \"bar\", \"baz\")\n    assert [2, 3, 1] == client.cms().query(\"B\", \"foo\", \"bar\", \"baz\")\n    assert client.cms().merge(\"C\", 2, [\"A\", \"B\"])\n    assert [7, 6, 10] == client.cms().query(\"C\", \"foo\", \"bar\", \"baz\")\n    assert client.cms().merge(\"C\", 2, [\"A\", \"B\"], [\"1\", \"2\"])\n    assert [9, 9, 11] == client.cms().query(\"C\", \"foo\", \"bar\", \"baz\")\n    assert client.cms().merge(\"C\", 2, [\"A\", \"B\"], [\"2\", \"3\"])\n    assert [16, 15, 21] == client.cms().query(\"C\", \"foo\", \"bar\", \"baz\")\n\n\n@pytest.mark.redismod\ndef test_topk(client):\n    # test list with empty buckets\n    assert client.topk().reserve(\"topk\", 3, 50, 4, 0.9)\n    assert [\n        None,\n        None,\n        None,\n        \"A\",\n        \"C\",\n        \"D\",\n        None,\n        None,\n        \"E\",\n        None,\n        \"B\",\n        \"C\",\n        None,\n        None,\n        None,\n        \"D\",\n        None,\n    ] == client.topk().add(\n        \"topk\",\n        \"A\",\n        \"B\",\n        \"C\",\n        \"D\",\n        \"E\",\n        \"A\",\n        \"A\",\n        \"B\",\n        \"C\",\n        \"G\",\n        \"D\",\n        \"B\",\n        \"D\",\n        \"A\",\n        \"E\",\n        \"E\",\n        1,\n    )\n    assert [1, 1, 0, 0, 1, 0, 0] == client.topk().query(\n        \"topk\", \"A\", \"B\", \"C\", \"D\", \"E\", \"F\", \"G\"\n    )\n    with pytest.deprecated_call():\n        assert [4, 3, 2, 3, 3, 0, 1] == client.topk().count(\n            \"topk\", \"A\", \"B\", \"C\", \"D\", \"E\", \"F\", \"G\"\n        )\n\n    # test full list\n    assert client.topk().reserve(\"topklist\", 3, 50, 3, 0.9)\n    assert client.topk().add(\n        \"topklist\",\n        \"A\",\n        \"B\",\n        \"C\",\n        \"D\",\n        \"E\",\n        \"A\",\n        \"A\",\n        \"B\",\n        \"C\",\n        \"G\",\n        \"D\",\n        \"B\",\n        \"D\",\n        \"A\",\n        \"E\",\n        \"E\",\n    )\n    assert [\"A\", \"B\", \"E\"] == client.topk().list(\"topklist\")\n    assert [\"A\", 4, \"B\", 3, \"E\", 3] == client.topk().list(\"topklist\", withcount=True)\n    info = client.topk().info(\"topklist\")\n    assert 3 == info[\"k\"]\n    assert 50 == info[\"width\"]\n    assert 3 == info[\"depth\"]\n    assert 0.9 == round(float(info[\"decay\"]), 1)\n\n\n@pytest.mark.redismod\ndef test_topk_list_with_special_words(client):\n    # test list with empty buckets\n    assert client.topk().reserve(\"topklist:specialwords\", 5, 20, 4, 0.9)\n    assert client.topk().add(\n        \"topklist:specialwords\",\n        \"infinity\",\n        \"B\",\n        \"nan\",\n        \"D\",\n        \"-infinity\",\n        \"infinity\",\n        \"infinity\",\n        \"B\",\n        \"nan\",\n        \"G\",\n        \"D\",\n        \"B\",\n        \"D\",\n        \"infinity\",\n        \"-infinity\",\n        \"-infinity\",\n    )\n    assert [\"infinity\", \"B\", \"D\", \"-infinity\", \"nan\"] == client.topk().list(\n        \"topklist:specialwords\"\n    )\n\n\n@pytest.mark.redismod\ndef test_topk_incrby(client):\n    client.flushdb()\n    assert client.topk().reserve(\"topk\", 3, 10, 3, 1)\n    assert [None, None, None] == client.topk().incrby(\n        \"topk\", [\"bar\", \"baz\", \"42\"], [3, 6, 2]\n    )\n    assert [None, \"bar\"] == client.topk().incrby(\"topk\", [\"42\", \"xyzzy\"], [8, 4])\n    with pytest.deprecated_call():\n        assert [3, 6, 10, 4, 0] == client.topk().count(\n            \"topk\", \"bar\", \"baz\", \"42\", \"xyzzy\", 4\n        )\n\n\n@pytest.mark.experimental\n@pytest.mark.redismod\ndef test_tdigest_reset(client):\n    assert client.tdigest().create(\"tDigest\", 10)\n    # reset on empty histogram\n    assert client.tdigest().reset(\"tDigest\")\n    # insert data-points into sketch\n    assert client.tdigest().add(\"tDigest\", list(range(10)))\n\n    assert client.tdigest().reset(\"tDigest\")\n    # assert we have 0 unmerged\n    info = client.tdigest().info(\"tDigest\")\n    assert_resp_response(\n        client, 0, info.get(\"unmerged_nodes\"), info.get(\"Unmerged nodes\")\n    )\n\n\n@pytest.mark.onlynoncluster\n@pytest.mark.redismod\ndef test_tdigest_merge(client):\n    assert client.tdigest().create(\"to-tDigest\", 10)\n    assert client.tdigest().create(\"from-tDigest\", 10)\n    # insert data-points into sketch\n    assert client.tdigest().add(\"from-tDigest\", [1.0] * 10)\n    assert client.tdigest().add(\"to-tDigest\", [2.0] * 10)\n    # merge from-tdigest into to-tdigest\n    assert client.tdigest().merge(\"to-tDigest\", 1, \"from-tDigest\")\n    # we should now have 110 weight on to-histogram\n    info = client.tdigest().info(\"to-tDigest\")\n    if is_resp2_connection(client):\n        assert 20 == float(info[\"merged_weight\"]) + float(info[\"unmerged_weight\"])\n    else:\n        assert 20 == float(info[\"Merged weight\"]) + float(info[\"Unmerged weight\"])\n    # test override\n    assert client.tdigest().create(\"from-override\", 10)\n    assert client.tdigest().create(\"from-override-2\", 10)\n    assert client.tdigest().add(\"from-override\", [3.0] * 10)\n    assert client.tdigest().add(\"from-override-2\", [4.0] * 10)\n    assert client.tdigest().merge(\n        \"to-tDigest\", 2, \"from-override\", \"from-override-2\", override=True\n    )\n    assert 3.0 == client.tdigest().min(\"to-tDigest\")\n    assert 4.0 == client.tdigest().max(\"to-tDigest\")\n\n\n@pytest.mark.experimental\n@pytest.mark.redismod\ndef test_tdigest_min_and_max(client):\n    assert client.tdigest().create(\"tDigest\", 100)\n    # insert data-points into sketch\n    assert client.tdigest().add(\"tDigest\", [1, 2, 3])\n    # min/max\n    assert 3 == client.tdigest().max(\"tDigest\")\n    assert 1 == client.tdigest().min(\"tDigest\")\n\n\n@pytest.mark.experimental\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"2.4.0\", \"bf\")\ndef test_tdigest_quantile(client):\n    assert client.tdigest().create(\"tDigest\", 500)\n    # insert data-points into sketch\n    assert client.tdigest().add(\"tDigest\", list([x * 0.01 for x in range(1, 10000)]))\n    # assert min min/max have same result as quantile 0 and 1\n    res = client.tdigest().quantile(\"tDigest\", 1.0)\n    assert client.tdigest().max(\"tDigest\") == res[0]\n    res = client.tdigest().quantile(\"tDigest\", 0.0)\n    assert client.tdigest().min(\"tDigest\") == res[0]\n\n    assert 1.0 == round(client.tdigest().quantile(\"tDigest\", 0.01)[0], 2)\n    assert 99.0 == round(client.tdigest().quantile(\"tDigest\", 0.99)[0], 2)\n\n    # test multiple quantiles\n    assert client.tdigest().create(\"t-digest\", 100)\n    assert client.tdigest().add(\"t-digest\", [1, 2, 3, 4, 5])\n    assert [3.0, 5.0] == client.tdigest().quantile(\"t-digest\", 0.5, 0.8)\n\n\n@pytest.mark.experimental\n@pytest.mark.redismod\ndef test_tdigest_cdf(client):\n    assert client.tdigest().create(\"tDigest\", 100)\n    # insert data-points into sketch\n    assert client.tdigest().add(\"tDigest\", list(range(1, 10)))\n    assert 0.1 == round(client.tdigest().cdf(\"tDigest\", 1.0)[0], 1)\n    assert 0.9 == round(client.tdigest().cdf(\"tDigest\", 9.0)[0], 1)\n    res = client.tdigest().cdf(\"tDigest\", 1.0, 9.0)\n    assert [0.1, 0.9] == [round(x, 1) for x in res]\n\n\n@pytest.mark.experimental\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"2.4.0\", \"bf\")\ndef test_tdigest_trimmed_mean(client):\n    assert client.tdigest().create(\"tDigest\", 100)\n    # insert data-points into sketch\n    assert client.tdigest().add(\"tDigest\", list(range(1, 10)))\n    assert 5 == client.tdigest().trimmed_mean(\"tDigest\", 0.1, 0.9)\n    assert 4.5 == client.tdigest().trimmed_mean(\"tDigest\", 0.4, 0.5)\n\n\n@pytest.mark.experimental\n@pytest.mark.redismod\ndef test_tdigest_rank(client):\n    assert client.tdigest().create(\"t-digest\", 500)\n    assert client.tdigest().add(\"t-digest\", list(range(0, 20)))\n    assert -1 == client.tdigest().rank(\"t-digest\", -1)[0]\n    assert 0 == client.tdigest().rank(\"t-digest\", 0)[0]\n    assert 10 == client.tdigest().rank(\"t-digest\", 10)[0]\n    assert [-1, 20, 9] == client.tdigest().rank(\"t-digest\", -20, 20, 9)\n\n\n@pytest.mark.experimental\n@pytest.mark.redismod\ndef test_tdigest_revrank(client):\n    assert client.tdigest().create(\"t-digest\", 500)\n    assert client.tdigest().add(\"t-digest\", list(range(0, 20)))\n    assert -1 == client.tdigest().revrank(\"t-digest\", 20)[0]\n    assert 19 == client.tdigest().revrank(\"t-digest\", 0)[0]\n    assert [-1, 19, 9] == client.tdigest().revrank(\"t-digest\", 21, 0, 10)\n\n\n@pytest.mark.experimental\n@pytest.mark.redismod\ndef test_tdigest_byrank(client):\n    assert client.tdigest().create(\"t-digest\", 500)\n    assert client.tdigest().add(\"t-digest\", list(range(1, 11)))\n    assert 1 == client.tdigest().byrank(\"t-digest\", 0)[0]\n    assert 10 == client.tdigest().byrank(\"t-digest\", 9)[0]\n    assert client.tdigest().byrank(\"t-digest\", 100)[0] == inf\n    with pytest.raises(redis.ResponseError):\n        client.tdigest().byrank(\"t-digest\", -1)[0]\n\n\n@pytest.mark.experimental\n@pytest.mark.redismod\ndef test_tdigest_byrevrank(client):\n    assert client.tdigest().create(\"t-digest\", 500)\n    assert client.tdigest().add(\"t-digest\", list(range(1, 11)))\n    assert 10 == client.tdigest().byrevrank(\"t-digest\", 0)[0]\n    assert 1 == client.tdigest().byrevrank(\"t-digest\", 9)[0]\n    assert client.tdigest().byrevrank(\"t-digest\", 100)[0] == -inf\n    with pytest.raises(redis.ResponseError):\n        client.tdigest().byrevrank(\"t-digest\", -1)[0]\n\n\n# # def test_pipeline(client):\n#     pipeline = client.bf().pipeline()\n#     assert not client.bf().execute_command(\"get pipeline\")\n#\n#     assert client.bf().create(\"pipeline\", 0.01, 1000)\n#     for i in range(100):\n#         pipeline.add(\"pipeline\", i)\n#     for i in range(100):\n#         assert not (client.bf().exists(\"pipeline\", i))\n#\n#     pipeline.execute()\n#\n#     for i in range(100):\n#         assert client.bf().exists(\"pipeline\", i)\n"
  },
  {
    "path": "tests/test_cache.py",
    "content": "import time\nfrom unittest.mock import MagicMock\n\nimport pytest\nimport redis\n\nfrom redis.cache import (\n    CacheConfig,\n    CacheEntry,\n    CacheEntryStatus,\n    CacheKey,\n    CacheProxy,\n    DefaultCache,\n    EvictionPolicy,\n    EvictionPolicyType,\n    LRUPolicy,\n)\nfrom redis.event import (\n    EventDispatcher,\n)\nfrom redis.observability.attributes import CSCReason\nfrom tests.conftest import _get_client, skip_if_resp_version, skip_if_server_version_lt\n\n\n@pytest.fixture()\ndef r(request):\n    cache = request.param.get(\"cache\")\n    cache_config = request.param.get(\"cache_config\")\n    kwargs = request.param.get(\"kwargs\", {})\n    protocol = request.param.get(\"protocol\", 3)\n    ssl = request.param.get(\"ssl\", False)\n    single_connection_client = request.param.get(\"single_connection_client\", False)\n    decode_responses = request.param.get(\"decode_responses\", False)\n    with _get_client(\n        redis.Redis,\n        request,\n        protocol=protocol,\n        ssl=ssl,\n        single_connection_client=single_connection_client,\n        cache=cache,\n        cache_config=cache_config,\n        decode_responses=decode_responses,\n        **kwargs,\n    ) as client:\n        yield client\n\n\n@pytest.mark.onlynoncluster\n@skip_if_resp_version(2)\n@skip_if_server_version_lt(\"7.4.0\")\nclass TestCache:\n    @pytest.mark.parametrize(\n        \"r\",\n        [\n            {\n                \"cache\": DefaultCache(CacheConfig(max_size=5)),\n                \"single_connection_client\": True,\n            },\n            {\n                \"cache\": DefaultCache(CacheConfig(max_size=5)),\n                \"single_connection_client\": False,\n            },\n            {\n                \"cache\": DefaultCache(CacheConfig(max_size=5)),\n                \"single_connection_client\": False,\n                \"decode_responses\": True,\n            },\n        ],\n        ids=[\"single\", \"pool\", \"decoded\"],\n        indirect=True,\n    )\n    @pytest.mark.onlynoncluster\n    def test_get_from_given_cache(self, r, r2):\n        cache = r.get_cache()\n        # add key to redis\n        r.set(\"foo\", \"bar\")\n        # get key from redis and save in local cache\n        assert r.get(\"foo\") in [b\"bar\", \"bar\"]\n        # get key from local cache\n        assert cache.get(\n            CacheKey(command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\"))\n        ).cache_value in [\n            b\"bar\",\n            \"bar\",\n        ]\n        # change key in redis (cause invalidation)\n        r2.set(\"foo\", \"barbar\")\n        # Retrieves a new value from server and cache it\n        assert r.get(\"foo\") in [b\"barbar\", \"barbar\"]\n        # Make sure that new value was cached\n        assert cache.get(\n            CacheKey(command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\"))\n        ).cache_value in [\n            b\"barbar\",\n            \"barbar\",\n        ]\n\n    @pytest.mark.parametrize(\n        \"r\",\n        [\n            {\n                \"cache\": DefaultCache(CacheConfig(max_size=5)),\n                \"single_connection_client\": True,\n            },\n            {\n                \"cache\": DefaultCache(CacheConfig(max_size=5)),\n                \"single_connection_client\": False,\n            },\n            {\n                \"cache\": DefaultCache(CacheConfig(max_size=5)),\n                \"single_connection_client\": False,\n                \"decode_responses\": True,\n            },\n        ],\n        ids=[\"single\", \"pool\", \"decoded\"],\n        indirect=True,\n    )\n    @pytest.mark.onlynoncluster\n    def test_hash_get_from_given_cache(self, r, r2):\n        cache = r.get_cache()\n        hash_key = \"hash_foo_key\"\n        field_1 = \"bar\"\n        field_2 = \"bar2\"\n\n        # add hash key to redis\n        r.hset(hash_key, field_1, \"baz\")\n        r.hset(hash_key, field_2, \"baz2\")\n        # get keys from redis and save them in local cache\n        assert r.hget(hash_key, field_1) in [b\"baz\", \"baz\"]\n        assert r.hget(hash_key, field_2) in [b\"baz2\", \"baz2\"]\n        # get key from local cache\n        assert cache.get(\n            CacheKey(\n                command=\"HGET\",\n                redis_keys=(hash_key,),\n                redis_args=(\"HGET\", hash_key, field_1),\n            )\n        ).cache_value in [\n            b\"baz\",\n            \"baz\",\n        ]\n        assert cache.get(\n            CacheKey(\n                command=\"HGET\",\n                redis_keys=(hash_key,),\n                redis_args=(\"HGET\", hash_key, field_2),\n            )\n        ).cache_value in [\n            b\"baz2\",\n            \"baz2\",\n        ]\n        # change key in redis (cause invalidation)\n        r2.hset(hash_key, field_1, \"barbar\")\n        # Retrieves a new value from server and cache it\n        assert r.hget(hash_key, field_1) in [b\"barbar\", \"barbar\"]\n        # Make sure that new value was cached\n        assert cache.get(\n            CacheKey(\n                command=\"HGET\",\n                redis_keys=(hash_key,),\n                redis_args=(\"HGET\", hash_key, field_1),\n            )\n        ).cache_value in [\n            b\"barbar\",\n            \"barbar\",\n        ]\n        # The other field is also reset, because the invalidation message contains only the hash key.\n        assert (\n            cache.get(\n                CacheKey(\n                    command=\"HGET\",\n                    redis_keys=(hash_key,),\n                    redis_args=(\"HGET\", hash_key, field_2),\n                )\n            )\n            is None\n        )\n        assert r.hget(hash_key, field_2) in [b\"baz2\", \"baz2\"]\n\n    @pytest.mark.parametrize(\n        \"r\",\n        [\n            {\n                \"cache_config\": CacheConfig(max_size=128),\n                \"single_connection_client\": True,\n            },\n            {\n                \"cache_config\": CacheConfig(max_size=128),\n                \"single_connection_client\": False,\n            },\n            {\n                \"cache_config\": CacheConfig(max_size=128),\n                \"single_connection_client\": False,\n                \"decode_responses\": True,\n            },\n        ],\n        ids=[\"single\", \"pool\", \"decoded\"],\n        indirect=True,\n    )\n    @pytest.mark.onlynoncluster\n    def test_get_from_default_cache(self, r, r2):\n        cache = r.get_cache()\n        assert isinstance(cache.eviction_policy, LRUPolicy)\n        assert cache.config.get_max_size() == 128\n\n        # add key to redis\n        r.set(\"foo\", \"bar\")\n        # get key from redis and save in local cache\n        assert r.get(\"foo\") in [b\"bar\", \"bar\"]\n        # get key from local cache\n        assert cache.get(\n            CacheKey(command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\"))\n        ).cache_value in [\n            b\"bar\",\n            \"bar\",\n        ]\n        # change key in redis (cause invalidation)\n        r2.set(\"foo\", \"barbar\")\n\n        # Add a small delay to allow invalidation to be processed\n        time.sleep(0.1)\n\n        # Retrieves a new value from server and cache it\n        assert r.get(\"foo\") in [b\"barbar\", \"barbar\"]\n        # Make sure that new value was cached\n        assert cache.get(\n            CacheKey(command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\"))\n        ).cache_value in [\n            b\"barbar\",\n            \"barbar\",\n        ]\n\n    @pytest.mark.parametrize(\n        \"r\",\n        [\n            {\n                \"cache_config\": CacheConfig(max_size=128),\n                \"single_connection_client\": True,\n            },\n            {\n                \"cache_config\": CacheConfig(max_size=128),\n                \"single_connection_client\": False,\n            },\n        ],\n        ids=[\"single\", \"pool\"],\n        indirect=True,\n    )\n    @pytest.mark.onlynoncluster\n    def test_cache_clears_on_disconnect(self, r, cache):\n        cache = r.get_cache()\n        # add key to redis\n        r.set(\"foo\", \"bar\")\n        # get key from redis and save in local cache\n        assert r.get(\"foo\") == b\"bar\"\n        # get key from local cache\n        assert (\n            cache.get(\n                CacheKey(command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\"))\n            ).cache_value\n            == b\"bar\"\n        )\n        # Force disconnection\n        r.connection_pool.get_connection().disconnect()\n        # Make sure cache is empty\n        assert cache.size == 0\n\n    @pytest.mark.parametrize(\n        \"r\",\n        [\n            {\n                \"cache_config\": CacheConfig(max_size=3),\n                \"single_connection_client\": True,\n            },\n            {\n                \"cache_config\": CacheConfig(max_size=3),\n                \"single_connection_client\": False,\n            },\n        ],\n        ids=[\"single\", \"pool\"],\n        indirect=True,\n    )\n    @pytest.mark.onlynoncluster\n    def test_cache_lru_eviction(self, r, cache):\n        cache = r.get_cache()\n        # add 3 keys to redis\n        r.set(\"foo\", \"bar\")\n        r.set(\"foo2\", \"bar2\")\n        r.set(\"foo3\", \"bar3\")\n        # get 3 keys from redis and save in local cache\n        assert r.get(\"foo\") == b\"bar\"\n        assert r.get(\"foo2\") == b\"bar2\"\n        assert r.get(\"foo3\") == b\"bar3\"\n        # get the 3 keys from local cache\n        assert (\n            cache.get(\n                CacheKey(command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\"))\n            ).cache_value\n            == b\"bar\"\n        )\n        assert (\n            cache.get(\n                CacheKey(\n                    command=\"GET\", redis_keys=(\"foo2\",), redis_args=(\"GET\", \"foo2\")\n                )\n            ).cache_value\n            == b\"bar2\"\n        )\n        assert (\n            cache.get(\n                CacheKey(\n                    command=\"GET\", redis_keys=(\"foo3\",), redis_args=(\"GET\", \"foo3\")\n                )\n            ).cache_value\n            == b\"bar3\"\n        )\n        # add 1 more key to redis (exceed the max size)\n        r.set(\"foo4\", \"bar4\")\n        assert r.get(\"foo4\") == b\"bar4\"\n        # the first key is not in the local cache anymore\n        assert (\n            cache.get(\n                CacheKey(command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\"))\n            )\n            is None\n        )\n        assert cache.size == 3\n\n    @pytest.mark.parametrize(\n        \"r\",\n        [\n            {\n                \"cache_config\": CacheConfig(max_size=128),\n                \"single_connection_client\": True,\n            },\n            {\n                \"cache_config\": CacheConfig(max_size=128),\n                \"single_connection_client\": False,\n            },\n        ],\n        ids=[\"single\", \"pool\"],\n        indirect=True,\n    )\n    @pytest.mark.onlynoncluster\n    def test_cache_ignore_not_allowed_command(self, r):\n        cache = r.get_cache()\n        # add fields to hash\n        assert r.hset(\"foo\", \"bar\", \"baz\")\n        # get random field\n        assert r.hrandfield(\"foo\") == b\"bar\"\n        assert (\n            cache.get(\n                CacheKey(\n                    command=\"HRANDFIELD\",\n                    redis_keys=(\"foo\",),\n                    redis_args=(\"HRANDFIELD\", \"foo\"),\n                )\n            )\n            is None\n        )\n\n    @pytest.mark.parametrize(\n        \"r\",\n        [\n            {\n                \"cache_config\": CacheConfig(max_size=128),\n                \"single_connection_client\": True,\n            },\n            {\n                \"cache_config\": CacheConfig(max_size=128),\n                \"single_connection_client\": False,\n            },\n        ],\n        ids=[\"single\", \"pool\"],\n        indirect=True,\n    )\n    @pytest.mark.onlynoncluster\n    def test_cache_invalidate_all_related_responses(self, r):\n        cache = r.get_cache()\n        # Add keys\n        assert r.set(\"foo\", \"bar\")\n        assert r.set(\"bar\", \"foo\")\n\n        res = r.mget(\"foo\", \"bar\")\n        # Make sure that replies was cached\n        assert res == [b\"bar\", b\"foo\"]\n        assert (\n            cache.get(\n                CacheKey(\n                    command=\"MGET\",\n                    redis_keys=(\"foo\", \"bar\"),\n                    redis_args=(\"MGET\", \"foo\", \"bar\"),\n                )\n            ).cache_value\n            == res\n        )\n\n        # Make sure that objects are immutable.\n        another_res = r.mget(\"foo\", \"bar\")\n        res.append(b\"baz\")\n        assert another_res != res\n\n        # Invalidate one of the keys and make sure that\n        # all associated cached entries was removed\n        assert r.set(\"foo\", \"baz\")\n        assert r.get(\"foo\") == b\"baz\"\n        assert (\n            cache.get(\n                CacheKey(\n                    command=\"MGET\",\n                    redis_keys=(\"foo\", \"bar\"),\n                    redis_args=(\"MGET\", \"foo\", \"bar\"),\n                )\n            )\n            is None\n        )\n        assert (\n            cache.get(\n                CacheKey(command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\"))\n            ).cache_value\n            == b\"baz\"\n        )\n\n    @pytest.mark.parametrize(\n        \"r\",\n        [\n            {\n                \"cache_config\": CacheConfig(max_size=128),\n                \"single_connection_client\": True,\n            },\n            {\n                \"cache_config\": CacheConfig(max_size=128),\n                \"single_connection_client\": False,\n            },\n        ],\n        ids=[\"single\", \"pool\"],\n        indirect=True,\n    )\n    @pytest.mark.onlynoncluster\n    def test_cache_flushed_on_server_flush(self, r):\n        cache = r.get_cache()\n        # Add keys\n        assert r.set(\"foo\", \"bar\")\n        assert r.set(\"bar\", \"foo\")\n        assert r.set(\"baz\", \"bar\")\n\n        # Make sure that replies was cached\n        assert r.get(\"foo\") == b\"bar\"\n        assert r.get(\"bar\") == b\"foo\"\n        assert r.get(\"baz\") == b\"bar\"\n        assert (\n            cache.get(\n                CacheKey(command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\"))\n            ).cache_value\n            == b\"bar\"\n        )\n        assert (\n            cache.get(\n                CacheKey(command=\"GET\", redis_keys=(\"bar\",), redis_args=(\"GET\", \"bar\"))\n            ).cache_value\n            == b\"foo\"\n        )\n        assert (\n            cache.get(\n                CacheKey(command=\"GET\", redis_keys=(\"baz\",), redis_args=(\"GET\", \"baz\"))\n            ).cache_value\n            == b\"bar\"\n        )\n\n        # Flush server and trying to access cached entry\n        assert r.flushall()\n        assert r.get(\"foo\") is None\n        assert cache.size == 0\n\n\n@pytest.mark.onlycluster\n@skip_if_resp_version(2)\n@skip_if_server_version_lt(\"7.4.0\")\nclass TestClusterCache:\n    @pytest.mark.parametrize(\n        \"r\",\n        [\n            {\n                \"cache\": DefaultCache(CacheConfig(max_size=128)),\n            },\n            {\n                \"cache\": DefaultCache(CacheConfig(max_size=128)),\n                \"decode_responses\": True,\n            },\n        ],\n        indirect=True,\n    )\n    @pytest.mark.onlycluster\n    def test_get_from_cache(self, r):\n        cache = r.nodes_manager.get_node_from_slot(12000).redis_connection.get_cache()\n        # add key to redis\n        r.set(\"foo\", \"bar\")\n        # get key from redis and save in local cache\n        assert r.get(\"foo\") in [b\"bar\", \"bar\"]\n        # get key from local cache\n        assert cache.get(\n            CacheKey(command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\"))\n        ).cache_value in [\n            b\"bar\",\n            \"bar\",\n        ]\n        # change key in redis (cause invalidation)\n        r.set(\"foo\", \"barbar\")\n        # Retrieves a new value from server and cache it\n        assert r.get(\"foo\") in [b\"barbar\", \"barbar\"]\n        # Make sure that new value was cached\n        assert cache.get(\n            CacheKey(command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\"))\n        ).cache_value in [\n            b\"barbar\",\n            \"barbar\",\n        ]\n        # Make sure that cache is shared between nodes.\n        assert (\n            cache == r.nodes_manager.get_node_from_slot(1).redis_connection.get_cache()\n        )\n\n    @pytest.mark.parametrize(\n        \"r\",\n        [\n            {\n                \"cache_config\": CacheConfig(max_size=128),\n            },\n            {\n                \"cache_config\": CacheConfig(max_size=128),\n                \"decode_responses\": True,\n            },\n        ],\n        indirect=True,\n    )\n    def test_get_from_custom_cache(self, r, r2):\n        cache = r.nodes_manager.get_node_from_slot(12000).redis_connection.get_cache()\n        assert isinstance(cache.eviction_policy, LRUPolicy)\n        assert cache.config.get_max_size() == 128\n\n        # add key to redis\n        assert r.set(\"foo\", \"bar\")\n        # get key from redis and save in local cache\n        assert r.get(\"foo\") in [b\"bar\", \"bar\"]\n        # get key from local cache\n        assert cache.get(\n            CacheKey(command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\"))\n        ).cache_value in [\n            b\"bar\",\n            \"bar\",\n        ]\n        # change key in redis (cause invalidation)\n        r2.set(\"foo\", \"barbar\")\n        # Retrieves a new value from server and cache it\n        assert r.get(\"foo\") in [b\"barbar\", \"barbar\"]\n        # Make sure that new value was cached\n        assert cache.get(\n            CacheKey(command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\"))\n        ).cache_value in [\n            b\"barbar\",\n            \"barbar\",\n        ]\n\n    @pytest.mark.parametrize(\n        \"r\",\n        [\n            {\n                \"cache_config\": CacheConfig(max_size=128),\n            },\n        ],\n        indirect=True,\n    )\n    @pytest.mark.onlycluster\n    def test_cache_clears_on_disconnect(self, r, r2):\n        cache = r.nodes_manager.get_node_from_slot(12000).redis_connection.get_cache()\n        # add key to redis\n        r.set(\"foo\", \"bar\")\n        # get key from redis and save in local cache\n        assert r.get(\"foo\") == b\"bar\"\n        # get key from local cache\n        assert (\n            cache.get(\n                CacheKey(command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\"))\n            ).cache_value\n            == b\"bar\"\n        )\n        # Force disconnection\n        r.nodes_manager.get_node_from_slot(\n            12000\n        ).redis_connection.connection_pool.get_connection().disconnect()\n        # Make sure cache is empty\n        assert cache.size == 0\n\n    @pytest.mark.parametrize(\n        \"r\",\n        [\n            {\n                \"cache_config\": CacheConfig(max_size=3),\n            },\n        ],\n        indirect=True,\n    )\n    @pytest.mark.onlycluster\n    def test_cache_lru_eviction(self, r):\n        cache = r.nodes_manager.get_node_from_slot(10).redis_connection.get_cache()\n        # add 3 keys to redis\n        r.set(\"foo{slot}\", \"bar\")\n        r.set(\"foo2{slot}\", \"bar2\")\n        r.set(\"foo3{slot}\", \"bar3\")\n        # get 3 keys from redis and save in local cache\n        assert r.get(\"foo{slot}\") == b\"bar\"\n        assert r.get(\"foo2{slot}\") == b\"bar2\"\n        assert r.get(\"foo3{slot}\") == b\"bar3\"\n        # get the 3 keys from local cache\n        assert (\n            cache.get(\n                CacheKey(\n                    command=\"GET\",\n                    redis_keys=(\"foo{slot}\",),\n                    redis_args=(\"GET\", \"foo{slot}\"),\n                )\n            ).cache_value\n            == b\"bar\"\n        )\n        assert (\n            cache.get(\n                CacheKey(\n                    command=\"GET\",\n                    redis_keys=(\"foo2{slot}\",),\n                    redis_args=(\"GET\", \"foo2{slot}\"),\n                )\n            ).cache_value\n            == b\"bar2\"\n        )\n        assert (\n            cache.get(\n                CacheKey(\n                    command=\"GET\",\n                    redis_keys=(\"foo3{slot}\",),\n                    redis_args=(\"GET\", \"foo3{slot}\"),\n                )\n            ).cache_value\n            == b\"bar3\"\n        )\n        # add 1 more key to redis (exceed the max size)\n        r.set(\"foo4{slot}\", \"bar4\")\n        assert r.get(\"foo4{slot}\") == b\"bar4\"\n        # the first key is not in the local cache_data anymore\n        assert (\n            cache.get(\n                CacheKey(\n                    command=\"GET\",\n                    redis_keys=(\"foo{slot}\",),\n                    redis_args=(\"GET\", \"foo{slot}\"),\n                )\n            )\n            is None\n        )\n\n    @pytest.mark.parametrize(\n        \"r\",\n        [\n            {\n                \"cache_config\": CacheConfig(max_size=128),\n            },\n        ],\n        indirect=True,\n    )\n    @pytest.mark.onlycluster\n    def test_cache_ignore_not_allowed_command(self, r):\n        cache = r.nodes_manager.get_node_from_slot(12000).redis_connection.get_cache()\n        # add fields to hash\n        assert r.hset(\"foo\", \"bar\", \"baz\")\n        # get random field\n        assert r.hrandfield(\"foo\") == b\"bar\"\n        assert (\n            cache.get(\n                CacheKey(\n                    command=\"HRANDFIELD\",\n                    redis_keys=(\"foo\",),\n                    redis_args=(\"HRANDFIELD\", \"foo\"),\n                )\n            )\n            is None\n        )\n\n    @pytest.mark.parametrize(\n        \"r\",\n        [\n            {\n                \"cache_config\": CacheConfig(max_size=128),\n            },\n        ],\n        indirect=True,\n    )\n    @pytest.mark.onlycluster\n    def test_cache_invalidate_all_related_responses(self, r, cache):\n        cache = r.nodes_manager.get_node_from_slot(10).redis_connection.get_cache()\n        # Add keys\n        assert r.set(\"foo{slot}\", \"bar\")\n        assert r.set(\"bar{slot}\", \"foo\")\n\n        # Make sure that replies was cached\n        assert r.mget(\"foo{slot}\", \"bar{slot}\") == [b\"bar\", b\"foo\"]\n        assert cache.get(\n            CacheKey(\n                command=\"MGET\",\n                redis_keys=(\"foo{slot}\", \"bar{slot}\"),\n                redis_args=(\n                    \"MGET\",\n                    \"foo{slot}\",\n                    \"bar{slot}\",\n                ),\n            ),\n        ).cache_value == [b\"bar\", b\"foo\"]\n\n        # Invalidate one of the keys and make sure\n        # that all associated cached entries was removed\n        assert r.set(\"foo{slot}\", \"baz\")\n        assert r.get(\"foo{slot}\") == b\"baz\"\n        assert (\n            cache.get(\n                CacheKey(\n                    command=\"MGET\",\n                    redis_keys=(\"foo{slot}\", \"bar{slot}\"),\n                    redis_args=(\"MGET\", \"foo{slot}\", \"bar{slot}\"),\n                ),\n            )\n            is None\n        )\n        assert (\n            cache.get(\n                CacheKey(\n                    command=\"GET\",\n                    redis_keys=(\"foo{slot}\",),\n                    redis_args=(\"GET\", \"foo{slot}\"),\n                )\n            ).cache_value\n            == b\"baz\"\n        )\n\n    @pytest.mark.parametrize(\n        \"r\",\n        [\n            {\n                \"cache_config\": CacheConfig(max_size=128),\n            },\n        ],\n        indirect=True,\n    )\n    @pytest.mark.onlycluster\n    def test_cache_flushed_on_server_flush(self, r, cache):\n        cache = r.nodes_manager.get_node_from_slot(10).redis_connection.get_cache()\n        # Add keys\n        assert r.set(\"foo{slot}\", \"bar\")\n        assert r.set(\"bar{slot}\", \"foo\")\n        assert r.set(\"baz{slot}\", \"bar\")\n\n        # Make sure that replies was cached\n        assert r.get(\"foo{slot}\") == b\"bar\"\n        assert r.get(\"bar{slot}\") == b\"foo\"\n        assert r.get(\"baz{slot}\") == b\"bar\"\n        assert (\n            cache.get(\n                CacheKey(\n                    command=\"GET\",\n                    redis_keys=(\"foo{slot}\",),\n                    redis_args=(\"GET\", \"foo{slot}\"),\n                )\n            ).cache_value\n            == b\"bar\"\n        )\n        assert (\n            cache.get(\n                CacheKey(\n                    command=\"GET\",\n                    redis_keys=(\"bar{slot}\",),\n                    redis_args=(\"GET\", \"bar{slot}\"),\n                )\n            ).cache_value\n            == b\"foo\"\n        )\n        assert (\n            cache.get(\n                CacheKey(\n                    command=\"GET\",\n                    redis_keys=(\"baz{slot}\",),\n                    redis_args=(\"GET\", \"baz{slot}\"),\n                )\n            ).cache_value\n            == b\"bar\"\n        )\n\n        # Flush server and trying to access cached entry\n        assert r.flushall()\n        assert r.get(\"foo{slot}\") is None\n        assert cache.size == 0\n\n\n@pytest.mark.onlynoncluster\n@skip_if_resp_version(2)\n@skip_if_server_version_lt(\"7.4.0\")\nclass TestSentinelCache:\n    @pytest.mark.parametrize(\n        \"sentinel_setup\",\n        [\n            {\n                \"cache\": DefaultCache(CacheConfig(max_size=128)),\n                \"force_master_ip\": \"localhost\",\n            },\n            {\n                \"cache\": DefaultCache(CacheConfig(max_size=128)),\n                \"force_master_ip\": \"localhost\",\n                \"decode_responses\": True,\n            },\n        ],\n        indirect=True,\n    )\n    @pytest.mark.onlynoncluster\n    def test_get_from_cache(self, master):\n        cache = master.get_cache()\n        master.set(\"foo\", \"bar\")\n        # get key from redis and save in local cache_data\n        assert master.get(\"foo\") in [b\"bar\", \"bar\"]\n        # get key from local cache_data\n        assert cache.get(\n            CacheKey(command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\"))\n        ).cache_value in [\n            b\"bar\",\n            \"bar\",\n        ]\n        # change key in redis (cause invalidation)\n        master.set(\"foo\", \"barbar\")\n        # get key from redis\n        assert master.get(\"foo\") in [b\"barbar\", \"barbar\"]\n        # Make sure that new value was cached\n        assert cache.get(\n            CacheKey(command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\"))\n        ).cache_value in [\n            b\"barbar\",\n            \"barbar\",\n        ]\n\n    @pytest.mark.parametrize(\n        \"r\",\n        [\n            {\n                \"cache_config\": CacheConfig(max_size=128),\n            },\n            {\n                \"cache_config\": CacheConfig(max_size=128),\n                \"decode_responses\": True,\n            },\n        ],\n        indirect=True,\n    )\n    def test_get_from_default_cache(self, r, r2):\n        cache = r.get_cache()\n        assert isinstance(cache.eviction_policy, LRUPolicy)\n\n        # add key to redis\n        r.set(\"foo\", \"bar\")\n        # get key from redis and save in local cache_data\n        assert r.get(\"foo\") in [b\"bar\", \"bar\"]\n        # get key from local cache_data\n        assert cache.get(\n            CacheKey(command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\"))\n        ).cache_value in [\n            b\"bar\",\n            \"bar\",\n        ]\n        # change key in redis (cause invalidation)\n        r2.set(\"foo\", \"barbar\")\n        time.sleep(0.1)\n        # Retrieves a new value from server and cache_data it\n        assert r.get(\"foo\") in [b\"barbar\", \"barbar\"]\n        # Make sure that new value was cached\n        assert cache.get(\n            CacheKey(command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\"))\n        ).cache_value in [\n            b\"barbar\",\n            \"barbar\",\n        ]\n\n    @pytest.mark.parametrize(\n        \"sentinel_setup\",\n        [\n            {\n                \"cache_config\": CacheConfig(max_size=128),\n                \"force_master_ip\": \"localhost\",\n            }\n        ],\n        indirect=True,\n    )\n    @pytest.mark.onlynoncluster\n    def test_cache_clears_on_disconnect(self, master, cache):\n        cache = master.get_cache()\n        # add key to redis\n        master.set(\"foo\", \"bar\")\n        # get key from redis and save in local cache_data\n        assert master.get(\"foo\") == b\"bar\"\n        # get key from local cache_data\n        assert (\n            cache.get(\n                CacheKey(command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\"))\n            ).cache_value\n            == b\"bar\"\n        )\n        # Force disconnection\n        master.connection_pool.get_connection().disconnect()\n        # Make sure cache_data is empty\n        assert cache.size == 0\n\n\n@pytest.mark.onlynoncluster\n@skip_if_resp_version(2)\n@skip_if_server_version_lt(\"7.4.0\")\nclass TestSSLCache:\n    @pytest.mark.parametrize(\n        \"r\",\n        [\n            {\n                \"cache\": DefaultCache(CacheConfig(max_size=128)),\n                \"ssl\": True,\n            },\n            {\n                \"cache\": DefaultCache(CacheConfig(max_size=128)),\n                \"ssl\": True,\n                \"decode_responses\": True,\n            },\n        ],\n        indirect=True,\n    )\n    @pytest.mark.onlynoncluster\n    def test_get_from_cache(self, r, r2, cache):\n        cache = r.get_cache()\n        # add key to redis\n        r.set(\"foo\", \"bar\")\n        # get key from redis and save in local cache_data\n        assert r.get(\"foo\") in [b\"bar\", \"bar\"]\n        # get key from local cache_data\n        assert cache.get(\n            CacheKey(command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\"))\n        ).cache_value in [\n            b\"bar\",\n            \"bar\",\n        ]\n        # change key in redis (cause invalidation)\n        assert r2.set(\"foo\", \"barbar\")\n        # Timeout needed for SSL connection because there's timeout\n        # between data appears in socket buffer\n        time.sleep(0.1)\n        # Retrieves a new value from server and cache_data it\n        assert r.get(\"foo\") in [b\"barbar\", \"barbar\"]\n        # Make sure that new value was cached\n        assert cache.get(\n            CacheKey(command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\"))\n        ).cache_value in [\n            b\"barbar\",\n            \"barbar\",\n        ]\n\n    @pytest.mark.parametrize(\n        \"r\",\n        [\n            {\n                \"cache_config\": CacheConfig(max_size=128),\n                \"ssl\": True,\n            },\n            {\n                \"cache_config\": CacheConfig(max_size=128),\n                \"ssl\": True,\n                \"decode_responses\": True,\n            },\n        ],\n        indirect=True,\n    )\n    def test_get_from_custom_cache(self, r, r2):\n        cache = r.get_cache()\n        assert isinstance(cache.eviction_policy, LRUPolicy)\n\n        # add key to redis\n        r.set(\"foo\", \"bar\")\n        # get key from redis and save in local cache_data\n        assert r.get(\"foo\") in [b\"bar\", \"bar\"]\n        # get key from local cache_data\n        assert cache.get(\n            CacheKey(command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\"))\n        ).cache_value in [\n            b\"bar\",\n            \"bar\",\n        ]\n        # change key in redis (cause invalidation)\n        r2.set(\"foo\", \"barbar\")\n        # Timeout needed for SSL connection because there's timeout\n        # between data appears in socket buffer\n        time.sleep(0.1)\n        # Retrieves a new value from server and cache_data it\n        assert r.get(\"foo\") in [b\"barbar\", \"barbar\"]\n        # Make sure that new value was cached\n        assert cache.get(\n            CacheKey(command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\"))\n        ).cache_value in [\n            b\"barbar\",\n            \"barbar\",\n        ]\n\n    @pytest.mark.parametrize(\n        \"r\",\n        [\n            {\n                \"cache_config\": CacheConfig(max_size=128),\n                \"ssl\": True,\n            }\n        ],\n        indirect=True,\n    )\n    @pytest.mark.onlynoncluster\n    def test_cache_invalidate_all_related_responses(self, r):\n        cache = r.get_cache()\n        # Add keys\n        assert r.set(\"foo\", \"bar\")\n        assert r.set(\"bar\", \"foo\")\n\n        # Make sure that replies was cached\n        assert r.mget(\"foo\", \"bar\") == [b\"bar\", b\"foo\"]\n        assert cache.get(\n            CacheKey(\n                command=\"MGET\",\n                redis_keys=(\"foo\", \"bar\"),\n                redis_args=(\"MGET\", \"foo\", \"bar\"),\n            )\n        ).cache_value == [b\"bar\", b\"foo\"]\n\n        # Invalidate one of the keys and make sure\n        # that all associated cached entries was removed\n        assert r.set(\"foo\", \"baz\")\n        # Timeout needed for SSL connection because there's timeout\n        # between data appears in socket buffer\n        time.sleep(0.1)\n        assert r.get(\"foo\") == b\"baz\"\n        assert (\n            cache.get(\n                CacheKey(\n                    command=\"MGET\",\n                    redis_keys=(\"foo\", \"bar\"),\n                    redis_args=(\"MGET\", \"foo\", \"bar\"),\n                )\n            )\n            is None\n        )\n        assert (\n            cache.get(\n                CacheKey(command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\"))\n            ).cache_value\n            == b\"baz\"\n        )\n\n\nclass TestUnitDefaultCache:\n    def test_get_eviction_policy(self):\n        cache = DefaultCache(CacheConfig(max_size=5))\n        assert isinstance(cache.eviction_policy, LRUPolicy)\n\n    def test_get_max_size(self):\n        cache = DefaultCache(CacheConfig(max_size=5))\n        assert cache.config.get_max_size() == 5\n\n    def test_get_size(self):\n        cache = DefaultCache(CacheConfig(max_size=5))\n        assert cache.size == 0\n\n    @pytest.mark.parametrize(\n        \"cache_key\", [{\"command\": \"GET\", \"redis_keys\": (\"bar\",)}], indirect=True\n    )\n    def test_set_non_existing_cache_key(self, cache_key, mock_connection):\n        cache = DefaultCache(CacheConfig(max_size=5))\n\n        assert cache.set(\n            CacheEntry(\n                cache_key=cache_key,\n                cache_value=b\"val\",\n                status=CacheEntryStatus.VALID,\n                connection_ref=mock_connection,\n            )\n        )\n        assert cache.get(cache_key).cache_value == b\"val\"\n\n    @pytest.mark.parametrize(\n        \"cache_key\", [{\"command\": \"GET\", \"redis_keys\": (\"bar\",)}], indirect=True\n    )\n    def test_set_updates_existing_cache_key(self, cache_key, mock_connection):\n        cache = DefaultCache(CacheConfig(max_size=5))\n\n        assert cache.set(\n            CacheEntry(\n                cache_key=cache_key,\n                cache_value=b\"val\",\n                status=CacheEntryStatus.VALID,\n                connection_ref=mock_connection,\n            )\n        )\n        assert cache.get(cache_key).cache_value == b\"val\"\n\n        cache.set(\n            CacheEntry(\n                cache_key=cache_key,\n                cache_value=b\"new_val\",\n                status=CacheEntryStatus.VALID,\n                connection_ref=mock_connection,\n            )\n        )\n        assert cache.get(cache_key).cache_value == b\"new_val\"\n\n    @pytest.mark.parametrize(\n        \"cache_key\", [{\"command\": \"HRANDFIELD\", \"redis_keys\": (\"bar\",)}], indirect=True\n    )\n    def test_set_does_not_store_not_allowed_key(self, cache_key, mock_connection):\n        cache = DefaultCache(CacheConfig(max_size=5))\n\n        assert not cache.set(\n            CacheEntry(\n                cache_key=cache_key,\n                cache_value=b\"val\",\n                status=CacheEntryStatus.VALID,\n                connection_ref=mock_connection,\n            )\n        )\n\n    @pytest.mark.parametrize(\n        \"cache_key\", [{\"command\": \"GET\", \"redis_keys\": (\"bar\",)}], indirect=True\n    )\n    def test_get_return_correct_value(self, cache_key, mock_connection):\n        cache = DefaultCache(CacheConfig(max_size=5))\n\n        assert cache.set(\n            CacheEntry(\n                cache_key=cache_key,\n                cache_value=b\"val\",\n                status=CacheEntryStatus.VALID,\n                connection_ref=mock_connection,\n            )\n        )\n        assert cache.get(cache_key).cache_value == b\"val\"\n\n        wrong_key = CacheKey(\n            command=\"HGET\", redis_keys=(\"foo\",), redis_args=(\"HGET\", \"foo\", \"bar\")\n        )\n        assert cache.get(wrong_key) is None\n\n        result = cache.get(cache_key)\n        assert cache.set(\n            CacheEntry(\n                cache_key=cache_key,\n                cache_value=b\"new_val\",\n                status=CacheEntryStatus.VALID,\n                connection_ref=mock_connection,\n            )\n        )\n\n        # Make sure that result is immutable.\n        assert result.cache_value != cache.get(cache_key).cache_value\n\n    def test_delete_by_cache_keys_removes_associated_entries(self, mock_connection):\n        cache = DefaultCache(CacheConfig(max_size=5))\n\n        cache_key1 = CacheKey(\n            command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\")\n        )\n        cache_key2 = CacheKey(\n            command=\"GET\", redis_keys=(\"foo1\",), redis_args=(\"GET\", \"foo1\")\n        )\n        cache_key3 = CacheKey(\n            command=\"GET\", redis_keys=(\"foo2\",), redis_args=(\"GET\", \"foo2\")\n        )\n        cache_key4 = CacheKey(\n            command=\"GET\", redis_keys=(\"foo3\",), redis_args=(\"GET\", \"foo3\")\n        )\n\n        # Set 3 different keys\n        assert cache.set(\n            CacheEntry(\n                cache_key=cache_key1,\n                cache_value=b\"bar\",\n                status=CacheEntryStatus.VALID,\n                connection_ref=mock_connection,\n            )\n        )\n        assert cache.set(\n            CacheEntry(\n                cache_key=cache_key2,\n                cache_value=b\"bar1\",\n                status=CacheEntryStatus.VALID,\n                connection_ref=mock_connection,\n            )\n        )\n        assert cache.set(\n            CacheEntry(\n                cache_key=cache_key3,\n                cache_value=b\"bar2\",\n                status=CacheEntryStatus.VALID,\n                connection_ref=mock_connection,\n            )\n        )\n\n        assert cache.delete_by_cache_keys([cache_key1, cache_key2, cache_key4]) == [\n            True,\n            True,\n            False,\n        ]\n        assert len(cache.collection) == 1\n        assert cache.get(cache_key3).cache_value == b\"bar2\"\n\n    def test_delete_by_redis_keys_removes_associated_entries(self, mock_connection):\n        cache = DefaultCache(CacheConfig(max_size=5))\n\n        cache_key1 = CacheKey(\n            command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\")\n        )\n        cache_key2 = CacheKey(\n            command=\"GET\", redis_keys=(\"foo1\",), redis_args=(\"GET\", \"foo1\")\n        )\n        cache_key3 = CacheKey(\n            command=\"MGET\",\n            redis_keys=(\"foo\", \"foo3\"),\n            redis_args=(\"MGET\", \"foo\", \"foo3\"),\n        )\n        cache_key4 = CacheKey(\n            command=\"MGET\",\n            redis_keys=(\"foo2\", \"foo3\"),\n            redis_args=(\"MGET\", \"foo2\", \"foo3\"),\n        )\n\n        # Set 3 different keys\n        assert cache.set(\n            CacheEntry(\n                cache_key=cache_key1,\n                cache_value=b\"bar\",\n                status=CacheEntryStatus.VALID,\n                connection_ref=mock_connection,\n            )\n        )\n        assert cache.set(\n            CacheEntry(\n                cache_key=cache_key2,\n                cache_value=b\"bar1\",\n                status=CacheEntryStatus.VALID,\n                connection_ref=mock_connection,\n            )\n        )\n        assert cache.set(\n            CacheEntry(\n                cache_key=cache_key3,\n                cache_value=b\"bar2\",\n                status=CacheEntryStatus.VALID,\n                connection_ref=mock_connection,\n            )\n        )\n        assert cache.set(\n            CacheEntry(\n                cache_key=cache_key4,\n                cache_value=b\"bar3\",\n                status=CacheEntryStatus.VALID,\n                connection_ref=mock_connection,\n            )\n        )\n\n        assert cache.delete_by_redis_keys([b\"foo\", b\"foo1\"]) == [True, True, True]\n        assert len(cache.collection) == 1\n        assert cache.get(cache_key4).cache_value == b\"bar3\"\n\n    def test_delete_by_redis_keys_with_non_utf8_bytes_key(self, mock_connection):\n        \"\"\"cache fails to invalidate entries when redis_keys contain non-UTF-8 bytes.\"\"\"\n        cache = DefaultCache(CacheConfig(max_size=5))\n\n        # Valid UTF-8 key works\n        utf8_key = b\"foo\"\n        utf8_cache_key = CacheKey(command=\"GET\", redis_keys=(utf8_key,))\n        assert cache.set(\n            CacheEntry(\n                cache_key=utf8_cache_key,\n                cache_value=b\"bar\",\n                status=CacheEntryStatus.VALID,\n                connection_ref=mock_connection,\n            )\n        )\n\n        # Non-UTF-8 bytes key\n        bad_key = b\"f\\xffoo\"\n        bad_cache_key = CacheKey(command=\"GET\", redis_keys=(bad_key,))\n        assert cache.set(\n            CacheEntry(\n                cache_key=bad_cache_key,\n                cache_value=b\"bar2\",\n                status=CacheEntryStatus.VALID,\n                connection_ref=mock_connection,\n            )\n        )\n\n        # Delete both keys: utf8 should succeed, non-utf8 exposes bug\n        results = cache.delete_by_redis_keys([utf8_key, bad_key])\n\n        assert results[0] is True\n        assert results[1] is True, \"Cache did not remove entry for non-UTF8 bytes key\"\n\n    def test_flush(self, mock_connection):\n        cache = DefaultCache(CacheConfig(max_size=5))\n\n        cache_key1 = CacheKey(\n            command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\")\n        )\n        cache_key2 = CacheKey(\n            command=\"GET\", redis_keys=(\"foo1\",), redis_args=(\"GET\", \"foo1\")\n        )\n        cache_key3 = CacheKey(\n            command=\"GET\", redis_keys=(\"foo2\",), redis_args=(\"GET\", \"foo2\")\n        )\n\n        # Set 3 different keys\n        assert cache.set(\n            CacheEntry(\n                cache_key=cache_key1,\n                cache_value=b\"bar\",\n                status=CacheEntryStatus.VALID,\n                connection_ref=mock_connection,\n            )\n        )\n        assert cache.set(\n            CacheEntry(\n                cache_key=cache_key2,\n                cache_value=b\"bar1\",\n                status=CacheEntryStatus.VALID,\n                connection_ref=mock_connection,\n            )\n        )\n        assert cache.set(\n            CacheEntry(\n                cache_key=cache_key3,\n                cache_value=b\"bar2\",\n                status=CacheEntryStatus.VALID,\n                connection_ref=mock_connection,\n            )\n        )\n\n        assert cache.flush() == 3\n        assert len(cache.collection) == 0\n\n\nclass TestUnitLRUPolicy:\n    def test_type(self):\n        policy = LRUPolicy()\n        assert policy.type == EvictionPolicyType.time_based\n\n    def test_evict_next(self, mock_connection):\n        cache = DefaultCache(\n            CacheConfig(max_size=5, eviction_policy=EvictionPolicy.LRU)\n        )\n        policy = cache.eviction_policy\n\n        cache_key1 = CacheKey(\n            command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\")\n        )\n        cache_key2 = CacheKey(\n            command=\"GET\", redis_keys=(\"bar\",), redis_args=(\"GET\", \"bar\")\n        )\n\n        assert cache.set(\n            CacheEntry(\n                cache_key=cache_key1,\n                cache_value=b\"bar\",\n                status=CacheEntryStatus.VALID,\n                connection_ref=mock_connection,\n            )\n        )\n        assert cache.set(\n            CacheEntry(\n                cache_key=cache_key2,\n                cache_value=b\"foo\",\n                status=CacheEntryStatus.VALID,\n                connection_ref=mock_connection,\n            )\n        )\n\n        assert policy.evict_next() == cache_key1\n        assert cache.get(cache_key1) is None\n\n    def test_evict_many(self, mock_connection):\n        cache = DefaultCache(\n            CacheConfig(max_size=5, eviction_policy=EvictionPolicy.LRU)\n        )\n        policy = cache.eviction_policy\n        cache_key1 = CacheKey(\n            command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\")\n        )\n        cache_key2 = CacheKey(\n            command=\"GET\", redis_keys=(\"bar\",), redis_args=(\"GET\", \"bar\")\n        )\n        cache_key3 = CacheKey(\n            command=\"GET\", redis_keys=(\"baz\",), redis_args=(\"GET\", \"baz\")\n        )\n\n        assert cache.set(\n            CacheEntry(\n                cache_key=cache_key1,\n                cache_value=b\"bar\",\n                status=CacheEntryStatus.VALID,\n                connection_ref=mock_connection,\n            )\n        )\n        assert cache.set(\n            CacheEntry(\n                cache_key=cache_key2,\n                cache_value=b\"foo\",\n                status=CacheEntryStatus.VALID,\n                connection_ref=mock_connection,\n            )\n        )\n        assert cache.set(\n            CacheEntry(\n                cache_key=cache_key3,\n                cache_value=b\"baz\",\n                status=CacheEntryStatus.VALID,\n                connection_ref=mock_connection,\n            )\n        )\n\n        assert policy.evict_many(2) == [cache_key1, cache_key2]\n        assert cache.get(cache_key1) is None\n        assert cache.get(cache_key2) is None\n\n        with pytest.raises(ValueError, match=\"Evictions count is above cache size\"):\n            policy.evict_many(99)\n\n    def test_touch(self, mock_connection):\n        cache = DefaultCache(\n            CacheConfig(max_size=5, eviction_policy=EvictionPolicy.LRU)\n        )\n        policy = cache.eviction_policy\n\n        cache_key1 = CacheKey(\n            command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\")\n        )\n        cache_key2 = CacheKey(\n            command=\"GET\", redis_keys=(\"bar\",), redis_args=(\"GET\", \"bar\")\n        )\n\n        cache.set(\n            CacheEntry(\n                cache_key=cache_key1,\n                cache_value=b\"bar\",\n                status=CacheEntryStatus.VALID,\n                connection_ref=mock_connection,\n            )\n        )\n        cache.set(\n            CacheEntry(\n                cache_key=cache_key2,\n                cache_value=b\"foo\",\n                status=CacheEntryStatus.VALID,\n                connection_ref=mock_connection,\n            )\n        )\n\n        assert cache.collection.popitem(last=True)[0] == cache_key2\n        cache.set(\n            CacheEntry(\n                cache_key=cache_key2,\n                cache_value=b\"foo\",\n                status=CacheEntryStatus.VALID,\n                connection_ref=mock_connection,\n            )\n        )\n\n        policy.touch(cache_key1)\n        assert cache.collection.popitem(last=True)[0] == cache_key1\n\n    def test_throws_error_on_invalid_cache(self):\n        policy = LRUPolicy()\n\n        with pytest.raises(\n            ValueError, match=\"Eviction policy should be associated with valid cache.\"\n        ):\n            policy.evict_next()\n\n        policy.cache = \"wrong_type\"\n\n        with pytest.raises(\n            ValueError, match=\"Eviction policy should be associated with valid cache.\"\n        ):\n            policy.evict_next()\n\n\nclass TestUnitCacheConfiguration:\n    MAX_SIZE = 100\n    EVICTION_POLICY = EvictionPolicy.LRU\n\n    def test_get_max_size(self, cache_conf: CacheConfig):\n        assert self.MAX_SIZE == cache_conf.get_max_size()\n\n    def test_get_eviction_policy(self, cache_conf: CacheConfig):\n        assert self.EVICTION_POLICY == cache_conf.get_eviction_policy()\n\n    def test_is_exceeds_max_size(self, cache_conf: CacheConfig):\n        assert not cache_conf.is_exceeds_max_size(self.MAX_SIZE)\n        assert cache_conf.is_exceeds_max_size(self.MAX_SIZE + 1)\n\n    def test_is_allowed_to_cache(self, cache_conf: CacheConfig):\n        assert cache_conf.is_allowed_to_cache(\"GET\")\n        assert not cache_conf.is_allowed_to_cache(\"SET\")\n\n\nclass TestUnitCacheProxy:\n    \"\"\"Unit tests for CacheProxy class with mocked event dispatcher.\"\"\"\n\n    @pytest.fixture\n    def mock_cache(self, mock_connection):\n        \"\"\"Create a DefaultCache for testing.\"\"\"\n        return DefaultCache(CacheConfig(max_size=5))\n\n    @pytest.fixture\n    def mock_event_dispatcher(self):\n        \"\"\"Create a mock event dispatcher.\"\"\"\n        return MagicMock(spec=EventDispatcher)\n\n    @pytest.fixture\n    def cache_key(self):\n        \"\"\"Create a sample cache key.\"\"\"\n        return CacheKey(command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\"))\n\n    def test_initialization_creates_cache_proxy(self, mock_cache):\n        \"\"\"Test that CacheProxy can be initialized with a cache.\"\"\"\n        # Should not raise an error\n        proxy = CacheProxy(mock_cache)\n        assert proxy is not None\n\n    def test_set_calls_record_csc_eviction_when_cache_exceeds_max_size(\n        self, mock_connection\n    ):\n        \"\"\"Test that record_csc_eviction is called when cache exceeds max size.\"\"\"\n        from unittest.mock import patch\n\n        # Create a cache with max_size=2\n        cache = DefaultCache(CacheConfig(max_size=2))\n        proxy = CacheProxy(cache)\n\n        with patch(\"redis.observability.recorder.record_csc_eviction\") as mock_record:\n            # Add 2 entries (at max capacity)\n            for i in range(2):\n                cache_key = CacheKey(\n                    command=\"GET\",\n                    redis_keys=(f\"key{i}\",),\n                    redis_args=(\"GET\", f\"key{i}\"),\n                )\n                proxy.set(\n                    CacheEntry(\n                        cache_key=cache_key,\n                        cache_value=f\"value{i}\".encode(),\n                        status=CacheEntryStatus.VALID,\n                        connection_ref=mock_connection,\n                    )\n                )\n\n            # No eviction yet\n            mock_record.assert_not_called()\n\n            # Add a 3rd entry, which should trigger eviction\n            cache_key = CacheKey(\n                command=\"GET\", redis_keys=(\"key3\",), redis_args=(\"GET\", \"key3\")\n            )\n            proxy.set(\n                CacheEntry(\n                    cache_key=cache_key,\n                    cache_value=b\"value3\",\n                    status=CacheEntryStatus.VALID,\n                    connection_ref=mock_connection,\n                )\n            )\n\n            # record_csc_eviction should be called\n            mock_record.assert_called_once_with(\n                count=1,\n                reason=CSCReason.FULL,\n            )\n\n    def test_set_does_not_call_record_csc_eviction_when_under_max_size(\n        self, mock_cache, mock_connection\n    ):\n        \"\"\"Test that record_csc_eviction is NOT called when cache is under max size.\"\"\"\n        from unittest.mock import patch\n\n        proxy = CacheProxy(mock_cache)\n\n        with patch(\"redis.observability.recorder.record_csc_eviction\") as mock_record:\n            cache_key = CacheKey(\n                command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\")\n            )\n            proxy.set(\n                CacheEntry(\n                    cache_key=cache_key,\n                    cache_value=b\"bar\",\n                    status=CacheEntryStatus.VALID,\n                    connection_ref=mock_connection,\n                )\n            )\n\n            mock_record.assert_not_called()\n\n    def test_collection_property_delegates_to_underlying_cache(self, mock_cache):\n        \"\"\"Test that collection property returns the underlying cache's collection.\"\"\"\n        proxy = CacheProxy(mock_cache)\n        assert proxy.collection is mock_cache.collection\n\n    def test_config_property_delegates_to_underlying_cache(self, mock_cache):\n        \"\"\"Test that config property returns the underlying cache's config.\"\"\"\n        proxy = CacheProxy(mock_cache)\n        assert proxy.config is mock_cache.config\n\n    def test_eviction_policy_property_delegates_to_underlying_cache(self, mock_cache):\n        \"\"\"Test that eviction_policy property returns the underlying cache's eviction_policy.\"\"\"\n        proxy = CacheProxy(mock_cache)\n        assert proxy.eviction_policy is mock_cache.eviction_policy\n\n    def test_size_property_delegates_to_underlying_cache(\n        self, mock_cache, mock_connection\n    ):\n        \"\"\"Test that size property returns the underlying cache's size.\"\"\"\n        proxy = CacheProxy(mock_cache)\n        assert proxy.size == 0\n\n        cache_key = CacheKey(\n            command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\")\n        )\n        proxy.set(\n            CacheEntry(\n                cache_key=cache_key,\n                cache_value=b\"bar\",\n                status=CacheEntryStatus.VALID,\n                connection_ref=mock_connection,\n            )\n        )\n        assert proxy.size == 1\n\n    def test_get_delegates_to_underlying_cache(self, mock_cache, mock_connection):\n        \"\"\"Test that get method delegates to the underlying cache.\"\"\"\n        proxy = CacheProxy(mock_cache)\n\n        cache_key = CacheKey(\n            command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\")\n        )\n        entry = CacheEntry(\n            cache_key=cache_key,\n            cache_value=b\"bar\",\n            status=CacheEntryStatus.VALID,\n            connection_ref=mock_connection,\n        )\n        proxy.set(entry)\n\n        result = proxy.get(cache_key)\n        assert result is not None\n        assert result.cache_value == b\"bar\"\n\n    def test_delete_by_cache_keys_delegates_to_underlying_cache(\n        self, mock_cache, mock_connection\n    ):\n        \"\"\"Test that delete_by_cache_keys method delegates to the underlying cache.\"\"\"\n        proxy = CacheProxy(mock_cache)\n\n        cache_key = CacheKey(\n            command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\")\n        )\n        proxy.set(\n            CacheEntry(\n                cache_key=cache_key,\n                cache_value=b\"bar\",\n                status=CacheEntryStatus.VALID,\n                connection_ref=mock_connection,\n            )\n        )\n\n        result = proxy.delete_by_cache_keys([cache_key])\n        assert result == [True]\n        assert proxy.get(cache_key) is None\n\n    def test_delete_by_redis_keys_delegates_to_underlying_cache(\n        self, mock_cache, mock_connection\n    ):\n        \"\"\"Test that delete_by_redis_keys method delegates to the underlying cache.\"\"\"\n        proxy = CacheProxy(mock_cache)\n\n        cache_key = CacheKey(\n            command=\"GET\", redis_keys=(b\"foo\",), redis_args=(\"GET\", \"foo\")\n        )\n        proxy.set(\n            CacheEntry(\n                cache_key=cache_key,\n                cache_value=b\"bar\",\n                status=CacheEntryStatus.VALID,\n                connection_ref=mock_connection,\n            )\n        )\n\n        result = proxy.delete_by_redis_keys([b\"foo\"])\n        assert result == [True]\n        assert proxy.get(cache_key) is None\n\n    def test_flush_delegates_to_underlying_cache(self, mock_cache, mock_connection):\n        \"\"\"Test that flush method delegates to the underlying cache.\"\"\"\n        proxy = CacheProxy(mock_cache)\n\n        for i in range(3):\n            cache_key = CacheKey(\n                command=\"GET\", redis_keys=(f\"key{i}\",), redis_args=(\"GET\", f\"key{i}\")\n            )\n            proxy.set(\n                CacheEntry(\n                    cache_key=cache_key,\n                    cache_value=f\"value{i}\".encode(),\n                    status=CacheEntryStatus.VALID,\n                    connection_ref=mock_connection,\n                )\n            )\n\n        assert proxy.size == 3\n        result = proxy.flush()\n        assert result == 3\n        assert proxy.size == 0\n\n    def test_is_cachable_delegates_to_underlying_cache(self, mock_cache):\n        \"\"\"Test that is_cachable method delegates to the underlying cache.\"\"\"\n        proxy = CacheProxy(mock_cache)\n\n        # GET is cachable by default\n        cache_key = CacheKey(command=\"GET\", redis_keys=(\"foo\",), redis_args=())\n        assert proxy.is_cachable(cache_key) is True\n\n        # SET is not cachable\n        cache_key = CacheKey(command=\"SET\", redis_keys=(\"foo\",), redis_args=())\n        assert proxy.is_cachable(cache_key) is False\n"
  },
  {
    "path": "tests/test_client.py",
    "content": "from unittest import mock\n\nimport pytest\n\nimport redis\nfrom redis.event import (\n    EventDispatcher,\n)\nfrom redis.observability import recorder\nfrom redis.observability.config import OTelConfig, MetricGroup\nfrom redis.observability.metrics import RedisMetricsCollector\n\n\nclass TestRedisClientMetricsRecording:\n    \"\"\"\n    Unit tests that verify metrics are properly recorded from Redis client\n    via direct record_* function calls.\n\n    These tests use fully mocked connection and connection pool - no real Redis\n    or OTel integration is used.\n    \"\"\"\n\n    @pytest.fixture\n    def mock_connection(self):\n        \"\"\"Create a mock connection with required attributes.\"\"\"\n        conn = mock.MagicMock()\n        conn.host = \"localhost\"\n        conn.port = 6379\n        conn.db = 0\n        conn.should_reconnect.return_value = False\n\n        # Mock retry to just execute the function directly\n        def mock_call_with_retry(do, fail, is_retryable=None, with_failure_count=False):\n            return do()\n\n        conn.retry.call_with_retry = mock_call_with_retry\n\n        return conn\n\n    @pytest.fixture\n    def mock_connection_pool(self, mock_connection):\n        \"\"\"Create a mock connection pool.\"\"\"\n        pool = mock.MagicMock()\n        pool.get_connection.return_value = mock_connection\n        pool.get_encoder.return_value = mock.MagicMock()\n        return pool\n\n    @pytest.fixture\n    def mock_meter(self):\n        \"\"\"Create a mock Meter that tracks all instrument calls.\"\"\"\n        meter = mock.MagicMock()\n\n        # Create mock histogram for operation duration\n        self.operation_duration = mock.MagicMock()\n        # Create mock counter for client errors\n        self.client_errors = mock.MagicMock()\n\n        def create_histogram_side_effect(name, **kwargs):\n            if name == \"db.client.operation.duration\":\n                return self.operation_duration\n            return mock.MagicMock()\n\n        def create_counter_side_effect(name, **kwargs):\n            if name == \"redis.client.errors\":\n                return self.client_errors\n            return mock.MagicMock()\n\n        meter.create_counter.side_effect = create_counter_side_effect\n        meter.create_up_down_counter.return_value = mock.MagicMock()\n        meter.create_histogram.side_effect = create_histogram_side_effect\n\n        return meter\n\n    @pytest.fixture\n    def setup_redis_client_with_otel(\n        self, mock_connection_pool, mock_connection, mock_meter\n    ):\n        \"\"\"\n        Setup a Redis client with mocked connection and OTel collector.\n        Returns tuple of (redis_client, operation_duration_mock).\n        \"\"\"\n\n        # Reset any existing collector state\n        recorder.reset_collector()\n\n        # Create config with COMMAND group enabled\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND])\n\n        # Create collector with mocked meter\n        with mock.patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        # Patch the recorder to use our collector\n        with mock.patch.object(\n            recorder, \"_get_or_create_collector\", return_value=collector\n        ):\n            # Create Redis client with mocked connection pool\n            client = redis.Redis(\n                connection_pool=mock_connection_pool,\n            )\n\n            yield client, self.operation_duration\n\n        # Cleanup\n        recorder.reset_collector()\n\n    def test_execute_command_records_metrics(self, setup_redis_client_with_otel):\n        \"\"\"\n        Test that executing a command records metrics\n        via the Meter's histogram.record() method.\n        \"\"\"\n        client, operation_duration_mock = setup_redis_client_with_otel\n\n        # Mock _send_command_parse_response to return a successful response\n        client._send_command_parse_response = mock.MagicMock(return_value=True)\n\n        # Execute a command\n        client.execute_command(\"SET\", \"key1\", \"value1\")\n\n        # Verify the Meter's histogram.record() was called\n        operation_duration_mock.record.assert_called_once()\n\n        # Get the call arguments\n        call_args = operation_duration_mock.record.call_args\n\n        # Verify duration was recorded (first positional arg)\n        duration = call_args[0][0]\n        assert isinstance(duration, float)\n        assert duration >= 0\n\n        # Verify attributes\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[\"db.operation.name\"] == \"SET\"\n        assert attrs[\"server.address\"] == \"localhost\"\n        assert attrs[\"server.port\"] == 6379\n        assert attrs[\"db.namespace\"] == \"0\"\n\n    def test_get_command_records_metrics(\n        self, mock_connection_pool, mock_connection, mock_meter\n    ):\n        \"\"\"\n        Test that GET command records metrics with correct command name.\n        \"\"\"\n\n        recorder.reset_collector()\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND])\n\n        with mock.patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        with mock.patch.object(\n            recorder, \"_get_or_create_collector\", return_value=collector\n        ):\n            client = redis.Redis(\n                connection_pool=mock_connection_pool,\n            )\n\n            client._send_command_parse_response = mock.MagicMock(return_value=b\"value1\")\n\n            # Execute GET command\n            client.execute_command(\"GET\", \"key1\")\n\n            # Verify command name is GET\n            call_args = self.operation_duration.record.call_args\n            attrs = call_args[1][\"attributes\"]\n            assert attrs[\"db.operation.name\"] == \"GET\"\n\n        recorder.reset_collector()\n\n    def test_command_error_records_error_count(\n        self, mock_connection_pool, mock_connection, mock_meter\n    ):\n        \"\"\"\n        Test that when a command execution raises an exception,\n        error count is recorded via record_error_count.\n\n        Note: record_operation_duration is NOT called for final errors -\n        only record_error_count is called. record_operation_duration is\n        only called during retries (in _close_connection) and on success.\n        \"\"\"\n\n        recorder.reset_collector()\n        # Enable RESILIENCY metric group for error counting\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND, MetricGroup.RESILIENCY])\n\n        with mock.patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        with mock.patch.object(\n            recorder, \"_get_or_create_collector\", return_value=collector\n        ):\n            client = redis.Redis(\n                connection_pool=mock_connection_pool,\n            )\n\n            # Make command raise an exception\n            test_error = redis.ResponseError(\"WRONGTYPE Operation error\")\n            client._send_command_parse_response = mock.MagicMock(side_effect=test_error)\n\n            # Execute should raise the error\n            with pytest.raises(redis.ResponseError):\n                client.execute_command(\"LPUSH\", \"string_key\", \"value\")\n\n            # Verify record_error_count was called (via client_errors counter)\n            self.client_errors.add.assert_called_once()\n\n            # Verify error type is recorded in attributes\n            call_args = self.client_errors.add.call_args\n            attrs = call_args[1][\"attributes\"]\n            assert \"error.type\" in attrs\n\n            # Verify operation_duration was NOT called (no retries, direct failure)\n            self.operation_duration.record.assert_not_called()\n\n        recorder.reset_collector()\n\n    def test_server_attributes_recorded_correctly(self, setup_redis_client_with_otel):\n        \"\"\"\n        Test that server address, port, and db namespace are correctly recorded.\n        \"\"\"\n        client, operation_duration_mock = setup_redis_client_with_otel\n\n        client._send_command_parse_response = mock.MagicMock(return_value=b\"PONG\")\n\n        client.execute_command(\"PING\")\n\n        call_args = operation_duration_mock.record.call_args\n        attrs = call_args[1][\"attributes\"]\n\n        # Verify server attributes match mock connection\n        assert attrs[\"server.address\"] == \"localhost\"\n        assert attrs[\"server.port\"] == 6379\n        assert attrs[\"db.namespace\"] == \"0\"\n\n    def test_multiple_commands_record_multiple_metrics(\n        self, mock_connection_pool, mock_connection, mock_meter\n    ):\n        \"\"\"\n        Test that each command execution records a separate metric to the Meter.\n        \"\"\"\n\n        recorder.reset_collector()\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND])\n\n        with mock.patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        with mock.patch.object(\n            recorder, \"_get_or_create_collector\", return_value=collector\n        ):\n            client = redis.Redis(\n                connection_pool=mock_connection_pool,\n            )\n\n            client._send_command_parse_response = mock.MagicMock(return_value=True)\n\n            # Execute multiple commands\n            client.execute_command(\"SET\", \"key1\", \"value1\")\n            client.execute_command(\"SET\", \"key2\", \"value2\")\n            client.execute_command(\"GET\", \"key1\")\n\n            # Verify histogram.record() was called three times\n            assert self.operation_duration.record.call_count == 3\n\n            # Verify command names in order\n            calls = self.operation_duration.record.call_args_list\n            assert calls[0][1][\"attributes\"][\"db.operation.name\"] == \"SET\"\n            assert calls[1][1][\"attributes\"][\"db.operation.name\"] == \"SET\"\n            assert calls[2][1][\"attributes\"][\"db.operation.name\"] == \"GET\"\n\n        recorder.reset_collector()\n\n    def test_different_db_namespace_recorded(self, mock_connection_pool, mock_meter):\n        \"\"\"\n        Test that different db namespace values are correctly recorded.\n        \"\"\"\n\n        # Create connection with different db\n        mock_connection = mock.MagicMock()\n        mock_connection.host = \"redis.example.com\"\n        mock_connection.port = 6380\n        mock_connection.db = 5\n        mock_connection.should_reconnect.return_value = False\n\n        def mock_call_with_retry(do, fail, is_retryable=None, with_failure_count=False):\n            return do()\n\n        mock_connection.retry.call_with_retry = mock_call_with_retry\n\n        mock_connection_pool.get_connection.return_value = mock_connection\n\n        recorder.reset_collector()\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND])\n\n        with mock.patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        with mock.patch.object(\n            recorder, \"_get_or_create_collector\", return_value=collector\n        ):\n            client = redis.Redis(\n                connection_pool=mock_connection_pool,\n            )\n\n            client._send_command_parse_response = mock.MagicMock(return_value=True)\n\n            client.execute_command(\"SET\", \"key\", \"value\")\n\n            call_args = self.operation_duration.record.call_args\n            attrs = call_args[1][\"attributes\"]\n\n            # Verify different server attributes\n            assert attrs[\"server.address\"] == \"redis.example.com\"\n            assert attrs[\"server.port\"] == 6380\n            assert attrs[\"db.namespace\"] == \"5\"\n\n        recorder.reset_collector()\n\n    def test_duration_is_positive(self, setup_redis_client_with_otel):\n        \"\"\"\n        Test that the recorded duration is a positive float value.\n        \"\"\"\n        client, operation_duration_mock = setup_redis_client_with_otel\n\n        client._send_command_parse_response = mock.MagicMock(return_value=True)\n\n        client.execute_command(\"SET\", \"key\", \"value\")\n\n        call_args = operation_duration_mock.record.call_args\n        duration = call_args[0][0]\n\n        assert isinstance(duration, float)\n        assert duration >= 0\n\n    def test_no_batch_size_for_single_command(self, setup_redis_client_with_otel):\n        \"\"\"\n        Test that single commands do not include batch_size attribute\n        (batch_size is only for pipeline operations).\n        \"\"\"\n        client, operation_duration_mock = setup_redis_client_with_otel\n\n        client._send_command_parse_response = mock.MagicMock(return_value=True)\n\n        client.execute_command(\"SET\", \"key\", \"value\")\n\n        call_args = operation_duration_mock.record.call_args\n        attrs = call_args[1][\"attributes\"]\n\n        # batch_size should not be present for single commands\n        assert \"db.operation.batch_size\" not in attrs\n\n    def test_retry_records_metrics_on_each_attempt(\n        self, mock_connection_pool, mock_meter\n    ):\n        \"\"\"\n        Test that when a command is retried, metrics are recorded\n        for each retry attempt with retry_attempts attribute.\n        \"\"\"\n        # Create connection with retry behavior\n        mock_connection = mock.MagicMock()\n        mock_connection.host = \"localhost\"\n        mock_connection.port = 6379\n        mock_connection.db = 0\n        mock_connection.should_reconnect.return_value = False\n\n        # Track retry attempts\n        attempt_count = [0]\n        max_retries = 2\n\n        def call_with_retry_impl(do, fail, is_retryable=None, with_failure_count=False):\n            \"\"\"Simulate retry behavior - fail twice, then succeed.\"\"\"\n            for attempt in range(max_retries + 1):\n                try:\n                    return do()\n                except redis.ConnectionError as e:\n                    attempt_count[0] += 1\n                    if attempt < max_retries:\n                        if with_failure_count:\n                            fail(e, attempt + 1)\n                        else:\n                            fail(e)\n                    else:\n                        raise\n\n        mock_connection.retry.call_with_retry = call_with_retry_impl\n        mock_connection.retry.get_retries.return_value = max_retries\n\n        mock_connection_pool.get_connection.return_value = mock_connection\n\n        recorder.reset_collector()\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND])\n\n        with mock.patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        with mock.patch.object(\n            recorder, \"_get_or_create_collector\", return_value=collector\n        ):\n            event_dispatcher = EventDispatcher()\n\n            client = redis.Redis(\n                connection_pool=mock_connection_pool,\n                event_dispatcher=event_dispatcher,\n            )\n\n            # Make command fail twice then succeed\n            call_count = [0]\n\n            def send_command_impl(*args, **kwargs):\n                call_count[0] += 1\n                if call_count[0] <= 2:\n                    raise redis.ConnectionError(\"Connection failed\")\n                return True\n\n            client._send_command_parse_response = mock.MagicMock(\n                side_effect=send_command_impl\n            )\n\n            # Execute command - should retry twice then succeed\n            client.execute_command(\"SET\", \"key\", \"value\")\n\n            # Verify histogram.record() was called 3 times:\n            # 2 retry attempts + 1 final success\n            assert self.operation_duration.record.call_count == 3\n\n            calls = self.operation_duration.record.call_args_list\n\n            # First two calls should have error.type (retry attempts)\n            assert \"error.type\" in calls[0][1][\"attributes\"]\n            assert \"error.type\" in calls[1][1][\"attributes\"]\n\n            # Last call should be success (no error.type)\n            assert \"error.type\" not in calls[2][1][\"attributes\"]\n\n        recorder.reset_collector()\n\n    def test_retry_exhausted_records_final_error_metrics(\n        self, mock_connection_pool, mock_meter\n    ):\n        \"\"\"\n        Test that when all retries are exhausted, metrics are recorded correctly:\n        - record_operation_duration is called for each retry attempt (with error info)\n        - record_error_count is called for the final error (after all retries exhausted)\n        \"\"\"\n        mock_connection = mock.MagicMock()\n        mock_connection.host = \"localhost\"\n        mock_connection.port = 6379\n        mock_connection.db = 0\n        mock_connection.should_reconnect.return_value = False\n\n        max_retries = 2\n\n        def call_with_retry_impl(do, fail, is_retryable=None, with_failure_count=False):\n            \"\"\"Simulate retry behavior - always fail.\"\"\"\n            for attempt in range(max_retries + 1):\n                try:\n                    return do()\n                except redis.ConnectionError as e:\n                    if attempt < max_retries:\n                        if with_failure_count:\n                            fail(e, attempt + 1)\n                        else:\n                            fail(e)\n                    else:\n                        raise\n\n        mock_connection.retry.call_with_retry = call_with_retry_impl\n        mock_connection.retry.get_retries.return_value = max_retries\n\n        mock_connection_pool.get_connection.return_value = mock_connection\n\n        recorder.reset_collector()\n        # Enable both COMMAND and RESILIENCY metric groups\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND, MetricGroup.RESILIENCY])\n\n        with mock.patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        with mock.patch.object(\n            recorder, \"_get_or_create_collector\", return_value=collector\n        ):\n            event_dispatcher = EventDispatcher()\n\n            client = redis.Redis(\n                connection_pool=mock_connection_pool,\n                event_dispatcher=event_dispatcher,\n            )\n\n            # Make command always fail\n            client._send_command_parse_response = mock.MagicMock(\n                side_effect=redis.ConnectionError(\"Connection failed\")\n            )\n\n            # Execute command - should fail after all retries\n            with pytest.raises(redis.ConnectionError):\n                client.execute_command(\"SET\", \"key\", \"value\")\n\n            # Verify histogram.record() was called 2 times (for retry attempts only)\n            # Final error does NOT call record_operation_duration\n            assert self.operation_duration.record.call_count == 2\n\n            calls = self.operation_duration.record.call_args_list\n\n            # Both retry calls should have error.type\n            for call in calls:\n                assert \"error.type\" in call[1][\"attributes\"]\n                assert call[1][\"attributes\"][\"db.operation.name\"] == \"SET\"\n\n            # Verify record_error_count was called once for the final error\n            self.client_errors.add.assert_called_once()\n            error_call_args = self.client_errors.add.call_args\n            error_attrs = error_call_args[1][\"attributes\"]\n            assert \"error.type\" in error_attrs\n\n        recorder.reset_collector()\n"
  },
  {
    "path": "tests/test_cluster.py",
    "content": "import binascii\nimport datetime\nimport select\nimport socket\nimport socketserver\nimport threading\nfrom typing import List\nimport warnings\nfrom queue import LifoQueue, Queue\nfrom time import sleep\nfrom unittest.mock import DEFAULT, Mock, call, patch\n\nimport pytest\nimport redis\nfrom redis import Redis\nfrom redis._parsers import CommandsParser\nfrom redis.backoff import (\n    ExponentialBackoff,\n    ExponentialWithJitterBackoff,\n    NoBackoff,\n)\nfrom redis.cluster import (\n    PRIMARY,\n    REDIS_CLUSTER_HASH_SLOTS,\n    REPLICA,\n    ClusterNode,\n    LoadBalancingStrategy,\n    NodesManager,\n    RedisCluster,\n    get_node_name,\n)\nfrom redis.commands.core import HotkeysMetricsTypes\nfrom redis.connection import BlockingConnectionPool, Connection, ConnectionPool\nfrom redis.crc import key_slot\nfrom redis.event import EventDispatcher\nfrom redis.exceptions import (\n    AskError,\n    ClusterDownError,\n    ConnectionError,\n    DataError,\n    MovedError,\n    NoPermissionError,\n    RedisClusterException,\n    RedisError,\n    ResponseError,\n    TimeoutError,\n)\nfrom redis.observability import recorder\nfrom redis.observability.config import OTelConfig, MetricGroup\nfrom redis.observability.metrics import RedisMetricsCollector\nfrom redis.retry import Retry\nfrom redis.utils import str_if_bytes\nfrom tests.test_pubsub import wait_for_message\n\nfrom .conftest import (\n    _get_client,\n    assert_resp_response,\n    is_resp2_connection,\n    skip_if_redis_enterprise,\n    skip_if_server_version_lt,\n    skip_unless_arch_bits,\n    wait_for_command,\n)\n\ndefault_host = \"127.0.0.1\"\ndefault_port = 7000\ndefault_cluster_slots = [\n    [0, 8191, [\"127.0.0.1\", 7000, \"node_0\"], [\"127.0.0.1\", 7003, \"node_3\"]],\n    [8192, 16383, [\"127.0.0.1\", 7001, \"node_1\"], [\"127.0.0.1\", 7002, \"node_2\"]],\n]\n\n\nclass ProxyRequestHandler(socketserver.BaseRequestHandler):\n    def recv(self, sock):\n        \"\"\"A recv with a timeout\"\"\"\n        r = select.select([sock], [], [], 0.01)\n        if not r[0]:\n            return None\n        return sock.recv(1000)\n\n    def handle(self):\n        self.server.proxy.n_connections += 1\n        conn = socket.create_connection(self.server.proxy.redis_addr)\n        stop = False\n\n        def from_server():\n            # read from server and pass to client\n            while not stop:\n                data = self.recv(conn)\n                if data is None:\n                    continue\n                if not data:\n                    self.request.shutdown(socket.SHUT_WR)\n                    return\n                self.request.sendall(data)\n\n        thread = threading.Thread(target=from_server)\n        thread.start()\n        try:\n            while True:\n                # read from client and send to server\n                data = self.request.recv(1000)\n                if not data:\n                    return\n                conn.sendall(data)\n        finally:\n            conn.shutdown(socket.SHUT_WR)\n            stop = True  # for safety\n            thread.join()\n            conn.close()\n\n\nclass NodeProxy:\n    \"\"\"A class to proxy a node connection to a different port\"\"\"\n\n    def __init__(self, addr, redis_addr):\n        self.addr = addr\n        self.redis_addr = redis_addr\n        self.server = socketserver.ThreadingTCPServer(\n            self.addr, ProxyRequestHandler, bind_and_activate=False\n        )\n        self.server.proxy = self\n        self.server.allow_reuse_address = True\n        self.server.server_bind()\n        self.server.server_activate()\n        self.thread = None\n        self.n_connections = 0\n\n    def start(self):\n        # test that we can connect to redis\n        s = socket.create_connection(self.redis_addr, timeout=2)\n        s.close()\n        # Start a thread with the server -- that thread will then start one\n        # more thread for each request\n        self.thread = threading.Thread(target=self.server.serve_forever)\n        # Exit the server thread when the main thread terminates\n        self.thread.daemon = True\n        self.thread.start()\n\n    def close(self):\n        self.server.shutdown()\n\n\n@pytest.fixture()\ndef slowlog(request, r):\n    \"\"\"\n    Set the slowlog threshold to 0, and the\n    max length to 128. This will force every\n    command into the slowlog and allow us\n    to test it\n    \"\"\"\n    # Save old values\n    current_config = r.config_get(target_nodes=r.get_primaries()[0])\n    old_slower_than_value = current_config[\"slowlog-log-slower-than\"]\n    old_max_legnth_value = current_config[\"slowlog-max-len\"]\n\n    # Function to restore the old values\n    def cleanup():\n        r.config_set(\"slowlog-log-slower-than\", old_slower_than_value)\n        r.config_set(\"slowlog-max-len\", old_max_legnth_value)\n\n    request.addfinalizer(cleanup)\n\n    # Set the new values\n    r.config_set(\"slowlog-log-slower-than\", 0)\n    r.config_set(\"slowlog-max-len\", 128)\n\n\ndef get_mocked_redis_client(\n    func=None, cluster_slots_raise_error=False, *args, **kwargs\n):\n    \"\"\"\n    Return a stable RedisCluster object that have deterministic\n    nodes and slots setup to remove the problem of different IP addresses\n    on different installations and machines.\n    \"\"\"\n    cluster_slots = kwargs.pop(\"cluster_slots\", default_cluster_slots)\n    coverage_res = kwargs.pop(\"coverage_result\", \"yes\")\n    cluster_enabled = kwargs.pop(\"cluster_enabled\", True)\n    with patch.object(Redis, \"execute_command\") as execute_command_mock:\n\n        def execute_command(*_args, **_kwargs):\n            if _args[0] == \"CLUSTER SLOTS\":\n                if cluster_slots_raise_error:\n                    raise ResponseError()\n                else:\n                    mock_cluster_slots = cluster_slots\n                    return mock_cluster_slots\n            elif _args[0] == \"COMMAND\":\n                return {\"get\": [], \"set\": []}\n            elif _args[0] == \"INFO\":\n                return {\"cluster_enabled\": cluster_enabled}\n            elif len(_args) > 1 and _args[1] == \"cluster-require-full-coverage\":\n                return {\"cluster-require-full-coverage\": coverage_res}\n            elif func is not None:\n                return func(*args, **kwargs)\n            else:\n                return execute_command_mock(*_args, **_kwargs)\n\n        execute_command_mock.side_effect = execute_command\n\n        with patch.object(\n            CommandsParser, \"initialize\", autospec=True\n        ) as cmd_parser_initialize:\n\n            def cmd_init_mock(self, r):\n                self.commands = {\n                    \"get\": {\n                        \"name\": \"get\",\n                        \"arity\": 2,\n                        \"flags\": [\"readonly\", \"fast\"],\n                        \"first_key_pos\": 1,\n                        \"last_key_pos\": 1,\n                        \"step_count\": 1,\n                    },\n                    \"cluster delslots\": {\n                        \"name\": \"cluster delslots\",\n                        \"flags\": [\"readonly\", \"fast\"],\n                        \"first_key_pos\": 0,\n                        \"last_key_pos\": 0,\n                        \"step_count\": 0,\n                    },\n                    \"cluster delslotsrange\": {\n                        \"name\": \"cluster delslotsrange\",\n                        \"flags\": [\"readonly\", \"fast\"],\n                        \"first_key_pos\": 0,\n                        \"last_key_pos\": 0,\n                        \"step_count\": 0,\n                    },\n                    \"cluster addslots\": {\n                        \"name\": \"cluster delslotsrange\",\n                        \"flags\": [\"readonly\", \"fast\"],\n                        \"first_key_pos\": 0,\n                        \"last_key_pos\": 0,\n                        \"step_count\": 0,\n                    },\n                }\n\n            cmd_parser_initialize.side_effect = cmd_init_mock\n\n            # Create a subclass of RedisCluster that overrides __del__\n            class MockedRedisCluster(RedisCluster):\n                def __del__(self):\n                    # Override to prevent connection cleanup attempts\n                    pass\n\n                @property\n                def connection_pool(self):\n                    # Required abstract property implementation\n                    return self.nodes_manager.get_default_node().redis_connection.connection_pool\n\n            return MockedRedisCluster(*args, **kwargs)\n\n\ndef mock_node_resp(node, response):\n    connection = Mock()\n    connection.read_response.return_value = response\n    node.redis_connection.connection = connection\n    return node\n\n\ndef mock_node_resp_func(node, func):\n    connection = Mock()\n    connection.read_response.side_effect = func\n    node.redis_connection.connection = connection\n    return node\n\n\ndef mock_all_nodes_resp(rc, response):\n    for node in rc.get_nodes():\n        mock_node_resp(node, response)\n    return rc\n\n\ndef find_node_ip_based_on_port(cluster_client, port):\n    for node in cluster_client.get_nodes():\n        if node.port == port:\n            return node.host\n\n\ndef moved_redirection_helper(request, failover=False, circular_moved=False):\n    \"\"\"\n    Test that the client correctly handles MOVED responses in the following scenarios:\n    1.\tSlot migration to a different shard (failover=False, circular_moved=False) —\n        a standard slot move between shards.\n    2.\tFailover event (failover=True, circular_moved=False) —\n        the redirect target is a replica that has just been promoted to primary.\n    3.\tCircular MOVED (failover=False, circular_moved=True) —\n        the redirect points to a node already known to be the primary of its shard.\n\n    At first call it should return a MOVED ResponseError that will point\n    the client to the next server it should talk to.\n\n    Verify that:\n    1. it tries to talk to the redirected node\n    2. it updates the slot's primary to the redirected node, if required\n\n    For a failover, also verify:\n    3. the redirected node's server type updated to 'primary'\n    4. the server type of the previous slot owner updated to 'replica'\n    \"\"\"\n    rc = _get_client(RedisCluster, request, flushdb=False)\n    slot = 12182\n    redirect_node = None\n    # Get the current primary that holds this slot\n    prev_primary = rc.nodes_manager.get_node_from_slot(slot)\n    if failover:\n        if len(rc.nodes_manager.slots_cache[slot]) < 2:\n            warnings.warn(\"Skipping this test since it requires to have a replica\")\n            return\n        redirect_node = rc.nodes_manager.slots_cache[slot][1]\n    elif circular_moved:\n        redirect_node = prev_primary\n    else:\n        # Use one of the other primaries to be the redirected node\n        redirect_node = rc.get_primaries()[0]\n    r_host = redirect_node.host\n    r_port = redirect_node.port\n    with patch.object(Redis, \"parse_response\") as parse_response:\n\n        def moved_redirect_effect(connection, *args, **options):\n            def ok_response(connection, *args, **options):\n                assert connection.host == r_host\n                assert connection.port == r_port\n\n                return \"MOCK_OK\"\n\n            parse_response.side_effect = ok_response\n            raise MovedError(f\"{slot} {r_host}:{r_port}\")\n\n        parse_response.side_effect = moved_redirect_effect\n        assert rc.execute_command(\"SET\", \"foo\", \"bar\") == \"MOCK_OK\"\n        slot_primary = rc.nodes_manager.slots_cache[slot][0]\n        assert slot_primary == redirect_node\n        if failover:\n            assert rc.get_node(host=r_host, port=r_port).server_type == PRIMARY\n            assert prev_primary.server_type == REPLICA\n        elif circular_moved:\n            fetched_node = rc.get_node(host=r_host, port=r_port)\n            assert fetched_node == prev_primary\n            assert fetched_node.server_type == PRIMARY\n\n\n@pytest.mark.onlycluster\nclass TestRedisClusterObj:\n    \"\"\"\n    Tests for the RedisCluster class\n    \"\"\"\n\n    def test_host_port_startup_node(self):\n        \"\"\"\n        Test that it is possible to use host & port arguments as startup node\n        args\n        \"\"\"\n        cluster = get_mocked_redis_client(host=default_host, port=default_port)\n        assert cluster.get_node(host=default_host, port=default_port) is not None\n\n    def test_startup_nodes(self):\n        \"\"\"\n        Test that it is possible to use startup_nodes\n        argument to init the cluster\n        \"\"\"\n        port_1 = 7000\n        port_2 = 7001\n        startup_nodes = [\n            ClusterNode(default_host, port_1),\n            ClusterNode(default_host, port_2),\n        ]\n        cluster = get_mocked_redis_client(startup_nodes=startup_nodes)\n        assert (\n            cluster.get_node(host=default_host, port=port_1) is not None\n            and cluster.get_node(host=default_host, port=port_2) is not None\n        )\n\n    def test_empty_startup_nodes(self):\n        \"\"\"\n        Test that exception is raised when empty providing empty startup_nodes\n        \"\"\"\n        with pytest.raises(RedisClusterException) as ex:\n            RedisCluster(startup_nodes=[])\n\n        assert str(ex.value).startswith(\n            \"RedisCluster requires at least one node to discover the cluster\"\n        ), str_if_bytes(ex.value)\n\n    def test_from_url(self, r):\n        redis_url = f\"redis://{default_host}:{default_port}/0\"\n        with patch.object(RedisCluster, \"from_url\") as from_url:\n\n            def from_url_mocked(_url, **_kwargs):\n                return get_mocked_redis_client(url=_url, **_kwargs)\n\n            from_url.side_effect = from_url_mocked\n            cluster = RedisCluster.from_url(redis_url)\n        assert cluster.get_node(host=default_host, port=default_port) is not None\n\n    def test_execute_command_errors(self, r):\n        \"\"\"\n        Test that if no key is provided then exception should be raised.\n        \"\"\"\n        with pytest.raises(RedisClusterException) as ex:\n            r.execute_command(\"GET\")\n        assert str(ex.value).startswith(\n            \"No way to dispatch this command to Redis Cluster. Missing key.\"\n        )\n\n    def test_execute_command_node_flag_primaries(self, r):\n        \"\"\"\n        Test command execution with nodes flag PRIMARIES\n        \"\"\"\n        primaries = r.get_primaries()\n        replicas = r.get_replicas()\n        mock_all_nodes_resp(r, \"PONG\")\n        assert r.ping(target_nodes=RedisCluster.PRIMARIES) is True\n        for primary in primaries:\n            conn = primary.redis_connection.connection\n            assert conn.read_response.called is True\n        for replica in replicas:\n            conn = replica.redis_connection.connection\n            assert conn.read_response.called is not True\n\n    def test_execute_command_node_flag_replicas(self, r):\n        \"\"\"\n        Test command execution with nodes flag REPLICAS\n        \"\"\"\n        replicas = r.get_replicas()\n        if not replicas:\n            r = get_mocked_redis_client(default_host, default_port)\n        primaries = r.get_primaries()\n        mock_all_nodes_resp(r, \"PONG\")\n        assert r.ping(target_nodes=RedisCluster.REPLICAS) is True\n        for replica in replicas:\n            conn = replica.redis_connection.connection\n            assert conn.read_response.called is True\n        for primary in primaries:\n            conn = primary.redis_connection.connection\n            assert conn.read_response.called is not True\n\n    def test_execute_command_node_flag_all_nodes(self, r):\n        \"\"\"\n        Test command execution with nodes flag ALL_NODES\n        \"\"\"\n        mock_all_nodes_resp(r, \"PONG\")\n        assert r.ping(target_nodes=RedisCluster.ALL_NODES) is True\n        for node in r.get_nodes():\n            conn = node.redis_connection.connection\n            assert conn.read_response.called is True\n\n    def test_execute_command_node_flag_random(self, r):\n        \"\"\"\n        Test command execution with nodes flag RANDOM\n        \"\"\"\n        mock_all_nodes_resp(r, \"PONG\")\n        assert r.ping(target_nodes=RedisCluster.RANDOM) is True\n        called_count = 0\n        for node in r.get_nodes():\n            conn = node.redis_connection.connection\n            if conn.read_response.called is True:\n                called_count += 1\n        assert called_count == 1\n\n    def test_execute_command_default_node(self, r):\n        \"\"\"\n        Test command execution without node flag is being executed on the\n        default node\n        \"\"\"\n        def_node = r.get_default_node()\n        mock_node_resp(def_node, \"PONG\")\n        assert r.ping() is True\n        conn = def_node.redis_connection.connection\n        assert conn.read_response.called\n\n    def test_ask_redirection(self, r):\n        \"\"\"\n        Test that the server handles ASK response.\n\n        At first call it should return a ASK ResponseError that will point\n        the client to the next server it should talk to.\n\n        Important thing to verify is that it tries to talk to the second node.\n        \"\"\"\n        redirect_node = r.get_nodes()[0]\n        with patch.object(Redis, \"parse_response\") as parse_response:\n\n            def ask_redirect_effect(connection, *args, **options):\n                def ok_response(connection, *args, **options):\n                    assert connection.host == redirect_node.host\n                    assert connection.port == redirect_node.port\n\n                    return \"MOCK_OK\"\n\n                parse_response.side_effect = ok_response\n                raise AskError(f\"12182 {redirect_node.host}:{redirect_node.port}\")\n\n            parse_response.side_effect = ask_redirect_effect\n\n            assert r.execute_command(\"SET\", \"foo\", \"bar\") == \"MOCK_OK\"\n\n    def test_handling_cluster_failover_to_a_replica(self, r):\n        # Set the key we'll test for\n        key = \"key\"\n        r.set(\"key\", \"value\")\n        primary = r.get_node_from_key(key, replica=False)\n        assert str_if_bytes(r.get(\"key\")) == \"value\"\n        # Get the current output of cluster slots\n        cluster_slots = primary.redis_connection.execute_command(\"CLUSTER SLOTS\")\n        replica_host = \"\"\n        replica_port = 0\n        # Replace one of the replicas to be the new primary based on the\n        # cluster slots output\n        for slot_range in cluster_slots:\n            primary_port = slot_range[2][1]\n            if primary_port == primary.port:\n                if len(slot_range) <= 3:\n                    # cluster doesn't have a replica, return\n                    return\n                replica_host = str_if_bytes(slot_range[3][0])\n                replica_port = slot_range[3][1]\n                # replace replica and primary in the cluster slots output\n                tmp_node = slot_range[2]\n                slot_range[2] = slot_range[3]\n                slot_range[3] = tmp_node\n                break\n\n        def raise_connection_error():\n            raise ConnectionError(\"error\")\n\n        def mock_execute_command(*_args, **_kwargs):\n            if _args[0] == \"CLUSTER SLOTS\":\n                return cluster_slots\n            else:\n                raise Exception(\"Failed to mock cluster slots\")\n\n        # Mock connection error for the current primary\n        mock_node_resp_func(primary, raise_connection_error)\n        primary.redis_connection.set_retry(Retry(NoBackoff(), 1))\n\n        # Mock the cluster slots response for all other nodes\n        redis_mock_node = Mock()\n        redis_mock_node.execute_command.side_effect = mock_execute_command\n        # Mock response value for all other commands\n        redis_mock_node.parse_response.return_value = \"MOCK_OK\"\n        for node in r.get_nodes():\n            if node.port != primary.port:\n                node.redis_connection = redis_mock_node\n\n        assert r.get(key) == \"MOCK_OK\"\n        new_primary = r.get_node_from_key(key, replica=False)\n        assert new_primary.host == replica_host\n        assert new_primary.port == replica_port\n        assert r.get_node(primary.host, primary.port).server_type == REPLICA\n\n    def test_moved_redirection(self, request):\n        \"\"\"\n        Test that the client handles MOVED response.\n        \"\"\"\n        moved_redirection_helper(request, failover=False)\n\n    def test_moved_redirection_after_failover(self, request):\n        \"\"\"\n        Test that the client handles MOVED response after a failover.\n        \"\"\"\n        moved_redirection_helper(request, failover=True)\n\n    def test_moved_redirection_circular_moved(self, request):\n        \"\"\"\n        Verify that the client does not update its slot map when receiving a circular MOVED response\n        (i.e., a MOVED redirect pointing back to the same node), and retries again the same node.\n        \"\"\"\n        moved_redirection_helper(request, failover=False, circular_moved=True)\n\n    def test_refresh_using_specific_nodes(self, request):\n        \"\"\"\n        Test making calls on specific nodes when the cluster has failed over to\n        another node\n        \"\"\"\n        node_7006 = ClusterNode(host=default_host, port=7006, server_type=PRIMARY)\n        node_7007 = ClusterNode(host=default_host, port=7007, server_type=PRIMARY)\n        with patch.object(Redis, \"parse_response\") as parse_response:\n            with patch.object(NodesManager, \"initialize\", autospec=True) as initialize:\n                with patch.multiple(\n                    Connection, send_command=DEFAULT, connect=DEFAULT, can_read=DEFAULT\n                ) as mocks:\n                    # simulate 7006 as a failed node\n                    def parse_response_mock(connection, command_name, **options):\n                        if connection.port == 7006:\n                            parse_response.failed_calls += 1\n                            raise ClusterDownError(\n                                \"CLUSTERDOWN The cluster is \"\n                                \"down. Use CLUSTER INFO for \"\n                                \"more information\"\n                            )\n                        elif connection.port == 7007:\n                            parse_response.successful_calls += 1\n\n                    def initialize_mock(self):\n                        # start with all slots mapped to 7006\n                        self.nodes_cache = {node_7006.name: node_7006}\n                        self.default_node = node_7006\n                        self.slots_cache = {}\n\n                        for i in range(0, 16383):\n                            self.slots_cache[i] = [node_7006]\n\n                        # After the first connection fails, a reinitialize\n                        # should follow the cluster to 7007\n                        def map_7007(self):\n                            self.nodes_cache = {node_7007.name: node_7007}\n                            self.default_node = node_7007\n                            self.slots_cache = {}\n\n                            for i in range(0, 16383):\n                                self.slots_cache[i] = [node_7007]\n\n                        # Change initialize side effect for the second call\n                        initialize.side_effect = map_7007\n\n                    parse_response.side_effect = parse_response_mock\n                    parse_response.successful_calls = 0\n                    parse_response.failed_calls = 0\n                    initialize.side_effect = initialize_mock\n                    mocks[\"can_read\"].return_value = False\n                    mocks[\"send_command\"].return_value = \"MOCK_OK\"\n                    mocks[\"connect\"].return_value = None\n                    with patch.object(\n                        CommandsParser, \"initialize\", autospec=True\n                    ) as cmd_parser_initialize:\n\n                        def cmd_init_mock(self, r):\n                            self.commands = {\n                                \"get\": {\n                                    \"name\": \"get\",\n                                    \"arity\": 2,\n                                    \"flags\": [\"readonly\", \"fast\"],\n                                    \"first_key_pos\": 1,\n                                    \"last_key_pos\": 1,\n                                    \"step_count\": 1,\n                                }\n                            }\n\n                        cmd_parser_initialize.side_effect = cmd_init_mock\n\n                        rc = _get_client(RedisCluster, request, flushdb=False)\n                        assert len(rc.get_nodes()) == 1\n                        assert rc.get_node(node_name=node_7006.name) is not None\n\n                        rc.get(\"foo\")\n\n                        # Cluster should now point to 7007, and there should be\n                        # one failed and one successful call\n                        assert len(rc.get_nodes()) == 1\n                        assert rc.get_node(node_name=node_7007.name) is not None\n                        assert rc.get_node(node_name=node_7006.name) is None\n                        assert parse_response.failed_calls == 1\n                        assert parse_response.successful_calls == 1\n\n    @pytest.mark.parametrize(\n        \"read_from_replicas,load_balancing_strategy,mocks_srv_ports\",\n        [\n            (True, None, [7001, 7002, 7001]),\n            (True, LoadBalancingStrategy.ROUND_ROBIN, [7001, 7002, 7001]),\n            (True, LoadBalancingStrategy.ROUND_ROBIN_REPLICAS, [7002, 7002, 7002]),\n            (True, LoadBalancingStrategy.RANDOM_REPLICA, [7002, 7002, 7002]),\n            (False, LoadBalancingStrategy.ROUND_ROBIN, [7001, 7002, 7001]),\n            (False, LoadBalancingStrategy.ROUND_ROBIN_REPLICAS, [7002, 7002, 7002]),\n            (False, LoadBalancingStrategy.RANDOM_REPLICA, [7002, 7002, 7002]),\n        ],\n    )\n    def test_reading_with_load_balancing_strategies(\n        self,\n        read_from_replicas: bool,\n        load_balancing_strategy: LoadBalancingStrategy,\n        mocks_srv_ports: List[int],\n    ):\n        with patch.multiple(\n            Connection,\n            send_command=DEFAULT,\n            read_response=DEFAULT,\n            _connect=DEFAULT,\n            can_read=DEFAULT,\n            on_connect=DEFAULT,\n        ) as mocks:\n            with patch.object(Redis, \"parse_response\") as parse_response:\n\n                def parse_response_mock_first(connection, *args, **options):\n                    # Primary\n                    assert connection.port == mocks_srv_ports[0]\n                    parse_response.side_effect = parse_response_mock_second\n                    return \"MOCK_OK\"\n\n                def parse_response_mock_second(connection, *args, **options):\n                    # Replica\n                    assert connection.port == mocks_srv_ports[1]\n                    parse_response.side_effect = parse_response_mock_third\n                    return \"MOCK_OK\"\n\n                def parse_response_mock_third(connection, *args, **options):\n                    # Primary\n                    assert connection.port == mocks_srv_ports[2]\n                    return \"MOCK_OK\"\n\n                # We don't need to create a real cluster connection but we\n                # do want RedisCluster.on_connect function to get called,\n                # so we'll mock some of the Connection's functions to allow it\n                parse_response.side_effect = parse_response_mock_first\n                mocks[\"send_command\"].return_value = True\n                mocks[\"read_response\"].return_value = \"OK\"\n                mocks[\"_connect\"].return_value = True\n                mocks[\"can_read\"].return_value = False\n                mocks[\"on_connect\"].return_value = True\n\n                # Create a cluster with reading from replications\n                read_cluster = get_mocked_redis_client(\n                    host=default_host,\n                    port=default_port,\n                    read_from_replicas=read_from_replicas,\n                    load_balancing_strategy=load_balancing_strategy,\n                )\n                assert read_cluster.read_from_replicas is read_from_replicas\n                assert read_cluster.load_balancing_strategy is load_balancing_strategy\n                # Check that we read from the slot's nodes in a round robin\n                # matter.\n                # 'foo' belongs to slot 12182 and the slot's nodes are:\n                # [(127.0.0.1,7001,primary), (127.0.0.1,7002,replica)]\n                read_cluster.get(\"foo\")\n                read_cluster.get(\"foo\")\n                read_cluster.get(\"foo\")\n                expected_calls_list = []\n                expected_calls_list.append(call(\"READONLY\"))\n                expected_calls_list.append(call(\"GET\", \"foo\", keys=[\"foo\"]))\n\n                if (\n                    load_balancing_strategy is None\n                    or load_balancing_strategy == LoadBalancingStrategy.ROUND_ROBIN\n                ):\n                    # in the round robin strategy the primary node can also receive read\n                    # requests and this means that there will be second node connected\n                    expected_calls_list.append(call(\"READONLY\"))\n\n                expected_calls_list.extend(\n                    [\n                        call(\"GET\", \"foo\", keys=[\"foo\"]),\n                        call(\"GET\", \"foo\", keys=[\"foo\"]),\n                    ]\n                )\n\n                mocks[\"send_command\"].assert_has_calls(expected_calls_list)\n\n    def test_keyslot(self, r):\n        \"\"\"\n        Test that method will compute correct key in all supported cases\n        \"\"\"\n        assert r.keyslot(\"foo\") == 12182\n        assert r.keyslot(\"{foo}bar\") == 12182\n        assert r.keyslot(\"{foo}\") == 12182\n        assert r.keyslot(1337) == 4314\n\n        assert r.keyslot(125) == r.keyslot(b\"125\")\n        assert r.keyslot(125) == r.keyslot(\"\\x31\\x32\\x35\")\n        assert r.keyslot(\"大奖\") == r.keyslot(b\"\\xe5\\xa4\\xa7\\xe5\\xa5\\x96\")\n        assert r.keyslot(\"大奖\") == r.keyslot(b\"\\xe5\\xa4\\xa7\\xe5\\xa5\\x96\")\n        assert r.keyslot(1337.1234) == r.keyslot(\"1337.1234\")\n        assert r.keyslot(1337) == r.keyslot(\"1337\")\n        assert r.keyslot(b\"abc\") == r.keyslot(\"abc\")\n\n    def test_get_node_name(self):\n        assert (\n            get_node_name(default_host, default_port)\n            == f\"{default_host}:{default_port}\"\n        )\n\n    def test_all_nodes(self, r):\n        \"\"\"\n        Set a list of nodes and it should be possible to iterate over all\n        \"\"\"\n        nodes = [node for node in r.nodes_manager.nodes_cache.values()]\n\n        for i, node in enumerate(r.get_nodes()):\n            assert node in nodes\n\n    def test_all_nodes_masters(self, r):\n        \"\"\"\n        Set a list of nodes with random primaries/replicas config and it shold\n        be possible to iterate over all of them.\n        \"\"\"\n        nodes = [\n            node\n            for node in r.nodes_manager.nodes_cache.values()\n            if node.server_type == PRIMARY\n        ]\n\n        for node in r.get_primaries():\n            assert node in nodes\n\n    @pytest.mark.parametrize(\"error\", RedisCluster.ERRORS_ALLOW_RETRY)\n    def test_cluster_down_overreaches_retry_attempts(self, error):\n        \"\"\"\n        When error that allows retry is thrown, test that we retry executing\n        the command as many times as configured in cluster_error_retry_attempts\n        and then raise the exception\n        \"\"\"\n        with patch.object(RedisCluster, \"_execute_command\") as execute_command:\n\n            def raise_error(target_node, *args, **kwargs):\n                execute_command.failed_calls += 1\n                raise error(\"mocked error\")\n\n            execute_command.side_effect = raise_error\n\n            rc = get_mocked_redis_client(host=default_host, port=default_port)\n\n            with pytest.raises(error):\n                rc.get(\"bar\")\n                assert execute_command.failed_calls == rc.cluster_error_retry_attempts\n\n    def test_user_on_connect_function(self, request):\n        \"\"\"\n        Test support in passing on_connect function by the user\n        \"\"\"\n\n        def on_connect(connection):\n            assert connection is not None\n\n        mock = Mock(side_effect=on_connect)\n\n        _get_client(RedisCluster, request, redis_connect_func=mock)\n        assert mock.called is True\n\n    def test_user_connection_pool_timeout(self, request):\n        \"\"\"\n        Test support in passing timeout value by the user when setting\n        up a RedisCluster with a BlockingConnectionPool\n        \"\"\"\n\n        timeout = 3\n        client = _get_client(\n            RedisCluster,\n            request,\n            timeout=timeout,\n            connection_pool_class=redis.BlockingConnectionPool,\n        )\n        for _, node_config in client.nodes_manager.startup_nodes.items():\n            assert node_config.redis_connection.connection_pool.timeout == timeout\n\n    def test_set_default_node_success(self, r):\n        \"\"\"\n        test successful replacement of the default cluster node\n        \"\"\"\n        default_node = r.get_default_node()\n        # get a different node\n        new_def_node = None\n        for node in r.get_nodes():\n            if node != default_node:\n                new_def_node = node\n                break\n        assert r.set_default_node(new_def_node) is True\n        assert r.get_default_node() == new_def_node\n\n    def test_set_default_node_failure(self, r):\n        \"\"\"\n        test failed replacement of the default cluster node\n        \"\"\"\n        default_node = r.get_default_node()\n        new_def_node = ClusterNode(\"1.1.1.1\", 1111)\n        assert r.set_default_node(None) is False\n        assert r.set_default_node(new_def_node) is False\n        assert r.get_default_node() == default_node\n\n    def test_get_node_from_key(self, r):\n        \"\"\"\n        Test that get_node_from_key function returns the correct node\n        \"\"\"\n        key = \"bar\"\n        slot = r.keyslot(key)\n        slot_nodes = r.nodes_manager.slots_cache.get(slot)\n        primary = slot_nodes[0]\n        assert r.get_node_from_key(key, replica=False) == primary\n        replica = r.get_node_from_key(key, replica=True)\n        if replica is not None:\n            assert replica.server_type == REPLICA\n            assert replica in slot_nodes\n\n    @skip_if_redis_enterprise()\n    def test_not_require_full_coverage_cluster_down_error(self, r):\n        \"\"\"\n        When require_full_coverage is set to False (default client config) and not\n        all slots are covered, if one of the nodes has 'cluster-require_full_coverage'\n        config set to 'yes' some key-based commands should throw ClusterDownError\n        \"\"\"\n        node = r.get_node_from_key(\"foo\")\n        missing_slot = r.keyslot(\"foo\")\n        assert r.set(\"foo\", \"bar\") is True\n        try:\n            assert all(r.cluster_delslots(missing_slot))\n            with pytest.raises(ClusterDownError):\n                r.exists(\"foo\")\n        except ResponseError as e:\n            assert \"CLUSTERDOWN\" in str(e)\n        finally:\n            try:\n                # Add back the missing slot\n                assert r.cluster_addslots(node, missing_slot) is True\n                # Make sure we are not getting ClusterDownError anymore\n                assert r.exists(\"foo\") == 1\n            except ResponseError as e:\n                if f\"Slot {missing_slot} is already busy\" in str(e):\n                    # It can happen if the test failed to delete this slot\n                    pass\n                else:\n                    raise e\n\n    def test_timeout_error_topology_refresh_reuse_connections(self, r):\n        \"\"\"\n        By mucking TIMEOUT errors, we'll force the cluster topology to be reinitialized,\n        and then ensure that only the impacted connection is replaced\n        \"\"\"\n        node = r.get_node_from_key(\"key\")\n        r.set(\"key\", \"value\")\n        node_conn_origin = {}\n        for n in r.get_nodes():\n            node_conn_origin[n.name] = n.redis_connection\n        real_func = r.get_redis_connection(node).parse_response\n\n        class counter:\n            def __init__(self, val=0):\n                self.val = int(val)\n\n        count = counter(0)\n        with patch.object(Redis, \"parse_response\") as parse_response:\n\n            def moved_redirect_effect(connection, *args, **options):\n                # raise a timeout for 5 times so we'll need to reinitialize the topology\n                if count.val == 4:\n                    parse_response.side_effect = real_func\n                count.val += 1\n                raise TimeoutError()\n\n            parse_response.side_effect = moved_redirect_effect\n            assert r.get(\"key\") == b\"value\"\n            for node_name, conn in node_conn_origin.items():\n                # all nodes' redis connection should have been reused during the\n                # topology refresh\n                # even the failing node doesn't need to establish a\n                # new Redis connection (which is actually a new Redis Client instance)\n                # but the connection pool is reused and all connections are reset and reconnected\n                cur_node = r.get_node(node_name=node_name)\n                assert conn == r.get_redis_connection(cur_node)\n\n    def test_cluster_get_set_retry_object(self, request):\n        retry = Retry(NoBackoff(), 2)\n        r = _get_client(RedisCluster, request, retry=retry)\n        assert r.retry.get_retries() == retry.get_retries()\n        assert isinstance(r.retry._backoff, NoBackoff)\n        for node in r.get_nodes():\n            assert node.redis_connection.get_retry().get_retries() == 0\n            assert isinstance(node.redis_connection.get_retry()._backoff, NoBackoff)\n        rand_node = r.get_random_node()\n        existing_conn = rand_node.redis_connection.connection_pool.get_connection()\n        # Change retry policy\n        new_retry = Retry(ExponentialBackoff(), 3)\n        r.set_retry(new_retry)\n        assert r.retry.get_retries() == new_retry.get_retries()\n        assert isinstance(r.retry._backoff, ExponentialBackoff)\n        for node in r.get_nodes():\n            assert node.redis_connection.get_retry()._retries == 0\n            assert isinstance(node.redis_connection.get_retry()._backoff, NoBackoff)\n        assert existing_conn.retry._retries == 0\n        new_conn = rand_node.redis_connection.connection_pool.get_connection()\n        assert new_conn.retry._retries == 0\n\n    def test_cluster_retry_object(self, r) -> None:\n        # Test default retry\n        # FIXME: Workaround for https://github.com/redis/redis-py/issues/3030\n        host = r.get_default_node().host\n\n        # test default retry config\n        retry = r.retry\n        assert isinstance(retry, Retry)\n        assert retry.get_retries() == 3\n        assert isinstance(retry._backoff, type(ExponentialWithJitterBackoff()))\n        node1_connection = r.get_node(host, 16379).redis_connection\n        node2_connection = r.get_node(host, 16380).redis_connection\n        assert node1_connection.get_retry()._retries == 0\n        assert node2_connection.get_retry()._retries == 0\n\n        # Test custom retry is not applied to nodes\n        retry = Retry(ExponentialBackoff(10, 5), 5)\n        rc_custom_retry = RedisCluster(host, 16379, retry=retry)\n        assert (\n            rc_custom_retry.get_node(host, 16379)\n            .redis_connection.get_retry()\n            .get_retries()\n            == 0\n        )\n\n    def test_replace_cluster_node(self, r) -> None:\n        prev_default_node = r.get_default_node()\n        r.replace_default_node()\n        assert r.get_default_node() != prev_default_node\n        r.replace_default_node(prev_default_node)\n        assert r.get_default_node() == prev_default_node\n\n    def test_default_node_is_replaced_after_exception(self, r):\n        curr_default_node = r.get_default_node()\n        # CLUSTER NODES command is being executed on the default node\n        nodes = r.cluster_nodes()\n        assert \"myself\" in nodes.get(curr_default_node.name).get(\"flags\")\n\n        def raise_connection_error():\n            raise ConnectionError(\"error\")\n\n        # Mock connection error for the default node\n        mock_node_resp_func(curr_default_node, raise_connection_error)\n        # Test that the command succeed from a different node\n        nodes = r.cluster_nodes()\n        assert \"myself\" not in nodes.get(curr_default_node.name).get(\"flags\")\n        assert r.get_default_node() != curr_default_node\n\n    def test_address_remap(self, request, master_host):\n        \"\"\"Test that we can create a rediscluster object with\n        a host-port remapper and map connections through proxy objects\n        \"\"\"\n\n        # we remap the first n nodes\n        offset = 1000\n        n = 6\n        hostname, master_port = master_host\n        ports = [master_port + i for i in range(n)]\n\n        def address_remap(address):\n            # remap first three nodes to our local proxy\n            # old = host, port\n            host, port = address\n            if int(port) in ports:\n                host, port = \"127.0.0.1\", int(port) + offset\n            # print(f\"{old} {host, port}\")\n            return host, port\n\n        # create the proxies\n        proxies = [\n            NodeProxy((\"127.0.0.1\", port + offset), (hostname, port)) for port in ports\n        ]\n        for p in proxies:\n            p.start()\n        try:\n            # create cluster:\n            r = _get_client(\n                RedisCluster, request, flushdb=False, address_remap=address_remap\n            )\n            try:\n                assert r.ping() is True\n                assert r.set(\"byte_string\", b\"giraffe\")\n                assert r.get(\"byte_string\") == b\"giraffe\"\n            finally:\n                r.close()\n        finally:\n            for p in proxies:\n                p.close()\n\n        # verify that the proxies were indeed used\n        n_used = sum((1 if p.n_connections else 0) for p in proxies)\n        assert n_used > 1\n\n\n@pytest.mark.onlycluster\nclass TestClusterRedisCommands:\n    \"\"\"\n    Tests for RedisCluster unique commands\n    \"\"\"\n\n    def test_case_insensitive_command_names(self, r):\n        assert (\n            r.cluster_response_callbacks[\"cluster slots\"]\n            == r.cluster_response_callbacks[\"CLUSTER SLOTS\"]\n        )\n\n    def test_get_and_set(self, r):\n        # get and set can't be tested independently of each other\n        assert r.get(\"a\") is None\n        byte_string = b\"value\"\n        integer = 5\n        unicode_string = chr(3456) + \"abcd\" + chr(3421)\n        assert r.set(\"byte_string\", byte_string)\n        assert r.set(\"integer\", 5)\n        assert r.set(\"unicode_string\", unicode_string)\n        assert r.get(\"byte_string\") == byte_string\n        assert r.get(\"integer\") == str(integer).encode()\n        assert r.get(\"unicode_string\").decode(\"utf-8\") == unicode_string\n\n    @pytest.mark.parametrize(\n        \"load_balancing_strategy\",\n        [\n            LoadBalancingStrategy.ROUND_ROBIN,\n            LoadBalancingStrategy.ROUND_ROBIN_REPLICAS,\n            LoadBalancingStrategy.RANDOM_REPLICA,\n        ],\n    )\n    def test_get_and_set_with_load_balanced_client(\n        self, request, load_balancing_strategy: LoadBalancingStrategy\n    ) -> None:\n        r = _get_client(\n            cls=RedisCluster,\n            request=request,\n            load_balancing_strategy=load_balancing_strategy,\n        )\n\n        # get and set can't be tested independently of each other\n        assert r.get(\"a\") is None\n\n        byte_string = b\"value\"\n        assert r.set(\"byte_string\", byte_string)\n\n        # run the get command for the same key several times\n        # to iterate over the read nodes\n        assert r.get(\"byte_string\") == byte_string\n        assert r.get(\"byte_string\") == byte_string\n        assert r.get(\"byte_string\") == byte_string\n\n    def test_mget_nonatomic(self, r):\n        assert r.mget_nonatomic([]) == []\n        assert r.mget_nonatomic([\"a\", \"b\"]) == [None, None]\n        r[\"a\"] = \"1\"\n        r[\"b\"] = \"2\"\n        r[\"c\"] = \"3\"\n\n        assert r.mget_nonatomic(\"a\", \"other\", \"b\", \"c\") == [b\"1\", None, b\"2\", b\"3\"]\n\n    def test_mset_nonatomic(self, r):\n        d = {\"a\": b\"1\", \"b\": b\"2\", \"c\": b\"3\", \"d\": b\"4\"}\n        assert r.mset_nonatomic(d)\n        for k, v in d.items():\n            assert r[k] == v\n\n    def test_config_set(self, r):\n        assert r.config_set(\"slowlog-log-slower-than\", 0)\n\n    def test_cluster_config_resetstat(self, r):\n        r.ping(target_nodes=\"all\")\n        all_info = r.info(target_nodes=\"all\")\n        prior_commands_processed = -1\n        for node_info in all_info.values():\n            prior_commands_processed = node_info[\"total_commands_processed\"]\n            assert prior_commands_processed >= 1\n        r.config_resetstat(target_nodes=\"all\")\n        all_info = r.info(target_nodes=\"all\")\n        for node_info in all_info.values():\n            reset_commands_processed = node_info[\"total_commands_processed\"]\n            assert reset_commands_processed < prior_commands_processed\n\n    def test_client_setname(self, r):\n        node = r.get_random_node()\n        r.client_setname(\"redis_py_test\", target_nodes=node)\n        client_name = r.client_getname(target_nodes=node)\n        assert_resp_response(r, client_name, \"redis_py_test\", b\"redis_py_test\")\n\n    def test_exists(self, r):\n        d = {\"a\": b\"1\", \"b\": b\"2\", \"c\": b\"3\", \"d\": b\"4\"}\n        r.mset_nonatomic(d)\n        assert r.exists(*d.keys()) == len(d)\n\n    def test_delete(self, r):\n        d = {\"a\": b\"1\", \"b\": b\"2\", \"c\": b\"3\", \"d\": b\"4\"}\n        r.mset_nonatomic(d)\n        assert r.delete(*d.keys()) == len(d)\n        assert r.delete(*d.keys()) == 0\n\n    def test_touch(self, r):\n        d = {\"a\": b\"1\", \"b\": b\"2\", \"c\": b\"3\", \"d\": b\"4\"}\n        r.mset_nonatomic(d)\n        assert r.touch(*d.keys()) == len(d)\n\n    def test_unlink(self, r):\n        d = {\"a\": b\"1\", \"b\": b\"2\", \"c\": b\"3\", \"d\": b\"4\"}\n        r.mset_nonatomic(d)\n        assert r.unlink(*d.keys()) == len(d)\n        # Unlink is non-blocking so we sleep before\n        # verifying the deletion\n        sleep(0.1)\n        assert r.unlink(*d.keys()) == 0\n\n    def test_pubsub_channels_merge_results(self, r):\n        nodes = r.get_nodes()\n        channels = []\n        pubsub_nodes = []\n        i = 0\n        for node in nodes:\n            channel = f\"foo{i}\"\n            # We will create different pubsub clients where each one is\n            # connected to a different node\n            p = r.pubsub(node)\n            pubsub_nodes.append(p)\n            p.subscribe(channel)\n            b_channel = channel.encode(\"utf-8\")\n            channels.append(b_channel)\n            # Assert that each node returns only the channel it subscribed to\n            sub_channels = node.redis_connection.pubsub_channels()\n            if not sub_channels:\n                # Try again after a short sleep\n                sleep(0.3)\n                sub_channels = node.redis_connection.pubsub_channels()\n            assert sub_channels == [b_channel]\n            i += 1\n        # Assert that the cluster's pubsub_channels function returns ALL of\n        # the cluster's channels\n        result = r.pubsub_channels(target_nodes=\"all\")\n        result.sort()\n        assert result == channels\n\n    def test_pubsub_numsub_merge_results(self, r):\n        nodes = r.get_nodes()\n        pubsub_nodes = []\n        channel = \"foo\"\n        b_channel = channel.encode(\"utf-8\")\n        for node in nodes:\n            # We will create different pubsub clients where each one is\n            # connected to a different node\n            p = r.pubsub(node)\n            pubsub_nodes.append(p)\n            p.subscribe(channel)\n            # Assert that each node returns that only one client is subscribed\n            sub_chann_num = node.redis_connection.pubsub_numsub(channel)\n            if sub_chann_num == [(b_channel, 0)]:\n                sleep(0.3)\n                sub_chann_num = node.redis_connection.pubsub_numsub(channel)\n            assert sub_chann_num == [(b_channel, 1)]\n        # Assert that the cluster's pubsub_numsub function returns ALL clients\n        # subscribed to this channel in the entire cluster\n        assert r.pubsub_numsub(channel, target_nodes=\"all\") == [(b_channel, len(nodes))]\n\n    def test_pubsub_numpat_merge_results(self, r):\n        nodes = r.get_nodes()\n        pubsub_nodes = []\n        pattern = \"foo*\"\n        for node in nodes:\n            # We will create different pubsub clients where each one is\n            # connected to a different node\n            p = r.pubsub(node)\n            pubsub_nodes.append(p)\n            p.psubscribe(pattern)\n            # Assert that each node returns that only one client is subscribed\n            sub_num_pat = node.redis_connection.pubsub_numpat()\n            if sub_num_pat == 0:\n                sleep(0.3)\n                sub_num_pat = node.redis_connection.pubsub_numpat()\n            assert sub_num_pat == 1\n        # Assert that the cluster's pubsub_numsub function returns ALL clients\n        # subscribed to this channel in the entire cluster\n        assert r.pubsub_numpat(target_nodes=\"all\") == len(nodes)\n\n    @skip_if_server_version_lt(\"2.8.0\")\n    def test_cluster_pubsub_channels(self, r):\n        p = r.pubsub()\n        p.subscribe(\"foo\", \"bar\", \"baz\", \"quux\")\n        for i in range(4):\n            assert wait_for_message(p, timeout=0.5)[\"type\"] == \"subscribe\"\n        expected = [b\"bar\", b\"baz\", b\"foo\", b\"quux\"]\n        assert all(\n            [channel in r.pubsub_channels(target_nodes=\"all\") for channel in expected]\n        )\n\n    @skip_if_server_version_lt(\"2.8.0\")\n    def test_cluster_pubsub_numsub(self, r):\n        p1 = r.pubsub()\n        p1.subscribe(\"foo\", \"bar\", \"baz\")\n        for i in range(3):\n            assert wait_for_message(p1, timeout=0.5)[\"type\"] == \"subscribe\"\n        p2 = r.pubsub()\n        p2.subscribe(\"bar\", \"baz\")\n        for i in range(2):\n            assert wait_for_message(p2, timeout=0.5)[\"type\"] == \"subscribe\"\n        p3 = r.pubsub()\n        p3.subscribe(\"baz\")\n        assert wait_for_message(p3, timeout=0.5)[\"type\"] == \"subscribe\"\n\n        channels = [(b\"foo\", 1), (b\"bar\", 2), (b\"baz\", 3)]\n        assert r.pubsub_numsub(\"foo\", \"bar\", \"baz\", target_nodes=\"all\") == channels\n\n    @skip_if_redis_enterprise()\n    def test_cluster_myid(self, r):\n        node = r.get_random_node()\n        myid = r.cluster_myid(node)\n        assert len(myid) == 40\n\n    @skip_if_redis_enterprise()\n    def test_cluster_slots(self, r):\n        mock_all_nodes_resp(r, default_cluster_slots)\n        cluster_slots = r.cluster_slots()\n        assert isinstance(cluster_slots, dict)\n        assert len(default_cluster_slots) == len(cluster_slots)\n        assert cluster_slots.get((0, 8191)) is not None\n        assert cluster_slots.get((0, 8191)).get(\"primary\") == (\"127.0.0.1\", 7000)\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    @skip_if_redis_enterprise()\n    def test_cluster_shards(self, r):\n        cluster_shards = r.cluster_shards()\n        assert isinstance(cluster_shards, list)\n        assert isinstance(cluster_shards[0], dict)\n        attributes = [\n            b\"id\",\n            b\"endpoint\",\n            b\"ip\",\n            b\"hostname\",\n            b\"port\",\n            b\"tls-port\",\n            b\"role\",\n            b\"replication-offset\",\n            b\"health\",\n        ]\n        for x in cluster_shards:\n            assert_resp_response(\n                r, list(x.keys()), [\"slots\", \"nodes\"], [b\"slots\", b\"nodes\"]\n            )\n            try:\n                x[\"nodes\"]\n                key = \"nodes\"\n            except KeyError:\n                key = b\"nodes\"\n            for node in x[key]:\n                for attribute in node.keys():\n                    assert attribute in attributes\n\n    @skip_if_server_version_lt(\"7.2.0\")\n    @skip_if_redis_enterprise()\n    def test_cluster_myshardid(self, r):\n        myshardid = r.cluster_myshardid()\n        assert isinstance(myshardid, str)\n        assert len(myshardid) > 0\n\n    @skip_if_redis_enterprise()\n    def test_cluster_addslots(self, r):\n        node = r.get_random_node()\n        mock_node_resp(node, \"OK\")\n        assert r.cluster_addslots(node, 1, 2, 3) is True\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    @skip_if_redis_enterprise()\n    def test_cluster_addslotsrange(self, r):\n        node = r.get_random_node()\n        mock_node_resp(node, \"OK\")\n        assert r.cluster_addslotsrange(node, 1, 5)\n\n    @skip_if_redis_enterprise()\n    def test_cluster_countkeysinslot(self, r):\n        node = r.nodes_manager.get_node_from_slot(1)\n        mock_node_resp(node, 2)\n        assert r.cluster_countkeysinslot(1) == 2\n\n    def test_cluster_count_failure_report(self, r):\n        mock_all_nodes_resp(r, 0)\n        assert r.cluster_count_failure_report(\"node_0\") == 0\n\n    @skip_if_redis_enterprise()\n    def test_cluster_delslots(self):\n        cluster_slots = [\n            [0, 8191, [\"127.0.0.1\", 7000, \"node_0\"]],\n            [8192, 16383, [\"127.0.0.1\", 7001, \"node_1\"]],\n        ]\n        r = get_mocked_redis_client(\n            host=default_host, port=default_port, cluster_slots=cluster_slots\n        )\n        mock_all_nodes_resp(r, \"OK\")\n        node0 = r.get_node(default_host, 7000)\n        node1 = r.get_node(default_host, 7001)\n        assert r.cluster_delslots(0, 8192) == [True, True]\n        assert node0.redis_connection.connection.read_response.called\n        assert node1.redis_connection.connection.read_response.called\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    @skip_if_redis_enterprise()\n    def test_cluster_delslotsrange(self):\n        cluster_slots = [\n            [\n                0,\n                8191,\n                [\"127.0.0.1\", 7000, \"node_0\"],\n            ],\n            [\n                8192,\n                16383,\n                [\"127.0.0.1\", 7001, \"node_1\"],\n            ],\n        ]\n        r = get_mocked_redis_client(\n            host=default_host, port=default_port, cluster_slots=cluster_slots\n        )\n        mock_all_nodes_resp(r, \"OK\")\n        node = r.get_random_node()\n        r.cluster_addslots(node, 1, 2, 3, 4, 5)\n        assert r.cluster_delslotsrange(1, 5)\n\n    @skip_if_redis_enterprise()\n    def test_cluster_failover(self, r):\n        node = r.get_random_node()\n        mock_node_resp(node, \"OK\")\n        assert r.cluster_failover(node) is True\n        assert r.cluster_failover(node, \"FORCE\") is True\n        assert r.cluster_failover(node, \"TAKEOVER\") is True\n        with pytest.raises(RedisError):\n            r.cluster_failover(node, \"FORCT\")\n\n    @skip_if_redis_enterprise()\n    def test_cluster_info(self, r):\n        info = r.cluster_info()\n        assert isinstance(info, dict)\n        assert info[\"cluster_state\"] == \"ok\"\n\n    @skip_if_redis_enterprise()\n    def test_cluster_keyslot(self, r):\n        mock_all_nodes_resp(r, 12182)\n        assert r.cluster_keyslot(\"foo\") == 12182\n\n    @skip_if_redis_enterprise()\n    def test_cluster_meet(self, r):\n        node = r.get_default_node()\n        mock_node_resp(node, \"OK\")\n        assert r.cluster_meet(\"127.0.0.1\", 6379) is True\n\n    @skip_if_redis_enterprise()\n    def test_cluster_nodes(self, r):\n        response = (\n            \"c8253bae761cb1ecb2b61857d85dfe455a0fec8b 172.17.0.7:7006 \"\n            \"slave aa90da731f673a99617dfe930306549a09f83a6b 0 \"\n            \"1447836263059 5 connected\\n\"\n            \"9bd595fe4821a0e8d6b99d70faa660638a7612b3 172.17.0.7:7008 \"\n            \"master - 0 1447836264065 0 connected\\n\"\n            \"aa90da731f673a99617dfe930306549a09f83a6b 172.17.0.7:7003 \"\n            \"myself,master - 0 0 2 connected 5461-10922\\n\"\n            \"1df047e5a594f945d82fc140be97a1452bcbf93e 172.17.0.7:7007 \"\n            \"slave 19efe5a631f3296fdf21a5441680f893e8cc96ec 0 \"\n            \"1447836262556 3 connected\\n\"\n            \"4ad9a12e63e8f0207025eeba2354bcf4c85e5b22 172.17.0.7:7005 \"\n            \"master - 0 1447836262555 7 connected 0-5460\\n\"\n            \"19efe5a631f3296fdf21a5441680f893e8cc96ec 172.17.0.7:7004 \"\n            \"master - 0 1447836263562 3 connected 10923-16383\\n\"\n            \"fbb23ed8cfa23f17eaf27ff7d0c410492a1093d6 172.17.0.7:7002 \"\n            \"master,fail - 1447829446956 1447829444948 1 disconnected\\n\"\n        )\n        mock_all_nodes_resp(r, response)\n        nodes = r.cluster_nodes()\n        assert len(nodes) == 7\n        assert nodes.get(\"172.17.0.7:7006\") is not None\n        assert (\n            nodes.get(\"172.17.0.7:7006\").get(\"node_id\")\n            == \"c8253bae761cb1ecb2b61857d85dfe455a0fec8b\"\n        )\n\n    @skip_if_redis_enterprise()\n    def test_cluster_nodes_importing_migrating(self, r):\n        response = (\n            \"488ead2fcce24d8c0f158f9172cb1f4a9e040fe5 127.0.0.1:16381@26381 \"\n            \"master - 0 1648975557664 3 connected 10923-16383\\n\"\n            \"8ae2e70812db80776f739a72374e57fc4ae6f89d 127.0.0.1:16380@26380 \"\n            \"master - 0 1648975555000 2 connected 1 5461-10922 [\"\n            \"2-<-ed8007ccfa2d91a7b76f8e6fba7ba7e257034a16]\\n\"\n            \"ed8007ccfa2d91a7b76f8e6fba7ba7e257034a16 127.0.0.1:16379@26379 \"\n            \"myself,master - 0 1648975556000 1 connected 0 2-5460 [\"\n            \"2->-8ae2e70812db80776f739a72374e57fc4ae6f89d]\\n\"\n        )\n        mock_all_nodes_resp(r, response)\n        nodes = r.cluster_nodes()\n        assert len(nodes) == 3\n        node_16379 = nodes.get(\"127.0.0.1:16379\")\n        node_16380 = nodes.get(\"127.0.0.1:16380\")\n        node_16381 = nodes.get(\"127.0.0.1:16381\")\n        assert node_16379.get(\"migrations\") == [\n            {\n                \"slot\": \"2\",\n                \"node_id\": \"8ae2e70812db80776f739a72374e57fc4ae6f89d\",\n                \"state\": \"migrating\",\n            }\n        ]\n        assert node_16379.get(\"slots\") == [[\"0\"], [\"2\", \"5460\"]]\n        assert node_16380.get(\"migrations\") == [\n            {\n                \"slot\": \"2\",\n                \"node_id\": \"ed8007ccfa2d91a7b76f8e6fba7ba7e257034a16\",\n                \"state\": \"importing\",\n            }\n        ]\n        assert node_16380.get(\"slots\") == [[\"1\"], [\"5461\", \"10922\"]]\n        assert node_16381.get(\"slots\") == [[\"10923\", \"16383\"]]\n        assert node_16381.get(\"migrations\") == []\n\n    @skip_if_redis_enterprise()\n    def test_cluster_replicate(self, r):\n        node = r.get_random_node()\n        all_replicas = r.get_replicas()\n        mock_all_nodes_resp(r, \"OK\")\n        assert r.cluster_replicate(node, \"c8253bae761cb61857d\") is True\n        results = r.cluster_replicate(all_replicas, \"c8253bae761cb61857d\")\n        if isinstance(results, dict):\n            for res in results.values():\n                assert res is True\n        else:\n            assert results is True\n\n    @skip_if_redis_enterprise()\n    def test_cluster_reset(self, r):\n        mock_all_nodes_resp(r, \"OK\")\n        assert r.cluster_reset() is True\n        assert r.cluster_reset(False) is True\n        all_results = r.cluster_reset(False, target_nodes=\"all\")\n        for res in all_results.values():\n            assert res is True\n\n    @skip_if_redis_enterprise()\n    def test_cluster_save_config(self, r):\n        node = r.get_random_node()\n        all_nodes = r.get_nodes()\n        mock_all_nodes_resp(r, \"OK\")\n        assert r.cluster_save_config(node) is True\n        all_results = r.cluster_save_config(all_nodes)\n        for res in all_results.values():\n            assert res is True\n\n    @skip_if_redis_enterprise()\n    def test_cluster_get_keys_in_slot(self, r):\n        response = [\"{foo}1\", \"{foo}2\"]\n        node = r.nodes_manager.get_node_from_slot(12182)\n        mock_node_resp(node, response)\n        keys = r.cluster_get_keys_in_slot(12182, 4)\n        assert keys == response\n\n    @skip_if_redis_enterprise()\n    def test_cluster_set_config_epoch(self, r):\n        mock_all_nodes_resp(r, \"OK\")\n        assert r.cluster_set_config_epoch(3) is True\n        all_results = r.cluster_set_config_epoch(3, target_nodes=\"all\")\n        for res in all_results.values():\n            assert res is True\n\n    @skip_if_redis_enterprise()\n    def test_cluster_setslot(self, r):\n        node = r.get_random_node()\n        mock_node_resp(node, \"OK\")\n        assert r.cluster_setslot(node, \"node_0\", 1218, \"IMPORTING\") is True\n        assert r.cluster_setslot(node, \"node_0\", 1218, \"NODE\") is True\n        assert r.cluster_setslot(node, \"node_0\", 1218, \"MIGRATING\") is True\n        with pytest.raises(RedisError):\n            r.cluster_failover(node, \"STABLE\")\n        with pytest.raises(RedisError):\n            r.cluster_failover(node, \"STATE\")\n\n    def test_cluster_setslot_stable(self, r):\n        node = r.nodes_manager.get_node_from_slot(12182)\n        mock_node_resp(node, \"OK\")\n        assert r.cluster_setslot_stable(12182) is True\n        assert node.redis_connection.connection.read_response.called\n\n    @skip_if_redis_enterprise()\n    def test_cluster_replicas(self, r):\n        response = [\n            b\"01eca22229cf3c652b6fca0d09ff6941e0d2e3 \"\n            b\"127.0.0.1:6377@16377 slave \"\n            b\"52611e796814b78e90ad94be9d769a4f668f9a 0 \"\n            b\"1634550063436 4 connected\",\n            b\"r4xfga22229cf3c652b6fca0d09ff69f3e0d4d \"\n            b\"127.0.0.1:6378@16378 slave \"\n            b\"52611e796814b78e90ad94be9d769a4f668f9a 0 \"\n            b\"1634550063436 4 connected\",\n        ]\n        mock_all_nodes_resp(r, response)\n        replicas = r.cluster_replicas(\"52611e796814b78e90ad94be9d769a4f668f9a\")\n        assert replicas.get(\"127.0.0.1:6377\") is not None\n        assert replicas.get(\"127.0.0.1:6378\") is not None\n        assert (\n            replicas.get(\"127.0.0.1:6378\").get(\"node_id\")\n            == \"r4xfga22229cf3c652b6fca0d09ff69f3e0d4d\"\n        )\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_cluster_links(self, r):\n        node = r.get_random_node()\n        res = r.cluster_links(node)\n        if is_resp2_connection(r):\n            links_to = sum(x.count(b\"to\") for x in res)\n            links_for = sum(x.count(b\"from\") for x in res)\n            assert links_to == links_for\n            for i in range(0, len(res) - 1, 2):\n                assert res[i][3] == res[i + 1][3]\n        else:\n            links_to = len(list(filter(lambda x: x[b\"direction\"] == b\"to\", res)))\n            links_for = len(list(filter(lambda x: x[b\"direction\"] == b\"from\", res)))\n            assert links_to == links_for\n            for i in range(0, len(res) - 1, 2):\n                assert res[i][b\"node\"] == res[i + 1][b\"node\"]\n\n    def test_cluster_flshslots_not_implemented(self, r):\n        with pytest.raises(NotImplementedError):\n            r.cluster_flushslots()\n\n    def test_cluster_bumpepoch_not_implemented(self, r):\n        with pytest.raises(NotImplementedError):\n            r.cluster_bumpepoch()\n\n    @skip_if_redis_enterprise()\n    def test_readonly(self):\n        r = get_mocked_redis_client(host=default_host, port=default_port)\n        mock_all_nodes_resp(r, \"OK\")\n        assert r.readonly() is True\n        all_replicas_results = r.readonly(target_nodes=\"replicas\")\n        for res in all_replicas_results.values():\n            assert res is True\n        for replica in r.get_replicas():\n            assert replica.redis_connection.connection.read_response.called\n\n    @skip_if_redis_enterprise()\n    def test_readwrite(self):\n        r = get_mocked_redis_client(host=default_host, port=default_port)\n        mock_all_nodes_resp(r, \"OK\")\n        assert r.readwrite() is True\n        all_replicas_results = r.readwrite(target_nodes=\"replicas\")\n        for res in all_replicas_results.values():\n            assert res is True\n        for replica in r.get_replicas():\n            assert replica.redis_connection.connection.read_response.called\n\n    @skip_if_redis_enterprise()\n    def test_bgsave(self, r):\n        assert r.bgsave()\n        sleep(0.3)\n        assert r.bgsave(True)\n\n    def test_info(self, r):\n        # Map keys to same slot\n        r.set(\"x{1}\", 1)\n        r.set(\"y{1}\", 2)\n        r.set(\"z{1}\", 3)\n        # Get node that handles the slot\n        slot = r.keyslot(\"x{1}\")\n        node = r.nodes_manager.get_node_from_slot(slot)\n        # Run info on that node\n        info = r.info(target_nodes=node)\n        assert isinstance(info, dict)\n        assert info[\"db0\"][\"keys\"] == 3\n\n    def _init_slowlog_test(self, r, node):\n        slowlog_lim = r.config_get(\"slowlog-log-slower-than\", target_nodes=node)\n        assert r.config_set(\"slowlog-log-slower-than\", 0, target_nodes=node) is True\n        return slowlog_lim[\"slowlog-log-slower-than\"]\n\n    def _teardown_slowlog_test(self, r, node, prev_limit):\n        assert (\n            r.config_set(\"slowlog-log-slower-than\", prev_limit, target_nodes=node)\n            is True\n        )\n\n    def test_slowlog_get(self, r, slowlog):\n        unicode_string = chr(3456) + \"abcd\" + chr(3421)\n        node = r.get_node_from_key(unicode_string)\n        slowlog_limit = self._init_slowlog_test(r, node)\n        assert r.slowlog_reset(target_nodes=node)\n        r.get(unicode_string)\n        slowlog = r.slowlog_get(target_nodes=node)\n        assert isinstance(slowlog, list)\n        commands = [log[\"command\"] for log in slowlog]\n\n        get_command = b\" \".join((b\"GET\", unicode_string.encode(\"utf-8\")))\n        assert get_command in commands\n        assert b\"SLOWLOG RESET\" in commands\n\n        # the order should be ['GET <uni string>', 'SLOWLOG RESET'],\n        # but if other clients are executing commands at the same time, there\n        # could be commands, before, between, or after, so just check that\n        # the two we care about are in the appropriate order.\n        assert commands.index(get_command) < commands.index(b\"SLOWLOG RESET\")\n\n        # make sure other attributes are typed correctly\n        assert isinstance(slowlog[0][\"start_time\"], int)\n        assert isinstance(slowlog[0][\"duration\"], int)\n        # rollback the slowlog limit to its original value\n        self._teardown_slowlog_test(r, node, slowlog_limit)\n\n    def test_slowlog_get_limit(self, r, slowlog):\n        assert r.slowlog_reset()\n        node = r.get_node_from_key(\"foo\")\n        slowlog_limit = self._init_slowlog_test(r, node)\n        r.get(\"foo\")\n        slowlog = r.slowlog_get(1, target_nodes=node)\n        assert isinstance(slowlog, list)\n        # only one command, based on the number we passed to slowlog_get()\n        assert len(slowlog) == 1\n        self._teardown_slowlog_test(r, node, slowlog_limit)\n\n    def test_slowlog_length(self, r, slowlog):\n        r.get(\"foo\")\n        node = r.nodes_manager.get_node_from_slot(key_slot(b\"foo\"))\n        slowlog_len = r.slowlog_len(target_nodes=node)\n        assert isinstance(slowlog_len, int)\n\n    def test_time(self, r):\n        t = r.time(target_nodes=r.get_primaries()[0])\n        assert len(t) == 2\n        assert isinstance(t[0], int)\n        assert isinstance(t[1], int)\n\n    @skip_if_server_version_lt(\"4.0.0\")\n    def test_memory_usage(self, r):\n        r.set(\"foo\", \"bar\")\n        assert isinstance(r.memory_usage(\"foo\"), int)\n\n    @skip_if_server_version_lt(\"4.0.0\")\n    @skip_if_redis_enterprise()\n    def test_memory_malloc_stats(self, r):\n        assert r.memory_malloc_stats()\n\n    @skip_if_server_version_lt(\"4.0.0\")\n    @skip_if_redis_enterprise()\n    def test_memory_stats(self, r):\n        # put a key into the current db to make sure that \"db.<current-db>\"\n        # has data\n        r.set(\"foo\", \"bar\")\n        node = r.nodes_manager.get_node_from_slot(key_slot(b\"foo\"))\n        stats = r.memory_stats(target_nodes=node)\n        assert isinstance(stats, dict)\n        for key, value in stats.items():\n            if key.startswith(\"db.\"):\n                assert not isinstance(value, list)\n\n    @skip_if_server_version_lt(\"4.0.0\")\n    def test_memory_help(self, r):\n        with pytest.raises(NotImplementedError):\n            r.memory_help()\n\n    @skip_if_server_version_lt(\"4.0.0\")\n    def test_memory_doctor(self, r):\n        with pytest.raises(NotImplementedError):\n            r.memory_doctor()\n\n    @skip_if_redis_enterprise()\n    def test_lastsave(self, r):\n        node = r.get_primaries()[0]\n        assert isinstance(r.lastsave(target_nodes=node), datetime.datetime)\n\n    def test_cluster_echo(self, r):\n        node = r.get_primaries()[0]\n        assert r.echo(\"foo bar\", target_nodes=node) == b\"foo bar\"\n\n    @skip_if_server_version_lt(\"1.0.0\")\n    def test_debug_segfault(self, r):\n        with pytest.raises(NotImplementedError):\n            r.debug_segfault()\n\n    def test_config_resetstat(self, r):\n        node = r.get_primaries()[0]\n        r.ping(target_nodes=node)\n        prior_commands_processed = int(\n            r.info(target_nodes=node)[\"total_commands_processed\"]\n        )\n        assert prior_commands_processed >= 1\n        r.config_resetstat(target_nodes=node)\n        reset_commands_processed = int(\n            r.info(target_nodes=node)[\"total_commands_processed\"]\n        )\n        assert reset_commands_processed < prior_commands_processed\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_client_trackinginfo(self, r):\n        node = r.get_primaries()[0]\n        res = r.client_trackinginfo(target_nodes=node)\n        assert len(res) > 2\n        assert \"prefixes\" in res or b\"prefixes\" in res\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    @skip_if_redis_enterprise()\n    def test_client_tracking(self, r):\n        # simple case - will execute on all nodes\n        assert r.client_tracking_on()\n        assert r.client_tracking_off()\n\n        # id based\n        node = r.get_default_node()\n        # when id is provided - the command should be sent to the node that\n        # owns the connection with this id\n        client_id = node.redis_connection.client_id()\n        assert r.client_tracking_on(clientid=client_id, target_nodes=node)\n        assert r.client_tracking_off(clientid=client_id, target_nodes=node)\n\n        # execute with client id and prefixes and bcast\n        assert r.client_tracking_on(\n            clientid=client_id, prefix=[\"foo\", \"bar\"], bcast=True, target_nodes=node\n        )\n\n        # now with some prefixes and without bcast\n        with pytest.raises(DataError):\n            assert r.client_tracking_on(prefix=[\"foo\", \"bar\", \"blee\"])\n\n    @skip_if_server_version_lt(\"2.9.50\")\n    def test_client_pause(self, r):\n        node = r.get_primaries()[0]\n        assert r.client_pause(1, target_nodes=node)\n        assert r.client_pause(timeout=1, target_nodes=node)\n        with pytest.raises(RedisError):\n            r.client_pause(timeout=\"not an integer\", target_nodes=node)\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    @skip_if_redis_enterprise()\n    def test_client_unpause(self, r):\n        assert r.client_unpause()\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    def test_client_id(self, r):\n        node = r.get_primaries()[0]\n        assert r.client_id(target_nodes=node) > 0\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    def test_client_unblock(self, r):\n        node = r.get_primaries()[0]\n        myid = r.client_id(target_nodes=node)\n        assert not r.client_unblock(myid, target_nodes=node)\n        assert not r.client_unblock(myid, error=True, target_nodes=node)\n        assert not r.client_unblock(myid, error=False, target_nodes=node)\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    def test_client_getredir(self, r):\n        node = r.get_primaries()[0]\n        assert isinstance(r.client_getredir(target_nodes=node), int)\n        assert r.client_getredir(target_nodes=node) == -1\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_client_info(self, r):\n        node = r.get_primaries()[0]\n        info = r.client_info(target_nodes=node)\n        assert isinstance(info, dict)\n        assert \"addr\" in info\n\n    @skip_if_server_version_lt(\"2.6.9\")\n    def test_client_kill(self, r, r2):\n        node = r.get_primaries()[0]\n        r.client_setname(\"redis-py-c1\", target_nodes=\"all\")\n        r2.client_setname(\"redis-py-c2\", target_nodes=\"all\")\n        clients = [\n            client\n            for client in r.client_list(target_nodes=node)\n            if client.get(\"name\") in [\"redis-py-c1\", \"redis-py-c2\"]\n        ]\n        assert len(clients) == 2\n        clients_by_name = {client.get(\"name\"): client for client in clients}\n\n        client_addr = clients_by_name[\"redis-py-c2\"].get(\"addr\")\n        assert r.client_kill(client_addr, target_nodes=node) is True\n\n        clients = [\n            client\n            for client in r.client_list(target_nodes=node)\n            if client.get(\"name\") in [\"redis-py-c1\", \"redis-py-c2\"]\n        ]\n        assert len(clients) == 1\n        assert clients[0].get(\"name\") == \"redis-py-c1\"\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    def test_cluster_bitop_not_empty_string(self, r):\n        r[\"{foo}a\"] = \"\"\n        r.bitop(\"not\", \"{foo}r\", \"{foo}a\")\n        assert r.get(\"{foo}r\") is None\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    def test_cluster_bitop_not(self, r):\n        test_str = b\"\\xaa\\x00\\xff\\x55\"\n        correct = ~0xAA00FF55 & 0xFFFFFFFF\n        r[\"{foo}a\"] = test_str\n        r.bitop(\"not\", \"{foo}r\", \"{foo}a\")\n        assert int(binascii.hexlify(r[\"{foo}r\"]), 16) == correct\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    def test_cluster_bitop_not_in_place(self, r):\n        test_str = b\"\\xaa\\x00\\xff\\x55\"\n        correct = ~0xAA00FF55 & 0xFFFFFFFF\n        r[\"{foo}a\"] = test_str\n        r.bitop(\"not\", \"{foo}a\", \"{foo}a\")\n        assert int(binascii.hexlify(r[\"{foo}a\"]), 16) == correct\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    def test_cluster_bitop_single_string(self, r):\n        test_str = b\"\\x01\\x02\\xff\"\n        r[\"{foo}a\"] = test_str\n        r.bitop(\"and\", \"{foo}res1\", \"{foo}a\")\n        r.bitop(\"or\", \"{foo}res2\", \"{foo}a\")\n        r.bitop(\"xor\", \"{foo}res3\", \"{foo}a\")\n        assert r[\"{foo}res1\"] == test_str\n        assert r[\"{foo}res2\"] == test_str\n        assert r[\"{foo}res3\"] == test_str\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    def test_cluster_bitop_string_operands(self, r):\n        r[\"{foo}a\"] = b\"\\x01\\x02\\xff\\xff\"\n        r[\"{foo}b\"] = b\"\\x01\\x02\\xff\"\n        r.bitop(\"and\", \"{foo}res1\", \"{foo}a\", \"{foo}b\")\n        r.bitop(\"or\", \"{foo}res2\", \"{foo}a\", \"{foo}b\")\n        r.bitop(\"xor\", \"{foo}res3\", \"{foo}a\", \"{foo}b\")\n        assert int(binascii.hexlify(r[\"{foo}res1\"]), 16) == 0x0102FF00\n        assert int(binascii.hexlify(r[\"{foo}res2\"]), 16) == 0x0102FFFF\n        assert int(binascii.hexlify(r[\"{foo}res3\"]), 16) == 0x000000FF\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_cluster_copy(self, r):\n        assert r.copy(\"{foo}a\", \"{foo}b\") == 0\n        r.set(\"{foo}a\", \"bar\")\n        assert r.copy(\"{foo}a\", \"{foo}b\") == 1\n        assert r.get(\"{foo}a\") == b\"bar\"\n        assert r.get(\"{foo}b\") == b\"bar\"\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_cluster_copy_and_replace(self, r):\n        r.set(\"{foo}a\", \"foo1\")\n        r.set(\"{foo}b\", \"foo2\")\n        assert r.copy(\"{foo}a\", \"{foo}b\") == 0\n        assert r.copy(\"{foo}a\", \"{foo}b\", replace=True) == 1\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_cluster_lmove(self, r):\n        r.rpush(\"{foo}a\", \"one\", \"two\", \"three\", \"four\")\n        assert r.lmove(\"{foo}a\", \"{foo}b\")\n        assert r.lmove(\"{foo}a\", \"{foo}b\", \"right\", \"left\")\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_cluster_blmove(self, r):\n        r.rpush(\"{foo}a\", \"one\", \"two\", \"three\", \"four\")\n        assert r.blmove(\"{foo}a\", \"{foo}b\", 5)\n        assert r.blmove(\"{foo}a\", \"{foo}b\", 1, \"RIGHT\", \"LEFT\")\n\n    def test_cluster_msetnx(self, r):\n        d = {\"{foo}a\": b\"1\", \"{foo}b\": b\"2\", \"{foo}c\": b\"3\"}\n        assert r.msetnx(d)\n        d2 = {\"{foo}a\": b\"x\", \"{foo}d\": b\"4\"}\n        assert not r.msetnx(d2)\n        for k, v in d.items():\n            assert r[k] == v\n        assert r.get(\"{foo}d\") is None\n\n    def test_cluster_rename(self, r):\n        r[\"{foo}a\"] = \"1\"\n        assert r.rename(\"{foo}a\", \"{foo}b\")\n        assert r.get(\"{foo}a\") is None\n        assert r[\"{foo}b\"] == b\"1\"\n\n    def test_cluster_renamenx(self, r):\n        r[\"{foo}a\"] = \"1\"\n        r[\"{foo}b\"] = \"2\"\n        assert not r.renamenx(\"{foo}a\", \"{foo}b\")\n        assert r[\"{foo}a\"] == b\"1\"\n        assert r[\"{foo}b\"] == b\"2\"\n\n    # LIST COMMANDS\n    def test_cluster_blpop(self, r):\n        r.rpush(\"{foo}a\", \"1\", \"2\")\n        r.rpush(\"{foo}b\", \"3\", \"4\")\n        assert_resp_response(\n            r,\n            r.blpop([\"{foo}b\", \"{foo}a\"], timeout=1),\n            (b\"{foo}b\", b\"3\"),\n            [b\"{foo}b\", b\"3\"],\n        )\n        assert_resp_response(\n            r,\n            r.blpop([\"{foo}b\", \"{foo}a\"], timeout=1),\n            (b\"{foo}b\", b\"4\"),\n            [b\"{foo}b\", b\"4\"],\n        )\n        assert_resp_response(\n            r,\n            r.blpop([\"{foo}b\", \"{foo}a\"], timeout=1),\n            (b\"{foo}a\", b\"1\"),\n            [b\"{foo}a\", b\"1\"],\n        )\n        assert_resp_response(\n            r,\n            r.blpop([\"{foo}b\", \"{foo}a\"], timeout=1),\n            (b\"{foo}a\", b\"2\"),\n            [b\"{foo}a\", b\"2\"],\n        )\n        assert r.blpop([\"{foo}b\", \"{foo}a\"], timeout=1) is None\n        r.rpush(\"{foo}c\", \"1\")\n        assert_resp_response(\n            r, r.blpop(\"{foo}c\", timeout=1), (b\"{foo}c\", b\"1\"), [b\"{foo}c\", b\"1\"]\n        )\n\n    def test_cluster_brpop(self, r):\n        r.rpush(\"{foo}a\", \"1\", \"2\")\n        r.rpush(\"{foo}b\", \"3\", \"4\")\n        assert_resp_response(\n            r,\n            r.brpop([\"{foo}b\", \"{foo}a\"], timeout=1),\n            (b\"{foo}b\", b\"4\"),\n            [b\"{foo}b\", b\"4\"],\n        )\n        assert_resp_response(\n            r,\n            r.brpop([\"{foo}b\", \"{foo}a\"], timeout=1),\n            (b\"{foo}b\", b\"3\"),\n            [b\"{foo}b\", b\"3\"],\n        )\n        assert_resp_response(\n            r,\n            r.brpop([\"{foo}b\", \"{foo}a\"], timeout=1),\n            (b\"{foo}a\", b\"2\"),\n            [b\"{foo}a\", b\"2\"],\n        )\n        assert_resp_response(\n            r,\n            r.brpop([\"{foo}b\", \"{foo}a\"], timeout=1),\n            (b\"{foo}a\", b\"1\"),\n            [b\"{foo}a\", b\"1\"],\n        )\n        assert r.brpop([\"{foo}b\", \"{foo}a\"], timeout=1) is None\n        r.rpush(\"{foo}c\", \"1\")\n        assert_resp_response(\n            r, r.brpop(\"{foo}c\", timeout=1), (b\"{foo}c\", b\"1\"), [b\"{foo}c\", b\"1\"]\n        )\n\n    def test_cluster_brpoplpush(self, r):\n        r.rpush(\"{foo}a\", \"1\", \"2\")\n        r.rpush(\"{foo}b\", \"3\", \"4\")\n        assert r.brpoplpush(\"{foo}a\", \"{foo}b\") == b\"2\"\n        assert r.brpoplpush(\"{foo}a\", \"{foo}b\") == b\"1\"\n        assert r.brpoplpush(\"{foo}a\", \"{foo}b\", timeout=1) is None\n        assert r.lrange(\"{foo}a\", 0, -1) == []\n        assert r.lrange(\"{foo}b\", 0, -1) == [b\"1\", b\"2\", b\"3\", b\"4\"]\n\n    def test_cluster_brpoplpush_empty_string(self, r):\n        r.rpush(\"{foo}a\", \"\")\n        assert r.brpoplpush(\"{foo}a\", \"{foo}b\") == b\"\"\n\n    def test_cluster_rpoplpush(self, r):\n        r.rpush(\"{foo}a\", \"a1\", \"a2\", \"a3\")\n        r.rpush(\"{foo}b\", \"b1\", \"b2\", \"b3\")\n        assert r.rpoplpush(\"{foo}a\", \"{foo}b\") == b\"a3\"\n        assert r.lrange(\"{foo}a\", 0, -1) == [b\"a1\", b\"a2\"]\n        assert r.lrange(\"{foo}b\", 0, -1) == [b\"a3\", b\"b1\", b\"b2\", b\"b3\"]\n\n    def test_cluster_sdiff(self, r):\n        r.sadd(\"{foo}a\", \"1\", \"2\", \"3\")\n        assert r.sdiff(\"{foo}a\", \"{foo}b\") == {b\"1\", b\"2\", b\"3\"}\n        r.sadd(\"{foo}b\", \"2\", \"3\")\n        assert r.sdiff(\"{foo}a\", \"{foo}b\") == {b\"1\"}\n\n    def test_cluster_sdiffstore(self, r):\n        r.sadd(\"{foo}a\", \"1\", \"2\", \"3\")\n        assert r.sdiffstore(\"{foo}c\", \"{foo}a\", \"{foo}b\") == 3\n        assert r.smembers(\"{foo}c\") == {b\"1\", b\"2\", b\"3\"}\n        r.sadd(\"{foo}b\", \"2\", \"3\")\n        assert r.sdiffstore(\"{foo}c\", \"{foo}a\", \"{foo}b\") == 1\n        assert r.smembers(\"{foo}c\") == {b\"1\"}\n\n    def test_cluster_sinter(self, r):\n        r.sadd(\"{foo}a\", \"1\", \"2\", \"3\")\n        assert r.sinter(\"{foo}a\", \"{foo}b\") == set()\n        r.sadd(\"{foo}b\", \"2\", \"3\")\n        assert r.sinter(\"{foo}a\", \"{foo}b\") == {b\"2\", b\"3\"}\n\n    def test_cluster_sinterstore(self, r):\n        r.sadd(\"{foo}a\", \"1\", \"2\", \"3\")\n        assert r.sinterstore(\"{foo}c\", \"{foo}a\", \"{foo}b\") == 0\n        assert r.smembers(\"{foo}c\") == set()\n        r.sadd(\"{foo}b\", \"2\", \"3\")\n        assert r.sinterstore(\"{foo}c\", \"{foo}a\", \"{foo}b\") == 2\n        assert r.smembers(\"{foo}c\") == {b\"2\", b\"3\"}\n\n    def test_cluster_smove(self, r):\n        r.sadd(\"{foo}a\", \"a1\", \"a2\")\n        r.sadd(\"{foo}b\", \"b1\", \"b2\")\n        assert r.smove(\"{foo}a\", \"{foo}b\", \"a1\")\n        assert r.smembers(\"{foo}a\") == {b\"a2\"}\n        assert r.smembers(\"{foo}b\") == {b\"b1\", b\"b2\", b\"a1\"}\n\n    def test_cluster_sunion(self, r):\n        r.sadd(\"{foo}a\", \"1\", \"2\")\n        r.sadd(\"{foo}b\", \"2\", \"3\")\n        assert r.sunion(\"{foo}a\", \"{foo}b\") == {b\"1\", b\"2\", b\"3\"}\n\n    def test_cluster_sunionstore(self, r):\n        r.sadd(\"{foo}a\", \"1\", \"2\")\n        r.sadd(\"{foo}b\", \"2\", \"3\")\n        assert r.sunionstore(\"{foo}c\", \"{foo}a\", \"{foo}b\") == 3\n        assert r.smembers(\"{foo}c\") == {b\"1\", b\"2\", b\"3\"}\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_cluster_zdiff(self, r):\n        r.zadd(\"{foo}a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        r.zadd(\"{foo}b\", {\"a1\": 1, \"a2\": 2})\n        assert r.zdiff([\"{foo}a\", \"{foo}b\"]) == [b\"a3\"]\n        response = r.zdiff([\"{foo}a\", \"{foo}b\"], withscores=True)\n        assert_resp_response(\n            r,\n            response,\n            [b\"a3\", b\"3\"],\n            [[b\"a3\", 3.0]],\n        )\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_cluster_zdiffstore(self, r):\n        r.zadd(\"{foo}a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        r.zadd(\"{foo}b\", {\"a1\": 1, \"a2\": 2})\n        assert r.zdiffstore(\"{foo}out\", [\"{foo}a\", \"{foo}b\"])\n        assert r.zrange(\"{foo}out\", 0, -1) == [b\"a3\"]\n        response = r.zrange(\"{foo}out\", 0, -1, withscores=True)\n        assert_resp_response(r, response, [(b\"a3\", 3.0)], [[b\"a3\", 3.0]])\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_cluster_zinter(self, r):\n        r.zadd(\"{foo}a\", {\"a1\": 1, \"a2\": 2, \"a3\": 1})\n        r.zadd(\"{foo}b\", {\"a1\": 2, \"a2\": 2, \"a3\": 2})\n        r.zadd(\"{foo}c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert r.zinter([\"{foo}a\", \"{foo}b\", \"{foo}c\"]) == [b\"a3\", b\"a1\"]\n        # invalid aggregation\n        with pytest.raises(DataError):\n            r.zinter([\"{foo}a\", \"{foo}b\", \"{foo}c\"], aggregate=\"foo\", withscores=True)\n        assert_resp_response(\n            r,\n            r.zinter([\"{foo}a\", \"{foo}b\", \"{foo}c\"], withscores=True),\n            [(b\"a3\", 8), (b\"a1\", 9)],\n            [[b\"a3\", 8], [b\"a1\", 9]],\n        )\n        assert_resp_response(\n            r,\n            r.zinter([\"{foo}a\", \"{foo}b\", \"{foo}c\"], withscores=True, aggregate=\"MAX\"),\n            [(b\"a3\", 5), (b\"a1\", 6)],\n            [[b\"a3\", 5], [b\"a1\", 6]],\n        )\n        assert_resp_response(\n            r,\n            r.zinter([\"{foo}a\", \"{foo}b\", \"{foo}c\"], withscores=True, aggregate=\"MIN\"),\n            [(b\"a1\", 1), (b\"a3\", 1)],\n            [[b\"a1\", 1], [b\"a3\", 1]],\n        )\n        assert_resp_response(\n            r,\n            r.zinter({\"{foo}a\": 1, \"{foo}b\": 2, \"{foo}c\": 3}, withscores=True),\n            [(b\"a3\", 20.0), (b\"a1\", 23.0)],\n            [[b\"a3\", 20.0], [b\"a1\", 23.0]],\n        )\n\n    def test_cluster_zinterstore_sum(self, r):\n        r.zadd(\"{foo}a\", {\"a1\": 1, \"a2\": 1, \"a3\": 1})\n        r.zadd(\"{foo}b\", {\"a1\": 2, \"a2\": 2, \"a3\": 2})\n        r.zadd(\"{foo}c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert r.zinterstore(\"{foo}d\", [\"{foo}a\", \"{foo}b\", \"{foo}c\"]) == 2\n        assert_resp_response(\n            r,\n            r.zrange(\"{foo}d\", 0, -1, withscores=True),\n            [(b\"a3\", 8), (b\"a1\", 9)],\n            [[b\"a3\", 8.0], [b\"a1\", 9.0]],\n        )\n\n    def test_cluster_zinterstore_max(self, r):\n        r.zadd(\"{foo}a\", {\"a1\": 1, \"a2\": 1, \"a3\": 1})\n        r.zadd(\"{foo}b\", {\"a1\": 2, \"a2\": 2, \"a3\": 2})\n        r.zadd(\"{foo}c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert (\n            r.zinterstore(\"{foo}d\", [\"{foo}a\", \"{foo}b\", \"{foo}c\"], aggregate=\"MAX\")\n            == 2\n        )\n        assert_resp_response(\n            r,\n            r.zrange(\"{foo}d\", 0, -1, withscores=True),\n            [(b\"a3\", 5), (b\"a1\", 6)],\n            [[b\"a3\", 5.0], [b\"a1\", 6.0]],\n        )\n\n    def test_cluster_zinterstore_min(self, r):\n        r.zadd(\"{foo}a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        r.zadd(\"{foo}b\", {\"a1\": 2, \"a2\": 3, \"a3\": 5})\n        r.zadd(\"{foo}c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert (\n            r.zinterstore(\"{foo}d\", [\"{foo}a\", \"{foo}b\", \"{foo}c\"], aggregate=\"MIN\")\n            == 2\n        )\n        assert_resp_response(\n            r,\n            r.zrange(\"{foo}d\", 0, -1, withscores=True),\n            [(b\"a1\", 1), (b\"a3\", 3)],\n            [[b\"a1\", 1.0], [b\"a3\", 3.0]],\n        )\n\n    def test_cluster_zinterstore_with_weight(self, r):\n        r.zadd(\"{foo}a\", {\"a1\": 1, \"a2\": 1, \"a3\": 1})\n        r.zadd(\"{foo}b\", {\"a1\": 2, \"a2\": 2, \"a3\": 2})\n        r.zadd(\"{foo}c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert r.zinterstore(\"{foo}d\", {\"{foo}a\": 1, \"{foo}b\": 2, \"{foo}c\": 3}) == 2\n        assert_resp_response(\n            r,\n            r.zrange(\"{foo}d\", 0, -1, withscores=True),\n            [(b\"a3\", 20), (b\"a1\", 23)],\n            [[b\"a3\", 20.0], [b\"a1\", 23.0]],\n        )\n\n    @skip_if_server_version_lt(\"4.9.0\")\n    def test_cluster_bzpopmax(self, r):\n        r.zadd(\"{foo}a\", {\"a1\": 1, \"a2\": 2})\n        r.zadd(\"{foo}b\", {\"b1\": 10, \"b2\": 20})\n        assert_resp_response(\n            r,\n            r.bzpopmax([\"{foo}b\", \"{foo}a\"], timeout=1),\n            (b\"{foo}b\", b\"b2\", 20),\n            [b\"{foo}b\", b\"b2\", 20],\n        )\n        assert_resp_response(\n            r,\n            r.bzpopmax([\"{foo}b\", \"{foo}a\"], timeout=1),\n            (b\"{foo}b\", b\"b1\", 10),\n            [b\"{foo}b\", b\"b1\", 10],\n        )\n        assert_resp_response(\n            r,\n            r.bzpopmax([\"{foo}b\", \"{foo}a\"], timeout=1),\n            (b\"{foo}a\", b\"a2\", 2),\n            [b\"{foo}a\", b\"a2\", 2],\n        )\n        assert_resp_response(\n            r,\n            r.bzpopmax([\"{foo}b\", \"{foo}a\"], timeout=1),\n            (b\"{foo}a\", b\"a1\", 1),\n            [b\"{foo}a\", b\"a1\", 1],\n        )\n        assert r.bzpopmax([\"{foo}b\", \"{foo}a\"], timeout=1) is None\n        r.zadd(\"{foo}c\", {\"c1\": 100})\n        assert_resp_response(\n            r,\n            r.bzpopmax(\"{foo}c\", timeout=1),\n            (b\"{foo}c\", b\"c1\", 100),\n            [b\"{foo}c\", b\"c1\", 100],\n        )\n\n    @skip_if_server_version_lt(\"4.9.0\")\n    def test_cluster_bzpopmin(self, r):\n        r.zadd(\"{foo}a\", {\"a1\": 1, \"a2\": 2})\n        r.zadd(\"{foo}b\", {\"b1\": 10, \"b2\": 20})\n        assert_resp_response(\n            r,\n            r.bzpopmin([\"{foo}b\", \"{foo}a\"], timeout=1),\n            (b\"{foo}b\", b\"b1\", 10),\n            [b\"{foo}b\", b\"b1\", 10],\n        )\n        assert_resp_response(\n            r,\n            r.bzpopmin([\"{foo}b\", \"{foo}a\"], timeout=1),\n            (b\"{foo}b\", b\"b2\", 20),\n            [b\"{foo}b\", b\"b2\", 20],\n        )\n        assert_resp_response(\n            r,\n            r.bzpopmin([\"{foo}b\", \"{foo}a\"], timeout=1),\n            (b\"{foo}a\", b\"a1\", 1),\n            [b\"{foo}a\", b\"a1\", 1],\n        )\n        assert_resp_response(\n            r,\n            r.bzpopmin([\"{foo}b\", \"{foo}a\"], timeout=1),\n            (b\"{foo}a\", b\"a2\", 2),\n            [b\"{foo}a\", b\"a2\", 2],\n        )\n        assert r.bzpopmin([\"{foo}b\", \"{foo}a\"], timeout=1) is None\n        r.zadd(\"{foo}c\", {\"c1\": 100})\n        assert_resp_response(\n            r,\n            r.bzpopmin(\"{foo}c\", timeout=1),\n            (b\"{foo}c\", b\"c1\", 100),\n            [b\"{foo}c\", b\"c1\", 100],\n        )\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_cluster_zrangestore(self, r):\n        r.zadd(\"{foo}a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        assert r.zrangestore(\"{foo}b\", \"{foo}a\", 0, 1)\n        assert r.zrange(\"{foo}b\", 0, -1) == [b\"a1\", b\"a2\"]\n        assert r.zrangestore(\"{foo}b\", \"{foo}a\", 1, 2)\n        assert r.zrange(\"{foo}b\", 0, -1) == [b\"a2\", b\"a3\"]\n        assert_resp_response(\n            r,\n            r.zrange(\"{foo}b\", 0, 1, withscores=True),\n            [(b\"a2\", 2), (b\"a3\", 3)],\n            [[b\"a2\", 2.0], [b\"a3\", 3.0]],\n        )\n        # reversed order\n        assert r.zrangestore(\"{foo}b\", \"{foo}a\", 1, 2, desc=True)\n        assert r.zrange(\"{foo}b\", 0, -1) == [b\"a1\", b\"a2\"]\n        # by score\n        assert r.zrangestore(\n            \"{foo}b\", \"{foo}a\", 2, 1, byscore=True, offset=0, num=1, desc=True\n        )\n        assert r.zrange(\"{foo}b\", 0, -1) == [b\"a2\"]\n        # by lex\n        assert r.zrangestore(\n            \"{foo}b\", \"{foo}a\", \"[a2\", \"(a3\", bylex=True, offset=0, num=1\n        )\n        assert r.zrange(\"{foo}b\", 0, -1) == [b\"a2\"]\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_cluster_zunion(self, r):\n        r.zadd(\"{foo}a\", {\"a1\": 1, \"a2\": 1, \"a3\": 1})\n        r.zadd(\"{foo}b\", {\"a1\": 2, \"a2\": 2, \"a3\": 2})\n        r.zadd(\"{foo}c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        # sum\n        assert r.zunion([\"{foo}a\", \"{foo}b\", \"{foo}c\"]) == [b\"a2\", b\"a4\", b\"a3\", b\"a1\"]\n        assert_resp_response(\n            r,\n            r.zunion([\"{foo}a\", \"{foo}b\", \"{foo}c\"], withscores=True),\n            [(b\"a2\", 3), (b\"a4\", 4), (b\"a3\", 8), (b\"a1\", 9)],\n            [[b\"a2\", 3.0], [b\"a4\", 4.0], [b\"a3\", 8.0], [b\"a1\", 9.0]],\n        )\n        # max\n        assert_resp_response(\n            r,\n            r.zunion([\"{foo}a\", \"{foo}b\", \"{foo}c\"], aggregate=\"MAX\", withscores=True),\n            [(b\"a2\", 2), (b\"a4\", 4), (b\"a3\", 5), (b\"a1\", 6)],\n            [[b\"a2\", 2.0], [b\"a4\", 4.0], [b\"a3\", 5.0], [b\"a1\", 6.0]],\n        )\n        # min\n        assert_resp_response(\n            r,\n            r.zunion([\"{foo}a\", \"{foo}b\", \"{foo}c\"], aggregate=\"MIN\", withscores=True),\n            [(b\"a1\", 1), (b\"a2\", 1), (b\"a3\", 1), (b\"a4\", 4)],\n            [[b\"a1\", 1.0], [b\"a2\", 1.0], [b\"a3\", 1.0], [b\"a4\", 4.0]],\n        )\n        # with weight\n        assert_resp_response(\n            r,\n            r.zunion({\"{foo}a\": 1, \"{foo}b\": 2, \"{foo}c\": 3}, withscores=True),\n            [(b\"a2\", 5), (b\"a4\", 12), (b\"a3\", 20), (b\"a1\", 23)],\n            [[b\"a2\", 5.0], [b\"a4\", 12.0], [b\"a3\", 20.0], [b\"a1\", 23.0]],\n        )\n\n    def test_cluster_zunionstore_sum(self, r):\n        r.zadd(\"{foo}a\", {\"a1\": 1, \"a2\": 1, \"a3\": 1})\n        r.zadd(\"{foo}b\", {\"a1\": 2, \"a2\": 2, \"a3\": 2})\n        r.zadd(\"{foo}c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert r.zunionstore(\"{foo}d\", [\"{foo}a\", \"{foo}b\", \"{foo}c\"]) == 4\n        assert_resp_response(\n            r,\n            r.zrange(\"{foo}d\", 0, -1, withscores=True),\n            [(b\"a2\", 3), (b\"a4\", 4), (b\"a3\", 8), (b\"a1\", 9)],\n            [[b\"a2\", 3.0], [b\"a4\", 4.0], [b\"a3\", 8.0], [b\"a1\", 9.0]],\n        )\n\n    def test_cluster_zunionstore_max(self, r):\n        r.zadd(\"{foo}a\", {\"a1\": 1, \"a2\": 1, \"a3\": 1})\n        r.zadd(\"{foo}b\", {\"a1\": 2, \"a2\": 2, \"a3\": 2})\n        r.zadd(\"{foo}c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert (\n            r.zunionstore(\"{foo}d\", [\"{foo}a\", \"{foo}b\", \"{foo}c\"], aggregate=\"MAX\")\n            == 4\n        )\n        assert_resp_response(\n            r,\n            r.zrange(\"{foo}d\", 0, -1, withscores=True),\n            [(b\"a2\", 2), (b\"a4\", 4), (b\"a3\", 5), (b\"a1\", 6)],\n            [[b\"a2\", 2.0], [b\"a4\", 4.0], [b\"a3\", 5.0], [b\"a1\", 6.0]],\n        )\n\n    def test_cluster_zunionstore_min(self, r):\n        r.zadd(\"{foo}a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        r.zadd(\"{foo}b\", {\"a1\": 2, \"a2\": 2, \"a3\": 4})\n        r.zadd(\"{foo}c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert (\n            r.zunionstore(\"{foo}d\", [\"{foo}a\", \"{foo}b\", \"{foo}c\"], aggregate=\"MIN\")\n            == 4\n        )\n        assert_resp_response(\n            r,\n            r.zrange(\"{foo}d\", 0, -1, withscores=True),\n            [(b\"a1\", 1), (b\"a2\", 2), (b\"a3\", 3), (b\"a4\", 4)],\n            [[b\"a1\", 1.0], [b\"a2\", 2.0], [b\"a3\", 3.0], [b\"a4\", 4.0]],\n        )\n\n    def test_cluster_zunionstore_with_weight(self, r):\n        r.zadd(\"{foo}a\", {\"a1\": 1, \"a2\": 1, \"a3\": 1})\n        r.zadd(\"{foo}b\", {\"a1\": 2, \"a2\": 2, \"a3\": 2})\n        r.zadd(\"{foo}c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert r.zunionstore(\"{foo}d\", {\"{foo}a\": 1, \"{foo}b\": 2, \"{foo}c\": 3}) == 4\n        assert_resp_response(\n            r,\n            r.zrange(\"{foo}d\", 0, -1, withscores=True),\n            [(b\"a2\", 5), (b\"a4\", 12), (b\"a3\", 20), (b\"a1\", 23)],\n            [[b\"a2\", 5.0], [b\"a4\", 12.0], [b\"a3\", 20.0], [b\"a1\", 23.0]],\n        )\n\n    @skip_if_server_version_lt(\"2.8.9\")\n    def test_cluster_pfcount(self, r):\n        members = {b\"1\", b\"2\", b\"3\"}\n        r.pfadd(\"{foo}a\", *members)\n        assert r.pfcount(\"{foo}a\") == len(members)\n        members_b = {b\"2\", b\"3\", b\"4\"}\n        r.pfadd(\"{foo}b\", *members_b)\n        assert r.pfcount(\"{foo}b\") == len(members_b)\n        assert r.pfcount(\"{foo}a\", \"{foo}b\") == len(members_b.union(members))\n\n    @skip_if_server_version_lt(\"2.8.9\")\n    def test_cluster_pfmerge(self, r):\n        mema = {b\"1\", b\"2\", b\"3\"}\n        memb = {b\"2\", b\"3\", b\"4\"}\n        memc = {b\"5\", b\"6\", b\"7\"}\n        r.pfadd(\"{foo}a\", *mema)\n        r.pfadd(\"{foo}b\", *memb)\n        r.pfadd(\"{foo}c\", *memc)\n        r.pfmerge(\"{foo}d\", \"{foo}c\", \"{foo}a\")\n        assert r.pfcount(\"{foo}d\") == 6\n        r.pfmerge(\"{foo}d\", \"{foo}b\")\n        assert r.pfcount(\"{foo}d\") == 7\n\n    def test_cluster_sort_store(self, r):\n        r.rpush(\"{foo}a\", \"2\", \"3\", \"1\")\n        assert r.sort(\"{foo}a\", store=\"{foo}sorted_values\") == 3\n        assert r.lrange(\"{foo}sorted_values\", 0, -1) == [b\"1\", b\"2\", b\"3\"]\n\n    # GEO COMMANDS\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_cluster_geosearchstore(self, r):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n\n        r.geoadd(\"{foo}barcelona\", values)\n        r.geosearchstore(\n            \"{foo}places_barcelona\",\n            \"{foo}barcelona\",\n            longitude=2.191,\n            latitude=41.433,\n            radius=1000,\n        )\n        assert r.zrange(\"{foo}places_barcelona\", 0, -1) == [b\"place1\"]\n\n    @skip_unless_arch_bits(64)\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_geosearchstore_dist(self, r):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n\n        r.geoadd(\"{foo}barcelona\", values)\n        r.geosearchstore(\n            \"{foo}places_barcelona\",\n            \"{foo}barcelona\",\n            longitude=2.191,\n            latitude=41.433,\n            radius=1000,\n            storedist=True,\n        )\n        # instead of save the geo score, the distance is saved.\n        assert r.zscore(\"{foo}places_barcelona\", \"place1\") == 88.05060698409301\n\n    @skip_if_server_version_lt(\"3.2.0\")\n    def test_cluster_georadius_store(self, r):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n\n        r.geoadd(\"{foo}barcelona\", values)\n        r.georadius(\n            \"{foo}barcelona\", 2.191, 41.433, 1000, store=\"{foo}places_barcelona\"\n        )\n        assert r.zrange(\"{foo}places_barcelona\", 0, -1) == [b\"place1\"]\n\n    @skip_unless_arch_bits(64)\n    @skip_if_server_version_lt(\"3.2.0\")\n    def test_cluster_georadius_store_dist(self, r):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n\n        r.geoadd(\"{foo}barcelona\", values)\n        r.georadius(\n            \"{foo}barcelona\", 2.191, 41.433, 1000, store_dist=\"{foo}places_barcelona\"\n        )\n        # instead of save the geo score, the distance is saved.\n        assert r.zscore(\"{foo}places_barcelona\", \"place1\") == 88.05060698409301\n\n    def test_cluster_dbsize(self, r):\n        d = {\"a\": b\"1\", \"b\": b\"2\", \"c\": b\"3\", \"d\": b\"4\"}\n        assert r.mset_nonatomic(d)\n        assert r.dbsize(target_nodes=\"primaries\") == len(d)\n\n    def test_cluster_keys(self, r):\n        assert r.keys() == []\n        keys_with_underscores = {b\"test_a\", b\"test_b\"}\n        keys = keys_with_underscores.union({b\"testc\"})\n        for key in keys:\n            r[key] = 1\n        assert (\n            set(r.keys(pattern=\"test_*\", target_nodes=\"primaries\"))\n            == keys_with_underscores\n        )\n        assert set(r.keys(pattern=\"test*\", target_nodes=\"primaries\")) == keys\n\n    # SCAN COMMANDS\n    @skip_if_server_version_lt(\"2.8.0\")\n    def test_cluster_scan(self, r):\n        r.set(\"a\", 1)\n        r.set(\"b\", 2)\n        r.set(\"c\", 3)\n\n        for target_nodes, nodes in zip(\n            [\"primaries\", \"replicas\"], [r.get_primaries(), r.get_replicas()]\n        ):\n            cursors, keys = r.scan(target_nodes=target_nodes)\n            assert sorted(keys) == [b\"a\", b\"b\", b\"c\"]\n            assert sorted(cursors.keys()) == sorted(node.name for node in nodes)\n            assert all(cursor == 0 for cursor in cursors.values())\n\n            cursors, keys = r.scan(match=\"a*\", target_nodes=target_nodes)\n            assert sorted(keys) == [b\"a\"]\n            assert sorted(cursors.keys()) == sorted(node.name for node in nodes)\n            assert all(cursor == 0 for cursor in cursors.values())\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    def test_cluster_scan_type(self, r):\n        r.sadd(\"a-set\", 1)\n        r.sadd(\"b-set\", 1)\n        r.sadd(\"c-set\", 1)\n        r.hset(\"a-hash\", \"foo\", 2)\n        r.lpush(\"a-list\", \"aux\", 3)\n\n        for target_nodes, nodes in zip(\n            [\"primaries\", \"replicas\"], [r.get_primaries(), r.get_replicas()]\n        ):\n            cursors, keys = r.scan(_type=\"SET\", target_nodes=target_nodes)\n            assert sorted(keys) == [b\"a-set\", b\"b-set\", b\"c-set\"]\n            assert sorted(cursors.keys()) == sorted(node.name for node in nodes)\n            assert all(cursor == 0 for cursor in cursors.values())\n\n            cursors, keys = r.scan(_type=\"SET\", match=\"a*\", target_nodes=target_nodes)\n            assert sorted(keys) == [b\"a-set\"]\n            assert sorted(cursors.keys()) == sorted(node.name for node in nodes)\n            assert all(cursor == 0 for cursor in cursors.values())\n\n    @skip_if_server_version_lt(\"2.8.0\")\n    def test_cluster_scan_iter(self, r):\n        keys_all = []\n        keys_1 = []\n        for i in range(100):\n            s = str(i)\n            r.set(s, 1)\n            keys_all.append(s.encode(\"utf-8\"))\n            if s.startswith(\"1\"):\n                keys_1.append(s.encode(\"utf-8\"))\n        keys_all.sort()\n        keys_1.sort()\n\n        for target_nodes in [\"primaries\", \"replicas\"]:\n            keys = r.scan_iter(target_nodes=target_nodes)\n            assert sorted(keys) == keys_all\n\n            keys = r.scan_iter(match=\"1*\", target_nodes=target_nodes)\n            assert sorted(keys) == keys_1\n\n    def test_cluster_randomkey(self, r):\n        node = r.get_node_from_key(\"{foo}\")\n        assert r.randomkey(target_nodes=node) is None\n        for key in (\"{foo}a\", \"{foo}b\", \"{foo}c\"):\n            r[key] = 1\n        assert r.randomkey(target_nodes=node) in (b\"{foo}a\", b\"{foo}b\", b\"{foo}c\")\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    @skip_if_redis_enterprise()\n    def test_acl_log(self, r, request):\n        key = \"{cache}:\"\n        node = r.get_node_from_key(key)\n        username = \"redis-py-user\"\n\n        def teardown():\n            r.acl_deluser(username, target_nodes=\"primaries\")\n\n        request.addfinalizer(teardown)\n        r.acl_setuser(\n            username,\n            enabled=True,\n            reset=True,\n            commands=[\"+get\", \"+set\", \"+select\", \"+cluster\", \"+command\", \"+info\"],\n            keys=[\"{cache}:*\"],\n            nopass=True,\n            target_nodes=\"primaries\",\n        )\n        r.acl_log_reset(target_nodes=node)\n\n        user_client = _get_client(\n            RedisCluster, request, flushdb=False, username=username\n        )\n\n        # Valid operation and key\n        assert user_client.set(\"{cache}:0\", 1)\n        assert user_client.get(\"{cache}:0\") == b\"1\"\n\n        # Invalid key\n        with pytest.raises(NoPermissionError):\n            user_client.get(\"{cache}violated_cache:0\")\n\n        # Invalid operation\n        with pytest.raises(NoPermissionError):\n            user_client.hset(\"{cache}:0\", \"hkey\", \"hval\")\n\n        assert isinstance(r.acl_log(target_nodes=node), list)\n        assert len(r.acl_log(target_nodes=node)) == 3\n        assert len(r.acl_log(count=1, target_nodes=node)) == 1\n        assert isinstance(r.acl_log(target_nodes=node)[0], dict)\n        assert \"client-info\" in r.acl_log(count=1, target_nodes=node)[0]\n        assert r.acl_log_reset(target_nodes=node)\n\n    def generate_lib_code(self, lib_name):\n        return f\"\"\"#!js api_version=1.0 name={lib_name}\\n redis.registerFunction('foo', ()=>{{return 'bar'}})\"\"\"  # noqa\n\n    def try_delete_libs(self, r, *lib_names):\n        for lib_name in lib_names:\n            try:\n                r.tfunction_delete(lib_name)\n            except Exception:\n                pass\n\n    @skip_if_server_version_lt(\"8.5.240\")\n    def test_hotkeys_cluster(self, r):\n        \"\"\"Test all HOTKEYS commands in cluster mode are raising an error\"\"\"\n\n        with pytest.raises(NotImplementedError):\n            r.hotkeys_start(count=10, metrics=[HotkeysMetricsTypes.CPU])\n        with pytest.raises(NotImplementedError):\n            r.hotkeys_get()\n        with pytest.raises(NotImplementedError):\n            r.hotkeys_reset()\n        with pytest.raises(NotImplementedError):\n            r.hotkeys_stop()\n\n\n@pytest.mark.onlycluster\nclass TestNodesManager:\n    \"\"\"\n    Tests for the NodesManager class\n    \"\"\"\n\n    def test_load_balancer(self, r):\n        n_manager = r.nodes_manager\n        lb = n_manager.read_load_balancer\n        slot_1 = 1257\n        slot_2 = 8975\n        node_1 = ClusterNode(default_host, 6379, PRIMARY)\n        node_2 = ClusterNode(default_host, 6378, REPLICA)\n        node_3 = ClusterNode(default_host, 6377, REPLICA)\n        node_4 = ClusterNode(default_host, 6376, PRIMARY)\n        node_5 = ClusterNode(default_host, 6375, REPLICA)\n        n_manager.slots_cache = {\n            slot_1: [node_1, node_2, node_3],\n            slot_2: [node_4, node_5],\n        }\n        primary1_name = n_manager.slots_cache[slot_1][0].name\n        primary2_name = n_manager.slots_cache[slot_2][0].name\n        list1_size = len(n_manager.slots_cache[slot_1])\n        list2_size = len(n_manager.slots_cache[slot_2])\n\n        # default load balancer strategy: LoadBalancerStrategy.ROUND_ROBIN\n        # slot 1\n        assert lb.get_server_index(primary1_name, list1_size) == 0\n        assert lb.get_server_index(primary1_name, list1_size) == 1\n        assert lb.get_server_index(primary1_name, list1_size) == 2\n        assert lb.get_server_index(primary1_name, list1_size) == 0\n        # slot 2\n        assert lb.get_server_index(primary2_name, list2_size) == 0\n        assert lb.get_server_index(primary2_name, list2_size) == 1\n        assert lb.get_server_index(primary2_name, list2_size) == 0\n\n        lb.reset()\n        assert lb.get_server_index(primary1_name, list1_size) == 0\n        assert lb.get_server_index(primary2_name, list2_size) == 0\n\n        # reset the indexes before load balancing strategy test\n        lb.reset()\n        # load balancer strategy: LoadBalancerStrategy.ROUND_ROBIN_REPLICAS\n        for i in [1, 2, 1]:\n            srv_index = lb.get_server_index(\n                primary1_name,\n                list1_size,\n                load_balancing_strategy=LoadBalancingStrategy.ROUND_ROBIN_REPLICAS,\n            )\n            assert srv_index == i\n\n        # reset the indexes before load balancing strategy test\n        lb.reset()  # reset the indexes\n        # load balancer strategy: LoadBalancerStrategy.RANDOM_REPLICA\n        for i in range(5):\n            srv_index = lb.get_server_index(\n                primary1_name,\n                list1_size,\n                load_balancing_strategy=LoadBalancingStrategy.RANDOM_REPLICA,\n            )\n\n            assert srv_index > 0 and srv_index <= 2\n\n    def test_init_slots_cache_not_all_slots_covered(self):\n        \"\"\"\n        Test that if not all slots are covered it should raise an exception\n        \"\"\"\n        # Missing slot 5460\n        cluster_slots = [\n            [0, 5459, [\"127.0.0.1\", 7000], [\"127.0.0.1\", 7003]],\n            [5461, 10922, [\"127.0.0.1\", 7001], [\"127.0.0.1\", 7004]],\n            [10923, 16383, [\"127.0.0.1\", 7002], [\"127.0.0.1\", 7005]],\n        ]\n        with pytest.raises(RedisClusterException) as ex:\n            get_mocked_redis_client(\n                host=default_host,\n                port=default_port,\n                cluster_slots=cluster_slots,\n                require_full_coverage=True,\n            )\n        assert str(ex.value).startswith(\n            \"All slots are not covered after query all startup_nodes.\"\n        )\n\n    def test_init_slots_cache_not_require_full_coverage_success(self):\n        \"\"\"\n        When require_full_coverage is set to False and not all slots are\n        covered the cluster client initialization should succeed\n        \"\"\"\n        # Missing slot 5460\n        cluster_slots = [\n            [0, 5459, [\"127.0.0.1\", 7000], [\"127.0.0.1\", 7003]],\n            [5461, 10922, [\"127.0.0.1\", 7001], [\"127.0.0.1\", 7004]],\n            [10923, 16383, [\"127.0.0.1\", 7002], [\"127.0.0.1\", 7005]],\n        ]\n\n        rc = get_mocked_redis_client(\n            host=default_host,\n            port=default_port,\n            cluster_slots=cluster_slots,\n            require_full_coverage=False,\n        )\n\n        assert 5460 not in rc.nodes_manager.slots_cache\n\n    def test_init_slots_cache(self):\n        \"\"\"\n        Test that slots cache can in initialized and all slots are covered\n        \"\"\"\n        good_slots_resp = [\n            [0, 5460, [\"127.0.0.1\", 7000], [\"127.0.0.2\", 7003]],\n            [5461, 10922, [\"127.0.0.1\", 7001], [\"127.0.0.2\", 7004]],\n            [10923, 16383, [\"127.0.0.1\", 7002], [\"127.0.0.2\", 7005]],\n        ]\n\n        rc = get_mocked_redis_client(\n            host=default_host, port=default_port, cluster_slots=good_slots_resp\n        )\n        n_manager = rc.nodes_manager\n        assert len(n_manager.slots_cache) == REDIS_CLUSTER_HASH_SLOTS\n        for slot_info in good_slots_resp:\n            all_hosts = [\"127.0.0.1\", \"127.0.0.2\"]\n            all_ports = [7000, 7001, 7002, 7003, 7004, 7005]\n            slot_start = slot_info[0]\n            slot_end = slot_info[1]\n            for i in range(slot_start, slot_end + 1):\n                assert len(n_manager.slots_cache[i]) == len(slot_info[2:])\n                assert n_manager.slots_cache[i][0].host in all_hosts\n                assert n_manager.slots_cache[i][1].host in all_hosts\n                assert n_manager.slots_cache[i][0].port in all_ports\n                assert n_manager.slots_cache[i][1].port in all_ports\n\n        assert len(n_manager.nodes_cache) == 6\n\n    def test_init_promote_server_type_for_node_in_cache(self):\n        \"\"\"\n        When replica is promoted to master, nodes_cache must change the server type\n        accordingly\n        \"\"\"\n        cluster_slots_before_promotion = [\n            [0, 16383, [\"127.0.0.1\", 7000], [\"127.0.0.1\", 7003]]\n        ]\n        cluster_slots_after_promotion = [\n            [0, 16383, [\"127.0.0.1\", 7003], [\"127.0.0.1\", 7004]]\n        ]\n\n        cluster_slots_results = [\n            cluster_slots_before_promotion,\n            cluster_slots_after_promotion,\n        ]\n\n        with patch.object(Redis, \"execute_command\") as execute_command_mock:\n\n            def execute_command(*_args, **_kwargs):\n                if _args[0] == \"CLUSTER SLOTS\":\n                    mock_cluster_slots = cluster_slots_results.pop(0)\n                    return mock_cluster_slots\n                elif _args[0] == \"COMMAND\":\n                    return {\"get\": [], \"set\": []}\n                elif _args[0] == \"INFO\":\n                    return {\"cluster_enabled\": True}\n                elif len(_args) > 1 and _args[1] == \"cluster-require-full-coverage\":\n                    return {\"cluster-require-full-coverage\": False}\n                else:\n                    return execute_command_mock(*_args, **_kwargs)\n\n            execute_command_mock.side_effect = execute_command\n\n            nm = NodesManager(\n                startup_nodes=[ClusterNode(host=default_host, port=default_port)],\n                from_url=False,\n                require_full_coverage=False,\n                dynamic_startup_nodes=True,\n            )\n\n            assert nm.default_node.host == \"127.0.0.1\"\n            assert nm.default_node.port == 7000\n            assert nm.default_node.server_type == PRIMARY\n\n            nm.initialize()\n\n            assert nm.default_node.host == \"127.0.0.1\"\n            assert nm.default_node.port == 7003\n            assert nm.default_node.server_type == PRIMARY\n\n    def test_init_slots_cache_cluster_mode_disabled(self):\n        \"\"\"\n        Test that creating a RedisCluster failes if one of the startup nodes\n        has cluster mode disabled\n        \"\"\"\n        with pytest.raises(RedisClusterException) as e:\n            get_mocked_redis_client(\n                cluster_slots_raise_error=True,\n                host=default_host,\n                port=default_port,\n                cluster_enabled=False,\n            )\n            assert \"Cluster mode is not enabled on this node\" in str(e.value)\n\n    def test_empty_startup_nodes(self):\n        \"\"\"\n        It should not be possible to create a node manager with no nodes\n        specified\n        \"\"\"\n        with pytest.raises(RedisClusterException):\n            NodesManager([])\n\n    def test_wrong_startup_nodes_type(self):\n        \"\"\"\n        If something other then a list type itteratable is provided it should\n        fail\n        \"\"\"\n        with pytest.raises(RedisClusterException):\n            NodesManager({})\n\n    def test_init_slots_cache_slots_collision(self, request):\n        \"\"\"\n        Test that if 2 nodes do not agree on the same slots setup it should\n        raise an error. In this test both nodes will say that the first\n        slots block should be bound to different servers.\n        \"\"\"\n        with patch.object(NodesManager, \"create_redis_node\") as create_redis_node:\n\n            def create_mocked_redis_node(host, port, **kwargs):\n                \"\"\"\n                Helper function to return custom slots cache_data data from\n                different redis nodes\n                \"\"\"\n                if port == 7000:\n                    result = [\n                        [0, 5460, [\"127.0.0.1\", 7000], [\"127.0.0.1\", 7003]],\n                        [5461, 10922, [\"127.0.0.1\", 7001], [\"127.0.0.1\", 7004]],\n                    ]\n\n                elif port == 7001:\n                    result = [\n                        [0, 5460, [\"127.0.0.1\", 7001], [\"127.0.0.1\", 7003]],\n                        [5461, 10922, [\"127.0.0.1\", 7000], [\"127.0.0.1\", 7004]],\n                    ]\n                else:\n                    result = []\n\n                r_node = Redis(host=host, port=port)\n\n                orig_execute_command = r_node.execute_command\n\n                def execute_command(*args, **kwargs):\n                    if args[0] == \"CLUSTER SLOTS\":\n                        return result\n                    elif args[0] == \"INFO\":\n                        return {\"cluster_enabled\": True}\n                    elif args[1] == \"cluster-require-full-coverage\":\n                        return {\"cluster-require-full-coverage\": \"yes\"}\n                    else:\n                        return orig_execute_command(*args, **kwargs)\n\n                r_node.execute_command = execute_command\n                return r_node\n\n            create_redis_node.side_effect = create_mocked_redis_node\n\n            with pytest.raises(RedisClusterException) as ex:\n                node_1 = ClusterNode(\"127.0.0.1\", 7000)\n                node_2 = ClusterNode(\"127.0.0.1\", 7001)\n                RedisCluster(startup_nodes=[node_1, node_2])\n            assert str(ex.value).startswith(\n                \"startup_nodes could not agree on a valid slots cache\"\n            ), str(ex.value)\n\n    def test_cluster_one_instance(self):\n        \"\"\"\n        If the cluster exists of only 1 node then there is some hacks that must\n        be validated they work.\n        \"\"\"\n        node = ClusterNode(default_host, default_port)\n        cluster_slots = [[0, 16383, [\"\", default_port]]]\n        rc = get_mocked_redis_client(startup_nodes=[node], cluster_slots=cluster_slots)\n\n        n = rc.nodes_manager\n        assert len(n.nodes_cache) == 1\n        n_node = rc.get_node(node_name=node.name)\n        assert n_node is not None\n        assert n_node == node\n        assert n_node.server_type == PRIMARY\n        assert len(n.slots_cache) == REDIS_CLUSTER_HASH_SLOTS\n        for i in range(0, REDIS_CLUSTER_HASH_SLOTS):\n            assert n.slots_cache[i] == [n_node]\n\n    def test_init_with_down_node(self):\n        \"\"\"\n        If I can't connect to one of the nodes, everything should still work.\n        But if I can't connect to any of the nodes, exception should be thrown.\n        \"\"\"\n        with patch.object(NodesManager, \"create_redis_node\") as create_redis_node:\n\n            def create_mocked_redis_node(host, port, **kwargs):\n                if port == 7000:\n                    raise ConnectionError(\"mock connection error for 7000\")\n\n                r_node = Redis(host=host, port=port, decode_responses=True)\n\n                def execute_command(*args, **kwargs):\n                    if args[0] == \"CLUSTER SLOTS\":\n                        return [\n                            [0, 8191, [\"127.0.0.1\", 7001, \"node_1\"]],\n                            [8192, 16383, [\"127.0.0.1\", 7002, \"node_2\"]],\n                        ]\n                    elif args[0] == \"INFO\":\n                        return {\"cluster_enabled\": True}\n                    elif args[1] == \"cluster-require-full-coverage\":\n                        return {\"cluster-require-full-coverage\": \"yes\"}\n\n                r_node.execute_command = execute_command\n\n                return r_node\n\n            create_redis_node.side_effect = create_mocked_redis_node\n\n            node_1 = ClusterNode(\"127.0.0.1\", 7000)\n            node_2 = ClusterNode(\"127.0.0.1\", 7001)\n\n            # If all startup nodes fail to connect, connection error should be\n            # thrown\n            with pytest.raises(RedisClusterException) as e:\n                RedisCluster(startup_nodes=[node_1])\n            assert \"Redis Cluster cannot be connected\" in str(e.value)\n\n            with patch.object(\n                CommandsParser, \"initialize\", autospec=True\n            ) as cmd_parser_initialize:\n\n                def cmd_init_mock(self, r):\n                    self.commands = {\n                        \"get\": {\n                            \"name\": \"get\",\n                            \"arity\": 2,\n                            \"flags\": [\"readonly\", \"fast\"],\n                            \"first_key_pos\": 1,\n                            \"last_key_pos\": 1,\n                            \"step_count\": 1,\n                        }\n                    }\n\n                cmd_parser_initialize.side_effect = cmd_init_mock\n                # When at least one startup node is reachable, the cluster\n                # initialization should succeeds\n                rc = RedisCluster(startup_nodes=[node_1, node_2])\n                assert rc.get_node(host=default_host, port=7001) is not None\n                assert rc.get_node(host=default_host, port=7002) is not None\n\n    @pytest.mark.parametrize(\"dynamic_startup_nodes\", [True, False])\n    def test_init_slots_dynamic_startup_nodes(self, dynamic_startup_nodes):\n        rc = get_mocked_redis_client(\n            host=\"my@DNS.com\",\n            port=7000,\n            cluster_slots=default_cluster_slots,\n            dynamic_startup_nodes=dynamic_startup_nodes,\n        )\n        # Nodes are taken from default_cluster_slots\n        discovered_nodes = [\n            \"127.0.0.1:7000\",\n            \"127.0.0.1:7001\",\n            \"127.0.0.1:7002\",\n            \"127.0.0.1:7003\",\n        ]\n        startup_nodes = list(rc.nodes_manager.startup_nodes.keys())\n        if dynamic_startup_nodes is True:\n            assert startup_nodes.sort() == discovered_nodes.sort()\n        else:\n            assert startup_nodes == [\"my@DNS.com:7000\"]\n\n    @pytest.mark.parametrize(\n        \"connection_pool_class\", [ConnectionPool, BlockingConnectionPool]\n    )\n    def test_connection_pool_class(self, connection_pool_class):\n        rc = get_mocked_redis_client(\n            url=\"redis://my@DNS.com:7000\",\n            cluster_slots=default_cluster_slots,\n            connection_pool_class=connection_pool_class,\n        )\n\n        for node in rc.nodes_manager.nodes_cache.values():\n            assert isinstance(\n                node.redis_connection.connection_pool, connection_pool_class\n            )\n\n    @pytest.mark.parametrize(\"queue_class\", [Queue, LifoQueue])\n    def test_allow_custom_queue_class(self, queue_class):\n        rc = get_mocked_redis_client(\n            url=\"redis://my@DNS.com:7000\",\n            cluster_slots=default_cluster_slots,\n            connection_pool_class=BlockingConnectionPool,\n            queue_class=queue_class,\n        )\n\n        for node in rc.nodes_manager.nodes_cache.values():\n            assert node.redis_connection.connection_pool.queue_class == queue_class\n\n    def test_concurrent_initialize_exact_timing(self):\n        \"\"\"\n        Test that exactly two concurrent initialize calls result in only\n        one actual cluster slots fetch by forcing them to start simultaneously\n        \"\"\"\n        initialization_count = {\"count\": 0}\n        epoch_barrier = threading.Barrier(2)\n\n        with (\n            patch.object(Redis, \"execute_command\") as execute_command_mock,\n        ):\n\n            def execute_command(*_args, **_kwargs):\n                if _args[0] == \"CLUSTER SLOTS\":\n                    # Track how many times we actually fetch cluster slots\n                    initialization_count[\"count\"] += 1\n                    return default_cluster_slots\n                else:\n                    return execute_command_mock(*_args, **_kwargs)\n\n            execute_command_mock.side_effect = execute_command\n\n            nm = NodesManager(\n                startup_nodes=[ClusterNode(host=default_host, port=default_port)],\n                from_url=False,\n                require_full_coverage=False,\n                dynamic_startup_nodes=True,\n            )\n\n            # Reset the counter after initial setup\n            initialization_count[\"count\"] = 0\n\n            # Store the original method\n            original_get_epoch = nm._get_epoch\n\n            def mocked_get_epoch():\n                \"\"\"\n                Mock _get_epoch to control race timing:\n                1. First thread fetches epoch\n                2. Both threads sync at epoch_barrier (ensures 2nd thread also fetches epoch)\n                3. Both threads sync at proceed_barrier (ensures both have same epoch before lock)\n                4. Both threads proceed to try to acquire _initialization_lock\n                \"\"\"\n                epoch = original_get_epoch()\n                # Signal that this thread has fetched the epoch\n                epoch_barrier.wait()\n                return epoch\n\n            # Patch the instance method directly\n            nm._get_epoch = mocked_get_epoch\n\n            errors: list[Exception] = []\n\n            def initialize_thread():\n                \"\"\"Call initialize to test concurrent access\"\"\"\n                try:\n                    nm.initialize()\n                except Exception as e:\n                    errors.append(e)\n\n            # Create exactly 2 threads that will initialize at the same time\n            threads: list[threading.Thread] = []\n            for _ in range(2):\n                threads.append(threading.Thread(target=initialize_thread))\n\n            # Start both threads\n            for t in threads:\n                t.start()\n\n            # Wait for both threads to complete\n            for t in threads:\n                t.join()\n\n            # Check that no errors occurred\n            assert len(errors) == 0, f\"Errors occurred: {errors}\"\n\n            # Due to the _initialization_lock, only one thread should have\n            # actually fetched cluster slots\n            assert initialization_count[\"count\"] == 1\n\n            # Verify that the nodes_cache is still consistent\n            assert len(nm.nodes_cache) > 0\n            assert len(nm.slots_cache) > 0\n\n    def test_concurrent_slot_moves(self):\n        # ensure multiple concurrently moved slots are processed correctly,\n        # eg: not dropping updates\n        r = get_mocked_redis_client(\n            host=default_host,\n            port=default_port,\n            cluster_enabled=True,\n        )\n        nm = r.nodes_manager\n        # Move slots 0-999 to 127.0.0.1 in concurrent threads\n        num_threads = 20\n        slots_per_thread = 50  # 1000 slots / 20 threads = 50 slots per thread\n        errors: list[Exception] = []\n\n        def move_slots_worker(thread_id: int):\n            \"\"\"Each thread moves a subset of slots to 127.0.0.1\"\"\"\n            try:\n                for i in range(slots_per_thread):\n                    moved_error = MovedError(\n                        f\"{thread_id * slots_per_thread + i} 127.0.0.1:7001\"\n                    )\n                    nm.move_slot(moved_error)\n            except Exception as e:\n                errors.append(e)\n\n        # Start all threads\n        threads: list[threading.Thread] = []\n        for i in range(num_threads):\n            threads.append(threading.Thread(target=move_slots_worker, args=(i,)))\n\n        for t in threads:\n            t.start()\n\n        # Wait for all threads to complete\n        for t in threads:\n            t.join()\n\n        # Check that no errors occurred\n        assert len(errors) == 0, f\"Errors occurred: {errors}\"\n\n        # Verify that all slots 0-999 are moved to 127.0.0.1:7001\n        for slot_id in range(num_threads * slots_per_thread):\n            assert slot_id in nm.slots_cache, f\"Slot {slot_id} missing\"\n            slot_nodes = nm.slots_cache[slot_id]\n            assert len(slot_nodes) >= 1, f\"Slot {slot_id} has no nodes\"\n            primary_node = slot_nodes[0]\n            assert primary_node.host == \"127.0.0.1\", (\n                f\"Slot {slot_id} not moved to 127.0.0.1, \"\n                f\"current host: {primary_node.host}\"\n            )\n            assert primary_node.port == 7001, (\n                f\"Slot {slot_id} not moved to port 7001, \"\n                f\"current port: {primary_node.port}\"\n            )\n            assert primary_node.server_type == PRIMARY\n\n    def test_concurrent_initialize_and_move_slot(self):\n        # race initialize & move slot to ensure that the two operations\n        # don't conflict with each other.\n\n        with (\n            patch.object(Redis, \"execute_command\") as execute_command_mock,\n        ):\n            r = get_mocked_redis_client(\n                host=default_host,\n                port=default_port,\n                cluster_enabled=True,\n            )\n            nm = r.nodes_manager\n\n            def execute_command(*_args, **_kwargs):\n                if _args[0] == \"CLUSTER SLOTS\":\n                    return default_cluster_slots\n                else:\n                    return execute_command_mock(*_args, **_kwargs)\n\n            execute_command_mock.side_effect = execute_command\n\n            errors: list[Exception] = []\n\n            def initialize_worker():\n                \"\"\"Reinitialize the cluster\"\"\"\n                try:\n                    nm.initialize()\n                except Exception as e:\n                    errors.append(e)\n\n            def move_slots_worker():\n                \"\"\"Move slots while initialize is running\"\"\"\n                for slot_id in range(10):\n                    try:\n                        # move slot to a mix of :7000 & :7003, which simulates failovers\n                        # within the same shard. Using nodes from the same shard ensures\n                        # move_slot preserves the 2-node structure (primary + replica),\n                        # so both initialize() and move_slot() result in 2 nodes per slot.\n                        new_slot = 7000 if slot_id % 2 == 0 else 7003\n                        moved_error = MovedError(f\"{slot_id} 127.0.0.1:{new_slot}\")\n                        nm.move_slot(moved_error)\n                    except Exception as e:\n                        errors.append(e)\n\n            for _ in range(100):\n                t1 = threading.Thread(target=initialize_worker)\n                t2 = threading.Thread(target=move_slots_worker)\n\n                t1.start()\n                t2.start()\n\n                t1.join()\n                t2.join()\n\n                # check that no errors occurred\n                assert len(errors) == 0, f\"Errors occurred: {errors}\"\n\n                # verify data consistency\n                for slot_id in range(REDIS_CLUSTER_HASH_SLOTS):\n                    assert slot_id in nm.slots_cache, f\"Slot {slot_id} missing\"\n                    slot_nodes = nm.slots_cache[slot_id]\n                    assert len(slot_nodes) == 2\n\n                    for node in slot_nodes:\n                        assert node.name in nm.nodes_cache\n\n                    # primary should be first\n                    assert slot_nodes[0].server_type == PRIMARY\n\n    def test_move_node_to_end_of_cached_nodes(self):\n        \"\"\"\n        Test that move_node_to_end_of_cached_nodes moves a node to the end of\n        startup_nodes and nodes_cache.\n        \"\"\"\n        node1 = ClusterNode(default_host, 7000)\n        node2 = ClusterNode(default_host, 7001)\n        node3 = ClusterNode(default_host, 7002)\n\n        with patch.object(NodesManager, \"initialize\"):\n            nodes_manager = NodesManager(\n                startup_nodes=[node1, node2, node3],\n                require_full_coverage=False,\n            )\n            # Also populate nodes_cache with the same nodes\n            nodes_manager.nodes_cache = {\n                node1.name: node1,\n                node2.name: node2,\n                node3.name: node3,\n            }\n\n            # Verify initial order\n            startup_node_names = list(nodes_manager.startup_nodes.keys())\n            nodes_cache_names = list(nodes_manager.nodes_cache.keys())\n            assert startup_node_names == [node1.name, node2.name, node3.name]\n            assert nodes_cache_names == [node1.name, node2.name, node3.name]\n\n            # Move first node to end\n            nodes_manager.move_node_to_end_of_cached_nodes(node1.name)\n            startup_node_names = list(nodes_manager.startup_nodes.keys())\n            nodes_cache_names = list(nodes_manager.nodes_cache.keys())\n            assert startup_node_names == [node2.name, node3.name, node1.name]\n            assert nodes_cache_names == [node2.name, node3.name, node1.name]\n\n            # Move middle node to end\n            nodes_manager.move_node_to_end_of_cached_nodes(node3.name)\n            startup_node_names = list(nodes_manager.startup_nodes.keys())\n            nodes_cache_names = list(nodes_manager.nodes_cache.keys())\n            assert startup_node_names == [node2.name, node1.name, node3.name]\n            assert nodes_cache_names == [node2.name, node1.name, node3.name]\n\n            # Moving last node should keep it at the end\n            nodes_manager.move_node_to_end_of_cached_nodes(node3.name)\n            startup_node_names = list(nodes_manager.startup_nodes.keys())\n            nodes_cache_names = list(nodes_manager.nodes_cache.keys())\n            assert startup_node_names == [node2.name, node1.name, node3.name]\n            assert nodes_cache_names == [node2.name, node1.name, node3.name]\n\n    def test_move_node_to_end_of_cached_nodes_nonexistent(self):\n        \"\"\"\n        Test that move_node_to_end_of_cached_nodes does nothing for a\n        nonexistent node.\n        \"\"\"\n        node1 = ClusterNode(default_host, 7000)\n        node2 = ClusterNode(default_host, 7001)\n\n        with patch.object(NodesManager, \"initialize\"):\n            nodes_manager = NodesManager(\n                startup_nodes=[node1, node2],\n                require_full_coverage=False,\n            )\n            # Also populate nodes_cache\n            nodes_manager.nodes_cache = {node1.name: node1, node2.name: node2}\n\n            # Try to move a non-existent node - should not raise\n            nodes_manager.move_node_to_end_of_cached_nodes(\"nonexistent:9999\")\n            startup_node_names = list(nodes_manager.startup_nodes.keys())\n            nodes_cache_names = list(nodes_manager.nodes_cache.keys())\n            assert startup_node_names == [node1.name, node2.name]\n            assert nodes_cache_names == [node1.name, node2.name]\n\n    def test_move_node_to_end_of_cached_nodes_single_node(self):\n        \"\"\"\n        Test that move_node_to_end_of_cached_nodes does nothing when there's\n        only one node.\n        \"\"\"\n        node1 = ClusterNode(default_host, 7000)\n\n        with patch.object(NodesManager, \"initialize\"):\n            nodes_manager = NodesManager(\n                startup_nodes=[node1],\n                require_full_coverage=False,\n            )\n            # Also populate nodes_cache\n            nodes_manager.nodes_cache = {node1.name: node1}\n\n            # Should not raise or change anything with single node\n            nodes_manager.move_node_to_end_of_cached_nodes(node1.name)\n            startup_node_names = list(nodes_manager.startup_nodes.keys())\n            nodes_cache_names = list(nodes_manager.nodes_cache.keys())\n            assert startup_node_names == [node1.name]\n            assert nodes_cache_names == [node1.name]\n\n\n@pytest.mark.onlycluster\nclass TestClusterPubSubObject:\n    \"\"\"\n    Tests for the ClusterPubSub class\n    \"\"\"\n\n    def test_init_pubsub_with_host_and_port(self, r):\n        \"\"\"\n        Test creation of pubsub instance with passed host and port\n        \"\"\"\n        node = r.get_default_node()\n        p = r.pubsub(host=node.host, port=node.port)\n        assert p.get_pubsub_node() == node\n\n    def test_init_pubsub_with_node(self, r):\n        \"\"\"\n        Test creation of pubsub instance with passed node\n        \"\"\"\n        node = r.get_default_node()\n        p = r.pubsub(node=node)\n        assert p.get_pubsub_node() == node\n\n    def test_init_pubusub_without_specifying_node(self, r):\n        \"\"\"\n        Test creation of pubsub instance without specifying a node. The node\n        should be determined based on the keyslot of the first command\n        execution.\n        \"\"\"\n        channel_name = \"foo\"\n        node = r.get_node_from_key(channel_name)\n        p = r.pubsub()\n        assert p.get_pubsub_node() is None\n        p.subscribe(channel_name)\n        assert p.get_pubsub_node() == node\n\n    def test_init_pubsub_with_a_non_existent_node(self, r):\n        \"\"\"\n        Test creation of pubsub instance with node that doesn't exists in the\n        cluster. RedisClusterException should be raised.\n        \"\"\"\n        node = ClusterNode(\"1.1.1.1\", 1111)\n        with pytest.raises(RedisClusterException):\n            r.pubsub(node)\n\n    def test_init_pubsub_with_a_non_existent_host_port(self, r):\n        \"\"\"\n        Test creation of pubsub instance with host and port that don't belong\n        to a node in the cluster.\n        RedisClusterException should be raised.\n        \"\"\"\n        with pytest.raises(RedisClusterException):\n            r.pubsub(host=\"1.1.1.1\", port=1111)\n\n    def test_init_pubsub_host_or_port(self, r):\n        \"\"\"\n        Test creation of pubsub instance with host but without port, and vice\n        versa. DataError should be raised.\n        \"\"\"\n        with pytest.raises(DataError):\n            r.pubsub(host=\"localhost\")\n\n        with pytest.raises(DataError):\n            r.pubsub(port=16379)\n\n    def test_get_redis_connection(self, r):\n        \"\"\"\n        Test that get_redis_connection() returns the redis connection of the\n        set pubsub node\n        \"\"\"\n        node = r.get_default_node()\n        p = r.pubsub(node=node)\n        assert p.get_redis_connection() == node.redis_connection\n\n\n@pytest.mark.onlycluster\nclass TestClusterPipeline:\n    \"\"\"\n    Tests for the ClusterPipeline class\n    \"\"\"\n\n    def test_blocked_methods(self, r):\n        \"\"\"\n        Currently some method calls on a Cluster pipeline\n        is blocked when using in cluster mode.\n        They maybe implemented in the future.\n        \"\"\"\n        pipe = r.pipeline()\n\n        with pytest.raises(RedisClusterException):\n            pipe.load_scripts()\n\n        with pytest.raises(RedisClusterException):\n            pipe.script_load_for_pipeline(None)\n\n        with pytest.raises(RedisClusterException):\n            pipe.eval()\n\n    def test_blocked_arguments(self, r):\n        \"\"\"\n        Currently some arguments is blocked when using in cluster mode.\n        They maybe implemented in the future.\n        \"\"\"\n        with pytest.raises(RedisClusterException) as ex:\n            r.pipeline(shard_hint=True)\n\n        assert (\n            str(ex.value).startswith(\"shard_hint is deprecated in cluster mode\") is True\n        )\n\n    def test_redis_cluster_pipeline(self, r):\n        \"\"\"\n        Test that we can use a pipeline with the RedisCluster class\n        \"\"\"\n        with r.pipeline() as pipe:\n            pipe.set(\"foo\", \"bar\")\n            pipe.get(\"foo\")\n            assert pipe.execute() == [True, b\"bar\"]\n\n    def test_mget_disabled(self, r):\n        \"\"\"\n        Test that mget is disabled for ClusterPipeline\n        \"\"\"\n        with r.pipeline() as pipe:\n            with pytest.raises(RedisClusterException):\n                pipe.mget([\"a\"])\n\n    def test_mset_disabled(self, r):\n        \"\"\"\n        Test that mset is disabled for ClusterPipeline\n        \"\"\"\n        with r.pipeline() as pipe:\n            with pytest.raises(RedisClusterException):\n                pipe.mset({\"a\": 1, \"b\": 2})\n\n    def test_rename_disabled(self, r):\n        \"\"\"\n        Test that rename is disabled for ClusterPipeline\n        \"\"\"\n        with r.pipeline(transaction=False) as pipe:\n            with pytest.raises(RedisClusterException):\n                pipe.rename(\"a\", \"b\")\n\n    def test_renamenx_disabled(self, r):\n        \"\"\"\n        Test that renamenx is disabled for ClusterPipeline\n        \"\"\"\n        with r.pipeline(transaction=False) as pipe:\n            with pytest.raises(RedisClusterException):\n                pipe.renamenx(\"a\", \"b\")\n\n    def test_delete_single(self, r):\n        \"\"\"\n        Test a single delete operation\n        \"\"\"\n        r[\"a\"] = 1\n        with r.pipeline(transaction=False) as pipe:\n            pipe.delete(\"a\")\n            assert pipe.execute() == [1]\n\n    def test_multi_delete_unsupported_cross_slot(self, r):\n        \"\"\"\n        Test that multi delete operation is unsupported\n        \"\"\"\n        with r.pipeline(transaction=False) as pipe:\n            r[\"a\"] = 1\n            r[\"b\"] = 2\n            with pytest.raises(RedisClusterException):\n                pipe.delete(\"a\", \"b\")\n\n    def test_multi_delete_supported_single_slot(self, r):\n        \"\"\"\n        Test that multi delete operation is supported when all keys are in the same hash slot\n        \"\"\"\n        with r.pipeline(transaction=True) as pipe:\n            r[\"{key}:a\"] = 1\n            r[\"{key}:b\"] = 2\n            pipe.delete(\"{key}:a\", \"{key}:b\")\n            assert pipe.execute()\n\n    def test_unlink_single(self, r):\n        \"\"\"\n        Test a single unlink operation\n        \"\"\"\n        r[\"a\"] = 1\n        with r.pipeline(transaction=False) as pipe:\n            pipe.unlink(\"a\")\n            assert pipe.execute() == [1]\n\n    def test_multi_unlink_unsupported(self, r):\n        \"\"\"\n        Test that multi unlink operation is unsupported\n        \"\"\"\n        with r.pipeline(transaction=False) as pipe:\n            r[\"a\"] = 1\n            r[\"b\"] = 2\n            with pytest.raises(RedisClusterException):\n                pipe.unlink(\"a\", \"b\")\n\n    def test_brpoplpush_disabled(self, r):\n        \"\"\"\n        Test that brpoplpush is disabled for ClusterPipeline\n        \"\"\"\n        with r.pipeline(transaction=False) as pipe:\n            with pytest.raises(RedisClusterException):\n                pipe.brpoplpush()\n\n    def test_rpoplpush_disabled(self, r):\n        \"\"\"\n        Test that rpoplpush is disabled for ClusterPipeline\n        \"\"\"\n        with r.pipeline(transaction=False) as pipe:\n            with pytest.raises(RedisClusterException):\n                pipe.rpoplpush()\n\n    def test_sort_disabled(self, r):\n        \"\"\"\n        Test that sort is disabled for ClusterPipeline\n        \"\"\"\n        with r.pipeline(transaction=False) as pipe:\n            with pytest.raises(RedisClusterException):\n                pipe.sort()\n\n    def test_sdiff_disabled(self, r):\n        \"\"\"\n        Test that sdiff is disabled for ClusterPipeline\n        \"\"\"\n        with r.pipeline(transaction=False) as pipe:\n            with pytest.raises(RedisClusterException):\n                pipe.sdiff()\n\n    def test_sdiffstore_disabled(self, r):\n        \"\"\"\n        Test that sdiffstore is disabled for ClusterPipeline\n        \"\"\"\n        with r.pipeline(transaction=False) as pipe:\n            with pytest.raises(RedisClusterException):\n                pipe.sdiffstore()\n\n    def test_sinter_disabled(self, r):\n        \"\"\"\n        Test that sinter is disabled for ClusterPipeline\n        \"\"\"\n        with r.pipeline(transaction=False) as pipe:\n            with pytest.raises(RedisClusterException):\n                pipe.sinter()\n\n    def test_sinterstore_disabled(self, r):\n        \"\"\"\n        Test that sinterstore is disabled for ClusterPipeline\n        \"\"\"\n        with r.pipeline(transaction=False) as pipe:\n            with pytest.raises(RedisClusterException):\n                pipe.sinterstore()\n\n    def test_smove_disabled(self, r):\n        \"\"\"\n        Test that move is disabled for ClusterPipeline\n        \"\"\"\n        with r.pipeline(transaction=False) as pipe:\n            with pytest.raises(RedisClusterException):\n                pipe.smove()\n\n    def test_sunion_disabled(self, r):\n        \"\"\"\n        Test that sunion is disabled for ClusterPipeline\n        \"\"\"\n        with r.pipeline(transaction=False) as pipe:\n            with pytest.raises(RedisClusterException):\n                pipe.sunion()\n\n    def test_sunionstore_disabled(self, r):\n        \"\"\"\n        Test that sunionstore is disabled for ClusterPipeline\n        \"\"\"\n        with r.pipeline(transaction=False) as pipe:\n            with pytest.raises(RedisClusterException):\n                pipe.sunionstore()\n\n    def test_spfmerge_disabled(self, r):\n        \"\"\"\n        Test that spfmerge is disabled for ClusterPipeline\n        \"\"\"\n        with r.pipeline(transaction=False) as pipe:\n            with pytest.raises(RedisClusterException):\n                pipe.pfmerge()\n\n    def test_multi_key_operation_with_a_single_slot(self, r):\n        \"\"\"\n        Test multi key operation with a single slot\n        \"\"\"\n        pipe = r.pipeline(transaction=False)\n        pipe.set(\"a{foo}\", 1)\n        pipe.set(\"b{foo}\", 2)\n        pipe.set(\"c{foo}\", 3)\n        pipe.get(\"a{foo}\")\n        pipe.get(\"b{foo}\")\n        pipe.get(\"c{foo}\")\n\n        res = pipe.execute()\n        assert res == [True, True, True, b\"1\", b\"2\", b\"3\"]\n\n    def test_multi_key_operation_with_multi_slots(self, r):\n        \"\"\"\n        Test multi key operation with more than one slot\n        \"\"\"\n        pipe = r.pipeline(transaction=False)\n        pipe.set(\"a{foo}\", 1)\n        pipe.set(\"b{foo}\", 2)\n        pipe.set(\"c{foo}\", 3)\n        pipe.set(\"bar\", 4)\n        pipe.set(\"bazz\", 5)\n        pipe.get(\"a{foo}\")\n        pipe.get(\"b{foo}\")\n        pipe.get(\"c{foo}\")\n        pipe.get(\"bar\")\n        pipe.get(\"bazz\")\n        res = pipe.execute()\n        assert res == [True, True, True, True, True, b\"1\", b\"2\", b\"3\", b\"4\", b\"5\"]\n\n    def test_connection_error_not_raised(self, r):\n        \"\"\"\n        Test that the pipeline doesn't raise an error on connection error when\n        raise_on_error=False\n        \"\"\"\n        key = \"foo\"\n        node = r.get_node_from_key(key, False)\n\n        def raise_connection_error():\n            e = ConnectionError(\"error\")\n            return e\n\n        with r.pipeline() as pipe:\n            mock_node_resp_func(node, raise_connection_error)\n            res = pipe.get(key).get(key).execute(raise_on_error=False)\n            assert node.redis_connection.connection.read_response.called\n            assert isinstance(res[0], ConnectionError)\n\n    def test_connection_error_raised(self, r):\n        \"\"\"\n        Test that the pipeline raises an error on connection error when\n        raise_on_error=True\n        \"\"\"\n        key = \"foo\"\n        node = r.get_node_from_key(key, False)\n\n        def raise_connection_error():\n            e = ConnectionError(\"error\")\n            return e\n\n        with r.pipeline() as pipe:\n            mock_node_resp_func(node, raise_connection_error)\n            with pytest.raises(ConnectionError):\n                pipe.get(key).get(key).execute(raise_on_error=True)\n\n    def test_asking_error(self, r):\n        \"\"\"\n        Test redirection on ASK error\n        \"\"\"\n        key = \"foo\"\n        first_node = r.get_node_from_key(key, False)\n        ask_node = None\n        for node in r.get_nodes():\n            if node != first_node:\n                ask_node = node\n                break\n        if ask_node is None:\n            warnings.warn(\"skipping this test since the cluster has only one node\")\n            return\n        ask_msg = f\"{r.keyslot(key)} {ask_node.host}:{ask_node.port}\"\n\n        def raise_ask_error():\n            raise AskError(ask_msg)\n\n        with r.pipeline() as pipe:\n            mock_node_resp_func(first_node, raise_ask_error)\n            mock_node_resp(ask_node, \"MOCK_OK\")\n            res = pipe.get(key).execute()\n            assert first_node.redis_connection.connection.read_response.called\n            assert ask_node.redis_connection.connection.read_response.called\n            assert res == [\"MOCK_OK\"]\n\n    def test_error_is_truncated(self, r):\n        \"\"\"\n        Test that an error from the pipeline is truncated correctly.\n        \"\"\"\n        key = \"a\" * 50\n        a_value = \"a\" * 20\n        b_value = \"b\" * 20\n\n        with r.pipeline() as pipe:\n            pipe.set(key, 1)\n            pipe.hset(key, mapping={\"field_a\": a_value, \"field_b\": b_value})\n            pipe.expire(key, 100)\n\n            with pytest.raises(Exception) as ex:\n                pipe.execute()\n\n            expected = f\"Command # 2 (HSET {key} field_a {a_value} field_b...) of pipeline caused error: \"\n            assert str(ex.value).startswith(expected)\n\n    def test_return_previously_acquired_connections(self, r):\n        # in order to ensure that a pipeline will make use of connections\n        #   from different nodes\n        assert r.keyslot(\"a\") != r.keyslot(\"b\")\n\n        orig_func = redis.cluster.get_connection\n        with patch(\"redis.cluster.get_connection\") as get_connection:\n\n            def raise_error(target_node, *args, **kwargs):\n                if get_connection.call_count == 2:\n                    raise ConnectionError(\"mocked error\")\n                else:\n                    return orig_func(target_node, *args, **kwargs)\n\n            get_connection.side_effect = raise_error\n\n            r.pipeline().get(\"a\").get(\"b\").execute()\n\n        # 4 = 2 get_connections per execution * 2 executions\n        assert get_connection.call_count == 4\n        for cluster_node in r.nodes_manager.nodes_cache.values():\n            connection_pool = cluster_node.redis_connection.connection_pool\n            num_of_conns = len(connection_pool._available_connections)\n            assert num_of_conns == connection_pool._created_connections\n\n    def test_empty_stack(self, r):\n        \"\"\"\n        If pipeline is executed with no commands it should\n        return a empty list.\n        \"\"\"\n        p = r.pipeline()\n        result = p.execute()\n        assert result == []\n\n    @pytest.mark.onlycluster\n    def test_exec_error_in_response(self, r):\n        \"\"\"\n        an invalid pipeline command at exec time adds the exception instance\n        to the list of returned values\n        \"\"\"\n        hashkey = \"{key}\"\n        r[f\"{hashkey}:c\"] = \"a\"\n        with r.pipeline() as pipe:\n            pipe.set(f\"{hashkey}:a\", 1).set(f\"{hashkey}:b\", 2)\n            pipe.lpush(f\"{hashkey}:c\", 3).set(f\"{hashkey}:d\", 4)\n            result = pipe.execute(raise_on_error=False)\n\n            assert result[0]\n            assert r[f\"{hashkey}:a\"] == b\"1\"\n            assert result[1]\n            assert r[f\"{hashkey}:b\"] == b\"2\"\n\n            # we can't lpush to a key that's a string value, so this should\n            # be a ResponseError exception\n            assert isinstance(result[2], redis.ResponseError)\n            assert r[f\"{hashkey}:c\"] == b\"a\"\n\n            # since this isn't a transaction, the other commands after the\n            # error are still executed\n            assert result[3]\n            assert r[f\"{hashkey}:d\"] == b\"4\"\n\n            # make sure the pipe was restored to a working state\n            assert pipe.set(f\"{hashkey}:z\", \"zzz\").execute() == [True]\n            assert r[f\"{hashkey}:z\"] == b\"zzz\"\n\n    def test_exec_error_in_no_transaction_pipeline(self, r):\n        r[\"a\"] = 1\n        with r.pipeline(transaction=False) as pipe:\n            pipe.llen(\"a\")\n            pipe.expire(\"a\", 100)\n\n            with pytest.raises(redis.ResponseError) as ex:\n                pipe.execute()\n\n            assert str(ex.value).startswith(\n                \"Command # 1 (LLEN a) of pipeline caused error: \"\n            )\n\n        assert r[\"a\"] == b\"1\"\n\n    @pytest.mark.onlycluster\n    @skip_if_server_version_lt(\"2.0.0\")\n    def test_pipeline_discard(self, r):\n        hashkey = \"{key}\"\n\n        # empty pipeline should raise an error\n        with r.pipeline() as pipe:\n            pipe.set(f\"{hashkey}:key\", \"someval\")\n            with pytest.raises(redis.exceptions.RedisClusterException) as ex:\n                pipe.discard()\n\n            assert str(ex.value).startswith(\n                \"method discard() is not supported outside of transactional context\"\n            )\n\n        # setting a pipeline and discarding should do the same\n        with r.pipeline() as pipe:\n            pipe.set(f\"{hashkey}:key\", \"someval\")\n            pipe.set(f\"{hashkey}:someotherkey\", \"val\")\n            response = pipe.execute()\n            pipe.set(f\"{hashkey}:key\", \"another value!\")\n            with pytest.raises(redis.exceptions.RedisClusterException) as ex:\n                pipe.discard()\n\n            assert str(ex.value).startswith(\n                \"method discard() is not supported outside of transactional context\"\n            )\n\n            pipe.set(f\"{hashkey}:foo\", \"bar\")\n            response = pipe.execute()\n\n        assert response[0]\n        assert r.get(f\"{hashkey}:foo\") == b\"bar\"\n\n    def test_connection_leak_on_non_timeout_error_during_connect(self, r):\n        \"\"\"\n        Test that connections are not leaked when a non-TimeoutError/ConnectionError\n        is raised during get_connection(). The bugfix ensures that if an error\n        occurs that isn't explicitly handled, we don't leak connections.\n        \"\"\"\n        # Ensure keys map to different nodes\n        assert r.keyslot(\"a\") != r.keyslot(\"b\")\n\n        orig_func = redis.cluster.get_connection\n        with patch(\"redis.cluster.get_connection\") as get_connection:\n\n            def raise_custom_error(target_node, *args, **kwargs):\n                # Raise a RuntimeError (not ConnectionError or TimeoutError)\n                # on the second call (when getting second connection)\n                if get_connection.call_count == 2:\n                    raise RuntimeError(\"Some unexpected error during connection\")\n                else:\n                    return orig_func(target_node, *args, **kwargs)\n\n            get_connection.side_effect = raise_custom_error\n\n            with pytest.raises(RuntimeError):\n                r.pipeline().get(\"a\").get(\"b\").execute()\n\n        # Verify that all connections were returned to the pool\n        # (not leaked) even though a non-standard error was raised\n        for cluster_node in r.nodes_manager.nodes_cache.values():\n            connection_pool = cluster_node.redis_connection.connection_pool\n            num_of_conns = len(connection_pool._available_connections)\n            assert num_of_conns == connection_pool._created_connections, (\n                f\"Connection leaked: expected {connection_pool._created_connections} \"\n                f\"available, got {num_of_conns}\"\n            )\n\n    def test_dirty_connection_not_reused(self, r):\n        \"\"\"\n        Test that dirty connections (with unread responses) are not reused.\n        A dirty connection is one where we've written commands but haven't\n        read all responses. If such a connection is returned to the pool,\n        the next caller will read responses from the previous request.\n        \"\"\"\n        # Ensure we're using multiple nodes to test the dirty connection scenario\n        assert r.keyslot(\"a\") != r.keyslot(\"b\")\n\n        # Mock the write method to raise an error after writing to only some nodes\n        orig_write = redis.cluster.NodeCommands.write\n\n        write_count = 0\n\n        def mock_write(self):\n            nonlocal write_count\n            write_count += 1\n            # Allow the first write to succeed\n            if write_count == 1:\n                return orig_write(self)\n            # Simulate a failure after the first write (leaving connection dirty)\n            else:\n                raise RuntimeError(\"Simulated write error\")\n\n        # Patch Connection.disconnect so we can assert that at least one\n        # connection was disconnected when the write error occurred.\n        original_disconnect = Connection.disconnect\n        disconnect_called = []\n\n        def track_disconnect(self, *args):\n            disconnect_called.append(True)\n            return original_disconnect(self, *args)\n\n        with patch.object(Connection, \"disconnect\", track_disconnect):\n            with patch.object(redis.cluster.NodeCommands, \"write\", mock_write):\n                with pytest.raises(RuntimeError):\n                    r.pipeline().get(\"a\").get(\"b\").execute()\n\n            # Ensure that at least one connection was disconnected as part of\n            # handling the dirty connection created by the write failure.\n            assert disconnect_called, (\n                \"Expected at least one connection to be disconnected when \"\n                \"handling a dirty connection, but disconnect() was not called.\"\n            )\n        # After the error, verify that no connections are in the available pool\n        # with dirty state (unread responses). If a connection is dirty, it should\n        # have been disconnected before being returned to the pool.\n        # We verify this by checking the connections can be reused successfully.\n        try:\n            # Try to execute a command on each connection to verify\n            # they're clean (not holding responses from previous requests)\n            result = r.ping()\n            assert result is True\n        except Exception as e:\n            pytest.fail(\n                f\"Connection reuse after dirty state failed: {e}. \"\n                f\"This indicates a dirty connection was returned to the pool.\"\n            )\n\n\n@pytest.mark.onlycluster\nclass TestReadOnlyPipeline:\n    \"\"\"\n    Tests for ClusterPipeline class in readonly mode\n    \"\"\"\n\n    def test_pipeline_readonly(self, r):\n        \"\"\"\n        On readonly mode, we supports get related stuff only.\n        \"\"\"\n        r.readonly(target_nodes=\"all\")\n        r.set(\"foo71\", \"a1\")  # we assume this key is set on 127.0.0.1:7001\n        r.zadd(\"foo88\", {\"z1\": 1})  # we assume this key is set on 127.0.0.1:7002\n        r.zadd(\"foo88\", {\"z2\": 4})\n\n        with r.pipeline() as readonly_pipe:\n            readonly_pipe.get(\"foo71\").zrange(\"foo88\", 0, 5, withscores=True)\n            assert_resp_response(\n                r,\n                readonly_pipe.execute(),\n                [b\"a1\", [(b\"z1\", 1.0), (b\"z2\", 4)]],\n                [b\"a1\", [[b\"z1\", 1.0], [b\"z2\", 4.0]]],\n            )\n\n    def test_moved_redirection_on_slave_with_default(self, r):\n        \"\"\"\n        On Pipeline, we redirected once and finally get from master with\n        readonly client when data is completely moved.\n        \"\"\"\n        key = \"bar\"\n        r.set(key, \"foo\")\n        # set read_from_replicas to True\n        r.read_from_replicas = True\n        primary = r.get_node_from_key(key, False)\n        replica = r.get_node_from_key(key, True)\n        with r.pipeline() as readwrite_pipe:\n            mock_node_resp(primary, \"MOCK_FOO\")\n            if replica is not None:\n                moved_error = f\"{r.keyslot(key)} {primary.host}:{primary.port}\"\n\n                def raise_moved_error():\n                    raise MovedError(moved_error)\n\n                mock_node_resp_func(replica, raise_moved_error)\n            assert readwrite_pipe.reinitialize_counter == 0\n            readwrite_pipe.get(key).get(key)\n            assert readwrite_pipe.execute() == [\"MOCK_FOO\", \"MOCK_FOO\"]\n            if replica is not None:\n                # the slot has a replica as well, so MovedError should have\n                # occurred. If MovedError occurs, we should see the\n                # reinitialize_counter increase.\n                assert readwrite_pipe.reinitialize_counter == 1\n                conn = replica.redis_connection.connection\n                assert conn.read_response.called is True\n\n    def test_readonly_pipeline_from_readonly_client(self, request):\n        \"\"\"\n        Test that the pipeline is initialized with readonly mode if the client\n        has it enabled\n        \"\"\"\n        # Create a cluster with reading from replications\n        ro = _get_client(RedisCluster, request, read_from_replicas=True)\n        key = \"bar\"\n        ro.set(key, \"foo\")\n        import time\n\n        time.sleep(0.2)\n        with ro.pipeline() as readonly_pipe:\n            mock_all_nodes_resp(ro, \"MOCK_OK\")\n            assert readonly_pipe.read_from_replicas is True\n            assert readonly_pipe.get(key).get(key).execute() == [\"MOCK_OK\", \"MOCK_OK\"]\n            slot_nodes = ro.nodes_manager.slots_cache[ro.keyslot(key)]\n            if len(slot_nodes) > 1:\n                executed_on_replica = False\n                for node in slot_nodes:\n                    if node.server_type == REPLICA:\n                        conn = node.redis_connection.connection\n                        executed_on_replica = conn.read_response.called\n                        if executed_on_replica:\n                            break\n                assert executed_on_replica is True\n\n    @pytest.mark.parametrize(\n        \"load_balancing_strategy\",\n        [\n            LoadBalancingStrategy.ROUND_ROBIN_REPLICAS,\n            LoadBalancingStrategy.RANDOM_REPLICA,\n        ],\n    )\n    def test_readonly_pipeline_with_reading_from_replicas_strategies(\n        self, request, load_balancing_strategy: LoadBalancingStrategy\n    ) -> None:\n        \"\"\"\n        Test that the pipeline uses replicas for different replica-based\n        load balancing strategies.\n        \"\"\"\n        ro = _get_client(\n            RedisCluster,\n            request,\n            load_balancing_strategy=load_balancing_strategy,\n        )\n        key = \"bar\"\n        ro.set(key, \"foo\")\n        import time\n\n        time.sleep(0.2)\n\n        with ro.pipeline() as readonly_pipe:\n            mock_all_nodes_resp(ro, \"MOCK_OK\")\n            assert readonly_pipe.load_balancing_strategy == load_balancing_strategy\n            assert readonly_pipe.get(key).get(key).execute() == [\"MOCK_OK\", \"MOCK_OK\"]\n            slot_nodes = ro.nodes_manager.slots_cache[ro.keyslot(key)]\n            executed_on_replicas_only = True\n            for node in slot_nodes:\n                if node.server_type == PRIMARY:\n                    conn = node.redis_connection.connection\n                    if conn.read_response.called:\n                        executed_on_replicas_only = False\n                        break\n            assert executed_on_replicas_only\n\n\n@pytest.mark.onlycluster\nclass TestClusterMonitor:\n    def test_wait_command_not_found(self, r):\n        \"Make sure the wait_for_command func works when command is not found\"\n        key = \"foo\"\n        node = r.get_node_from_key(key)\n        with r.monitor(target_node=node) as m:\n            response = wait_for_command(r, m, \"nothing\", key=key)\n            assert response is None\n\n    def test_response_values(self, r):\n        db = 0\n        key = \"foo\"\n        node = r.get_node_from_key(key)\n        with r.monitor(target_node=node) as m:\n            r.ping(target_nodes=node)\n            response = wait_for_command(r, m, \"PING\", key=key)\n            assert isinstance(response[\"time\"], float)\n            assert response[\"db\"] == db\n            assert response[\"client_type\"] in (\"tcp\", \"unix\")\n            assert isinstance(response[\"client_address\"], str)\n            assert isinstance(response[\"client_port\"], str)\n            assert response[\"command\"] == \"PING\"\n\n    def test_command_with_quoted_key(self, r):\n        key = \"{foo}1\"\n        node = r.get_node_from_key(key)\n        with r.monitor(node) as m:\n            r.get('{foo}\"bar')\n            response = wait_for_command(r, m, 'GET {foo}\"bar', key=key)\n            assert response[\"command\"] == 'GET {foo}\"bar'\n\n    def test_command_with_binary_data(self, r):\n        key = \"{foo}1\"\n        node = r.get_node_from_key(key)\n        with r.monitor(target_node=node) as m:\n            byte_string = b\"{foo}bar\\x92\"\n            r.get(byte_string)\n            response = wait_for_command(r, m, \"GET {foo}bar\\\\x92\", key=key)\n            assert response[\"command\"] == \"GET {foo}bar\\\\x92\"\n\n    def test_command_with_escaped_data(self, r):\n        key = \"{foo}1\"\n        node = r.get_node_from_key(key)\n        with r.monitor(target_node=node) as m:\n            byte_string = b\"{foo}bar\\\\x92\"\n            r.get(byte_string)\n            response = wait_for_command(r, m, \"GET {foo}bar\\\\\\\\x92\", key=key)\n            assert response[\"command\"] == \"GET {foo}bar\\\\\\\\x92\"\n\n    def test_flush(self, r):\n        r.set(\"x\", \"1\")\n        r.set(\"z\", \"1\")\n        r.flushall()\n        assert r.get(\"x\") is None\n        assert r.get(\"y\") is None\n\n\n@pytest.mark.onlycluster\nclass TestClusterMetricsRecording:\n    \"\"\"\n    Integration tests that verify metrics are properly recorded\n    from RedisCluster and delivered to the Meter through the observability recorder.\n\n    These tests use a real Redis cluster connection but mock the OTel Meter\n    to verify metrics are correctly recorded.\n    \"\"\"\n\n    @pytest.fixture\n    def mock_meter(self):\n        \"\"\"Create a mock Meter that tracks all instrument calls.\"\"\"\n        meter = Mock()\n\n        # Create mock histogram for operation duration\n        self.operation_duration = Mock()\n\n        def create_histogram_side_effect(name, **kwargs):\n            if name == \"db.client.operation.duration\":\n                return self.operation_duration\n            return Mock()\n\n        meter.create_counter.return_value = Mock()\n        meter.create_up_down_counter.return_value = Mock()\n        meter.create_histogram.side_effect = create_histogram_side_effect\n\n        return meter\n\n    @pytest.fixture\n    def cluster_with_otel(self, r, mock_meter):\n        \"\"\"\n        Setup a RedisCluster with real connection and mocked OTel collector.\n        Returns tuple of (cluster, operation_duration_mock).\n        \"\"\"\n\n        # Reset any existing collector state\n        recorder.reset_collector()\n\n        # Create config with COMMAND group enabled\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND])\n\n        # Create collector with mocked meter\n        with patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        # Patch the recorder to use our collector\n        with patch.object(recorder, \"_get_or_create_collector\", return_value=collector):\n            # Also set the collector directly to ensure it's used\n            recorder._metrics_collector = collector\n\n            # Create a new event dispatcher and attach it to the cluster\n            event_dispatcher = EventDispatcher()\n            r._event_dispatcher = event_dispatcher\n\n            yield r, self.operation_duration\n\n        # Cleanup\n        recorder.reset_collector()\n\n    def test_execute_command_records_metric(self, cluster_with_otel):\n        \"\"\"\n        Test that execute_command records operation duration metric to Meter.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_with_otel\n\n        # Execute a command\n        cluster.set(\"test_key\", \"test_value\")\n\n        # Verify the Meter's histogram.record() was called\n        operation_duration_mock.record.assert_called()\n\n        # Get the last call arguments\n        call_args = operation_duration_mock.record.call_args\n\n        # Verify duration was recorded\n        duration = call_args[0][0]\n        assert isinstance(duration, float)\n        assert duration >= 0\n\n        # Verify attributes\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[\"db.operation.name\"] == \"SET\"\n        assert \"server.address\" in attrs\n        assert \"server.port\" in attrs\n        assert \"db.namespace\" in attrs\n\n    def test_get_command_records_metric(self, cluster_with_otel):\n        \"\"\"\n        Test that GET command records metric with correct command name.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_with_otel\n\n        # Execute GET command\n        cluster.get(\"test_key\")\n\n        # Verify command name is GET\n        call_args = operation_duration_mock.record.call_args\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[\"db.operation.name\"] == \"GET\"\n\n    def test_multiple_commands_record_multiple_metrics(self, cluster_with_otel):\n        \"\"\"\n        Test that multiple command executions record multiple metrics.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_with_otel\n\n        # Execute multiple commands\n        cluster.set(\"key1\", \"value1\")\n        cluster.get(\"key1\")\n        cluster.delete(\"key1\")\n\n        # Verify histogram.record() was called 3 times\n        assert operation_duration_mock.record.call_count == 3\n\n    def test_server_attributes_recorded(self, cluster_with_otel):\n        \"\"\"\n        Test that server address, port, and db namespace are recorded.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_with_otel\n\n        cluster.ping()\n\n        call_args = operation_duration_mock.record.call_args\n        attrs = call_args[1][\"attributes\"]\n\n        # Verify server attributes are present and have valid values\n        assert \"server.address\" in attrs\n        assert isinstance(attrs[\"server.address\"], str)\n        assert len(attrs[\"server.address\"]) > 0\n\n        assert \"server.port\" in attrs\n        assert isinstance(attrs[\"server.port\"], int)\n        assert attrs[\"server.port\"] > 0\n\n        assert \"db.namespace\" in attrs\n\n    def test_duration_is_positive(self, cluster_with_otel):\n        \"\"\"\n        Test that the recorded duration is a positive float.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_with_otel\n\n        cluster.set(\"duration_test\", \"value\")\n\n        call_args = operation_duration_mock.record.call_args\n        duration = call_args[0][0]\n\n        assert isinstance(duration, float)\n        assert duration >= 0\n\n    def test_no_batch_size_for_single_command(self, cluster_with_otel):\n        \"\"\"\n        Test that single commands don't include batch_size attribute.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_with_otel\n\n        cluster.get(\"single_command_key\")\n\n        call_args = operation_duration_mock.record.call_args\n        attrs = call_args[1][\"attributes\"]\n\n        # batch_size should not be present for single commands\n        assert \"db.operation.batch_size\" not in attrs\n\n    def test_different_commands_record_correct_names(self, cluster_with_otel):\n        \"\"\"\n        Test that different commands record metrics with correct command names.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_with_otel\n\n        commands_to_test = [\n            (\"SET\", lambda: cluster.set(\"cmd_test\", \"value\")),\n            (\"GET\", lambda: cluster.get(\"cmd_test\")),\n            (\"PIPELINE\", lambda: cluster.delete(\"cmd_test\")),\n            (\"PING\", lambda: cluster.ping()),\n        ]\n\n        for expected_cmd, cmd_func in commands_to_test:\n            operation_duration_mock.reset_mock()\n            cmd_func()\n\n            call_args = operation_duration_mock.record.call_args\n            attrs = call_args[1][\"attributes\"]\n            assert attrs[\"db.operation.name\"] == expected_cmd\n\n    def test_command_error_records_metric_with_error_type(self, cluster_with_otel):\n        \"\"\"\n        Test that when a command fails, the recorded metric includes error.type attribute.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_with_otel\n\n        # Execute a command that will fail (wrong type operation)\n        cluster.set(\"error_test_key\", \"string_value\")\n\n        try:\n            # Try to use LPUSH on a string key - this will fail\n            cluster.lpush(\"error_test_key\", \"value\")\n        except ResponseError:\n            pass\n\n        # Find the LPUSH event in recorded calls\n        lpush_calls = [\n            call_obj\n            for call_obj in operation_duration_mock.record.call_args_list\n            if call_obj[1][\"attributes\"].get(\"db.operation.name\") == \"LPUSH\"\n        ]\n\n        assert len(lpush_calls) >= 1\n        attrs = lpush_calls[0][1][\"attributes\"]\n        assert \"error.type\" in attrs\n\n\n@pytest.mark.onlycluster\nclass TestClusterPipelineMetricsRecording:\n    \"\"\"\n    Integration tests that verify metrics are properly recorded\n    from ClusterPipeline and delivered to the Meter through the observability recorder.\n\n    These tests use a real Redis cluster connection but mock the OTel Meter\n    to verify metrics are correctly recorded.\n    \"\"\"\n\n    @pytest.fixture\n    def mock_meter(self):\n        \"\"\"Create a mock Meter that tracks all instrument calls.\"\"\"\n        meter = Mock()\n\n        # Create mock histogram for operation duration\n        self.operation_duration = Mock()\n\n        def create_histogram_side_effect(name, **kwargs):\n            if name == \"db.client.operation.duration\":\n                return self.operation_duration\n            return Mock()\n\n        meter.create_counter.return_value = Mock()\n        meter.create_up_down_counter.return_value = Mock()\n        meter.create_histogram.side_effect = create_histogram_side_effect\n\n        return meter\n\n    @pytest.fixture\n    def cluster_pipeline_with_otel(self, r, mock_meter):\n        \"\"\"\n        Setup a ClusterPipeline with real connection and mocked OTel collector.\n        Returns tuple of (cluster, operation_duration_mock).\n        \"\"\"\n\n        # Reset any existing collector state\n        recorder.reset_collector()\n\n        # Create config with COMMAND group enabled\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND])\n\n        # Create collector with mocked meter\n        with patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        # Patch the recorder to use our collector\n        with patch.object(recorder, \"_get_or_create_collector\", return_value=collector):\n            # Also set the collector directly to ensure it's used\n            recorder._metrics_collector = collector\n\n            # Create a new event dispatcher and attach it to the cluster\n            event_dispatcher = EventDispatcher()\n            r._event_dispatcher = event_dispatcher\n\n            yield r, self.operation_duration\n\n        # Cleanup\n        recorder.reset_collector()\n\n    def test_pipeline_execute_records_metric(self, cluster_pipeline_with_otel):\n        \"\"\"\n        Test that pipeline execute records operation duration metric to Meter.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_pipeline_with_otel\n\n        # Execute a pipeline\n        pipe = cluster.pipeline()\n        pipe.set(\"pipe_key1\", \"value1\")\n        pipe.get(\"pipe_key1\")\n        pipe.execute()\n\n        # Verify the Meter's histogram.record() was called\n        operation_duration_mock.record.assert_called()\n\n        # Get the last call arguments (pipeline event)\n        call_args = operation_duration_mock.record.call_args\n\n        # Verify duration was recorded\n        duration = call_args[0][0]\n        assert isinstance(duration, float)\n        assert duration >= 0\n\n        # Verify attributes\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[\"db.operation.name\"] == \"PIPELINE\"\n\n    def test_pipeline_server_attributes_recorded(self, cluster_pipeline_with_otel):\n        \"\"\"\n        Test that server address, port, and db namespace are recorded for pipeline.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_pipeline_with_otel\n\n        pipe = cluster.pipeline()\n        pipe.set(\"server_attr_key\", \"value\")\n        pipe.execute()\n\n        # Find the PIPELINE event call\n        pipeline_call = None\n        for call_obj in operation_duration_mock.record.call_args_list:\n            attrs = call_obj[1][\"attributes\"]\n            if attrs.get(\"db.operation.name\") == \"PIPELINE\":\n                pipeline_call = call_obj\n                break\n\n        assert pipeline_call is not None\n        attrs = pipeline_call[1][\"attributes\"]\n\n        # Verify server attributes are present\n        assert \"server.address\" in attrs\n        assert isinstance(attrs[\"server.address\"], str)\n\n        assert \"server.port\" in attrs\n        assert isinstance(attrs[\"server.port\"], int)\n\n        assert \"db.namespace\" in attrs\n\n    def test_pipeline_duration_is_positive(self, cluster_pipeline_with_otel):\n        \"\"\"\n        Test that the recorded duration for pipeline is a positive float.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_pipeline_with_otel\n\n        pipe = cluster.pipeline()\n        pipe.set(\"duration_key\", \"value\")\n        pipe.execute()\n\n        # Find the PIPELINE event call\n        pipeline_call = None\n        for call_obj in operation_duration_mock.record.call_args_list:\n            attrs = call_obj[1][\"attributes\"]\n            if attrs.get(\"db.operation.name\") == \"PIPELINE\":\n                pipeline_call = call_obj\n                break\n\n        assert pipeline_call is not None\n        duration = pipeline_call[0][0]\n        assert isinstance(duration, float)\n        assert duration >= 0\n\n    def test_multiple_pipeline_executions_record_multiple_metrics(\n        self, cluster_pipeline_with_otel\n    ):\n        \"\"\"\n        Test that multiple pipeline executions record multiple metrics.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_pipeline_with_otel\n\n        # Execute first pipeline\n        pipe1 = cluster.pipeline()\n        pipe1.set(\"multi_key1\", \"value1\")\n        pipe1.execute()\n\n        # Execute second pipeline\n        pipe2 = cluster.pipeline()\n        pipe2.set(\"multi_key2\", \"value2\")\n        pipe2.execute()\n\n        # Count PIPELINE events\n        pipeline_count = sum(\n            1\n            for call_obj in operation_duration_mock.record.call_args_list\n            if call_obj[1][\"attributes\"].get(\"db.operation.name\") == \"PIPELINE\"\n        )\n\n        assert pipeline_count >= 2\n\n    def test_empty_pipeline_does_not_record_metric(self, cluster_pipeline_with_otel):\n        \"\"\"\n        Test that an empty pipeline does not record metrics.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_pipeline_with_otel\n\n        # Execute an empty pipeline\n        pipe = cluster.pipeline()\n        pipe.execute()\n\n        # Count PIPELINE events - should be 0\n        pipeline_count = sum(\n            1\n            for call_obj in operation_duration_mock.record.call_args_list\n            if call_obj[1][\"attributes\"].get(\"db.operation.name\") == \"PIPELINE\"\n        )\n\n        assert pipeline_count == 0\n\n    def test_pipeline_error_records_metric_with_error(self, cluster_pipeline_with_otel):\n        \"\"\"\n        Test that when a pipeline command fails, the recorded metric includes error.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_pipeline_with_otel\n\n        # Set a string value\n        cluster.set(\"pipe_error_key\", \"string_value\")\n\n        try:\n            # Execute a pipeline with a command that will fail\n            pipe = cluster.pipeline()\n            pipe.lpush(\"pipe_error_key\", \"value\")  # Will fail - wrong type\n            pipe.execute()\n        except ResponseError:\n            pass\n\n        # Find the PIPELINE event calls\n        pipeline_calls = [\n            call_obj\n            for call_obj in operation_duration_mock.record.call_args_list\n            if call_obj[1][\"attributes\"].get(\"db.operation.name\") == \"PIPELINE\"\n        ]\n\n        # There should be at least one PIPELINE event\n        assert len(pipeline_calls) >= 1\n\n    def test_pipeline_error_records_metric_with_error_type(\n        self, cluster_pipeline_with_otel\n    ):\n        \"\"\"\n        Test that when a pipeline command fails, the recorded metric includes error.type.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_pipeline_with_otel\n\n        # Set a string value\n        cluster.set(\"pipe_err_type_key\", \"string_value\")\n\n        try:\n            pipe = cluster.pipeline()\n            pipe.lpush(\"pipe_err_type_key\", \"value\")  # Will fail - wrong type\n            pipe.execute()\n        except ResponseError:\n            pass\n\n        # Find PIPELINE events with error.type\n        pipeline_calls = [\n            call_obj\n            for call_obj in operation_duration_mock.record.call_args_list\n            if call_obj[1][\"attributes\"].get(\"db.operation.name\") == \"PIPELINE\"\n        ]\n\n        # At least one PIPELINE event should exist\n        assert len(pipeline_calls) >= 1\n\n        # Check if any has error.type (the one from _raise_first_error)\n        error_calls = [\n            call_obj\n            for call_obj in pipeline_calls\n            if \"error.type\" in call_obj[1][\"attributes\"]\n        ]\n\n        # The error event should have error.type\n        if error_calls:\n            attrs = error_calls[0][1][\"attributes\"]\n            assert \"error.type\" in attrs\n\n    def test_pipeline_multi_node_records_multiple_metrics(\n        self, cluster_pipeline_with_otel\n    ):\n        \"\"\"\n        Test that pipeline commands to multiple nodes record metrics for each node.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_pipeline_with_otel\n\n        # Execute pipeline with keys that may go to different nodes\n        pipe = cluster.pipeline()\n        for i in range(10):\n            pipe.set(f\"multi_node_key_{i}\", f\"value_{i}\")\n        pipe.execute()\n\n        # Find PIPELINE events\n        pipeline_calls = [\n            call_obj\n            for call_obj in operation_duration_mock.record.call_args_list\n            if call_obj[1][\"attributes\"].get(\"db.operation.name\") == \"PIPELINE\"\n        ]\n\n        # Should have at least one PIPELINE event\n        assert len(pipeline_calls) >= 1\n\n        # Each event should have server info\n        for call_obj in pipeline_calls:\n            attrs = call_obj[1][\"attributes\"]\n            assert \"server.address\" in attrs\n            assert \"server.port\" in attrs\n            assert \"db.namespace\" in attrs\n\n    def test_pipeline_duration_recorded_per_node(self, cluster_pipeline_with_otel):\n        \"\"\"\n        Test that pipeline duration is recorded for each node's commands.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_pipeline_with_otel\n\n        pipe = cluster.pipeline()\n        pipe.set(\"duration_node_key\", \"value\")\n        pipe.get(\"duration_node_key\")\n        pipe.execute()\n\n        # Find PIPELINE events\n        pipeline_calls = [\n            call_obj\n            for call_obj in operation_duration_mock.record.call_args_list\n            if call_obj[1][\"attributes\"].get(\"db.operation.name\") == \"PIPELINE\"\n        ]\n\n        assert len(pipeline_calls) >= 1\n\n        # Each event should have a positive duration\n        for call_obj in pipeline_calls:\n            duration = call_obj[0][0]\n            assert isinstance(duration, float)\n            assert duration >= 0\n"
  },
  {
    "path": "tests/test_cluster_transaction.py",
    "content": "import threading\nfrom typing import Tuple\nfrom unittest.mock import patch, Mock\n\nimport pytest\nfrom redis.exceptions import ResponseError\n\nimport redis\nfrom redis import CrossSlotTransactionError, ConnectionPool, RedisClusterException\nfrom redis.backoff import NoBackoff\nfrom redis.client import Redis\nfrom redis.cluster import PRIMARY, ClusterNode, RedisCluster\nfrom redis.observability import recorder\nfrom redis.observability.config import OTelConfig, MetricGroup\nfrom redis.observability.metrics import RedisMetricsCollector\nfrom redis.retry import Retry\n\nfrom .conftest import skip_if_server_version_lt\n\n\ndef _find_source_and_target_node_for_slot(\n    r: RedisCluster, slot: int\n) -> Tuple[ClusterNode, ClusterNode]:\n    \"\"\"Returns a pair of ClusterNodes, where the first node is the\n    one that owns the slot and the second is a possible target\n    for that slot, i.e. a primary node different from the first\n    one.\n    \"\"\"\n    node_migrating = r.nodes_manager.get_node_from_slot(slot)\n    assert node_migrating, f\"No node could be found that owns slot #{slot}\"\n\n    available_targets = [\n        n\n        for n in r.nodes_manager.startup_nodes.values()\n        if node_migrating.name != n.name and n.server_type == PRIMARY\n    ]\n\n    assert available_targets, f\"No possible target nodes for slot #{slot}\"\n    return node_migrating, available_targets[0]\n\n\nclass TestClusterTransaction:\n    @pytest.mark.onlycluster\n    def test_pipeline_is_true(self, r):\n        \"Ensure pipeline instances are not false-y\"\n        with r.pipeline(transaction=True) as pipe:\n            assert pipe\n\n    @pytest.mark.onlycluster\n    def test_pipeline_empty_transaction(self, r):\n        r[\"a\"] = 0\n\n        with r.pipeline(transaction=True) as pipe:\n            assert pipe.execute() == []\n\n    @pytest.mark.onlycluster\n    def test_executes_transaction_against_cluster(self, r):\n        with r.pipeline(transaction=True) as tx:\n            tx.set(\"{foo}bar\", \"value1\")\n            tx.set(\"{foo}baz\", \"value2\")\n            tx.set(\"{foo}bad\", \"value3\")\n            tx.get(\"{foo}bar\")\n            tx.get(\"{foo}baz\")\n            tx.get(\"{foo}bad\")\n            assert tx.execute() == [\n                b\"OK\",\n                b\"OK\",\n                b\"OK\",\n                b\"value1\",\n                b\"value2\",\n                b\"value3\",\n            ]\n\n        r.flushall()\n\n        tx = r.pipeline(transaction=True)\n        tx.set(\"{foo}bar\", \"value1\")\n        tx.set(\"{foo}baz\", \"value2\")\n        tx.set(\"{foo}bad\", \"value3\")\n        tx.get(\"{foo}bar\")\n        tx.get(\"{foo}baz\")\n        tx.get(\"{foo}bad\")\n        assert tx.execute() == [b\"OK\", b\"OK\", b\"OK\", b\"value1\", b\"value2\", b\"value3\"]\n\n    @pytest.mark.onlycluster\n    def test_throws_exception_on_different_hash_slots(self, r):\n        with r.pipeline(transaction=True) as tx:\n            tx.set(\"{foo}bar\", \"value1\")\n            tx.set(\"{foobar}baz\", \"value2\")\n\n            with pytest.raises(\n                CrossSlotTransactionError,\n                match=\"All keys involved in a cluster transaction must map to the same slot\",\n            ):\n                tx.execute()\n\n    @pytest.mark.onlycluster\n    def test_throws_exception_with_watch_on_different_hash_slots(self, r):\n        with r.pipeline(transaction=True) as tx:\n            with pytest.raises(\n                RedisClusterException,\n                match=\"WATCH - all keys must map to the same key slot\",\n            ):\n                tx.watch(\"key1\", \"key2\")\n\n    @pytest.mark.onlycluster\n    def test_transaction_with_watched_keys(self, r):\n        r[\"a\"] = 0\n\n        with r.pipeline(transaction=True) as pipe:\n            pipe.watch(\"a\")\n            a = pipe.get(\"a\")\n            pipe.multi()\n            pipe.set(\"a\", int(a) + 1)\n            assert pipe.execute() == [b\"OK\"]\n\n    @pytest.mark.onlycluster\n    def test_retry_transaction_during_unfinished_slot_migration(self, r):\n        \"\"\"\n        When a transaction is triggered during a migration, MovedError\n        or AskError may appear (depends on the key being already migrated\n        or the key not existing already). The patch on parse_response\n        simulates such an error, but the slot cache is not updated\n        (meaning the migration is still ongogin) so the pipeline eventually\n        fails as if it was retried but the migration is not yet complete.\n        \"\"\"\n        key = \"book\"\n        slot = r.keyslot(key)\n        node_migrating, node_importing = _find_source_and_target_node_for_slot(r, slot)\n\n        with (\n            patch.object(Redis, \"parse_response\") as parse_response,\n        ):\n\n            def ask_redirect_effect(connection, *args, **options):\n                if \"MULTI\" in args:\n                    return\n                elif \"EXEC\" in args:\n                    raise redis.exceptions.ExecAbortError()\n\n                raise redis.exceptions.AskError(f\"{slot} {node_importing.name}\")\n\n            parse_response.side_effect = ask_redirect_effect\n\n            with r.pipeline(transaction=True) as pipe:\n                pipe.set(key, \"val\")\n                with pytest.raises(redis.exceptions.AskError) as ex:\n                    pipe.execute()\n\n                assert str(ex.value).startswith(\n                    \"Command # 1 (SET book val) of pipeline caused error:\"\n                    f\" {slot} {node_importing.name}\"\n                )\n\n    @pytest.mark.onlycluster\n    def test_retry_transaction_during_slot_migration_successful(self, r):\n        \"\"\"\n        If a MovedError or AskError appears when calling EXEC and no key is watched,\n        the pipeline is retried after updating the node manager slot table. If the\n        migration was completed, the transaction may then complete successfully.\n        \"\"\"\n        key = \"book\"\n        slot = r.keyslot(key)\n        node_migrating, node_importing = _find_source_and_target_node_for_slot(r, slot)\n\n        with (\n            patch.object(Redis, \"parse_response\") as parse_response,\n        ):\n\n            def ask_redirect_effect(conn, *args, **options):\n                # first call should go here, we trigger an AskError\n                if f\"{conn.host}:{conn.port}\" == node_migrating.name:\n                    if \"MULTI\" in args:\n                        return\n                    elif \"EXEC\" in args:\n                        raise redis.exceptions.ExecAbortError()\n\n                    raise redis.exceptions.AskError(f\"{slot} {node_importing.name}\")\n                # if the slot table is updated, the next call will go here\n                elif f\"{conn.host}:{conn.port}\" == node_importing.name:\n                    if \"EXEC\" in args:\n                        return [\n                            \"MOCK_OK\"\n                        ]  # mock value to validate this section was called\n                    return\n                else:\n                    assert False, f\"unexpected node {conn.host}:{conn.port} was called\"\n\n            parse_response.side_effect = ask_redirect_effect\n\n            result = None\n            with r.pipeline(transaction=True) as pipe:\n                pipe.multi()\n                pipe.set(key, \"val\")\n                result = pipe.execute()\n\n            assert result and \"MOCK_OK\" in result, \"Target node was not called\"\n\n    @pytest.mark.onlycluster\n    def test_retry_transaction_with_watch_after_slot_migration(self, r):\n        \"\"\"\n        If a MovedError or AskError appears when calling WATCH, the client\n        must attempt to recover itself before proceeding and no WatchError\n        should appear.\n        \"\"\"\n        key = \"book\"\n        slot = r.keyslot(key)\n        r.reinitialize_steps = 1\n\n        # force a MovedError on the first call to pipe.watch()\n        # by switching the node that owns the slot to another one\n        _node_migrating, node_importing = _find_source_and_target_node_for_slot(r, slot)\n        r.nodes_manager.slots_cache[slot] = [node_importing]\n\n        with r.pipeline(transaction=True) as pipe:\n            pipe.watch(key)\n            pipe.multi()\n            pipe.set(key, \"val\")\n            assert pipe.execute() == [b\"OK\"]\n\n    @pytest.mark.onlycluster\n    def test_retry_transaction_with_watch_during_slot_migration(self, r):\n        \"\"\"\n        If a MovedError or AskError appears when calling EXEC and keys were\n        being watched before the migration started, a WatchError should appear.\n        These errors imply resetting the connection and connecting to a new node,\n        so watches are lost anyway and the client code must be notified.\n        \"\"\"\n        key = \"book\"\n        slot = r.keyslot(key)\n        node_migrating, node_importing = _find_source_and_target_node_for_slot(r, slot)\n\n        with patch.object(Redis, \"parse_response\") as parse_response:\n\n            def ask_redirect_effect(conn, *args, **options):\n                if f\"{conn.host}:{conn.port}\" == node_migrating.name:\n                    # we simulate the watch was sent before the migration started\n                    if \"WATCH\" in args:\n                        return b\"OK\"\n                    # but the pipeline was triggered after the migration started\n                    elif \"MULTI\" in args:\n                        return\n                    elif \"EXEC\" in args:\n                        raise redis.exceptions.ExecAbortError()\n\n                    raise redis.exceptions.AskError(f\"{slot} {node_importing.name}\")\n                # we should not try to connect to any other node\n                else:\n                    assert False, f\"unexpected node {conn.host}:{conn.port} was called\"\n\n            parse_response.side_effect = ask_redirect_effect\n\n            with r.pipeline(transaction=True) as pipe:\n                pipe.watch(key)\n                pipe.multi()\n                pipe.set(key, \"val\")\n                with pytest.raises(redis.exceptions.WatchError) as ex:\n                    pipe.execute()\n\n                assert str(ex.value).startswith(\n                    \"Slot rebalancing occurred while watching keys\"\n                )\n\n    @pytest.mark.onlycluster\n    def test_retry_transaction_on_connection_error(self, r, mock_connection):\n        key = \"book\"\n        slot = r.keyslot(key)\n\n        mock_connection.read_response.side_effect = redis.exceptions.ConnectionError(\n            \"Conn error\"\n        )\n        mock_connection.retry = Retry(NoBackoff(), 0)\n        mock_pool = Mock(spec=ConnectionPool)\n        mock_pool.get_connection.return_value = mock_connection\n        mock_pool._available_connections = [mock_connection]\n        mock_pool._lock = threading.RLock()\n\n        _node_migrating, node_importing = _find_source_and_target_node_for_slot(r, slot)\n        # Set mock connection's host/port/db to match the node for find_connection_owner\n        mock_connection.host = node_importing.host\n        mock_connection.port = node_importing.port\n        mock_connection.db = 0\n        # Save original pool to restore later\n        original_pool = node_importing.redis_connection.connection_pool\n        original_slots_cache = r.nodes_manager.slots_cache[slot]\n        try:\n            node_importing.redis_connection.connection_pool = mock_pool\n            r.nodes_manager.slots_cache[slot] = [node_importing]\n            r.reinitialize_steps = 1\n\n            with r.pipeline(transaction=True) as pipe:\n                pipe.set(key, \"val\")\n                assert pipe.execute() == [b\"OK\"]\n        finally:\n            # Restore original pool so teardown can work\n            node_importing.redis_connection.connection_pool = original_pool\n            r.nodes_manager.slots_cache[slot] = original_slots_cache\n\n    @pytest.mark.onlycluster\n    def test_retry_transaction_on_connection_error_with_watched_keys(\n        self, r, mock_connection\n    ):\n        key = \"book\"\n        slot = r.keyslot(key)\n\n        mock_connection.read_response.side_effect = redis.exceptions.ConnectionError(\n            \"Conn error\"\n        )\n        mock_connection.retry = Retry(NoBackoff(), 0)\n        mock_pool = Mock(spec=ConnectionPool)\n        mock_pool.get_connection.return_value = mock_connection\n        mock_pool._available_connections = [mock_connection]\n        mock_pool._lock = threading.RLock()\n        mock_pool.connection_kwargs = {}\n\n        _node_migrating, node_importing = _find_source_and_target_node_for_slot(r, slot)\n        # Set mock connection's host/port/db to match the node for find_connection_owner\n        mock_connection.host = node_importing.host\n        mock_connection.port = node_importing.port\n        mock_connection.db = 0\n        # Save original pool to restore later\n        original_pool = node_importing.redis_connection.connection_pool\n        original_slots_cache = r.nodes_manager.slots_cache[slot]\n        try:\n            node_importing.redis_connection.connection_pool = mock_pool\n            r.nodes_manager.slots_cache[slot] = [node_importing]\n            r.reinitialize_steps = 1\n\n            with r.pipeline(transaction=True) as pipe:\n                pipe.watch(key)\n                pipe.multi()\n                pipe.set(key, \"val\")\n                assert pipe.execute() == [b\"OK\"]\n        finally:\n            # Restore original pool so teardown can work\n            node_importing.redis_connection.connection_pool = original_pool\n            r.nodes_manager.slots_cache[slot] = original_slots_cache\n\n    @pytest.mark.onlycluster\n    def test_exec_error_raised(self, r):\n        hashkey = \"{key}\"\n        r[f\"{hashkey}:c\"] = \"a\"\n        with r.pipeline(transaction=True) as pipe:\n            pipe.set(f\"{hashkey}:a\", 1).set(f\"{hashkey}:b\", 2)\n            pipe.lpush(f\"{hashkey}:c\", 3).set(f\"{hashkey}:d\", 4)\n            with pytest.raises(redis.ResponseError) as ex:\n                pipe.execute()\n            assert str(ex.value).startswith(\n                \"Command # 3 (LPUSH {key}:c 3) of pipeline caused error: \"\n            )\n\n            # make sure the pipe was restored to a working state\n            assert pipe.set(f\"{hashkey}:z\", \"zzz\").execute() == [b\"OK\"]\n            assert r[f\"{hashkey}:z\"] == b\"zzz\"\n\n    @pytest.mark.onlycluster\n    def test_parse_error_raised(self, r):\n        hashkey = \"{key}\"\n        with r.pipeline(transaction=True) as pipe:\n            # the zrem is invalid because we don't pass any keys to it\n            pipe.set(f\"{hashkey}:a\", 1).zrem(f\"{hashkey}:b\").set(f\"{hashkey}:b\", 2)\n            with pytest.raises(redis.ResponseError) as ex:\n                pipe.execute()\n\n            assert str(ex.value).startswith(\n                \"Command # 2 (ZREM {key}:b) of pipeline caused error: wrong number\"\n            )\n\n            # make sure the pipe was restored to a working state\n            assert pipe.set(f\"{hashkey}:z\", \"zzz\").execute() == [b\"OK\"]\n            assert r[f\"{hashkey}:z\"] == b\"zzz\"\n\n    @pytest.mark.onlycluster\n    def test_transaction_callable(self, r):\n        hashkey = \"{key}\"\n        r[f\"{hashkey}:a\"] = 1\n        r[f\"{hashkey}:b\"] = 2\n        has_run = []\n\n        def my_transaction(pipe):\n            a_value = pipe.get(f\"{hashkey}:a\")\n            assert a_value in (b\"1\", b\"2\")\n            b_value = pipe.get(f\"{hashkey}:b\")\n            assert b_value == b\"2\"\n\n            # silly run-once code... incr's \"a\" so WatchError should be raised\n            # forcing this all to run again. this should incr \"a\" once to \"2\"\n            if not has_run:\n                r.incr(f\"{hashkey}:a\")\n                has_run.append(\"it has\")\n\n            pipe.multi()\n            pipe.set(f\"{hashkey}:c\", int(a_value) + int(b_value))\n\n        result = r.transaction(my_transaction, f\"{hashkey}:a\", f\"{hashkey}:b\")\n        assert result == [b\"OK\"]\n        assert r[f\"{hashkey}:c\"] == b\"4\"\n\n    @pytest.mark.onlycluster\n    @skip_if_server_version_lt(\"2.0.0\")\n    def test_transaction_discard(self, r):\n        hashkey = \"{key}\"\n\n        # pipelines enabled as transactions can be discarded at any point\n        with r.pipeline(transaction=True) as pipe:\n            pipe.watch(f\"{hashkey}:key\")\n            pipe.set(f\"{hashkey}:key\", \"someval\")\n            pipe.discard()\n\n            assert not pipe._execution_strategy._watching\n            assert not pipe.command_stack\n\n\n@pytest.mark.onlycluster\nclass TestClusterTransactionMetricsRecording:\n    \"\"\"\n    Integration tests that verify metrics are properly recorded\n    from ClusterPipeline (transaction mode) and delivered to the Meter through\n    the observability recorder.\n\n    These tests use a real Redis cluster connection but mock the OTel Meter\n    to verify metrics are correctly recorded.\n    \"\"\"\n\n    @pytest.fixture\n    def mock_meter(self):\n        \"\"\"Create a mock Meter that tracks all instrument calls.\"\"\"\n        meter = Mock()\n\n        # Create mock histogram for operation duration\n        self.operation_duration = Mock()\n\n        def create_histogram_side_effect(name, **kwargs):\n            if name == \"db.client.operation.duration\":\n                return self.operation_duration\n            return Mock()\n\n        meter.create_counter.return_value = Mock()\n        meter.create_up_down_counter.return_value = Mock()\n        meter.create_histogram.side_effect = create_histogram_side_effect\n\n        return meter\n\n    @pytest.fixture\n    def cluster_transaction_with_otel(self, r, mock_meter):\n        \"\"\"\n        Setup a ClusterPipeline (transaction mode) with real connection\n        and mocked OTel collector.\n        Returns tuple of (cluster, operation_duration_mock).\n        \"\"\"\n\n        # Reset any existing collector state\n        recorder.reset_collector()\n\n        # Create config with COMMAND group enabled\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND])\n\n        # Create collector with mocked meter\n        with patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        # Patch the recorder to use our collector\n        with patch.object(recorder, \"_get_or_create_collector\", return_value=collector):\n            yield r, self.operation_duration\n\n        # Cleanup\n        recorder.reset_collector()\n\n    def test_transaction_execute_records_metric(self, cluster_transaction_with_otel):\n        \"\"\"\n        Test that transaction execute records operation duration metric to Meter.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_transaction_with_otel\n\n        # Execute a transaction\n        with cluster.pipeline(transaction=True) as tx:\n            tx.set(\"{tx_key}1\", \"value1\")\n            tx.get(\"{tx_key}1\")\n            tx.execute()\n\n        # Verify the Meter's histogram.record() was called\n        operation_duration_mock.record.assert_called()\n\n        # Find the TRANSACTION event call\n        transaction_call = None\n        for call_obj in operation_duration_mock.record.call_args_list:\n            attrs = call_obj[1][\"attributes\"]\n            if attrs.get(\"db.operation.name\") == \"TRANSACTION\":\n                transaction_call = call_obj\n                break\n\n        assert transaction_call is not None\n\n        # Verify duration was recorded\n        duration = transaction_call[0][0]\n        assert isinstance(duration, float)\n        assert duration >= 0\n\n        # Verify attributes\n        attrs = transaction_call[1][\"attributes\"]\n        assert attrs[\"db.operation.name\"] == \"TRANSACTION\"\n\n    def test_transaction_server_attributes_recorded(\n        self, cluster_transaction_with_otel\n    ):\n        \"\"\"\n        Test that server address, port, and db namespace are recorded for transaction.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_transaction_with_otel\n\n        with cluster.pipeline(transaction=True) as tx:\n            tx.set(\"{server_attr}key\", \"value\")\n            tx.execute()\n\n        # Find the TRANSACTION event call\n        transaction_call = None\n        for call_obj in operation_duration_mock.record.call_args_list:\n            attrs = call_obj[1][\"attributes\"]\n            if attrs.get(\"db.operation.name\") == \"TRANSACTION\":\n                transaction_call = call_obj\n                break\n\n        assert transaction_call is not None\n        attrs = transaction_call[1][\"attributes\"]\n\n        # Verify server attributes are present\n        assert \"server.address\" in attrs\n        assert isinstance(attrs[\"server.address\"], str)\n\n        assert \"server.port\" in attrs\n        assert isinstance(attrs[\"server.port\"], int)\n\n        assert \"db.namespace\" in attrs\n\n    def test_transaction_duration_is_positive(self, cluster_transaction_with_otel):\n        \"\"\"\n        Test that the recorded duration for transaction is a positive float.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_transaction_with_otel\n\n        with cluster.pipeline(transaction=True) as tx:\n            tx.set(\"{duration}key\", \"value\")\n            tx.execute()\n\n        # Find the TRANSACTION event call\n        transaction_call = None\n        for call_obj in operation_duration_mock.record.call_args_list:\n            attrs = call_obj[1][\"attributes\"]\n            if attrs.get(\"db.operation.name\") == \"TRANSACTION\":\n                transaction_call = call_obj\n                break\n\n        assert transaction_call is not None\n        duration = transaction_call[0][0]\n        assert isinstance(duration, float)\n        assert duration >= 0\n\n    def test_multiple_transaction_executions_record_multiple_metrics(\n        self, cluster_transaction_with_otel\n    ):\n        \"\"\"\n        Test that multiple transaction executions record multiple metrics.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_transaction_with_otel\n\n        # Execute first transaction\n        with cluster.pipeline(transaction=True) as tx1:\n            tx1.set(\"{multi1}key\", \"value1\")\n            tx1.execute()\n\n        # Execute second transaction\n        with cluster.pipeline(transaction=True) as tx2:\n            tx2.set(\"{multi2}key\", \"value2\")\n            tx2.execute()\n\n        # Count TRANSACTION events\n        transaction_count = sum(\n            1\n            for call_obj in operation_duration_mock.record.call_args_list\n            if call_obj[1][\"attributes\"].get(\"db.operation.name\") == \"TRANSACTION\"\n        )\n\n        assert transaction_count >= 2\n\n    def test_empty_transaction_does_not_record_metric(\n        self, cluster_transaction_with_otel\n    ):\n        \"\"\"\n        Test that an empty transaction does not record TRANSACTION metrics.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_transaction_with_otel\n\n        # Execute an empty transaction\n        with cluster.pipeline(transaction=True) as tx:\n            tx.execute()\n\n        # Count TRANSACTION events - should be 0\n        transaction_count = sum(\n            1\n            for call_obj in operation_duration_mock.record.call_args_list\n            if call_obj[1][\"attributes\"].get(\"db.operation.name\") == \"TRANSACTION\"\n        )\n\n        assert transaction_count == 0\n\n    def test_transaction_with_watch_records_metric(self, cluster_transaction_with_otel):\n        \"\"\"\n        Test that transaction with WATCH records metric correctly.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_transaction_with_otel\n\n        # Set initial value\n        cluster.set(\"{watch}key\", \"0\")\n\n        with cluster.pipeline(transaction=True) as tx:\n            tx.watch(\"{watch}key\")\n            val = tx.get(\"{watch}key\")\n            tx.multi()\n            tx.set(\"{watch}key\", int(val or 0) + 1)\n            tx.execute()\n\n        # Find the TRANSACTION event call\n        transaction_call = None\n        for call_obj in operation_duration_mock.record.call_args_list:\n            attrs = call_obj[1][\"attributes\"]\n            if attrs.get(\"db.operation.name\") == \"TRANSACTION\":\n                transaction_call = call_obj\n                break\n\n        assert transaction_call is not None\n        attrs = transaction_call[1][\"attributes\"]\n        assert attrs[\"db.operation.name\"] == \"TRANSACTION\"\n\n    # Tests for metrics recording in TransactionStrategy\n\n    def test_watch_command_records_metric(self, cluster_transaction_with_otel):\n        \"\"\"\n        Test that WATCH command records operation duration metric.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_transaction_with_otel\n\n        # Set initial value\n        cluster.set(\"{watch_event}key\", \"value\")\n\n        with cluster.pipeline(transaction=True) as tx:\n            tx.watch(\"{watch_event}key\")\n            tx.multi()\n            tx.set(\"{watch_event}key\", \"new_value\")\n            tx.execute()\n\n        # Find the WATCH event call\n        watch_calls = [\n            call_obj\n            for call_obj in operation_duration_mock.record.call_args_list\n            if call_obj[1][\"attributes\"].get(\"db.operation.name\") == \"WATCH\"\n        ]\n\n        assert len(watch_calls) >= 1\n        attrs = watch_calls[0][1][\"attributes\"]\n        assert attrs[\"db.operation.name\"] == \"WATCH\"\n        assert \"server.address\" in attrs\n        assert \"server.port\" in attrs\n\n    def test_immediate_command_records_metric_with_server_info(\n        self, cluster_transaction_with_otel\n    ):\n        \"\"\"\n        Test that immediate commands (WATCH, GET before MULTI) record metrics\n        with server address and port.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_transaction_with_otel\n\n        cluster.set(\"{immediate}key\", \"value\")\n\n        with cluster.pipeline(transaction=True) as tx:\n            tx.watch(\"{immediate}key\")\n            tx.get(\"{immediate}key\")\n            tx.multi()\n            tx.set(\"{immediate}key\", \"updated\")\n            tx.execute()\n\n        # Find WATCH and GET events\n        watch_calls = [\n            call_obj\n            for call_obj in operation_duration_mock.record.call_args_list\n            if call_obj[1][\"attributes\"].get(\"db.operation.name\") == \"WATCH\"\n        ]\n        get_calls = [\n            call_obj\n            for call_obj in operation_duration_mock.record.call_args_list\n            if call_obj[1][\"attributes\"].get(\"db.operation.name\") == \"GET\"\n        ]\n\n        # Verify WATCH event has server info\n        assert len(watch_calls) >= 1\n        watch_attrs = watch_calls[0][1][\"attributes\"]\n        assert \"server.address\" in watch_attrs\n        assert isinstance(watch_attrs[\"server.address\"], str)\n        assert \"server.port\" in watch_attrs\n        assert isinstance(watch_attrs[\"server.port\"], int)\n\n        # Verify GET event has server info\n        assert len(get_calls) >= 1\n        get_attrs = get_calls[0][1][\"attributes\"]\n        assert \"server.address\" in get_attrs\n        assert \"server.port\" in get_attrs\n\n    def test_transaction_error_records_metric_with_error_type(\n        self, cluster_transaction_with_otel\n    ):\n        \"\"\"\n        Test that when a transaction fails, the recorded metric includes error.type.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_transaction_with_otel\n\n        # Set a string value\n        cluster.set(\"{tx_err_type}key\", \"string_value\")\n\n        try:\n            with cluster.pipeline(transaction=True) as tx:\n                # Try to use LPUSH on a string key - this will fail\n                tx.lpush(\"{tx_err_type}key\", \"value\")\n                tx.execute()\n        except ResponseError:\n            pass\n\n        # Find the TRANSACTION event call - it should have error.type\n        transaction_calls = [\n            call_obj\n            for call_obj in operation_duration_mock.record.call_args_list\n            if call_obj[1][\"attributes\"].get(\"db.operation.name\") == \"TRANSACTION\"\n        ]\n\n        # There should be at least one TRANSACTION event\n        assert len(transaction_calls) >= 1\n\n    def test_watch_and_transaction_record_separate_metrics(\n        self, cluster_transaction_with_otel\n    ):\n        \"\"\"\n        Test that WATCH commands and TRANSACTION record separate metrics.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_transaction_with_otel\n\n        cluster.set(\"{separate}key\", \"0\")\n\n        with cluster.pipeline(transaction=True) as tx:\n            tx.watch(\"{separate}key\")\n            val = tx.get(\"{separate}key\")\n            tx.multi()\n            tx.set(\"{separate}key\", int(val or 0) + 1)\n            tx.execute()\n\n        # Count WATCH events\n        watch_count = sum(\n            1\n            for call_obj in operation_duration_mock.record.call_args_list\n            if call_obj[1][\"attributes\"].get(\"db.operation.name\") == \"WATCH\"\n        )\n\n        # Count TRANSACTION events\n        transaction_count = sum(\n            1\n            for call_obj in operation_duration_mock.record.call_args_list\n            if call_obj[1][\"attributes\"].get(\"db.operation.name\") == \"TRANSACTION\"\n        )\n\n        # Should have at least 1 WATCH and 1 TRANSACTION event\n        assert watch_count >= 1\n        assert transaction_count >= 1\n\n    def test_immediate_get_command_records_metric(self, cluster_transaction_with_otel):\n        \"\"\"\n        Test that GET command executed immediately (after WATCH, before MULTI)\n        records operation duration metric.\n        \"\"\"\n        cluster, operation_duration_mock = cluster_transaction_with_otel\n\n        cluster.set(\"{imm_get}key\", \"test_value\")\n\n        with cluster.pipeline(transaction=True) as tx:\n            tx.watch(\"{imm_get}key\")\n            # This GET is executed immediately because we're watching\n            tx.get(\"{imm_get}key\")\n            tx.multi()\n            tx.set(\"{imm_get}key\", \"new_value\")\n            tx.execute()\n\n        # Find GET events\n        get_calls = [\n            call_obj\n            for call_obj in operation_duration_mock.record.call_args_list\n            if call_obj[1][\"attributes\"].get(\"db.operation.name\") == \"GET\"\n        ]\n\n        assert len(get_calls) >= 1\n        attrs = get_calls[0][1][\"attributes\"]\n        assert attrs[\"db.operation.name\"] == \"GET\"\n        assert \"server.address\" in attrs\n        assert \"server.port\" in attrs\n        assert \"db.namespace\" in attrs\n"
  },
  {
    "path": "tests/test_command_parser.py",
    "content": "import pytest\nfrom redis._parsers import CommandsParser\nfrom redis._parsers.commands import RequestPolicy, ResponsePolicy\nfrom tests.helpers import get_expected_command_policies\n\nfrom .conftest import (\n    assert_resp_response,\n    skip_if_redis_enterprise,\n    skip_if_server_version_gte,\n    skip_if_server_version_lt,\n)\n\n\nclass TestCommandsParser:\n    def test_init_commands(self, r):\n        commands_parser = CommandsParser(r)\n        assert commands_parser.commands is not None\n        assert \"get\" in commands_parser.commands\n\n    def test_get_keys_predetermined_key_location(self, r):\n        commands_parser = CommandsParser(r)\n        args1 = [\"GET\", \"foo\"]\n        args2 = [\"OBJECT\", \"encoding\", \"foo\"]\n        args3 = [\"MGET\", \"foo\", \"bar\", \"foobar\"]\n        assert commands_parser.get_keys(r, *args1) == [\"foo\"]\n        assert commands_parser.get_keys(r, *args2) == [\"foo\"]\n        assert commands_parser.get_keys(r, *args3) == [\"foo\", \"bar\", \"foobar\"]\n\n    @pytest.mark.filterwarnings(\"ignore:ResponseError\")\n    @skip_if_redis_enterprise()\n    def test_get_moveable_keys(self, r):\n        commands_parser = CommandsParser(r)\n        args1 = [\n            \"EVAL\",\n            \"return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}\",\n            2,\n            \"key1\",\n            \"key2\",\n            \"first\",\n            \"second\",\n        ]\n        args2 = [\"XREAD\", \"COUNT\", 2, b\"STREAMS\", \"mystream\", \"writers\", 0, 0]\n        args3 = [\"ZUNIONSTORE\", \"out\", 2, \"zset1\", \"zset2\", \"WEIGHTS\", 2, 3]\n        args4 = [\"GEORADIUS\", \"Sicily\", 15, 37, 200, \"km\", \"WITHCOORD\", b\"STORE\", \"out\"]\n        args5 = [\"MEMORY USAGE\", \"foo\"]\n        args6 = [\n            \"MIGRATE\",\n            \"192.168.1.34\",\n            6379,\n            \"\",\n            0,\n            5000,\n            b\"KEYS\",\n            \"key1\",\n            \"key2\",\n            \"key3\",\n        ]\n        args7 = [\"MIGRATE\", \"192.168.1.34\", 6379, \"key1\", 0, 5000]\n\n        assert_resp_response(\n            r,\n            sorted(commands_parser.get_keys(r, *args1)),\n            [\"key1\", \"key2\"],\n            [b\"key1\", b\"key2\"],\n        )\n        assert_resp_response(\n            r,\n            sorted(commands_parser.get_keys(r, *args2)),\n            [\"mystream\", \"writers\"],\n            [b\"mystream\", b\"writers\"],\n        )\n        assert_resp_response(\n            r,\n            sorted(commands_parser.get_keys(r, *args3)),\n            [\"out\", \"zset1\", \"zset2\"],\n            [b\"out\", b\"zset1\", b\"zset2\"],\n        )\n        assert_resp_response(\n            r,\n            sorted(commands_parser.get_keys(r, *args4)),\n            [\"Sicily\", \"out\"],\n            [b\"Sicily\", b\"out\"],\n        )\n        assert sorted(commands_parser.get_keys(r, *args5)) in [[\"foo\"], [b\"foo\"]]\n        assert_resp_response(\n            r,\n            sorted(commands_parser.get_keys(r, *args6)),\n            [\"key1\", \"key2\", \"key3\"],\n            [b\"key1\", b\"key2\", b\"key3\"],\n        )\n        assert_resp_response(\n            r, sorted(commands_parser.get_keys(r, *args7)), [\"key1\"], [b\"key1\"]\n        )\n\n    # A bug in redis<7.0 causes this to fail: https://github.com/redis/redis/issues/9493\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_get_eval_keys_with_0_keys(self, r):\n        commands_parser = CommandsParser(r)\n        args = [\"EVAL\", \"return {ARGV[1],ARGV[2]}\", 0, \"key1\", \"key2\"]\n        assert commands_parser.get_keys(r, *args) == []\n\n    def test_get_pubsub_keys(self, r):\n        commands_parser = CommandsParser(r)\n        args1 = [\"PUBLISH\", \"foo\", \"bar\"]\n        args2 = [\"PUBSUB NUMSUB\", \"foo1\", \"foo2\", \"foo3\"]\n        args3 = [\"PUBSUB channels\", \"*\"]\n        args4 = [\"SUBSCRIBE\", \"foo1\", \"foo2\", \"foo3\"]\n        assert commands_parser.get_keys(r, *args1) == [\"foo\"]\n        assert commands_parser.get_keys(r, *args2) == [\"foo1\", \"foo2\", \"foo3\"]\n        assert commands_parser.get_keys(r, *args3) == [\"*\"]\n        assert commands_parser.get_keys(r, *args4) == [\"foo1\", \"foo2\", \"foo3\"]\n\n    @skip_if_server_version_lt(\"8.0.0\")\n    @skip_if_server_version_gte(\"8.5.240\")\n    @pytest.mark.onlycluster\n    def test_get_command_policies(self, r):\n        commands_parser = CommandsParser(r)\n\n        expected_command_policies = get_expected_command_policies()\n\n        actual_policies = commands_parser.get_command_policies()\n        assert len(actual_policies) > 0\n\n        for module_name, commands in expected_command_policies.items():\n            for command, command_policies in commands.items():\n                assert command in actual_policies[module_name]\n                assert command_policies == [\n                    command,\n                    actual_policies[module_name][command].request_policy,\n                    actual_policies[module_name][command].response_policy,\n                ]\n\n    @skip_if_server_version_lt(\"8.5.240\")\n    @pytest.mark.onlycluster\n    def test_get_command_policies_json_debug_updated(self, r):\n        commands_parser = CommandsParser(r)\n\n        changes_in_defaults = {\n            \"json\": {\n                \"debug\": [\n                    \"debug\",\n                    RequestPolicy.DEFAULT_KEYLESS,\n                    ResponsePolicy.DEFAULT_KEYLESS,\n                ],\n            },\n        }\n\n        expected_command_policies = get_expected_command_policies(changes_in_defaults)\n\n        actual_policies = commands_parser.get_command_policies()\n        assert len(actual_policies) > 0\n\n        for module_name, commands in expected_command_policies.items():\n            for command, command_policies in commands.items():\n                assert command in actual_policies[module_name]\n                assert command_policies == [\n                    command,\n                    actual_policies[module_name][command].request_policy,\n                    actual_policies[module_name][command].response_policy,\n                ]\n"
  },
  {
    "path": "tests/test_command_policies.py",
    "content": "import random\nfrom unittest.mock import Mock, patch\n\nimport pytest\n\nfrom redis import ResponseError\n\nfrom redis._parsers import CommandsParser\nfrom redis._parsers.commands import CommandPolicies, RequestPolicy, ResponsePolicy\nfrom redis.commands.policies import DynamicPolicyResolver, StaticPolicyResolver\nfrom redis.commands.search.aggregation import AggregateRequest, Cursor\nfrom redis.commands.search.field import TextField, NumericField\nfrom tests.conftest import skip_if_server_version_lt, is_resp2_connection\n\n\n@pytest.mark.onlycluster\nclass TestBasePolicyResolver:\n    def test_resolve(self):\n        mock_command_parser = Mock(spec=CommandsParser)\n        zcount_policy = CommandPolicies(\n            request_policy=RequestPolicy.DEFAULT_KEYED,\n            response_policy=ResponsePolicy.DEFAULT_KEYED,\n        )\n        rpoplpush_policy = CommandPolicies(\n            request_policy=RequestPolicy.DEFAULT_KEYED,\n            response_policy=ResponsePolicy.DEFAULT_KEYED,\n        )\n\n        mock_command_parser.get_command_policies.return_value = {\n            \"core\": {\n                \"zcount\": zcount_policy,\n                \"rpoplpush\": rpoplpush_policy,\n            }\n        }\n\n        dynamic_resolver = DynamicPolicyResolver(mock_command_parser)\n        assert dynamic_resolver.resolve(\"zcount\") == zcount_policy\n        assert dynamic_resolver.resolve(\"rpoplpush\") == rpoplpush_policy\n\n        with pytest.raises(\n            ValueError, match=\"Wrong command or module name: foo.bar.baz\"\n        ):\n            dynamic_resolver.resolve(\"foo.bar.baz\")\n\n        assert dynamic_resolver.resolve(\"foo.bar\") is None\n        assert dynamic_resolver.resolve(\"core.foo\") is None\n\n        # Test that policy fallback correctly\n        static_resolver = StaticPolicyResolver()\n        with_fallback_dynamic_resolver = dynamic_resolver.with_fallback(static_resolver)\n\n        assert (\n            with_fallback_dynamic_resolver.resolve(\"ft.aggregate\").request_policy\n            == RequestPolicy.DEFAULT_KEYLESS\n        )\n        assert (\n            with_fallback_dynamic_resolver.resolve(\"ft.aggregate\").response_policy\n            == ResponsePolicy.DEFAULT_KEYLESS\n        )\n\n        # Extended chain with one more resolver\n        mock_command_parser = Mock(spec=CommandsParser)\n        foo_bar_policy = CommandPolicies(\n            request_policy=RequestPolicy.DEFAULT_KEYLESS,\n            response_policy=ResponsePolicy.DEFAULT_KEYLESS,\n        )\n\n        mock_command_parser.get_command_policies.return_value = {\n            \"foo\": {\n                \"bar\": foo_bar_policy,\n            }\n        }\n        another_dynamic_resolver = DynamicPolicyResolver(mock_command_parser)\n        with_fallback_static_resolver = static_resolver.with_fallback(\n            another_dynamic_resolver\n        )\n        with_double_fallback_dynamic_resolver = dynamic_resolver.with_fallback(\n            with_fallback_static_resolver\n        )\n\n        assert (\n            with_double_fallback_dynamic_resolver.resolve(\"foo.bar\") == foo_bar_policy\n        )\n\n\n@pytest.mark.onlycluster\n@skip_if_server_version_lt(\"8.0.0\")\nclass TestClusterWithPolicies:\n    def test_resolves_correctly_policies(self, r, monkeypatch):\n        # original nodes selection method\n        determine_nodes = r._determine_nodes\n        determined_nodes = []\n        primary_nodes = r.get_primaries()\n        calls = iter(list(range(len(primary_nodes))))\n\n        def wrapper(*args, request_policy: RequestPolicy, **kwargs):\n            nonlocal determined_nodes\n            determined_nodes = determine_nodes(\n                *args, request_policy=request_policy, **kwargs\n            )\n            return determined_nodes\n\n        # Mock random.choice to always return a pre-defined sequence of nodes\n        monkeypatch.setattr(random, \"choice\", lambda seq: seq[next(calls)])\n\n        with patch.object(r, \"_determine_nodes\", side_effect=wrapper, autospec=True):\n            # Routed to a random primary node\n            r.ft().create_index(\n                (\n                    NumericField(\"random_num\"),\n                    TextField(\"title\"),\n                    TextField(\"body\"),\n                    TextField(\"parent\"),\n                )\n            )\n            assert determined_nodes[0] == primary_nodes[0]\n\n            # Routed to another random primary node\n            info = r.ft().info()\n            if is_resp2_connection(r):\n                assert info[\"index_name\"] == \"idx\"\n            else:\n                assert info[b\"index_name\"] == b\"idx\"\n\n            assert determined_nodes[0] == primary_nodes[1]\n\n            expected_node = r.get_nodes_from_slot(\"ft.suglen\", *[\"FT.SUGLEN\", \"foo\"])\n            r.ft().suglen(\"foo\")\n            assert determined_nodes[0] == expected_node[0]\n\n            # Indexing a document\n            r.hset(\n                \"search\",\n                mapping={\n                    \"title\": \"RediSearch\",\n                    \"body\": \"Redisearch impements a search engine on top of redis\",\n                    \"parent\": \"redis\",\n                    \"random_num\": 10,\n                },\n            )\n            r.hset(\n                \"ai\",\n                mapping={\n                    \"title\": \"RedisAI\",\n                    \"body\": \"RedisAI executes Deep Learning/Machine Learning models and managing their data.\",  # noqa\n                    \"parent\": \"redis\",\n                    \"random_num\": 3,\n                },\n            )\n            r.hset(\n                \"json\",\n                mapping={\n                    \"title\": \"RedisJson\",\n                    \"body\": \"RedisJSON implements ECMA-404 The JSON Data Interchange Standard as a native data type.\",  # noqa\n                    \"parent\": \"redis\",\n                    \"random_num\": 8,\n                },\n            )\n\n            req = AggregateRequest(\"redis\").group_by(\"@parent\").cursor(1)\n\n            if is_resp2_connection(r):\n                cursor = r.ft().aggregate(req).cursor\n            else:\n                cursor = Cursor(r.ft().aggregate(req)[1])\n\n            # Ensure that aggregate node was cached.\n            assert determined_nodes[0] == r._aggregate_nodes[0]\n\n            r.ft().aggregate(cursor)\n\n            # Verify that FT.CURSOR dispatched to the same node.\n            assert determined_nodes[0] == r._aggregate_nodes[0]\n\n            # Error propagates to a user\n            with pytest.raises(ResponseError, match=\"Cursor not found, id:\"):\n                r.ft().aggregate(cursor)\n\n            assert determined_nodes[0] == primary_nodes[2]\n\n            # Core commands also randomly distributed across masters\n            r.randomkey()\n            assert determined_nodes[0] == primary_nodes[0]\n"
  },
  {
    "path": "tests/test_commands.py",
    "content": "import binascii\nimport datetime\nimport re\nimport threading\nimport time\nfrom asyncio import CancelledError\nfrom string import ascii_letters\nfrom unittest import mock\nfrom unittest.mock import patch\n\nimport pytest\nfrom redis import DataError, RedisClusterException, ResponseError\nimport redis\nfrom redis import exceptions\nfrom redis._parsers.helpers import (\n    _RedisCallbacks,\n    _RedisCallbacksRESP2,\n    _RedisCallbacksRESP3,\n    parse_info,\n)\nfrom redis.client import EMPTY_RESPONSE, NEVER_DECODE\nfrom redis.commands.core import DataPersistOptions, HotkeysMetricsTypes\nfrom redis.commands.json.path import Path\nfrom redis.commands.search.field import TextField\nfrom redis.commands.search.query import Query\nfrom redis.utils import safe_str\nfrom tests.test_utils import redis_server_time\n\nfrom .conftest import (\n    _get_client,\n    assert_resp_response,\n    assert_resp_response_in,\n    is_resp2_connection,\n    skip_if_redis_enterprise,\n    skip_if_server_version_gte,\n    skip_if_server_version_lt,\n    skip_unless_arch_bits,\n)\n\n\n@pytest.fixture()\ndef slowlog(request, r):\n    current_config = r.config_get()\n    old_slower_than_value = current_config[\"slowlog-log-slower-than\"]\n    old_max_legnth_value = current_config[\"slowlog-max-len\"]\n\n    def cleanup():\n        r.config_set(\"slowlog-log-slower-than\", old_slower_than_value)\n        r.config_set(\"slowlog-max-len\", old_max_legnth_value)\n\n    request.addfinalizer(cleanup)\n\n    r.config_set(\"slowlog-log-slower-than\", 0)\n    r.config_set(\"slowlog-max-len\", 128)\n\n\ndef get_stream_message(client, stream, message_id):\n    \"Fetch a stream message and format it as a (message_id, fields) pair\"\n    response = client.xrange(stream, min=message_id, max=message_id)\n    assert len(response) == 1\n    return response[0]\n\n\n# RESPONSE CALLBACKS\n@pytest.mark.onlynoncluster\nclass TestResponseCallbacks:\n    \"Tests for the response callback system\"\n\n    def test_response_callbacks(self, r):\n        callbacks = _RedisCallbacks\n        if is_resp2_connection(r):\n            callbacks.update(_RedisCallbacksRESP2)\n        else:\n            callbacks.update(_RedisCallbacksRESP3)\n        assert r.response_callbacks == callbacks\n        assert id(r.response_callbacks) != id(_RedisCallbacks)\n        r.set_response_callback(\"GET\", lambda x: \"static\")\n        r[\"a\"] = \"foo\"\n        assert r[\"a\"] == \"static\"\n\n    def test_case_insensitive_command_names(self, r):\n        assert r.response_callbacks[\"ping\"] == r.response_callbacks[\"PING\"]\n\n\nclass TestRedisCommands:\n    @pytest.mark.onlynoncluster\n    @skip_if_redis_enterprise()\n    def test_auth(self, r, request):\n        # sending an AUTH command before setting a user/password on the\n        # server should return an AuthenticationError\n        with pytest.raises(exceptions.AuthenticationError):\n            r.auth(\"some_password\")\n\n        with pytest.raises(exceptions.AuthenticationError):\n            r.auth(\"some_password\", \"some_user\")\n\n        # first, test for default user (`username` is supposed to be optional)\n        default_username = \"default\"\n        temp_pass = \"temp_pass\"\n        r.config_set(\"requirepass\", temp_pass)\n\n        assert r.auth(temp_pass, default_username) is True\n        assert r.auth(temp_pass) is True\n\n        # test for other users\n        username = \"redis-py-auth\"\n\n        def teardown():\n            try:\n                # this is needed because after an AuthenticationError the connection\n                # is closed, and if we send an AUTH command a new connection is\n                # created, but in this case we'd get an \"Authentication required\"\n                # error when switching to the db 9 because we're not authenticated yet\n                # setting the password on the connection itself triggers the\n                # authentication in the connection's `on_connect` method\n                r.connection.password = temp_pass\n            except AttributeError:\n                # connection field is not set in Redis Cluster, but that's ok\n                # because the problem discussed above does not apply to Redis Cluster\n                pass\n            r.auth(temp_pass)\n            r.config_set(\"requirepass\", \"\")\n            r.acl_deluser(username)\n\n        request.addfinalizer(teardown)\n\n        assert r.acl_setuser(\n            username, enabled=True, passwords=[\"+strong_password\"], commands=[\"+acl\"]\n        )\n\n        assert r.auth(username=username, password=\"strong_password\") is True\n\n        with pytest.raises(exceptions.AuthenticationError):\n            r.auth(username=username, password=\"wrong_password\")\n\n    def test_command_on_invalid_key_type(self, r):\n        r.lpush(\"a\", \"1\")\n        with pytest.raises(redis.ResponseError):\n            r[\"a\"]\n\n    # SERVER INFORMATION\n    @skip_if_server_version_lt(\"6.0.0\")\n    def test_acl_cat_no_category(self, r):\n        categories = r.acl_cat()\n        assert isinstance(categories, list)\n        assert \"read\" in categories or b\"read\" in categories\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"7.9.0\")\n    def test_acl_cat_contain_modules_no_category(self, r):\n        modules_list = [\n            \"search\",\n            \"bloom\",\n            \"json\",\n            \"cuckoo\",\n            \"timeseries\",\n            \"cms\",\n            \"topk\",\n            \"tdigest\",\n        ]\n        categories = r.acl_cat()\n        assert isinstance(categories, list)\n        for module_cat in modules_list:\n            assert module_cat in categories or module_cat.encode() in categories\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    def test_acl_cat_with_category(self, r):\n        commands = r.acl_cat(\"read\")\n        assert isinstance(commands, list)\n        assert \"get\" in commands or b\"get\" in commands\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"7.9.0\")\n    def test_acl_modules_cat_with_category(self, r):\n        search_commands = r.acl_cat(\"search\")\n        assert isinstance(search_commands, list)\n        assert \"FT.SEARCH\" in search_commands or b\"FT.SEARCH\" in search_commands\n\n        bloom_commands = r.acl_cat(\"bloom\")\n        assert isinstance(bloom_commands, list)\n        assert \"bf.add\" in bloom_commands or b\"bf.add\" in bloom_commands\n\n        json_commands = r.acl_cat(\"json\")\n        assert isinstance(json_commands, list)\n        assert \"json.get\" in json_commands or b\"json.get\" in json_commands\n\n        cuckoo_commands = r.acl_cat(\"cuckoo\")\n        assert isinstance(cuckoo_commands, list)\n        assert \"cf.insert\" in cuckoo_commands or b\"cf.insert\" in cuckoo_commands\n\n        cms_commands = r.acl_cat(\"cms\")\n        assert isinstance(cms_commands, list)\n        assert \"cms.query\" in cms_commands or b\"cms.query\" in cms_commands\n\n        topk_commands = r.acl_cat(\"topk\")\n        assert isinstance(topk_commands, list)\n        assert \"topk.list\" in topk_commands or b\"topk.list\" in topk_commands\n\n        tdigest_commands = r.acl_cat(\"tdigest\")\n        assert isinstance(tdigest_commands, list)\n        assert \"tdigest.rank\" in tdigest_commands or b\"tdigest.rank\" in tdigest_commands\n\n        timeseries_commands = r.acl_cat(\"timeseries\")\n        assert isinstance(timeseries_commands, list)\n        assert \"ts.range\" in timeseries_commands or b\"ts.range\" in timeseries_commands\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    @skip_if_redis_enterprise()\n    def test_acl_dryrun(self, r, request):\n        username = \"redis-py-user\"\n\n        def teardown():\n            r.acl_deluser(username)\n\n        request.addfinalizer(teardown)\n\n        r.acl_setuser(username, keys=[\"*\"], commands=[\"+set\"])\n        assert r.acl_dryrun(username, \"set\", \"key\", \"value\") == b\"OK\"\n        no_permissions_message = b\"user has no permissions to run the\"\n        assert no_permissions_message in r.acl_dryrun(username, \"get\", \"key\")\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    @skip_if_redis_enterprise()\n    def test_acl_deluser(self, r, request):\n        username = \"redis-py-user\"\n\n        def teardown():\n            r.acl_deluser(username)\n\n        request.addfinalizer(teardown)\n\n        assert r.acl_deluser(username) == 0\n        assert r.acl_setuser(username, enabled=False, reset=True)\n        assert r.acl_deluser(username) == 1\n\n        # now, a group of users\n        users = [f\"bogususer_{r}\" for r in range(0, 5)]\n        for u in users:\n            r.acl_setuser(u, enabled=False, reset=True)\n        assert r.acl_deluser(*users) > 1\n        assert r.acl_getuser(users[0]) is None\n        assert r.acl_getuser(users[1]) is None\n        assert r.acl_getuser(users[2]) is None\n        assert r.acl_getuser(users[3]) is None\n        assert r.acl_getuser(users[4]) is None\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    @skip_if_redis_enterprise()\n    def test_acl_genpass(self, r):\n        password = r.acl_genpass()\n        assert isinstance(password, (str, bytes))\n\n        with pytest.raises(exceptions.DataError):\n            r.acl_genpass(\"value\")\n            r.acl_genpass(-5)\n            r.acl_genpass(5555)\n\n        password = r.acl_genpass(555)\n        assert isinstance(password, (str, bytes))\n        assert len(password) == 139\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    @skip_if_redis_enterprise()\n    def test_acl_getuser_setuser(self, r, request):\n        r.flushall()\n        username = \"redis-py-user\"\n\n        def teardown():\n            r.acl_deluser(username)\n\n        request.addfinalizer(teardown)\n\n        # test enabled=False\n        assert r.acl_setuser(username, enabled=False, reset=True)\n        acl = r.acl_getuser(username)\n        assert acl[\"categories\"] == [\"-@all\"]\n        assert acl[\"commands\"] == []\n        assert acl[\"keys\"] == []\n        assert acl[\"passwords\"] == []\n        assert \"off\" in acl[\"flags\"]\n        assert acl[\"enabled\"] is False\n\n        # test nopass=True\n        assert r.acl_setuser(username, enabled=True, reset=True, nopass=True)\n        acl = r.acl_getuser(username)\n        assert acl[\"categories\"] == [\"-@all\"]\n        assert acl[\"commands\"] == []\n        assert acl[\"keys\"] == []\n        assert acl[\"passwords\"] == []\n        assert \"on\" in acl[\"flags\"]\n        assert \"nopass\" in acl[\"flags\"]\n        assert acl[\"enabled\"] is True\n\n        # test all args\n        assert r.acl_setuser(\n            username,\n            enabled=True,\n            reset=True,\n            passwords=[\"+pass1\", \"+pass2\"],\n            categories=[\"+set\", \"+@hash\", \"-@geo\"],\n            commands=[\"+get\", \"+mget\", \"-hset\"],\n            keys=[\"cache:*\", \"objects:*\"],\n        )\n        acl = r.acl_getuser(username)\n        assert set(acl[\"categories\"]) == {\"+@hash\", \"+@set\", \"-@all\", \"-@geo\"}\n        assert set(acl[\"commands\"]) == {\"+get\", \"+mget\", \"-hset\"}\n        assert acl[\"enabled\"] is True\n        assert \"on\" in acl[\"flags\"]\n        assert set(acl[\"keys\"]) == {\"~cache:*\", \"~objects:*\"}\n        assert len(acl[\"passwords\"]) == 2\n\n        # # test reset=False keeps existing ACL and applies new ACL on top\n        assert r.acl_setuser(\n            username,\n            enabled=True,\n            reset=True,\n            passwords=[\"+pass1\"],\n            categories=[\"+@set\"],\n            commands=[\"+get\"],\n            keys=[\"cache:*\"],\n        )\n        assert r.acl_setuser(\n            username,\n            enabled=True,\n            passwords=[\"+pass2\"],\n            categories=[\"+@hash\"],\n            commands=[\"+mget\"],\n            keys=[\"objects:*\"],\n        )\n        acl = r.acl_getuser(username)\n        assert set(acl[\"commands\"]) == {\"+get\", \"+mget\"}\n        assert acl[\"enabled\"] is True\n        assert \"on\" in acl[\"flags\"]\n        assert set(acl[\"keys\"]) == {\"~cache:*\", \"~objects:*\"}\n        assert len(acl[\"passwords\"]) == 2\n\n        # # test removal of passwords\n        assert r.acl_setuser(\n            username, enabled=True, reset=True, passwords=[\"+pass1\", \"+pass2\"]\n        )\n        assert len(r.acl_getuser(username)[\"passwords\"]) == 2\n        assert r.acl_setuser(username, enabled=True, passwords=[\"-pass2\"])\n        assert len(r.acl_getuser(username)[\"passwords\"]) == 1\n\n        # # Resets and tests that hashed passwords are set properly.\n        hashed_password = (\n            \"5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8\"\n        )\n        assert r.acl_setuser(\n            username, enabled=True, reset=True, hashed_passwords=[\"+\" + hashed_password]\n        )\n        acl = r.acl_getuser(username)\n        assert acl[\"passwords\"] == [hashed_password]\n\n        # test removal of hashed passwords\n        assert r.acl_setuser(\n            username,\n            enabled=True,\n            reset=True,\n            hashed_passwords=[\"+\" + hashed_password],\n            passwords=[\"+pass1\"],\n        )\n        assert len(r.acl_getuser(username)[\"passwords\"]) == 2\n        assert r.acl_setuser(\n            username, enabled=True, hashed_passwords=[\"-\" + hashed_password]\n        )\n        assert len(r.acl_getuser(username)[\"passwords\"]) == 1\n\n        # # test selectors\n        assert r.acl_setuser(\n            username,\n            enabled=True,\n            reset=True,\n            passwords=[\"+pass1\", \"+pass2\"],\n            categories=[\"+set\", \"+@hash\", \"-geo\"],\n            commands=[\"+get\", \"+mget\", \"-hset\"],\n            keys=[\"cache:*\", \"objects:*\"],\n            channels=[\"message:*\"],\n            selectors=[(\"+set\", \"%W~app*\")],\n        )\n        acl = r.acl_getuser(username)\n        assert set(acl[\"categories\"]) == {\"+@hash\", \"+@set\", \"-@all\", \"-@geo\"}\n        assert set(acl[\"commands\"]) == {\"+get\", \"+mget\", \"-hset\"}\n        assert acl[\"enabled\"] is True\n        assert \"on\" in acl[\"flags\"]\n        assert set(acl[\"keys\"]) == {\"~cache:*\", \"~objects:*\"}\n        assert len(acl[\"passwords\"]) == 2\n        assert set(acl[\"channels\"]) == {\"&message:*\"}\n        assert_resp_response(\n            r,\n            acl[\"selectors\"],\n            [[\"commands\", \"-@all +set\", \"keys\", \"%W~app*\", \"channels\", \"\"]],\n            [{\"commands\": \"-@all +set\", \"keys\": \"%W~app*\", \"channels\": \"\"}],\n        )\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    def test_acl_help(self, r):\n        res = r.acl_help()\n        assert isinstance(res, list)\n        assert len(res) != 0\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    @skip_if_redis_enterprise()\n    def test_acl_list(self, r, request):\n        username = \"redis-py-user\"\n        start = r.acl_list()\n\n        def teardown():\n            r.acl_deluser(username)\n\n        request.addfinalizer(teardown)\n\n        assert r.acl_setuser(username, enabled=False, reset=True)\n        users = r.acl_list()\n        assert len(users) == len(start) + 1\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    @skip_if_redis_enterprise()\n    @pytest.mark.onlynoncluster\n    def test_acl_log(self, r, request):\n        username = \"redis-py-user\"\n\n        def teardown():\n            r.acl_deluser(username)\n\n        request.addfinalizer(teardown)\n        r.acl_setuser(\n            username,\n            enabled=True,\n            reset=True,\n            commands=[\"+get\", \"+set\", \"+select\"],\n            keys=[\"cache:*\"],\n            nopass=True,\n        )\n        r.acl_log_reset()\n\n        user_client = _get_client(\n            redis.Redis, request, flushdb=False, username=username\n        )\n\n        # Valid operation and key\n        assert user_client.set(\"cache:0\", 1)\n        assert user_client.get(\"cache:0\") == b\"1\"\n\n        # Invalid key\n        with pytest.raises(exceptions.NoPermissionError):\n            user_client.get(\"violated_cache:0\")\n\n        # Invalid operation\n        with pytest.raises(exceptions.NoPermissionError):\n            user_client.hset(\"cache:0\", \"hkey\", \"hval\")\n\n        assert isinstance(r.acl_log(), list)\n        assert len(r.acl_log()) == 3\n        assert len(r.acl_log(count=1)) == 1\n        assert isinstance(r.acl_log()[0], dict)\n        expected = r.acl_log(count=1)[0]\n        assert_resp_response_in(\n            r,\n            \"client-info\",\n            expected,\n            expected.keys(),\n        )\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    @skip_if_redis_enterprise()\n    def test_acl_setuser_categories_without_prefix_fails(self, r, request):\n        username = \"redis-py-user\"\n\n        def teardown():\n            r.acl_deluser(username)\n\n        request.addfinalizer(teardown)\n\n        with pytest.raises(exceptions.DataError):\n            r.acl_setuser(username, categories=[\"list\"])\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    @skip_if_redis_enterprise()\n    def test_acl_setuser_commands_without_prefix_fails(self, r, request):\n        username = \"redis-py-user\"\n\n        def teardown():\n            r.acl_deluser(username)\n\n        request.addfinalizer(teardown)\n\n        with pytest.raises(exceptions.DataError):\n            r.acl_setuser(username, commands=[\"get\"])\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    @skip_if_redis_enterprise()\n    def test_acl_setuser_add_passwords_and_nopass_fails(self, r, request):\n        username = \"redis-py-user\"\n\n        def teardown():\n            r.acl_deluser(username)\n\n        request.addfinalizer(teardown)\n\n        with pytest.raises(exceptions.DataError):\n            r.acl_setuser(username, passwords=\"+mypass\", nopass=True)\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    def test_acl_users(self, r):\n        users = r.acl_users()\n        assert isinstance(users, list)\n        assert len(users) > 0\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    def test_acl_whoami(self, r):\n        username = r.acl_whoami()\n        assert isinstance(username, (str, bytes))\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"7.9.0\")\n    def test_acl_modules_commands(self, r, request):\n        default_username = \"default\"\n        username = \"redis-py-user\"\n        password = \"pass-for-test-user\"\n\n        def teardown():\n            r.auth(\"\", default_username)\n            r.acl_deluser(username)\n\n        request.addfinalizer(teardown)\n\n        r.ft().create_index((TextField(\"txt\"),))\n        r.hset(\"doc1\", mapping={\"txt\": \"foo baz\"})\n        r.hset(\"doc2\", mapping={\"txt\": \"foo bar\"})\n\n        r.acl_setuser(\n            username,\n            enabled=True,\n            reset=True,\n            passwords=[f\"+{password}\"],\n            categories=[\"-all\"],\n            commands=[\n                \"+FT.SEARCH\",\n                \"-FT.DROPINDEX\",\n                \"+json.set\",\n                \"+json.get\",\n                \"-json.clear\",\n                \"+bf.reserve\",\n                \"-bf.info\",\n                \"+cf.reserve\",\n                \"+cms.initbydim\",\n                \"+topk.reserve\",\n                \"+tdigest.create\",\n                \"+ts.create\",\n                \"-ts.info\",\n            ],\n            keys=[\"*\"],\n        )\n        r.auth(password, username)\n\n        assert r.ft().search(Query(\"foo ~bar\"))\n        with pytest.raises(exceptions.NoPermissionError):\n            r.ft().dropindex()\n\n        r.json().set(\"foo\", Path.root_path(), \"bar\")\n        assert r.json().get(\"foo\") == \"bar\"\n        with pytest.raises(exceptions.NoPermissionError):\n            r.json().clear(\"foo\")\n\n        assert r.bf().create(\"bloom\", 0.01, 1000)\n        assert r.cf().create(\"cuckoo\", 1000)\n        assert r.cms().initbydim(\"cmsDim\", 100, 5)\n        assert r.topk().reserve(\"topk\", 5, 100, 5, 0.9)\n        assert r.tdigest().create(\"to-tDigest\", 10)\n        with pytest.raises(exceptions.NoPermissionError):\n            r.bf().info(\"bloom\")\n\n        assert r.ts().create(1, labels={\"Redis\": \"Labs\"})\n        with pytest.raises(exceptions.NoPermissionError):\n            r.ts().info(1)\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"7.9.0\")\n    def test_acl_modules_category_commands(self, r, request):\n        default_username = \"default\"\n        username = \"redis-py-user\"\n        password = \"pass-for-test-user\"\n\n        def teardown():\n            r.auth(\"\", default_username)\n            r.acl_deluser(username)\n\n        request.addfinalizer(teardown)\n\n        # validate modules categories acl config\n        r.acl_setuser(\n            username,\n            enabled=True,\n            reset=True,\n            passwords=[f\"+{password}\"],\n            categories=[\n                \"-all\",\n                \"+@search\",\n                \"+@json\",\n                \"+@bloom\",\n                \"+@cuckoo\",\n                \"+@topk\",\n                \"+@cms\",\n                \"+@timeseries\",\n                \"+@tdigest\",\n            ],\n            keys=[\"*\"],\n        )\n        r.ft().create_index((TextField(\"txt\"),))\n        r.hset(\"doc1\", mapping={\"txt\": \"foo baz\"})\n        r.hset(\"doc2\", mapping={\"txt\": \"foo bar\"})\n\n        r.auth(password, username)\n\n        assert r.ft().search(Query(\"foo ~bar\"))\n        assert r.ft().dropindex()\n\n        assert r.json().set(\"foo\", Path.root_path(), \"bar\")\n        assert r.json().get(\"foo\") == \"bar\"\n\n        assert r.bf().create(\"bloom\", 0.01, 1000)\n        assert r.bf().info(\"bloom\")\n        assert r.cf().create(\"cuckoo\", 1000)\n        assert r.cms().initbydim(\"cmsDim\", 100, 5)\n        assert r.topk().reserve(\"topk\", 5, 100, 5, 0.9)\n        assert r.tdigest().create(\"to-tDigest\", 10)\n\n        assert r.ts().create(1, labels={\"Redis\": \"Labs\"})\n        assert r.ts().info(1)\n\n    @pytest.mark.onlynoncluster\n    def test_client_list(self, r):\n        clients = r.client_list()\n        assert isinstance(clients[0], dict)\n        assert \"addr\" in clients[0]\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_client_info(self, r):\n        info = r.client_info()\n        assert isinstance(info, dict)\n        assert \"addr\" in info\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"5.0.0\")\n    def test_client_list_types_not_replica(self, r):\n        with pytest.raises(exceptions.RedisError):\n            r.client_list(_type=\"not a client type\")\n        for client_type in [\"normal\", \"master\", \"pubsub\"]:\n            clients = r.client_list(_type=client_type)\n            assert isinstance(clients, list)\n\n    @skip_if_redis_enterprise()\n    def test_client_list_replica(self, r):\n        clients = r.client_list(_type=\"replica\")\n        assert isinstance(clients, list)\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_client_list_client_id(self, r, request):\n        clients = r.client_list()\n        clients = r.client_list(client_id=[clients[0][\"id\"]])\n        assert len(clients) == 1\n        assert \"addr\" in clients[0]\n\n        # testing multiple client ids\n        client_list = list()\n        client_count = 3\n        for i in range(client_count):\n            client = _get_client(redis.Redis, request, flushdb=False)\n            client_list.append(client)\n\n        multiple_client_ids = [str(client.client_id()) for client in client_list]\n        clients_listed = r.client_list(client_id=multiple_client_ids)\n        assert len(clients_listed) == len(multiple_client_ids)\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"5.0.0\")\n    def test_client_id(self, r):\n        assert r.client_id() > 0\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"6.2.0\")\n    @skip_if_redis_enterprise()\n    def test_client_trackinginfo(self, r):\n        res = r.client_trackinginfo()\n        assert len(res) > 2\n        assert \"prefixes\" in res or b\"prefixes\" in res\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"6.0.0\")\n    @skip_if_redis_enterprise()\n    def test_client_tracking(self, r, r2):\n        # simple case\n        assert r.client_tracking_on()\n        assert r.client_tracking_off()\n\n        # id based\n        client_id = r.client_id()\n        assert r.client_tracking_on(client_id)\n        assert r.client_tracking_off(client_id)\n\n        # id exists\n        client_id = r2.client_id()\n        assert r.client_tracking_on(client_id)\n        assert r2.client_tracking_off(client_id)\n\n        # now with some prefixes\n        with pytest.raises(exceptions.DataError):\n            assert r.client_tracking_on(prefix=[\"foo\", \"bar\", \"blee\"])\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"5.0.0\")\n    def test_client_unblock(self, r):\n        myid = r.client_id()\n        assert not r.client_unblock(myid)\n        assert not r.client_unblock(myid, error=True)\n        assert not r.client_unblock(myid, error=False)\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"2.6.9\")\n    def test_client_getname(self, r):\n        assert r.client_getname() is None\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"2.6.9\")\n    def test_client_setname(self, r):\n        assert r.client_setname(\"redis_py_test\")\n        assert_resp_response(r, r.client_getname(), \"redis_py_test\", b\"redis_py_test\")\n\n    @skip_if_server_version_lt(\"7.2.0\")\n    def test_client_setinfo(self, r: redis.Redis):\n        from redis.utils import get_lib_version\n\n        r.ping()\n        info = r.client_info()\n        assert info[\"lib-name\"] == \"redis-py\"\n        assert info[\"lib-ver\"] == get_lib_version()\n        assert r.client_setinfo(\"lib-name\", \"test\")\n        assert r.client_setinfo(\"lib-ver\", \"123\")\n        info = r.client_info()\n        assert info[\"lib-name\"] == \"test\"\n        assert info[\"lib-ver\"] == \"123\"\n\n        # Test deprecated lib_name/lib_version parameters\n        with pytest.warns(DeprecationWarning):\n            r2 = redis.Redis(lib_name=\"test2\", lib_version=\"1234\")\n        info = r2.client_info()\n        assert info[\"lib-name\"] == \"test2\"\n        assert info[\"lib-ver\"] == \"1234\"\n\n    @skip_if_server_version_lt(\"7.2.0\")\n    def test_client_setinfo_with_driver_info(self, r: redis.Redis):\n        from redis import DriverInfo\n        from redis.utils import get_lib_version\n\n        info = DriverInfo().add_upstream_driver(\"django-redis\", \"5.4.0\")\n        r2 = redis.Redis(driver_info=info)\n        r2.ping()\n        client_info = r2.client_info()\n        assert client_info[\"lib-name\"] == \"redis-py(django-redis_v5.4.0)\"\n        assert client_info[\"lib-ver\"] == get_lib_version()\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"2.6.9\")\n    def test_client_kill(self, r, r2):\n        r.client_setname(\"redis-py-c1\")\n        r2.client_setname(\"redis-py-c2\")\n        clients = [\n            client\n            for client in r.client_list()\n            if client.get(\"name\") in [\"redis-py-c1\", \"redis-py-c2\"]\n        ]\n        assert len(clients) == 2\n\n        clients_by_name = {client.get(\"name\"): client for client in clients}\n\n        client_addr = clients_by_name[\"redis-py-c2\"].get(\"addr\")\n        assert r.client_kill(client_addr) is True\n\n        clients = [\n            client\n            for client in r.client_list()\n            if client.get(\"name\") in [\"redis-py-c1\", \"redis-py-c2\"]\n        ]\n        assert len(clients) == 1\n        assert clients[0].get(\"name\") == \"redis-py-c1\"\n\n    @skip_if_server_version_lt(\"2.8.12\")\n    def test_client_kill_filter_invalid_params(self, r):\n        # empty\n        with pytest.raises(exceptions.DataError):\n            r.client_kill_filter()\n\n        # invalid skipme\n        with pytest.raises(exceptions.DataError):\n            r.client_kill_filter(skipme=\"yeah\")\n\n        # invalid type\n        with pytest.raises(exceptions.DataError):\n            r.client_kill_filter(_type=\"caster\")\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"2.8.12\")\n    def test_client_kill_filter_by_id(self, r, r2):\n        r.client_setname(\"redis-py-c1\")\n        r2.client_setname(\"redis-py-c2\")\n        clients = [\n            client\n            for client in r.client_list()\n            if client.get(\"name\") in [\"redis-py-c1\", \"redis-py-c2\"]\n        ]\n        assert len(clients) == 2\n\n        clients_by_name = {client.get(\"name\"): client for client in clients}\n\n        client_2_id = clients_by_name[\"redis-py-c2\"].get(\"id\")\n        resp = r.client_kill_filter(_id=client_2_id)\n        assert resp == 1\n\n        clients = [\n            client\n            for client in r.client_list()\n            if client.get(\"name\") in [\"redis-py-c1\", \"redis-py-c2\"]\n        ]\n        assert len(clients) == 1\n        assert clients[0].get(\"name\") == \"redis-py-c1\"\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"2.8.12\")\n    def test_client_kill_filter_by_addr(self, r, r2):\n        r.client_setname(\"redis-py-c1\")\n        r2.client_setname(\"redis-py-c2\")\n        clients = [\n            client\n            for client in r.client_list()\n            if client.get(\"name\") in [\"redis-py-c1\", \"redis-py-c2\"]\n        ]\n        assert len(clients) == 2\n\n        clients_by_name = {client.get(\"name\"): client for client in clients}\n\n        client_2_addr = clients_by_name[\"redis-py-c2\"].get(\"addr\")\n        resp = r.client_kill_filter(addr=client_2_addr)\n        assert resp == 1\n\n        clients = [\n            client\n            for client in r.client_list()\n            if client.get(\"name\") in [\"redis-py-c1\", \"redis-py-c2\"]\n        ]\n        assert len(clients) == 1\n        assert clients[0].get(\"name\") == \"redis-py-c1\"\n\n    @skip_if_server_version_lt(\"2.6.9\")\n    def test_client_list_after_client_setname(self, r):\n        r.client_setname(\"redis_py_test\")\n        clients = r.client_list()\n        # we don't know which client ours will be\n        assert \"redis_py_test\" in [c[\"name\"] for c in clients]\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_client_kill_filter_by_laddr(self, r, r2):\n        r.client_setname(\"redis-py-c1\")\n        r2.client_setname(\"redis-py-c2\")\n        clients = [\n            client\n            for client in r.client_list()\n            if client.get(\"name\") in [\"redis-py-c1\", \"redis-py-c2\"]\n        ]\n        assert len(clients) == 2\n\n        clients_by_name = {client.get(\"name\"): client for client in clients}\n\n        client_2_addr = clients_by_name[\"redis-py-c2\"].get(\"laddr\")\n        assert r.client_kill_filter(laddr=client_2_addr)\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    @skip_if_redis_enterprise()\n    def test_client_kill_filter_by_user(self, r, request):\n        killuser = \"user_to_kill\"\n        r.acl_setuser(\n            killuser,\n            enabled=True,\n            reset=True,\n            commands=[\"+get\", \"+set\", \"+select\", \"+cluster\", \"+command\", \"+info\"],\n            keys=[\"cache:*\"],\n            nopass=True,\n        )\n        _get_client(redis.Redis, request, flushdb=False, username=killuser)\n        r.client_kill_filter(user=killuser)\n        clients = r.client_list()\n        for c in clients:\n            assert c[\"user\"] != killuser\n        r.acl_deluser(killuser)\n\n    @skip_if_server_version_lt(\"7.3.240\")\n    @skip_if_redis_enterprise()\n    @pytest.mark.onlynoncluster\n    def test_client_kill_filter_by_maxage(self, r, request):\n        r2 = _get_client(redis.Redis, request, flushdb=False)\n        name = \"target-foobar\"\n        r2.client_setname(name)\n        time.sleep(4)\n        initial_clients = [c[\"name\"] for c in r.client_list()]\n        assert name in initial_clients\n        r.client_kill_filter(maxage=2)\n        final_clients = [c[\"name\"] for c in r.client_list()]\n        assert name not in final_clients\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"2.9.50\")\n    @skip_if_redis_enterprise()\n    def test_client_pause(self, r):\n        assert r.client_pause(1)\n        assert r.client_pause(timeout=1)\n        with pytest.raises(exceptions.RedisError):\n            r.client_pause(timeout=\"not an integer\")\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    @skip_if_redis_enterprise()\n    def test_client_pause_all(self, r, r2):\n        assert r.client_pause(1, all=False)\n        assert r2.set(\"foo\", \"bar\")\n        assert r2.get(\"foo\") == b\"bar\"\n        assert r.get(\"foo\") == b\"bar\"\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"6.2.0\")\n    @skip_if_redis_enterprise()\n    def test_client_unpause(self, r):\n        assert r.client_unpause() == b\"OK\"\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_client_no_evict(self, r):\n        assert r.client_no_evict(\"ON\")\n        with pytest.raises(TypeError):\n            r.client_no_evict()\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"7.2.0\")\n    def test_client_no_touch(self, r):\n        assert r.client_no_touch(\"ON\") == b\"OK\"\n        assert r.client_no_touch(\"OFF\") == b\"OK\"\n        with pytest.raises(TypeError):\n            r.client_no_touch()\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"3.2.0\")\n    def test_client_reply(self, r, r_timeout):\n        assert r_timeout.client_reply(\"ON\") == b\"OK\"\n        with pytest.raises(exceptions.RedisError):\n            r_timeout.client_reply(\"OFF\")\n\n            r_timeout.client_reply(\"SKIP\")\n\n        assert r_timeout.set(\"foo\", \"bar\")\n\n        # validate it was set\n        assert r.get(\"foo\") == b\"bar\"\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"6.0.0\")\n    @skip_if_redis_enterprise()\n    def test_client_getredir(self, r):\n        assert isinstance(r.client_getredir(), int)\n        assert r.client_getredir() == -1\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    def test_hello_notI_implemented(self, r):\n        with pytest.raises(NotImplementedError):\n            r.hello()\n\n    def test_config_get(self, r):\n        data = r.config_get()\n        assert len(data.keys()) > 10\n        # # assert 'maxmemory' in data\n        # assert data['maxmemory'].isdigit()\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_config_get_multi_params(self, r: redis.Redis):\n        res = r.config_get(\"*max-*-entries*\", \"maxmemory\")\n        assert \"maxmemory\" in res\n        assert \"hash-max-listpack-entries\" in res\n\n    @pytest.mark.onlynoncluster\n    @skip_if_redis_enterprise()\n    def test_config_resetstat(self, r):\n        r.ping()\n        prior_commands_processed = int(r.info()[\"total_commands_processed\"])\n        assert prior_commands_processed >= 1\n        r.config_resetstat()\n        reset_commands_processed = int(r.info()[\"total_commands_processed\"])\n        assert reset_commands_processed < prior_commands_processed\n\n    @skip_if_redis_enterprise()\n    def test_config_set(self, r):\n        r.config_set(\"timeout\", 70)\n        assert r.config_get()[\"timeout\"] == \"70\"\n        assert r.config_set(\"timeout\", 0)\n        assert r.config_get()[\"timeout\"] == \"0\"\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    @skip_if_redis_enterprise()\n    def test_config_set_multi_params(self, r: redis.Redis):\n        r.config_set(\"timeout\", 70, \"maxmemory\", 100)\n        assert r.config_get()[\"timeout\"] == \"70\"\n        assert r.config_get()[\"maxmemory\"] == \"100\"\n        assert r.config_set(\"timeout\", 0, \"maxmemory\", 0)\n        assert r.config_get()[\"timeout\"] == \"0\"\n        assert r.config_get()[\"maxmemory\"] == \"0\"\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"7.9.0\")\n    def test_config_get_for_modules(self, r: redis.Redis):\n        search_module_configs = r.config_get(\"search-*\")\n        assert \"search-timeout\" in search_module_configs\n\n        ts_module_configs = r.config_get(\"ts-*\")\n        assert \"ts-retention-policy\" in ts_module_configs\n\n        bf_module_configs = r.config_get(\"bf-*\")\n        assert \"bf-error-rate\" in bf_module_configs\n\n        cf_module_configs = r.config_get(\"cf-*\")\n        assert \"cf-initial-size\" in cf_module_configs\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"7.9.0\")\n    def test_config_set_for_search_module(self, r: redis.Redis):\n        initial_default_search_dialect = r.config_get(\"*\")[\"search-default-dialect\"]\n        try:\n            default_dialect_new = \"3\"\n            assert r.config_set(\"search-default-dialect\", default_dialect_new)\n            assert r.config_get(\"*\")[\"search-default-dialect\"] == default_dialect_new\n            assert (\n                (r.ft().config_get(\"*\")[b\"DEFAULT_DIALECT\"]).decode()\n                == default_dialect_new\n            )\n        except AssertionError as ex:\n            raise ex\n        finally:\n            assert r.config_set(\n                \"search-default-dialect\", initial_default_search_dialect\n            )\n\n        with pytest.raises(exceptions.ResponseError):\n            r.config_set(\"search-max-doctablesize\", 2000000)\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    @skip_if_redis_enterprise()\n    def test_failover(self, r):\n        with pytest.raises(NotImplementedError):\n            r.failover()\n\n    @pytest.mark.onlynoncluster\n    def test_dbsize(self, r):\n        r[\"a\"] = \"foo\"\n        r[\"b\"] = \"bar\"\n        assert r.dbsize() == 2\n\n    @pytest.mark.onlynoncluster\n    def test_echo(self, r):\n        assert r.echo(\"foo bar\") == b\"foo bar\"\n\n    @pytest.mark.onlynoncluster\n    def test_info(self, r):\n        r[\"a\"] = \"foo\"\n        r[\"b\"] = \"bar\"\n        info = r.info()\n        assert isinstance(info, dict)\n        assert \"arch_bits\" in info.keys()\n        assert \"redis_version\" in info.keys()\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_info_multi_sections(self, r):\n        res = r.info(\"clients\", \"server\")\n        assert isinstance(res, dict)\n        assert \"redis_version\" in res\n        assert \"connected_clients\" in res\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"7.9.0\")\n    def test_info_with_modules(self, r: redis.Redis):\n        res = r.info(section=\"everything\")\n        assert \"modules\" in res\n        assert \"search_number_of_indexes\" in res\n\n        res = r.info(section=\"modules\")\n        assert \"modules\" in res\n        assert \"search_number_of_indexes\" in res\n\n        res = r.info(section=\"search\")\n        assert \"modules\" not in res\n        assert \"search_number_of_indexes\" in res\n\n    @pytest.mark.onlynoncluster\n    @skip_if_redis_enterprise()\n    def test_lastsave(self, r):\n        assert isinstance(r.lastsave(), datetime.datetime)\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"5.0.0\")\n    @skip_if_server_version_gte(\"8.0.0\")\n    def test_lolwut(self, r):\n        lolwut = r.lolwut().decode(\"utf-8\")\n        assert \"Redis ver.\" in lolwut\n\n        lolwut = r.lolwut(5, 6, 7, 8).decode(\"utf-8\")\n        assert \"Redis ver.\" in lolwut\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.0.0\")\n    def test_lolwut_v8_and_higher(self, r):\n        lolwut = r.lolwut().decode(\"utf-8\")\n        assert lolwut\n\n        lolwut = r.lolwut(5, 6, 7, 8).decode(\"utf-8\")\n        assert lolwut\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"6.2.0\")\n    @skip_if_redis_enterprise()\n    def test_reset(self, r):\n        assert_resp_response(r, r.reset(), \"RESET\", b\"RESET\")\n\n    def test_object(self, r):\n        r[\"a\"] = \"foo\"\n        assert isinstance(r.object(\"refcount\", \"a\"), int)\n        assert isinstance(r.object(\"idletime\", \"a\"), int)\n        assert r.object(\"encoding\", \"a\") in (b\"raw\", b\"embstr\")\n        assert r.object(\"idletime\", \"invalid-key\") is None\n\n    def test_ping(self, r):\n        assert r.ping()\n\n    @pytest.mark.onlynoncluster\n    def test_quit(self, r):\n        assert r.quit()\n\n    @skip_if_server_version_lt(\"2.8.12\")\n    @skip_if_redis_enterprise()\n    @pytest.mark.onlynoncluster\n    def test_role(self, r):\n        assert r.role()[0] == b\"master\"\n        assert isinstance(r.role()[1], int)\n        assert isinstance(r.role()[2], list)\n\n    @pytest.mark.onlynoncluster\n    @skip_if_redis_enterprise()\n    def test_select(self, r):\n        assert r.select(5)\n        assert r.select(2)\n        assert r.select(9)\n\n    @pytest.mark.onlynoncluster\n    def test_slowlog_get(self, r, slowlog):\n        assert r.slowlog_reset()\n        unicode_string = chr(3456) + \"abcd\" + chr(3421)\n        r.get(unicode_string)\n        slowlog = r.slowlog_get()\n        assert isinstance(slowlog, list)\n        commands = [log[\"command\"] for log in slowlog]\n\n        get_command = b\" \".join((b\"GET\", unicode_string.encode(\"utf-8\")))\n        assert get_command in commands\n        assert b\"SLOWLOG RESET\" in commands\n        # the order should be ['GET <uni string>', 'SLOWLOG RESET'],\n        # but if other clients are executing commands at the same time, there\n        # could be commands, before, between, or after, so just check that\n        # the two we care about are in the appropriate order.\n        assert commands.index(get_command) < commands.index(b\"SLOWLOG RESET\")\n\n        # make sure other attributes are typed correctly\n        assert isinstance(slowlog[0][\"start_time\"], int)\n        assert isinstance(slowlog[0][\"duration\"], int)\n        assert isinstance(slowlog[0][\"client_address\"], bytes)\n        assert isinstance(slowlog[0][\"client_name\"], bytes)\n\n        # Mock result if we didn't get slowlog complexity info.\n        if \"complexity\" not in slowlog[0]:\n            # monkey patch parse_response()\n            COMPLEXITY_STATEMENT = \"Complexity info: N:4712,M:3788\"\n            old_parse_response = r.parse_response\n\n            def parse_response(connection, command_name, **options):\n                if command_name != \"SLOWLOG GET\":\n                    return old_parse_response(connection, command_name, **options)\n                responses = connection.read_response()\n                for response in responses:\n                    # Complexity info stored as fourth item in list\n                    response.insert(3, COMPLEXITY_STATEMENT)\n                return r.response_callbacks[command_name](responses, **options)\n\n            r.parse_response = parse_response\n\n            # test\n            slowlog = r.slowlog_get()\n            assert isinstance(slowlog, list)\n            commands = [log[\"command\"] for log in slowlog]\n            assert get_command in commands\n            idx = commands.index(get_command)\n            assert slowlog[idx][\"complexity\"] == COMPLEXITY_STATEMENT\n\n            # tear down monkeypatch\n            r.parse_response = old_parse_response\n\n    @pytest.mark.onlynoncluster\n    def test_slowlog_get_limit(self, r, slowlog):\n        assert r.slowlog_reset()\n        r.get(\"foo\")\n        slowlog = r.slowlog_get(1)\n        assert isinstance(slowlog, list)\n        # only one command, based on the number we passed to slowlog_get()\n        assert len(slowlog) == 1\n\n    @pytest.mark.onlynoncluster\n    def test_slowlog_length(self, r, slowlog):\n        r.get(\"foo\")\n        assert isinstance(r.slowlog_len(), int)\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    def test_time(self, r):\n        t = r.time()\n        assert len(t) == 2\n        assert isinstance(t[0], int)\n        assert isinstance(t[1], int)\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.5.240\")\n    def test_hotkeys_start_basic(self, r):\n        \"\"\"Test basic HOTKEYS START command with CPU metric\"\"\"\n        # Reset any previous session\n        try:\n            r.hotkeys_stop()\n        except Exception:\n            pass\n\n        # Start collection with CPU metric\n        result = r.hotkeys_start(count=10, metrics=[HotkeysMetricsTypes.CPU])\n        assert result == b\"OK\"\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.5.240\")\n    def test_hotkeys_start_with_all_metrics(self, r):\n        \"\"\"Test HOTKEYS START with both CPU and NET metrics\"\"\"\n        try:\n            r.hotkeys_stop()\n        except Exception:\n            pass\n\n        result = r.hotkeys_start(\n            count=5, metrics=[HotkeysMetricsTypes.CPU, HotkeysMetricsTypes.NET]\n        )\n        assert result == b\"OK\"\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.5.240\")\n    def test_hotkeys_start_with_duration(self, r):\n        \"\"\"Test HOTKEYS START with duration parameter\"\"\"\n        try:\n            r.hotkeys_stop()\n        except Exception:\n            pass\n\n        result = r.hotkeys_start(\n            count=10, metrics=[HotkeysMetricsTypes.CPU], duration=60\n        )\n        assert result == b\"OK\"\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.5.240\")\n    def test_hotkeys_start_with_sample_ratio(self, r):\n        \"\"\"Test HOTKEYS START with sample ratio\"\"\"\n        try:\n            r.hotkeys_stop()\n        except Exception:\n            pass\n\n        result = r.hotkeys_start(\n            count=10, metrics=[HotkeysMetricsTypes.CPU], sample_ratio=10\n        )\n        assert result == b\"OK\"\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.5.240\")\n    def test_hotkeys_start_fail__on_noncluster_setup_with_slots(self, r):\n        \"\"\"Test HOTKEYS START with specific hash slots\"\"\"\n        try:\n            r.hotkeys_stop()\n        except Exception:\n            pass\n\n        # slots is not supported argument for non-cluster setups\n        with pytest.raises(Exception):\n            r.hotkeys_start(\n                count=10, metrics=[HotkeysMetricsTypes.CPU], slots=[0, 100, 200]\n            )\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.5.240\")\n    def test_hotkeys_start_with_all_parameters(self, r):\n        \"\"\"Test HOTKEYS START with all optional parameters\"\"\"\n        try:\n            r.hotkeys_stop()\n        except Exception:\n            pass\n\n        result = r.hotkeys_start(\n            count=5,\n            metrics=[HotkeysMetricsTypes.CPU, HotkeysMetricsTypes.NET],\n            duration=30,\n            sample_ratio=5,\n        )\n        assert result == b\"OK\"\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.5.240\")\n    def test_hotkeys_stop(self, r):\n        \"\"\"Test HOTKEYS STOP command\"\"\"\n        try:\n            r.hotkeys_stop()\n        except Exception:\n            pass\n\n        # Start a collection session\n        r.hotkeys_start(count=10, metrics=[HotkeysMetricsTypes.CPU])\n\n        # Stop the session\n        result = r.hotkeys_stop()\n        assert result == b\"OK\"\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.5.240\")\n    def test_hotkeys_reset(self, r):\n        \"\"\"Test HOTKEYS RESET command\"\"\"\n        try:\n            r.hotkeys_stop()\n        except Exception:\n            pass\n\n        # Start a session and generate some data\n        r.hotkeys_start(count=10, metrics=[HotkeysMetricsTypes.CPU])\n\n        # Perform some operations to generate hotkeys data\n        for i in range(5):\n            r.set(f\"resetkey{i}\", f\"value{i}\")\n            r.get(f\"resetkey{i}\")\n\n        # Stop the session\n        r.hotkeys_stop()\n\n        # Get results before reset - should have data\n        result_before = r.hotkeys_get()\n        assert isinstance(result_before, list)\n        for res_elem in result_before:\n            assert isinstance(res_elem, dict)\n            assert b\"tracking-active\" in res_elem\n\n        # Reset the results\n        result = r.hotkeys_reset()\n        assert result == b\"OK\"\n\n        # Try to get results after reset - should fail or return empty\n        try:\n            result_after = r.hotkeys_get()\n            # If it doesn't fail, verify the data is cleared\n            # The response might indicate no session exists\n            assert result_after != result_before\n        except Exception:\n            # Expected - no session exists after reset\n            pass\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.5.240\")\n    def test_hotkeys_get_ongoing_session(self, r):\n        \"\"\"Test HOTKEYS GET during an ongoing collection session\"\"\"\n        try:\n            r.hotkeys_stop()\n        except Exception:\n            pass\n\n        # Start a collection session\n        r.hotkeys_start(\n            count=10, metrics=[HotkeysMetricsTypes.CPU, HotkeysMetricsTypes.NET]\n        )\n\n        # Perform some operations to generate hotkeys data\n        for i in range(10):\n            r.set(f\"key{i}\", f\"value{i}\")\n            r.get(f\"key{i}\")\n\n        # Get the results\n        result = r.hotkeys_get()\n\n        # Verify the response structure\n        assert isinstance(result, list)\n        for res_elem in result:\n            assert isinstance(res_elem, dict)\n            # Check tracking-active is 1 (ongoing session)\n            assert res_elem[b\"tracking-active\"] == 1\n\n        # Stop the session\n        r.hotkeys_stop()\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.5.240\")\n    def test_hotkeys_get_terminated_session(self, r):\n        \"\"\"Test HOTKEYS GET after stopping a collection session\"\"\"\n        try:\n            r.hotkeys_stop()\n        except Exception:\n            pass\n\n        # Start a collection session\n        r.hotkeys_start(count=10, metrics=[HotkeysMetricsTypes.CPU])\n\n        # Perform some operations\n        for i in range(5):\n            r.set(f\"testkey{i}\", f\"testvalue{i}\")\n\n        # Stop the session\n        r.hotkeys_stop()\n\n        # Get the results\n        result = r.hotkeys_get()\n\n        # Verify the response structure\n        assert isinstance(result, list)\n        for res_elem in result:\n            assert isinstance(res_elem, dict)\n\n            # Check tracking-active is 0 (terminated session)\n            assert res_elem[b\"tracking-active\"] == 0\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.5.240\")\n    def test_hotkeys_get_all_fields(self, r):\n        \"\"\"Test HOTKEYS GET returns all documented fields\"\"\"\n        try:\n            r.hotkeys_stop()\n        except Exception:\n            pass\n\n        # Start a collection session with all parameters\n        r.hotkeys_start(\n            count=5,\n            metrics=[HotkeysMetricsTypes.CPU, HotkeysMetricsTypes.NET],\n            sample_ratio=1,\n        )\n\n        # Perform operations to generate data\n        for i in range(20):\n            r.set(\"anyprefix:{3}:key\", f\"value{i}\")\n            r.get(f\"anyprefix:{3}:key\")\n            r.set(\"anyprefix:{1}:key\", f\"value{i}\")\n            r.get(f\"anyprefix:{1}:key\")\n\n        # Stop the session\n        r.hotkeys_stop()\n\n        # Get the results\n        result = r.hotkeys_get()\n        assert isinstance(result, list)\n\n        # Verify all documented fields are present\n        expected_fields = [\n            b\"tracking-active\",\n            b\"sample-ratio\",\n            b\"selected-slots\",\n            b\"net-bytes-all-commands-all-slots\",\n            b\"collection-start-time-unix-ms\",\n            b\"collection-duration-ms\",\n            b\"total-cpu-time-user-ms\",\n            b\"total-cpu-time-sys-ms\",\n            b\"total-net-bytes\",\n            b\"by-cpu-time-us\",\n            b\"by-net-bytes\",\n        ]\n\n        for res_elem in result:\n            for field in expected_fields:\n                assert field in res_elem, (\n                    f\"Field '{field}' is missing from HOTKEYS GET response\"\n                )\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.5.240\")\n    def test_hotkeys_get_all_fields_decoded(self, decoded_r: redis.Redis):\n        \"\"\"Test HOTKEYS GET returns all documented fields\"\"\"\n        try:\n            decoded_r.hotkeys_stop()\n        except Exception:\n            pass\n\n        # Start a collection session with all parameters\n        decoded_r.hotkeys_start(\n            count=5,\n            metrics=[HotkeysMetricsTypes.CPU, HotkeysMetricsTypes.NET],\n            sample_ratio=1,\n        )\n\n        # Perform operations to generate data\n        for i in range(20):\n            decoded_r.set(\"anyprefix:{3}:key\", f\"value{i}\")\n            decoded_r.get(f\"anyprefix:{3}:key\")\n            decoded_r.set(\"anyprefix:{1}:key\", f\"value{i}\")\n            decoded_r.get(f\"anyprefix:{1}:key\")\n\n        # Stop the session\n        decoded_r.hotkeys_stop()\n\n        # Get the results\n        result = decoded_r.hotkeys_get()\n\n        # Verify all documented fields are present\n        expected_fields = [\n            \"tracking-active\",\n            \"sample-ratio\",\n            \"selected-slots\",\n            \"net-bytes-all-commands-all-slots\",\n            \"collection-start-time-unix-ms\",\n            \"collection-duration-ms\",\n            \"total-cpu-time-user-ms\",\n            \"total-cpu-time-sys-ms\",\n            \"total-net-bytes\",\n            \"by-cpu-time-us\",\n            \"by-net-bytes\",\n        ]\n\n        for elem in result:\n            for field in expected_fields:\n                assert field in elem, (\n                    f\"Field '{field}' is missing from HOTKEYS GET response\"\n                )\n\n    @skip_if_redis_enterprise()\n    def test_bgsave(self, r):\n        assert r.bgsave()\n        time.sleep(0.3)\n        assert r.bgsave(True)\n\n    def test_never_decode_option(self, r: redis.Redis):\n        opts = {NEVER_DECODE: []}\n        r.delete(\"a\")\n        assert r.execute_command(\"EXISTS\", \"a\", **opts) == 0\n\n    def test_empty_response_option(self, r: redis.Redis):\n        opts = {EMPTY_RESPONSE: []}\n        r.delete(\"a\")\n        assert r.execute_command(\"EXISTS\", \"a\", **opts) == 0\n\n    # BASIC KEY COMMANDS\n    def test_append(self, r):\n        assert r.append(\"a\", \"a1\") == 2\n        assert r[\"a\"] == b\"a1\"\n        assert r.append(\"a\", \"a2\") == 4\n        assert r[\"a\"] == b\"a1a2\"\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    def test_bitcount(self, r):\n        r.setbit(\"a\", 5, True)\n        assert r.bitcount(\"a\") == 1\n        r.setbit(\"a\", 6, True)\n        assert r.bitcount(\"a\") == 2\n        r.setbit(\"a\", 5, False)\n        assert r.bitcount(\"a\") == 1\n        r.setbit(\"a\", 9, True)\n        r.setbit(\"a\", 17, True)\n        r.setbit(\"a\", 25, True)\n        r.setbit(\"a\", 33, True)\n        assert r.bitcount(\"a\") == 5\n        assert r.bitcount(\"a\", 0, -1) == 5\n        assert r.bitcount(\"a\", 2, 3) == 2\n        assert r.bitcount(\"a\", 2, -1) == 3\n        assert r.bitcount(\"a\", -2, -1) == 2\n        assert r.bitcount(\"a\", 1, 1) == 1\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_bitcount_mode(self, r):\n        r.set(\"mykey\", \"foobar\")\n        assert r.bitcount(\"mykey\") == 26\n        assert r.bitcount(\"mykey\", 1, 1, \"byte\") == 6\n        assert r.bitcount(\"mykey\", 5, 30, \"bit\") == 17\n        with pytest.raises(redis.ResponseError):\n            assert r.bitcount(\"mykey\", 5, 30, \"but\")\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"2.6.0\")\n    def test_bitop_not_empty_string(self, r):\n        r[\"a\"] = \"\"\n        r.bitop(\"not\", \"r\", \"a\")\n        assert r.get(\"r\") is None\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"2.6.0\")\n    def test_bitop_not(self, r):\n        test_str = b\"\\xaa\\x00\\xff\\x55\"\n        correct = ~0xAA00FF55 & 0xFFFFFFFF\n        r[\"a\"] = test_str\n        r.bitop(\"not\", \"r\", \"a\")\n        assert int(binascii.hexlify(r[\"r\"]), 16) == correct\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"2.6.0\")\n    def test_bitop_not_in_place(self, r):\n        test_str = b\"\\xaa\\x00\\xff\\x55\"\n        correct = ~0xAA00FF55 & 0xFFFFFFFF\n        r[\"a\"] = test_str\n        r.bitop(\"not\", \"a\", \"a\")\n        assert int(binascii.hexlify(r[\"a\"]), 16) == correct\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"2.6.0\")\n    def test_bitop_single_string(self, r):\n        test_str = b\"\\x01\\x02\\xff\"\n        r[\"a\"] = test_str\n        r.bitop(\"and\", \"res1\", \"a\")\n        r.bitop(\"or\", \"res2\", \"a\")\n        r.bitop(\"xor\", \"res3\", \"a\")\n        assert r[\"res1\"] == test_str\n        assert r[\"res2\"] == test_str\n        assert r[\"res3\"] == test_str\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"2.6.0\")\n    def test_bitop_string_operands(self, r):\n        r[\"a\"] = b\"\\x01\\x02\\xff\\xff\"\n        r[\"b\"] = b\"\\x01\\x02\\xff\"\n        r.bitop(\"and\", \"res1\", \"a\", \"b\")\n        r.bitop(\"or\", \"res2\", \"a\", \"b\")\n        r.bitop(\"xor\", \"res3\", \"a\", \"b\")\n        assert int(binascii.hexlify(r[\"res1\"]), 16) == 0x0102FF00\n        assert int(binascii.hexlify(r[\"res2\"]), 16) == 0x0102FFFF\n        assert int(binascii.hexlify(r[\"res3\"]), 16) == 0x000000FF\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.1.224\")\n    def test_bitop_diff(self, r):\n        r[\"a\"] = b\"\\xf0\"\n        r[\"b\"] = b\"\\xc0\"\n        r[\"c\"] = b\"\\x80\"\n\n        result = r.bitop(\"DIFF\", \"result\", \"a\", \"b\", \"c\")\n        assert result == 1\n        assert r[\"result\"] == b\"\\x30\"\n\n        r.bitop(\"DIFF\", \"result2\", \"a\", \"nonexistent\")\n        assert r[\"result2\"] == b\"\\xf0\"\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.1.224\")\n    def test_bitop_diff1(self, r):\n        r[\"a\"] = b\"\\xf0\"\n        r[\"b\"] = b\"\\xc0\"\n        r[\"c\"] = b\"\\x80\"\n\n        result = r.bitop(\"DIFF1\", \"result\", \"a\", \"b\", \"c\")\n        assert result == 1\n        assert r[\"result\"] == b\"\\x00\"\n\n        r[\"d\"] = b\"\\x0f\"\n        r[\"e\"] = b\"\\x03\"\n        r.bitop(\"DIFF1\", \"result2\", \"d\", \"e\")\n        assert r[\"result2\"] == b\"\\x00\"\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.1.224\")\n    def test_bitop_andor(self, r):\n        r[\"a\"] = b\"\\xf0\"\n        r[\"b\"] = b\"\\xc0\"\n        r[\"c\"] = b\"\\x80\"\n\n        result = r.bitop(\"ANDOR\", \"result\", \"a\", \"b\", \"c\")\n        assert result == 1\n        assert r[\"result\"] == b\"\\xc0\"\n\n        r[\"x\"] = b\"\\xf0\"\n        r[\"y\"] = b\"\\x0f\"\n        r.bitop(\"ANDOR\", \"result2\", \"x\", \"y\")\n        assert r[\"result2\"] == b\"\\x00\"\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.1.224\")\n    def test_bitop_one(self, r):\n        r[\"a\"] = b\"\\xf0\"\n        r[\"b\"] = b\"\\xc0\"\n        r[\"c\"] = b\"\\x80\"\n\n        result = r.bitop(\"ONE\", \"result\", \"a\", \"b\", \"c\")\n        assert result == 1\n        assert r[\"result\"] == b\"\\x30\"\n\n        r[\"x\"] = b\"\\xf0\"\n        r[\"y\"] = b\"\\x0f\"\n        r.bitop(\"ONE\", \"result2\", \"x\", \"y\")\n        assert r[\"result2\"] == b\"\\xff\"\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.1.224\")\n    def test_bitop_new_operations_with_empty_keys(self, r):\n        r[\"a\"] = b\"\\xff\"\n\n        r.bitop(\"DIFF\", \"empty_result\", \"nonexistent\", \"a\")\n        assert r.get(\"empty_result\") == b\"\\x00\"\n\n        r.bitop(\"DIFF1\", \"empty_result2\", \"a\", \"nonexistent\")\n        assert r.get(\"empty_result2\") == b\"\\x00\"\n\n        r.bitop(\"ANDOR\", \"empty_result3\", \"a\", \"nonexistent\")\n        assert r.get(\"empty_result3\") == b\"\\x00\"\n\n        r.bitop(\"ONE\", \"empty_result4\", \"nonexistent\")\n        assert r.get(\"empty_result4\") is None\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.1.224\")\n    def test_bitop_new_operations_return_values(self, r):\n        r[\"a\"] = b\"\\xff\\x00\\xff\"\n        r[\"b\"] = b\"\\x00\\xff\"\n\n        result1 = r.bitop(\"DIFF\", \"result1\", \"a\", \"b\")\n        assert result1 == 3\n\n        result2 = r.bitop(\"DIFF1\", \"result2\", \"a\", \"b\")\n        assert result2 == 3\n\n        result3 = r.bitop(\"ANDOR\", \"result3\", \"a\", \"b\")\n        assert result3 == 3\n\n        result4 = r.bitop(\"ONE\", \"result4\", \"a\", \"b\")\n        assert result4 == 3\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"2.8.7\")\n    def test_bitpos(self, r):\n        key = \"key:bitpos\"\n        r.set(key, b\"\\xff\\xf0\\x00\")\n        assert r.bitpos(key, 0) == 12\n        assert r.bitpos(key, 0, 2, -1) == 16\n        assert r.bitpos(key, 0, -2, -1) == 12\n        r.set(key, b\"\\x00\\xff\\xf0\")\n        assert r.bitpos(key, 1, 0) == 8\n        assert r.bitpos(key, 1, 1) == 8\n        r.set(key, b\"\\x00\\x00\\x00\")\n        assert r.bitpos(key, 1) == -1\n\n    @skip_if_server_version_lt(\"2.8.7\")\n    def test_bitpos_wrong_arguments(self, r):\n        key = \"key:bitpos:wrong:args\"\n        r.set(key, b\"\\xff\\xf0\\x00\")\n        with pytest.raises(exceptions.RedisError):\n            r.bitpos(key, 0, end=1) == 12\n        with pytest.raises(exceptions.RedisError):\n            r.bitpos(key, 7) == 12\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_bitpos_mode(self, r):\n        r.set(\"mykey\", b\"\\x00\\xff\\xf0\")\n        assert r.bitpos(\"mykey\", 1, 0) == 8\n        assert r.bitpos(\"mykey\", 1, 2, -1, \"byte\") == 16\n        assert r.bitpos(\"mykey\", 0, 7, 15, \"bit\") == 7\n        with pytest.raises(redis.ResponseError):\n            r.bitpos(\"mykey\", 1, 7, 15, \"bite\")\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_copy(self, r):\n        assert r.copy(\"a\", \"b\") == 0\n        r.set(\"a\", \"foo\")\n        assert r.copy(\"a\", \"b\") == 1\n        assert r.get(\"a\") == b\"foo\"\n        assert r.get(\"b\") == b\"foo\"\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_copy_and_replace(self, r):\n        r.set(\"a\", \"foo1\")\n        r.set(\"b\", \"foo2\")\n        assert r.copy(\"a\", \"b\") == 0\n        assert r.copy(\"a\", \"b\", replace=True) == 1\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_copy_to_another_database(self, request):\n        r0 = _get_client(redis.Redis, request, db=0)\n        r1 = _get_client(redis.Redis, request, db=1)\n        r0.set(\"a\", \"foo\")\n        assert r0.copy(\"a\", \"b\", destination_db=1) == 1\n        assert r1.get(\"b\") == b\"foo\"\n\n    def test_decr(self, r):\n        assert r.decr(\"a\") == -1\n        assert r[\"a\"] == b\"-1\"\n        assert r.decr(\"a\") == -2\n        assert r[\"a\"] == b\"-2\"\n        assert r.decr(\"a\", amount=5) == -7\n        assert r[\"a\"] == b\"-7\"\n\n    def test_decrby(self, r):\n        assert r.decrby(\"a\", amount=2) == -2\n        assert r.decrby(\"a\", amount=3) == -5\n        assert r[\"a\"] == b\"-5\"\n\n    def test_delete(self, r):\n        assert r.delete(\"a\") == 0\n        r[\"a\"] = \"foo\"\n        assert r.delete(\"a\") == 1\n\n    def test_delete_with_multiple_keys(self, r):\n        r[\"a\"] = \"foo\"\n        r[\"b\"] = \"bar\"\n        assert r.delete(\"a\", \"b\") == 2\n        assert r.get(\"a\") is None\n        assert r.get(\"b\") is None\n\n    def test_delitem(self, r):\n        r[\"a\"] = \"foo\"\n        del r[\"a\"]\n        assert r.get(\"a\") is None\n\n    def _ensure_str(self, x):\n        return x.decode(\"ascii\") if isinstance(x, (bytes, bytearray)) else x\n\n    def _server_xxh3_digest(self, r, key):\n        \"\"\"\n        Get the server-computed XXH3 hex digest for the key's value.\n        Requires the DIGEST command implemented on the server.\n        \"\"\"\n        d = r.execute_command(\"DIGEST\", key)\n        return None if d is None else self._ensure_str(d).lower()\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_delex_nonexistent(self, r):\n        r.delete(\"nope\")\n        assert r.delex(\"nope\") == 0\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_delex_unconditional_delete_string(self, r):\n        r.set(\"k\", b\"v\")\n        assert r.exists(\"k\") == 1\n        assert r.delex(\"k\") == 1\n        assert r.exists(\"k\") == 0\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_delex_unconditional_delete_nonstring_allowed(self, r):\n        # Spec: error happens only when a condition is specified on a non-string key.\n        r.lpush(\"lst\", \"a\")\n        assert r.delex(\"lst\") == 1\n        assert r.exists(\"lst\") == 0\n\n        r.lpush(\"lst\", \"a\")\n\n        with pytest.raises(redis.ResponseError):\n            r.delex(\"lst\", ifeq=b\"a\")\n        assert r.exists(\"lst\") == 1\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_delex_ifeq(self, r):\n        r.set(\"k\", b\"abc\")\n        assert r.delex(\"k\", ifeq=b\"abc\") == 1  # matches → deleted\n        assert r.exists(\"k\") == 0\n\n        r.set(\"k\", b\"abc\")\n        assert r.delex(\"k\", ifeq=b\"zzz\") == 0  # not match → not deleted\n        assert r.get(\"k\") == b\"abc\"  # still there\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_delex_ifne(self, r):\n        r.set(\"k2\", b\"abc\")\n        assert r.delex(\"k2\", ifne=b\"zzz\") == 1  # different → deleted\n        assert r.exists(\"k2\") == 0\n\n        r.set(\"k2\", b\"abc\")\n        assert r.delex(\"k2\", ifne=b\"abc\") == 0  # equal → not deleted\n        assert r.get(\"k2\") == b\"abc\"\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_delex_with_conditionon_nonstring_values(self, r):\n        r.lpush(\"nk\", \"x\")\n        with pytest.raises(redis.ResponseError):\n            r.delex(\"nk\", ifeq=b\"x\")\n        with pytest.raises(redis.ResponseError):\n            r.delex(\"nk\", ifne=b\"x\")\n        with pytest.raises(redis.ResponseError):\n            r.delex(\"nk\", ifdeq=\"deadbeef\")\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    @pytest.mark.parametrize(\"val\", [b\"\", b\"abc\", b\"The quick brown fox\"])\n    def test_delex_ifdeq_and_ifdne(self, r, val):\n        r.set(\"h\", val)\n        d = self._server_xxh3_digest(r, \"h\")\n        assert d is not None\n\n        # IFDEQ should delete with exact digest\n        r.set(\"h\", val)\n        assert r.delex(\"h\", ifdeq=d) == 1\n        assert r.exists(\"h\") == 0\n\n        # IFDNE should NOT delete when digest matches\n        r.set(\"h\", val)\n        assert r.delex(\"h\", ifdne=d) == 0\n        assert r.get(\"h\") == val\n\n        # IFDNE should delete when digest doesn't match\n        r.set(\"h\", val)\n        wrong = \"0\" * len(d)\n        if wrong == d:\n            wrong = \"f\" * len(d)\n        assert r.delex(\"h\", ifdne=wrong) == 1\n        assert r.exists(\"h\") == 0\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_delex_pipeline(self, r):\n        r.mset({\"p1{45}\": b\"A\", \"p2{45}\": b\"B\"})\n        p = r.pipeline()\n        p.delex(\"p1{45}\", ifeq=b\"A\")\n        p.delex(\"p2{45}\", ifne=b\"B\")  # false → 0\n        p.delex(\"nope\")  # nonexistent → 0\n        out = p.execute()\n        assert out == [1, 0, 0]\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_delex_mutual_exclusion_client_side(self, r):\n        with pytest.raises(ValueError):\n            r.delex(\"k\", ifeq=b\"A\", ifne=b\"B\")\n        with pytest.raises(ValueError):\n            r.delex(\"k\", ifdeq=\"aa\", ifdne=\"bb\")\n\n    @skip_if_server_version_lt(\"4.0.0\")\n    def test_unlink(self, r):\n        assert r.unlink(\"a\") == 0\n        r[\"a\"] = \"foo\"\n        assert r.unlink(\"a\") == 1\n        assert r.get(\"a\") is None\n\n    @skip_if_server_version_lt(\"4.0.0\")\n    def test_unlink_with_multiple_keys(self, r):\n        r[\"a\"] = \"foo\"\n        r[\"b\"] = \"bar\"\n        assert r.unlink(\"a\", \"b\") == 2\n        assert r.get(\"a\") is None\n        assert r.get(\"b\") is None\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_lcs(self, r):\n        r.mset({\"foo\": \"ohmytext\", \"bar\": \"mynewtext\"})\n        assert r.lcs(\"foo\", \"bar\") == b\"mytext\"\n        assert r.lcs(\"foo\", \"bar\", len=True) == 6\n        assert_resp_response(\n            r,\n            r.lcs(\"foo\", \"bar\", idx=True, minmatchlen=3),\n            [b\"matches\", [[[4, 7], [5, 8]]], b\"len\", 6],\n            {b\"matches\": [[[4, 7], [5, 8]]], b\"len\": 6},\n        )\n        with pytest.raises(redis.ResponseError):\n            assert r.lcs(\"foo\", \"bar\", len=True, idx=True)\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    def test_dump_and_restore(self, r):\n        r[\"a\"] = \"foo\"\n        dumped = r.dump(\"a\")\n        del r[\"a\"]\n        r.restore(\"a\", 0, dumped)\n        assert r[\"a\"] == b\"foo\"\n\n    @skip_if_server_version_lt(\"3.0.0\")\n    def test_dump_and_restore_and_replace(self, r):\n        r[\"a\"] = \"bar\"\n        dumped = r.dump(\"a\")\n        with pytest.raises(redis.ResponseError):\n            r.restore(\"a\", 0, dumped)\n\n        r.restore(\"a\", 0, dumped, replace=True)\n        assert r[\"a\"] == b\"bar\"\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    def test_dump_and_restore_absttl(self, r):\n        r[\"a\"] = \"foo\"\n        dumped = r.dump(\"a\")\n        del r[\"a\"]\n        ttl = int(\n            (redis_server_time(r) + datetime.timedelta(minutes=1)).timestamp() * 1000\n        )\n        r.restore(\"a\", ttl, dumped, absttl=True)\n        assert r[\"a\"] == b\"foo\"\n        assert 0 < r.ttl(\"a\") <= 61\n\n    def test_exists(self, r):\n        assert r.exists(\"a\") == 0\n        r[\"a\"] = \"foo\"\n        r[\"b\"] = \"bar\"\n        assert r.exists(\"a\") == 1\n        assert r.exists(\"a\", \"b\") == 2\n\n    def test_exists_contains(self, r):\n        assert \"a\" not in r\n        r[\"a\"] = \"foo\"\n        assert \"a\" in r\n\n    def test_expire(self, r):\n        assert r.expire(\"a\", 10) is False\n        r[\"a\"] = \"foo\"\n        assert r.expire(\"a\", 10) is True\n        assert 0 < r.ttl(\"a\") <= 10\n        assert r.persist(\"a\")\n        assert r.ttl(\"a\") == -1\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_expire_option_nx(self, r):\n        r.set(\"key\", \"val\")\n        assert r.expire(\"key\", 100, nx=True) == 1\n        assert r.expire(\"key\", 500, nx=True) == 0\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_expire_option_xx(self, r):\n        r.set(\"key\", \"val\")\n        assert r.expire(\"key\", 100, xx=True) == 0\n        assert r.expire(\"key\", 100)\n        assert r.expire(\"key\", 500, xx=True) == 1\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_expire_option_gt(self, r):\n        r.set(\"key\", \"val\", 100)\n        assert r.expire(\"key\", 50, gt=True) == 0\n        assert r.expire(\"key\", 500, gt=True) == 1\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_expire_option_lt(self, r):\n        r.set(\"key\", \"val\", 100)\n        assert r.expire(\"key\", 50, lt=True) == 1\n        assert r.expire(\"key\", 150, lt=True) == 0\n\n    def test_expireat_datetime(self, r):\n        expire_at = redis_server_time(r) + datetime.timedelta(minutes=1)\n        r[\"a\"] = \"foo\"\n        assert r.expireat(\"a\", expire_at) is True\n        assert 0 < r.ttl(\"a\") <= 61\n\n    def test_expireat_no_key(self, r):\n        expire_at = redis_server_time(r) + datetime.timedelta(minutes=1)\n        assert r.expireat(\"a\", expire_at) is False\n\n    def test_expireat_unixtime(self, r):\n        expire_at = redis_server_time(r) + datetime.timedelta(minutes=1)\n        r[\"a\"] = \"foo\"\n        expire_at_seconds = int(expire_at.timestamp())\n        assert r.expireat(\"a\", expire_at_seconds) is True\n        assert 0 < r.ttl(\"a\") <= 61\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_expiretime(self, r):\n        r.set(\"a\", \"foo\")\n        r.expireat(\"a\", 33177117420)\n        assert r.expiretime(\"a\") == 33177117420\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_expireat_option_nx(self, r):\n        assert r.set(\"key\", \"val\") is True\n        expire_at = redis_server_time(r) + datetime.timedelta(minutes=1)\n        assert r.expireat(\"key\", expire_at, nx=True) is True\n        expire_at = redis_server_time(r) + datetime.timedelta(minutes=2)\n        assert r.expireat(\"key\", expire_at, nx=True) is False\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_expireat_option_xx(self, r):\n        assert r.set(\"key\", \"val\") is True\n        expire_at = redis_server_time(r) + datetime.timedelta(minutes=1)\n        assert r.expireat(\"key\", expire_at, xx=True) is False\n        assert r.expireat(\"key\", expire_at) is True\n        expire_at = redis_server_time(r) + datetime.timedelta(minutes=2)\n        assert r.expireat(\"key\", expire_at, xx=True) is True\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_expireat_option_gt(self, r):\n        expire_at = redis_server_time(r) + datetime.timedelta(minutes=2)\n        assert r.set(\"key\", \"val\") is True\n        assert r.expireat(\"key\", expire_at) is True\n        expire_at = redis_server_time(r) + datetime.timedelta(minutes=1)\n        assert r.expireat(\"key\", expire_at, gt=True) is False\n        expire_at = redis_server_time(r) + datetime.timedelta(minutes=3)\n        assert r.expireat(\"key\", expire_at, gt=True) is True\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_expireat_option_lt(self, r):\n        expire_at = redis_server_time(r) + datetime.timedelta(minutes=2)\n        assert r.set(\"key\", \"val\") is True\n        assert r.expireat(\"key\", expire_at) is True\n        expire_at = redis_server_time(r) + datetime.timedelta(minutes=3)\n        assert r.expireat(\"key\", expire_at, lt=True) is False\n        expire_at = redis_server_time(r) + datetime.timedelta(minutes=1)\n        assert r.expireat(\"key\", expire_at, lt=True) is True\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_digest_nonexistent_returns_none(self, r):\n        assert r.digest(\"no:such:key\") is None\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_digest_wrong_type_raises(self, r):\n        r.lpush(\"alist\", \"x\")\n        with pytest.raises(Exception):  # or redis.exceptions.ResponseError\n            r.digest(\"alist\")\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    @pytest.mark.parametrize(\n        \"value\", [b\"\", b\"abc\", b\"The quick brown fox jumps over the lazy dog\"]\n    )\n    def test_digest_response_when_available(self, r, value):\n        key = \"k:digest\"\n        r.delete(key)\n        r.set(key, value)\n\n        res = r.digest(key)\n\n        # got is str if decode_responses=True; ensure bytes->str for comparison\n        if isinstance(res, bytes):\n            res = res.decode()\n        assert res is not None\n        assert all(c in \"0123456789abcdefABCDEF\" for c in res)\n\n        assert len(res) == 16\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    @pytest.mark.parametrize(\n        \"value\",\n        [\n            b\"\",\n            b\"abc\",\n            b\"The quick brown fox jumps over the lazy dog\",\n            \"\",\n            \"abc\",\n            \"The quick brown fox jumps over the lazy dog\",\n        ],\n    )\n    def test_local_digest_matches_server(self, r, value):\n        key = \"k:digest\"\n        r.delete(key)\n        r.set(key, value)\n\n        res_server = r.digest(key)\n        res_local = r.digest_local(value)\n\n        # Verify type consistency between server and local digest\n        if isinstance(res_server, bytes):\n            assert isinstance(res_local, bytes)\n        else:\n            assert isinstance(res_local, str)\n\n        assert res_server is not None\n        assert len(res_server) == 16\n        assert res_local is not None\n        assert len(res_local) == 16\n        assert res_server == res_local\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_pipeline_digest(self, r):\n        k1, k2 = \"k:d1{42}\", \"k:d2{42}\"\n        r.mset({k1: b\"A\", k2: b\"B\"})\n        p = r.pipeline()\n        p.digest(k1)\n        p.digest(k2)\n        out = p.execute()\n        assert len(out) == 2\n        for d in out:\n            if isinstance(d, bytes):\n                d = d.decode()\n            assert d is None or len(d) == 16\n\n    def test_get_and_set(self, r):\n        # get and set can't be tested independently of each other\n        assert r.get(\"a\") is None\n        byte_string = b\"value\"\n        integer = 5\n        unicode_string = chr(3456) + \"abcd\" + chr(3421)\n        assert r.set(\"byte_string\", byte_string)\n        assert r.set(\"integer\", 5)\n        assert r.set(\"unicode_string\", unicode_string)\n        assert r.get(\"byte_string\") == byte_string\n        assert r.get(\"integer\") == str(integer).encode()\n        assert r.get(\"unicode_string\").decode(\"utf-8\") == unicode_string\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_getdel(self, r):\n        assert r.getdel(\"a\") is None\n        r.set(\"a\", 1)\n        assert r.getdel(\"a\") == b\"1\"\n        assert r.getdel(\"a\") is None\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_getex(self, r):\n        r.set(\"a\", 1)\n        assert r.getex(\"a\") == b\"1\"\n        assert r.ttl(\"a\") == -1\n        assert r.getex(\"a\", ex=60) == b\"1\"\n        assert r.ttl(\"a\") == 60\n        assert r.getex(\"a\", px=6000) == b\"1\"\n        assert r.ttl(\"a\") == 6\n        expire_at = redis_server_time(r) + datetime.timedelta(minutes=1)\n        assert r.getex(\"a\", pxat=expire_at) == b\"1\"\n        assert r.ttl(\"a\") <= 61\n        assert r.getex(\"a\", persist=True) == b\"1\"\n        assert r.ttl(\"a\") == -1\n\n    def test_getitem_and_setitem(self, r):\n        r[\"a\"] = \"bar\"\n        assert r[\"a\"] == b\"bar\"\n\n    def test_getitem_raises_keyerror_for_missing_key(self, r):\n        with pytest.raises(KeyError):\n            r[\"a\"]\n\n    def test_getitem_does_not_raise_keyerror_for_empty_string(self, r):\n        r[\"a\"] = b\"\"\n        assert r[\"a\"] == b\"\"\n\n    def test_get_set_bit(self, r):\n        # no value\n        assert not r.getbit(\"a\", 5)\n        # set bit 5\n        assert not r.setbit(\"a\", 5, True)\n        assert r.getbit(\"a\", 5)\n        # unset bit 4\n        assert not r.setbit(\"a\", 4, False)\n        assert not r.getbit(\"a\", 4)\n        # set bit 4\n        assert not r.setbit(\"a\", 4, True)\n        assert r.getbit(\"a\", 4)\n        # set bit 5 again\n        assert r.setbit(\"a\", 5, True)\n        assert r.getbit(\"a\", 5)\n\n    def test_getrange(self, r):\n        r[\"a\"] = \"foo\"\n        assert r.getrange(\"a\", 0, 0) == b\"f\"\n        assert r.getrange(\"a\", 0, 2) == b\"foo\"\n        assert r.getrange(\"a\", 3, 4) == b\"\"\n\n    def test_getset(self, r):\n        assert r.getset(\"a\", \"foo\") is None\n        assert r.getset(\"a\", \"bar\") == b\"foo\"\n        assert r.get(\"a\") == b\"bar\"\n\n    def test_incr(self, r):\n        assert r.incr(\"a\") == 1\n        assert r[\"a\"] == b\"1\"\n        assert r.incr(\"a\") == 2\n        assert r[\"a\"] == b\"2\"\n        assert r.incr(\"a\", amount=5) == 7\n        assert r[\"a\"] == b\"7\"\n\n    def test_incrby(self, r):\n        assert r.incrby(\"a\") == 1\n        assert r.incrby(\"a\", 4) == 5\n        assert r[\"a\"] == b\"5\"\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    def test_incrbyfloat(self, r):\n        assert r.incrbyfloat(\"a\") == 1.0\n        assert r[\"a\"] == b\"1\"\n        assert r.incrbyfloat(\"a\", 1.1) == 2.1\n        assert float(r[\"a\"]) == float(2.1)\n\n    @pytest.mark.onlynoncluster\n    def test_keys(self, r):\n        assert r.keys() == []\n        keys_with_underscores = {b\"test_a\", b\"test_b\"}\n        keys = keys_with_underscores.union({b\"testc\"})\n        for key in keys:\n            r[key] = 1\n        assert set(r.keys(pattern=\"test_*\")) == keys_with_underscores\n        assert set(r.keys(pattern=\"test*\")) == keys\n\n    @pytest.mark.onlynoncluster\n    def test_mget(self, r):\n        assert r.mget([]) == []\n        assert r.mget([\"a\", \"b\"]) == [None, None]\n        r[\"a\"] = \"1\"\n        r[\"b\"] = \"2\"\n        r[\"c\"] = \"3\"\n        assert r.mget(\"a\", \"other\", \"b\", \"c\") == [b\"1\", None, b\"2\", b\"3\"]\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_lmove(self, r):\n        r.rpush(\"a\", \"one\", \"two\", \"three\", \"four\")\n        assert r.lmove(\"a\", \"b\")\n        assert r.lmove(\"a\", \"b\", \"right\", \"left\")\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_blmove(self, r):\n        r.rpush(\"a\", \"one\", \"two\", \"three\", \"four\")\n        assert r.blmove(\"a\", \"b\", 5)\n        assert r.blmove(\"a\", \"b\", 1, \"RIGHT\", \"LEFT\")\n\n    @pytest.mark.onlynoncluster\n    def test_mset(self, r):\n        d = {\"a\": b\"1\", \"b\": b\"2\", \"c\": b\"3\"}\n        assert r.mset(d)\n        for k, v in d.items():\n            assert r[k] == v\n\n    @pytest.mark.onlycluster\n    def test_mset_on_cluster(self, r):\n        # validate that mset command works in cluster client\n        # when the keys are in the same slot\n        d = {\"a:{test:1}\": b\"1\", \"b:{test:1}\": b\"2\", \"c:{test:1}\": b\"3\"}\n        assert r.mset(d)\n        for k, v in d.items():\n            assert r[k] == v\n\n    @pytest.mark.onlycluster\n    def test_mset_on_cluster_multiple_slots(self, r):\n        d = {\"a\": b\"1\", \"b\": b\"2\", \"c\": b\"3\"}\n        with pytest.raises(RedisClusterException):\n            assert r.mset(d)\n\n    @pytest.mark.onlycluster\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_msetex_no_expiration_with_cluster_client(self, r):\n        all_test_keys = [\"1:{test:1}\", \"2:{test:1}\"]\n        for key in all_test_keys:\n            r.delete(key)\n\n        # set items from mapping without expiration\n        assert r.msetex(mapping={\"1:{test:1}\": 1, \"2:{test:1}\": b\"four\"}) == 1\n        assert r.mget(\"1:{test:1}\", \"2:{test:1}\") == [b\"1\", b\"four\"]\n        assert r.ttl(\"1:{test:1}\") == -1\n        assert r.ttl(\"2:{test:1}\") == -1\n\n    @pytest.mark.onlycluster\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_msetex_expiration_ex_and_keepttl_with_cluster_client(self, r):\n        all_test_keys = [\"1:{test:1}\", \"2:{test:1}\"]\n        for key in all_test_keys:\n            r.delete(key)\n\n        # set items from mapping with expiration - testing ex field\n        assert (\n            r.msetex(\n                mapping={\"1:{test:1}\": 1, \"2:{test:1}\": \"2\"},\n                ex=10,\n            )\n            == 1\n        )\n        ttls = [r.ttl(key) for key in all_test_keys]\n        for ttl in ttls:\n            assert pytest.approx(ttl) == 10\n\n        assert r.mget(*all_test_keys) == [b\"1\", b\"2\"]\n        time.sleep(1.1)\n        # validate keepttl\n        assert r.msetex(mapping={\"1:{test:1}\": 11}, keepttl=True) == 1\n        assert r.ttl(\"1:{test:1}\") < 10\n\n    @pytest.mark.onlycluster\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_msetex_expiration_px_with_cluster_client(self, r):\n        all_test_keys = [\"1:{test:1}\", \"2:{test:1}\"]\n        for key in all_test_keys:\n            r.delete(key)\n\n        mapping = {\"1:{test:1}\": 1, \"2:{test:1}\": \"2\"}\n        # set key/value pairs provided in mapping\n        # with expiration - testing px field\n        assert r.msetex(mapping=mapping, px=60000) == 1\n\n        ttls = [r.ttl(key) for key in mapping.keys()]\n        for ttl in ttls:\n            assert pytest.approx(ttl) == 60\n        assert r.mget(*mapping.keys()) == [b\"1\", b\"2\"]\n\n    @pytest.mark.onlycluster\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_msetex_expiration_pxat_and_nx_with_cluster_client(self, r):\n        all_test_keys = [\n            \"1:{test:1}\",\n            \"2:{test:1}\",\n            \"3:{test:1}\",\n            \"new:{test:1}\",\n            \"new_2:{test:1}\",\n        ]\n        for key in all_test_keys:\n            r.delete(key)\n\n        mapping = {\"1:{test:1}\": 1, \"2:{test:1}\": \"2\", \"3:{test:1}\": \"three\"}\n        assert r.msetex(mapping=mapping, ex=30) == 1\n\n        # NX is set with existing keys - nothing should be saved or updated\n        expire_at = redis_server_time(r) + datetime.timedelta(seconds=10)\n        assert (\n            r.msetex(\n                mapping={\"1:{test:1}\": \"new_value\", \"new:{test:1}\": \"ok\"},\n                pxat=expire_at,\n                data_persist_option=DataPersistOptions.NX,\n            )\n            == 0\n        )\n\n        ttls = [r.ttl(key) for key in mapping.keys()]\n        for ttl in ttls:\n            assert 10 < ttl <= 30\n        assert r.mget(*mapping.keys(), \"new:{test:1}\") == [b\"1\", b\"2\", b\"three\", None]\n\n        # NX is set with non existing keys - values should be set\n        assert (\n            r.msetex(\n                mapping={\"new:{test:1}\": \"ok\", \"new_2:{test:1}\": \"ok_2\"},\n                pxat=expire_at,\n                data_persist_option=DataPersistOptions.NX,\n            )\n            == 1\n        )\n        old_ttls = [r.ttl(key) for key in mapping.keys()]\n        new_ttls = [r.ttl(key) for key in [\"new:{test:1}\", \"new_2:{test:1}\"]]\n        for ttl in old_ttls:\n            assert 10 < ttl <= 30\n        for ttl in new_ttls:\n            assert ttl <= 11\n        assert r.mget(*mapping.keys(), \"new:{test:1}\", \"new_2:{test:1}\") == [\n            b\"1\",\n            b\"2\",\n            b\"three\",\n            b\"ok\",\n            b\"ok_2\",\n        ]\n\n    @pytest.mark.onlycluster\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_msetex_expiration_exat_and_xx_with_cluster_client(self, r):\n        all_test_keys = [\"1:{test:1}\", \"2:{test:1}\", \"3:{test:1}\", \"new:{test:1}\"]\n        for key in all_test_keys:\n            r.delete(key)\n\n        mapping = {\"1:{test:1}\": 1, \"2:{test:1}\": \"2\", \"3:{test:1}\": \"three\"}\n        assert r.msetex(mapping, ex=30) == 1\n\n        expire_at = redis_server_time(r) + datetime.timedelta(seconds=10)\n        ## XX is set with unexisting key - nothing should be saved or updated\n        assert (\n            r.msetex(\n                mapping={\"1:{test:1}\": \"new_value\", \"new:{test:1}\": \"ok\"},\n                exat=expire_at,\n                data_persist_option=DataPersistOptions.XX,\n            )\n            == 0\n        )\n        ttls = [r.ttl(key) for key in mapping.keys()]\n        for ttl in ttls:\n            assert 10 < ttl <= 30\n        assert r.mget(*mapping.keys(), \"new:{test:1}\") == [b\"1\", b\"2\", b\"three\", None]\n\n        # XX is set with existing keys - values should be updated\n        assert (\n            r.msetex(\n                mapping={\"1:{test:1}\": \"new_value\", \"2:{test:1}\": \"new_value_2\"},\n                exat=expire_at,\n                data_persist_option=DataPersistOptions.XX,\n            )\n            == 1\n        )\n        ttls = [r.ttl(key) for key in mapping.keys()]\n        assert ttls[0] <= 11\n        assert ttls[1] <= 11\n        assert 10 < ttls[2] <= 30\n        assert r.mget(\"1:{test:1}\", \"2:{test:1}\", \"3:{test:1}\", \"new:{test:1}\") == [\n            b\"new_value\",\n            b\"new_value_2\",\n            b\"three\",\n            None,\n        ]\n\n    @pytest.mark.onlycluster\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_msetex_invalid_inputs_with_cluster_client(self, r):\n        mapping = {\"1\": 1, \"2\": \"2\", \"3\": \"three\"}\n        with pytest.raises(exceptions.RedisClusterException):\n            r.msetex(mapping)\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_msetex_no_expiration(self, r):\n        all_test_keys = [\"1\", \"2\"]\n        for key in all_test_keys:\n            r.delete(key)\n\n        # # set items from mapping without expiration\n        assert r.msetex(mapping={\"1\": 1, \"2\": b\"four\"}) == 1\n        assert r.mget(\"1\", \"2\") == [b\"1\", b\"four\"]\n        assert r.ttl(\"1\") == -1\n        assert r.ttl(\"2\") == -1\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_msetex_expiration_ex_and_keepttl(self, r):\n        all_test_keys = [\"1\", \"2\"]\n        for key in all_test_keys:\n            r.delete(key)\n\n        # set items from mapping with expiration - testing ex field\n        assert (\n            r.msetex(\n                mapping={\"1\": 1, \"2\": \"2\"},\n                ex=10,\n            )\n            == 1\n        )\n        ttls = [r.ttl(key) for key in all_test_keys]\n        for ttl in ttls:\n            assert pytest.approx(ttl) == 10\n\n        assert r.mget(*all_test_keys) == [b\"1\", b\"2\"]\n        time.sleep(1.1)\n        # validate keepttl\n        assert r.msetex(mapping={\"1\": 11}, keepttl=True) == 1\n        assert r.ttl(\"1\") < 10\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_msetex_expiration_px(self, r):\n        all_test_keys = [\"1\", \"2\"]\n        for key in all_test_keys:\n            r.delete(key)\n\n        mapping = {\"1\": 1, \"2\": \"2\"}\n        # set key/value pairs provided in mapping\n        # with expiration - testing px field\n        assert r.msetex(mapping=mapping, px=60000) == 1\n\n        ttls = [r.ttl(key) for key in mapping.keys()]\n        for ttl in ttls:\n            assert pytest.approx(ttl) == 60\n        assert r.mget(*mapping.keys()) == [b\"1\", b\"2\"]\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_msetex_expiration_pxat_and_nx(self, r):\n        all_test_keys = [\"1\", \"2\", \"3\", \"new\", \"new_2\"]\n        for key in all_test_keys:\n            r.delete(key)\n\n        mapping = {\"1\": 1, \"2\": \"2\", \"3\": \"three\"}\n        assert r.msetex(mapping=mapping, ex=30) == 1\n\n        # NX is set with existing keys - nothing should be saved or updated\n        expire_at = redis_server_time(r) + datetime.timedelta(seconds=10)\n        assert (\n            r.msetex(\n                mapping={\"1\": \"new_value\", \"new\": \"ok\"},\n                pxat=expire_at,\n                data_persist_option=DataPersistOptions.NX,\n            )\n            == 0\n        )\n        ttls = [r.ttl(key) for key in mapping.keys()]\n        for ttl in ttls:\n            assert 10 < ttl <= 30\n        assert r.mget(*mapping.keys(), \"new\") == [b\"1\", b\"2\", b\"three\", None]\n\n        # NX is set with non existing keys - values should be set\n        assert (\n            r.msetex(\n                mapping={\"new\": \"ok\", \"new_2\": \"ok_2\"},\n                pxat=expire_at,\n                data_persist_option=DataPersistOptions.NX,\n            )\n            == 1\n        )\n        old_ttls = [r.ttl(key) for key in mapping.keys()]\n        new_ttls = [r.ttl(key) for key in [\"new\", \"new_2\"]]\n        for ttl in old_ttls:\n            assert 10 < ttl <= 30\n        for ttl in new_ttls:\n            assert ttl <= 11\n        assert r.mget(*mapping.keys(), \"new\", \"new_2\") == [\n            b\"1\",\n            b\"2\",\n            b\"three\",\n            b\"ok\",\n            b\"ok_2\",\n        ]\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_msetex_expiration_exat_and_xx(self, r):\n        all_test_keys = [\"1\", \"2\", \"3\", \"new\"]\n        for key in all_test_keys:\n            r.delete(key)\n\n        mapping = {\"1\": 1, \"2\": \"2\", \"3\": \"three\"}\n        assert r.msetex(mapping, ex=30) == 1\n\n        expire_at = redis_server_time(r) + datetime.timedelta(seconds=10)\n        ## XX is set with unexisting key - nothing should be saved or updated\n        assert (\n            r.msetex(\n                mapping={\"1\": \"new_value\", \"new\": \"ok\"},\n                exat=expire_at,\n                data_persist_option=DataPersistOptions.XX,\n            )\n            == 0\n        )\n        ttls = [r.ttl(key) for key in mapping.keys()]\n        for ttl in ttls:\n            assert 10 < ttl <= 30\n        assert r.mget(*mapping.keys(), \"new\") == [b\"1\", b\"2\", b\"three\", None]\n\n        # XX is set with existing keys - values should be updated\n        assert (\n            r.msetex(\n                mapping={\"1\": \"new_value\", \"2\": \"new_value_2\"},\n                exat=expire_at,\n                data_persist_option=DataPersistOptions.XX,\n            )\n            == 1\n        )\n        ttls = [r.ttl(key) for key in mapping.keys()]\n        assert ttls[0] <= 11\n        assert ttls[1] <= 11\n        assert 10 < ttls[2] <= 30\n        assert r.mget(\"1\", \"2\", \"3\", \"new\") == [\n            b\"new_value\",\n            b\"new_value_2\",\n            b\"three\",\n            None,\n        ]\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_msetex_invalid_inputs(self, r):\n        mapping = {\"1\": 1, \"2\": \"2\"}\n        with pytest.raises(exceptions.DataError):\n            r.msetex(mapping, ex=10, keepttl=True)\n\n    @pytest.mark.onlynoncluster\n    def test_msetnx(self, r):\n        d = {\"a\": b\"1\", \"b\": b\"2\", \"c\": b\"3\"}\n        assert r.msetnx(d)\n        d2 = {\"a\": b\"x\", \"d\": b\"4\"}\n        assert not r.msetnx(d2)\n        for k, v in d.items():\n            assert r[k] == v\n        assert r.get(\"d\") is None\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    def test_pexpire(self, r):\n        assert r.pexpire(\"a\", 60000) is False\n        r[\"a\"] = \"foo\"\n        assert r.pexpire(\"a\", 60000) is True\n        assert 0 < r.pttl(\"a\") <= 60000\n        assert r.persist(\"a\")\n        assert r.pttl(\"a\") == -1\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_pexpire_option_nx(self, r):\n        assert r.set(\"key\", \"val\") is True\n        assert r.pexpire(\"key\", 60000, nx=True) is True\n        assert r.pexpire(\"key\", 60000, nx=True) is False\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_pexpire_option_xx(self, r):\n        assert r.set(\"key\", \"val\") is True\n        assert r.pexpire(\"key\", 60000, xx=True) is False\n        assert r.pexpire(\"key\", 60000) is True\n        assert r.pexpire(\"key\", 70000, xx=True) is True\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_pexpire_option_gt(self, r):\n        assert r.set(\"key\", \"val\") is True\n        assert r.pexpire(\"key\", 60000) is True\n        assert r.pexpire(\"key\", 70000, gt=True) is True\n        assert r.pexpire(\"key\", 50000, gt=True) is False\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_pexpire_option_lt(self, r):\n        assert r.set(\"key\", \"val\") is True\n        assert r.pexpire(\"key\", 60000) is True\n        assert r.pexpire(\"key\", 50000, lt=True) is True\n        assert r.pexpire(\"key\", 70000, lt=True) is False\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    def test_pexpireat_datetime(self, r):\n        expire_at = redis_server_time(r) + datetime.timedelta(minutes=1)\n        r[\"a\"] = \"foo\"\n        assert r.pexpireat(\"a\", expire_at) is True\n        assert 0 < r.pttl(\"a\") <= 61000\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    def test_pexpireat_no_key(self, r):\n        expire_at = redis_server_time(r) + datetime.timedelta(minutes=1)\n        assert r.pexpireat(\"a\", expire_at) is False\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    def test_pexpireat_unixtime(self, r):\n        expire_at = redis_server_time(r) + datetime.timedelta(minutes=1)\n        r[\"a\"] = \"foo\"\n        expire_at_milliseconds = int(expire_at.timestamp() * 1000)\n        assert r.pexpireat(\"a\", expire_at_milliseconds) is True\n        assert 0 < r.pttl(\"a\") <= 61000\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_pexpireat_option_nx(self, r):\n        assert r.set(\"key\", \"val\") is True\n        expire_at = redis_server_time(r) + datetime.timedelta(minutes=1)\n        assert r.pexpireat(\"key\", expire_at, nx=True) is True\n        assert r.pexpireat(\"key\", expire_at, nx=True) is False\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_pexpireat_option_xx(self, r):\n        assert r.set(\"key\", \"val\") is True\n        expire_at = redis_server_time(r) + datetime.timedelta(minutes=1)\n        assert r.pexpireat(\"key\", expire_at, xx=True) is False\n        assert r.pexpireat(\"key\", expire_at) is True\n        assert r.pexpireat(\"key\", expire_at, xx=True) is True\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_pexpireat_option_gt(self, r):\n        assert r.set(\"key\", \"val\") is True\n        expire_at = redis_server_time(r) + datetime.timedelta(minutes=2)\n        assert r.pexpireat(\"key\", expire_at) is True\n        expire_at = redis_server_time(r) + datetime.timedelta(minutes=1)\n        assert r.pexpireat(\"key\", expire_at, gt=True) is False\n        expire_at = redis_server_time(r) + datetime.timedelta(minutes=3)\n        assert r.pexpireat(\"key\", expire_at, gt=True) is True\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_pexpireat_option_lt(self, r):\n        assert r.set(\"key\", \"val\") is True\n        expire_at = redis_server_time(r) + datetime.timedelta(minutes=2)\n        assert r.pexpireat(\"key\", expire_at) is True\n        expire_at = redis_server_time(r) + datetime.timedelta(minutes=3)\n        assert r.pexpireat(\"key\", expire_at, lt=True) is False\n        expire_at = redis_server_time(r) + datetime.timedelta(minutes=1)\n        assert r.pexpireat(\"key\", expire_at, lt=True) is True\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_pexpiretime(self, r):\n        r.set(\"a\", \"foo\")\n        r.pexpireat(\"a\", 33177117420000)\n        assert r.pexpiretime(\"a\") == 33177117420000\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    def test_psetex(self, r):\n        assert r.psetex(\"a\", 1000, \"value\")\n        assert r[\"a\"] == b\"value\"\n        assert 0 < r.pttl(\"a\") <= 1000\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    def test_psetex_timedelta(self, r):\n        expire_at = datetime.timedelta(milliseconds=1000)\n        assert r.psetex(\"a\", expire_at, \"value\")\n        assert r[\"a\"] == b\"value\"\n        assert 0 < r.pttl(\"a\") <= 1000\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    def test_pttl(self, r):\n        assert r.pexpire(\"a\", 10000) is False\n        r[\"a\"] = \"1\"\n        assert r.pexpire(\"a\", 10000) is True\n        assert 0 < r.pttl(\"a\") <= 10000\n        assert r.persist(\"a\")\n        assert r.pttl(\"a\") == -1\n\n    @skip_if_server_version_lt(\"2.8.0\")\n    def test_pttl_no_key(self, r):\n        \"PTTL on servers 2.8 and after return -2 when the key doesn't exist\"\n        assert r.pttl(\"a\") == -2\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_hrandfield(self, r):\n        assert r.hrandfield(\"key\") is None\n        r.hset(\"key\", mapping={\"a\": 1, \"b\": 2, \"c\": 3, \"d\": 4, \"e\": 5})\n        assert r.hrandfield(\"key\") is not None\n        assert len(r.hrandfield(\"key\", 2)) == 2\n        # with values\n        assert_resp_response(r, len(r.hrandfield(\"key\", 2, withvalues=True)), 4, 2)\n        # without duplications\n        assert len(r.hrandfield(\"key\", 10)) == 5\n        # with duplications\n        assert len(r.hrandfield(\"key\", -10)) == 10\n\n    @pytest.mark.onlynoncluster\n    def test_randomkey(self, r):\n        assert r.randomkey() is None\n        for key in (\"a\", \"b\", \"c\"):\n            r[key] = 1\n        assert r.randomkey() in (b\"a\", b\"b\", b\"c\")\n\n    @pytest.mark.onlynoncluster\n    def test_rename(self, r):\n        r[\"a\"] = \"1\"\n        assert r.rename(\"a\", \"b\")\n        assert r.get(\"a\") is None\n        assert r[\"b\"] == b\"1\"\n\n    @pytest.mark.onlynoncluster\n    def test_renamenx(self, r):\n        r[\"a\"] = \"1\"\n        r[\"b\"] = \"2\"\n        assert not r.renamenx(\"a\", \"b\")\n        assert r[\"a\"] == b\"1\"\n        assert r[\"b\"] == b\"2\"\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    def test_set_nx(self, r):\n        assert r.set(\"a\", \"1\", nx=True)\n        assert not r.set(\"a\", \"2\", nx=True)\n        assert r[\"a\"] == b\"1\"\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    def test_set_xx(self, r):\n        assert not r.set(\"a\", \"1\", xx=True)\n        assert r.get(\"a\") is None\n        r[\"a\"] = \"bar\"\n        assert r.set(\"a\", \"2\", xx=True)\n        assert r.get(\"a\") == b\"2\"\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    def test_set_px(self, r):\n        assert r.set(\"a\", \"1\", px=10000)\n        assert r[\"a\"] == b\"1\"\n        assert 0 < r.pttl(\"a\") <= 10000\n        assert 0 < r.ttl(\"a\") <= 10\n        with pytest.raises(exceptions.DataError):\n            assert r.set(\"a\", \"1\", px=10.0)\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    def test_set_px_timedelta(self, r):\n        expire_at = datetime.timedelta(milliseconds=1000)\n        assert r.set(\"a\", \"1\", px=expire_at)\n        assert 0 < r.pttl(\"a\") <= 1000\n        assert 0 < r.ttl(\"a\") <= 1\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    def test_set_ex(self, r):\n        assert r.set(\"a\", \"1\", ex=10)\n        assert 0 < r.ttl(\"a\") <= 10\n        with pytest.raises(exceptions.DataError):\n            assert r.set(\"a\", \"1\", ex=10.0)\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    def test_set_ex_str(self, r):\n        assert r.set(\"a\", \"1\", ex=\"10\")\n        assert 0 < r.ttl(\"a\") <= 10\n        with pytest.raises(exceptions.DataError):\n            assert r.set(\"a\", \"1\", ex=\"10.5\")\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    def test_set_ex_timedelta(self, r):\n        expire_at = datetime.timedelta(seconds=60)\n        assert r.set(\"a\", \"1\", ex=expire_at)\n        assert 0 < r.ttl(\"a\") <= 60\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_set_exat_timedelta(self, r):\n        expire_at = redis_server_time(r) + datetime.timedelta(seconds=10)\n        assert r.set(\"a\", \"1\", exat=expire_at)\n        assert 0 < r.ttl(\"a\") <= 10\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_set_pxat_timedelta(self, r):\n        expire_at = redis_server_time(r) + datetime.timedelta(seconds=50)\n        assert r.set(\"a\", \"1\", pxat=expire_at)\n        assert 0 < r.ttl(\"a\") <= 100\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    def test_set_multipleoptions(self, r):\n        r[\"a\"] = \"val\"\n        assert r.set(\"a\", \"1\", xx=True, px=10000)\n        assert 0 < r.ttl(\"a\") <= 10\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    def test_set_keepttl(self, r):\n        r[\"a\"] = \"val\"\n        assert r.set(\"a\", \"1\", xx=True, px=10000)\n        assert 0 < r.ttl(\"a\") <= 10\n        r.set(\"a\", \"2\", keepttl=True)\n        assert r.get(\"a\") == b\"2\"\n        assert 0 < r.ttl(\"a\") <= 10\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_set_ifeq_true_sets_and_returns_true(self, r):\n        r.delete(\"k\")\n        r.set(\"k\", b\"foo\")\n        assert r.set(\"k\", b\"bar\", ifeq=b\"foo\") is True\n        assert r.get(\"k\") == b\"bar\"\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_set_ifeq_false_does_not_set_returns_none(self, r):\n        r.delete(\"k\")\n        r.set(\"k\", b\"foo\")\n        assert r.set(\"k\", b\"bar\", ifeq=b\"nope\") is None\n        assert r.get(\"k\") == b\"foo\"\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_set_ifne_true_sets(self, r):\n        r.delete(\"k\")\n        r.set(\"k\", b\"foo\")\n        assert r.set(\"k\", b\"bar\", ifne=b\"zzz\") is True\n        assert r.get(\"k\") == b\"bar\"\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_set_ifne_false_does_not_set(self, r):\n        r.delete(\"k\")\n        r.set(\"k\", b\"foo\")\n        assert r.set(\"k\", b\"bar\", ifne=b\"foo\") is None\n        assert r.get(\"k\") == b\"foo\"\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_set_ifeq_when_key_missing_does_not_create(self, r):\n        r.delete(\"k\")\n        assert r.set(\"k\", b\"bar\", ifeq=b\"foo\") is None\n        assert r.exists(\"k\") == 0\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_set_ifne_when_key_missing_creates(self, r):\n        r.delete(\"k\")\n        assert r.set(\"k\", b\"bar\", ifne=b\"foo\") is True\n        assert r.get(\"k\") == b\"bar\"\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    @pytest.mark.parametrize(\"val\", [b\"\", b\"abc\", b\"The quick brown fox\"])\n    def test_set_ifdeq_and_ifdne(self, r, val):\n        r.delete(\"k\")\n        r.set(\"k\", val)\n        d = self._server_xxh3_digest(r, \"k\")\n        assert d is not None\n\n        # sanity check: local digest matches server's\n        assert d == self._ensure_str(r.digest_local(val))\n\n        # IFDEQ must match to set; if key missing => won't create\n        assert r.set(\"k\", b\"X\", ifdeq=d) is True\n        assert r.get(\"k\") == b\"X\"\n\n        r.delete(\"k\")\n        # key missing + IFDEQ => not created\n        assert r.set(\"k\", b\"Y\", ifdeq=d) is None\n        assert r.exists(\"k\") == 0\n\n        # IFDNE: create when missing, and set when digest differs\n        assert r.set(\"k\", b\"bar\", ifdne=d) is True\n        prev_d = self._server_xxh3_digest(r, \"k\")\n        assert prev_d is not None\n        # If digest equal → do not set\n        assert r.set(\"k\", b\"zzz\", ifdne=prev_d) is None\n        assert r.get(\"k\") == b\"bar\"\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_set_with_get_returns_previous_value(self, r):\n        r.delete(\"k\")\n        # when key didn’t exist → returns None, and key is created if condition allows it\n        prev = r.set(\"k\", b\"v1\", get=True, ifne=b\"any\")  # IFNE on missing creates\n        assert prev is None\n        # subsequent GET returns previous value, regardless of whether set occurs\n        prev2 = r.set(\"k\", b\"v2\", get=True, ifeq=b\"v1\")  # matches → set; returns \"v1\"\n        assert prev2 == b\"v1\"\n        prev3 = r.set(\"k\", b\"v3\", get=True, ifeq=b\"no\")  # no set; returns previous \"v2\"\n        assert prev3 == b\"v2\"\n        assert r.get(\"k\") == b\"v2\"\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_set_mutual_exclusion_client_side(self, r):\n        r.delete(\"k\")\n        with pytest.raises(DataError):\n            r.set(\"k\", b\"v\", nx=True, ifeq=b\"x\")\n        with pytest.raises(DataError):\n            r.set(\"k\", b\"v\", ifdeq=\"aa\", ifdne=\"bb\")\n        with pytest.raises(DataError):\n            r.set(\"k\", b\"v\", ex=1, px=1)\n        with pytest.raises(DataError):\n            r.set(\"k\", b\"v\", exat=1, pxat=1)\n        with pytest.raises(DataError):\n            r.set(\"k\", b\"v\", ex=1, exat=1)\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_set_get(self, r):\n        assert r.set(\"a\", \"True\", get=True) is None\n        assert r.set(\"a\", \"True\", get=True) == b\"True\"\n        assert r.set(\"a\", \"foo\") is True\n        assert r.set(\"a\", \"bar\", get=True) == b\"foo\"\n        assert r.get(\"a\") == b\"bar\"\n\n    def test_setex(self, r):\n        assert r.setex(\"a\", 60, \"1\")\n        assert r[\"a\"] == b\"1\"\n        assert 0 < r.ttl(\"a\") <= 60\n\n    def test_setnx(self, r):\n        assert r.setnx(\"a\", \"1\")\n        assert r[\"a\"] == b\"1\"\n        assert not r.setnx(\"a\", \"2\")\n        assert r[\"a\"] == b\"1\"\n\n    def test_setrange(self, r):\n        assert r.setrange(\"a\", 5, \"foo\") == 8\n        assert r[\"a\"] == b\"\\0\\0\\0\\0\\0foo\"\n        r[\"a\"] = \"abcdefghijh\"\n        assert r.setrange(\"a\", 6, \"12345\") == 11\n        assert r[\"a\"] == b\"abcdef12345\"\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    @skip_if_server_version_gte(\"7.0.0\")\n    @skip_if_redis_enterprise()\n    def test_stralgo_lcs(self, r):\n        key1 = \"{foo}key1\"\n        key2 = \"{foo}key2\"\n        value1 = \"ohmytext\"\n        value2 = \"mynewtext\"\n        res = \"mytext\"\n\n        if skip_if_redis_enterprise().args[0] is True:\n            with pytest.raises(redis.exceptions.ResponseError):\n                assert r.stralgo(\"LCS\", value1, value2) == res\n            return\n\n        # test LCS of strings\n        assert r.stralgo(\"LCS\", value1, value2) == res\n        # test using keys\n        r.mset({key1: value1, key2: value2})\n        assert r.stralgo(\"LCS\", key1, key2, specific_argument=\"keys\") == res\n        # test other labels\n        assert r.stralgo(\"LCS\", value1, value2, len=True) == len(res)\n        assert_resp_response(\n            r,\n            r.stralgo(\"LCS\", value1, value2, idx=True),\n            {\"len\": len(res), \"matches\": [[(4, 7), (5, 8)], [(2, 3), (0, 1)]]},\n            {\"len\": len(res), \"matches\": [[[4, 7], [5, 8]], [[2, 3], [0, 1]]]},\n        )\n        assert_resp_response(\n            r,\n            r.stralgo(\"LCS\", value1, value2, idx=True, withmatchlen=True),\n            {\"len\": len(res), \"matches\": [[4, (4, 7), (5, 8)], [2, (2, 3), (0, 1)]]},\n            {\"len\": len(res), \"matches\": [[[4, 7], [5, 8], 4], [[2, 3], [0, 1], 2]]},\n        )\n        assert_resp_response(\n            r,\n            r.stralgo(\n                \"LCS\", value1, value2, idx=True, withmatchlen=True, minmatchlen=4\n            ),\n            {\"len\": len(res), \"matches\": [[4, (4, 7), (5, 8)]]},\n            {\"len\": len(res), \"matches\": [[[4, 7], [5, 8], 4]]},\n        )\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    @skip_if_server_version_gte(\"7.0.0\")\n    @skip_if_redis_enterprise()\n    def test_stralgo_negative(self, r):\n        with pytest.raises(exceptions.DataError):\n            r.stralgo(\"ISSUB\", \"value1\", \"value2\")\n        with pytest.raises(exceptions.DataError):\n            r.stralgo(\"LCS\", \"value1\", \"value2\", len=True, idx=True)\n        with pytest.raises(exceptions.DataError):\n            r.stralgo(\"LCS\", \"value1\", \"value2\", specific_argument=\"INT\")\n        with pytest.raises(ValueError):\n            r.stralgo(\"LCS\", \"value1\", \"value2\", idx=True, minmatchlen=\"one\")\n\n    def test_strlen(self, r):\n        r[\"a\"] = \"foo\"\n        assert r.strlen(\"a\") == 3\n\n    def test_substr(self, r):\n        r[\"a\"] = \"0123456789\"\n\n        if skip_if_redis_enterprise().args[0] is True:\n            with pytest.raises(redis.exceptions.ResponseError):\n                assert r.substr(\"a\", 0) == b\"0123456789\"\n            return\n\n        assert r.substr(\"a\", 0) == b\"0123456789\"\n        assert r.substr(\"a\", 2) == b\"23456789\"\n        assert r.substr(\"a\", 3, 5) == b\"345\"\n        assert r.substr(\"a\", 3, -2) == b\"345678\"\n\n    def generate_lib_code(self, lib_name):\n        return f\"\"\"#!js api_version=1.0 name={lib_name}\\n redis.registerFunction('foo', ()=>{{return 'bar'}})\"\"\"  # noqa\n\n    def try_delete_libs(self, r, *lib_names):\n        for lib_name in lib_names:\n            try:\n                r.tfunction_delete(lib_name)\n            except Exception:\n                pass\n\n    def test_ttl(self, r):\n        r[\"a\"] = \"1\"\n        assert r.expire(\"a\", 10)\n        assert 0 < r.ttl(\"a\") <= 10\n        assert r.persist(\"a\")\n        assert r.ttl(\"a\") == -1\n\n    @skip_if_server_version_lt(\"2.8.0\")\n    def test_ttl_nokey(self, r):\n        \"TTL on servers 2.8 and after return -2 when the key doesn't exist\"\n        assert r.ttl(\"a\") == -2\n\n    def test_type(self, r):\n        assert r.type(\"a\") == b\"none\"\n        r[\"a\"] = \"1\"\n        assert r.type(\"a\") == b\"string\"\n        del r[\"a\"]\n        r.lpush(\"a\", \"1\")\n        assert r.type(\"a\") == b\"list\"\n        del r[\"a\"]\n        r.sadd(\"a\", \"1\")\n        assert r.type(\"a\") == b\"set\"\n        del r[\"a\"]\n        r.zadd(\"a\", {\"1\": 1})\n        assert r.type(\"a\") == b\"zset\"\n\n    # LIST COMMANDS\n    @pytest.mark.onlynoncluster\n    def test_blpop(self, r):\n        r.rpush(\"a\", \"1\", \"2\")\n        r.rpush(\"b\", \"3\", \"4\")\n        assert_resp_response(\n            r, r.blpop([\"b\", \"a\"], timeout=1), (b\"b\", b\"3\"), [b\"b\", b\"3\"]\n        )\n        assert_resp_response(\n            r, r.blpop([\"b\", \"a\"], timeout=1), (b\"b\", b\"4\"), [b\"b\", b\"4\"]\n        )\n        assert_resp_response(\n            r, r.blpop([\"b\", \"a\"], timeout=1), (b\"a\", b\"1\"), [b\"a\", b\"1\"]\n        )\n        assert_resp_response(\n            r, r.blpop([\"b\", \"a\"], timeout=1), (b\"a\", b\"2\"), [b\"a\", b\"2\"]\n        )\n        assert r.blpop([\"b\", \"a\"], timeout=1) is None\n        r.rpush(\"c\", \"1\")\n        assert_resp_response(r, r.blpop(\"c\", timeout=1), (b\"c\", b\"1\"), [b\"c\", b\"1\"])\n\n    @pytest.mark.onlynoncluster\n    def test_brpop(self, r):\n        r.rpush(\"a\", \"1\", \"2\")\n        r.rpush(\"b\", \"3\", \"4\")\n        assert_resp_response(\n            r, r.brpop([\"b\", \"a\"], timeout=1), (b\"b\", b\"4\"), [b\"b\", b\"4\"]\n        )\n        assert_resp_response(\n            r, r.brpop([\"b\", \"a\"], timeout=1), (b\"b\", b\"3\"), [b\"b\", b\"3\"]\n        )\n        assert_resp_response(\n            r, r.brpop([\"b\", \"a\"], timeout=1), (b\"a\", b\"2\"), [b\"a\", b\"2\"]\n        )\n        assert_resp_response(\n            r, r.brpop([\"b\", \"a\"], timeout=1), (b\"a\", b\"1\"), [b\"a\", b\"1\"]\n        )\n        assert r.brpop([\"b\", \"a\"], timeout=1) is None\n        r.rpush(\"c\", \"1\")\n        assert_resp_response(r, r.brpop(\"c\", timeout=1), (b\"c\", b\"1\"), [b\"c\", b\"1\"])\n\n    @pytest.mark.onlynoncluster\n    def test_brpoplpush(self, r):\n        r.rpush(\"a\", \"1\", \"2\")\n        r.rpush(\"b\", \"3\", \"4\")\n        assert r.brpoplpush(\"a\", \"b\") == b\"2\"\n        assert r.brpoplpush(\"a\", \"b\") == b\"1\"\n        assert r.brpoplpush(\"a\", \"b\", timeout=1) is None\n        assert r.lrange(\"a\", 0, -1) == []\n        assert r.lrange(\"b\", 0, -1) == [b\"1\", b\"2\", b\"3\", b\"4\"]\n\n    @pytest.mark.onlynoncluster\n    def test_brpoplpush_empty_string(self, r):\n        r.rpush(\"a\", \"\")\n        assert r.brpoplpush(\"a\", \"b\") == b\"\"\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_blmpop(self, r):\n        r.rpush(\"a\", \"1\", \"2\", \"3\", \"4\", \"5\")\n        res = [b\"a\", [b\"1\", b\"2\"]]\n        assert r.blmpop(1, \"2\", \"b\", \"a\", direction=\"LEFT\", count=2) == res\n        with pytest.raises(TypeError):\n            r.blmpop(1, \"2\", \"b\", \"a\", count=2)\n        r.rpush(\"b\", \"6\", \"7\", \"8\", \"9\")\n        assert r.blmpop(0, \"2\", \"b\", \"a\", direction=\"LEFT\") == [b\"b\", [b\"6\"]]\n        assert r.blmpop(1, \"2\", \"foo\", \"bar\", direction=\"RIGHT\") is None\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_lmpop(self, r):\n        r.rpush(\"foo\", \"1\", \"2\", \"3\", \"4\", \"5\")\n        result = [b\"foo\", [b\"1\", b\"2\"]]\n        assert r.lmpop(\"2\", \"bar\", \"foo\", direction=\"LEFT\", count=2) == result\n        with pytest.raises(redis.ResponseError):\n            r.lmpop(\"2\", \"bar\", \"foo\", direction=\"up\", count=2)\n        r.rpush(\"bar\", \"a\", \"b\", \"c\", \"d\")\n        assert r.lmpop(\"2\", \"bar\", \"foo\", direction=\"LEFT\") == [b\"bar\", [b\"a\"]]\n\n    def test_lindex(self, r):\n        r.rpush(\"a\", \"1\", \"2\", \"3\")\n        assert r.lindex(\"a\", \"0\") == b\"1\"\n        assert r.lindex(\"a\", \"1\") == b\"2\"\n        assert r.lindex(\"a\", \"2\") == b\"3\"\n\n    def test_linsert(self, r):\n        r.rpush(\"a\", \"1\", \"2\", \"3\")\n        assert r.linsert(\"a\", \"after\", \"2\", \"2.5\") == 4\n        assert r.lrange(\"a\", 0, -1) == [b\"1\", b\"2\", b\"2.5\", b\"3\"]\n        assert r.linsert(\"a\", \"before\", \"2\", \"1.5\") == 5\n        assert r.lrange(\"a\", 0, -1) == [b\"1\", b\"1.5\", b\"2\", b\"2.5\", b\"3\"]\n\n    def test_llen(self, r):\n        r.rpush(\"a\", \"1\", \"2\", \"3\")\n        assert r.llen(\"a\") == 3\n\n    def test_lpop(self, r):\n        r.rpush(\"a\", \"1\", \"2\", \"3\")\n        assert r.lpop(\"a\") == b\"1\"\n        assert r.lpop(\"a\") == b\"2\"\n        assert r.lpop(\"a\") == b\"3\"\n        assert r.lpop(\"a\") is None\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_lpop_count(self, r):\n        r.rpush(\"a\", \"1\", \"2\", \"3\")\n        assert r.lpop(\"a\", 2) == [b\"1\", b\"2\"]\n        assert r.lpop(\"a\", 1) == [b\"3\"]\n        assert r.lpop(\"a\") is None\n        assert r.lpop(\"a\", 3) is None\n\n    def test_lpush(self, r):\n        assert r.lpush(\"a\", \"1\") == 1\n        assert r.lpush(\"a\", \"2\") == 2\n        assert r.lpush(\"a\", \"3\", \"4\") == 4\n        assert r.lrange(\"a\", 0, -1) == [b\"4\", b\"3\", b\"2\", b\"1\"]\n\n    def test_lpushx(self, r):\n        assert r.lpushx(\"a\", \"1\") == 0\n        assert r.lrange(\"a\", 0, -1) == []\n        r.rpush(\"a\", \"1\", \"2\", \"3\")\n        assert r.lpushx(\"a\", \"4\") == 4\n        assert r.lrange(\"a\", 0, -1) == [b\"4\", b\"1\", b\"2\", b\"3\"]\n\n    @skip_if_server_version_lt(\"4.0.0\")\n    def test_lpushx_with_list(self, r):\n        # now with a list\n        r.lpush(\"somekey\", \"a\")\n        r.lpush(\"somekey\", \"b\")\n        assert r.lpushx(\"somekey\", \"foo\", \"asdasd\", 55, \"asdasdas\") == 6\n        res = r.lrange(\"somekey\", 0, -1)\n        assert res == [b\"asdasdas\", b\"55\", b\"asdasd\", b\"foo\", b\"b\", b\"a\"]\n\n    def test_lrange(self, r):\n        r.rpush(\"a\", \"1\", \"2\", \"3\", \"4\", \"5\")\n        assert r.lrange(\"a\", 0, 2) == [b\"1\", b\"2\", b\"3\"]\n        assert r.lrange(\"a\", 2, 10) == [b\"3\", b\"4\", b\"5\"]\n        assert r.lrange(\"a\", 0, -1) == [b\"1\", b\"2\", b\"3\", b\"4\", b\"5\"]\n        r.rpush(b\"345\", \"12\", \"22\", \"32\", \"42\", \"52\")\n        assert r.lrange(b\"345\", 0, 0) == [b\"12\"]\n\n    def test_lrem(self, r):\n        r.rpush(\"a\", \"Z\", \"b\", \"Z\", \"Z\", \"c\", \"Z\", \"Z\")\n        # remove the first 'Z'  item\n        assert r.lrem(\"a\", 1, \"Z\") == 1\n        assert r.lrange(\"a\", 0, -1) == [b\"b\", b\"Z\", b\"Z\", b\"c\", b\"Z\", b\"Z\"]\n        # remove the last 2 'Z' items\n        assert r.lrem(\"a\", -2, \"Z\") == 2\n        assert r.lrange(\"a\", 0, -1) == [b\"b\", b\"Z\", b\"Z\", b\"c\"]\n        # remove all 'Z' items\n        assert r.lrem(\"a\", 0, \"Z\") == 2\n        assert r.lrange(\"a\", 0, -1) == [b\"b\", b\"c\"]\n\n    def test_lset(self, r):\n        r.rpush(\"a\", \"1\", \"2\", \"3\")\n        assert r.lrange(\"a\", 0, -1) == [b\"1\", b\"2\", b\"3\"]\n        assert r.lset(\"a\", 1, \"4\")\n        assert r.lrange(\"a\", 0, 2) == [b\"1\", b\"4\", b\"3\"]\n\n    def test_ltrim(self, r):\n        r.rpush(\"a\", \"1\", \"2\", \"3\")\n        assert r.ltrim(\"a\", 0, 1)\n        assert r.lrange(\"a\", 0, -1) == [b\"1\", b\"2\"]\n\n    def test_rpop(self, r):\n        r.rpush(\"a\", \"1\", \"2\", \"3\")\n        assert r.rpop(\"a\") == b\"3\"\n        assert r.rpop(\"a\") == b\"2\"\n        assert r.rpop(\"a\") == b\"1\"\n        assert r.rpop(\"a\") is None\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_rpop_count(self, r):\n        r.rpush(\"a\", \"1\", \"2\", \"3\")\n        assert r.rpop(\"a\", 2) == [b\"3\", b\"2\"]\n        assert r.rpop(\"a\", 1) == [b\"1\"]\n        assert r.rpop(\"a\") is None\n        assert r.rpop(\"a\", 3) is None\n\n    @pytest.mark.onlynoncluster\n    def test_rpoplpush(self, r):\n        r.rpush(\"a\", \"a1\", \"a2\", \"a3\")\n        r.rpush(\"b\", \"b1\", \"b2\", \"b3\")\n        assert r.rpoplpush(\"a\", \"b\") == b\"a3\"\n        assert r.lrange(\"a\", 0, -1) == [b\"a1\", b\"a2\"]\n        assert r.lrange(\"b\", 0, -1) == [b\"a3\", b\"b1\", b\"b2\", b\"b3\"]\n\n    def test_rpush(self, r):\n        assert r.rpush(\"a\", \"1\") == 1\n        assert r.rpush(\"a\", \"2\") == 2\n        assert r.rpush(\"a\", \"3\", \"4\") == 4\n        assert r.lrange(\"a\", 0, -1) == [b\"1\", b\"2\", b\"3\", b\"4\"]\n\n    @skip_if_server_version_lt(\"6.0.6\")\n    def test_lpos(self, r):\n        assert r.rpush(\"a\", \"a\", \"b\", \"c\", \"1\", \"2\", \"3\", \"c\", \"c\") == 8\n        assert r.lpos(\"a\", \"a\") == 0\n        assert r.lpos(\"a\", \"c\") == 2\n\n        assert r.lpos(\"a\", \"c\", rank=1) == 2\n        assert r.lpos(\"a\", \"c\", rank=2) == 6\n        assert r.lpos(\"a\", \"c\", rank=4) is None\n        assert r.lpos(\"a\", \"c\", rank=-1) == 7\n        assert r.lpos(\"a\", \"c\", rank=-2) == 6\n\n        assert r.lpos(\"a\", \"c\", count=0) == [2, 6, 7]\n        assert r.lpos(\"a\", \"c\", count=1) == [2]\n        assert r.lpos(\"a\", \"c\", count=2) == [2, 6]\n        assert r.lpos(\"a\", \"c\", count=100) == [2, 6, 7]\n\n        assert r.lpos(\"a\", \"c\", count=0, rank=2) == [6, 7]\n        assert r.lpos(\"a\", \"c\", count=2, rank=-1) == [7, 6]\n\n        assert r.lpos(\"axxx\", \"c\", count=0, rank=2) == []\n        assert r.lpos(\"axxx\", \"c\") is None\n\n        assert r.lpos(\"a\", \"x\", count=2) == []\n        assert r.lpos(\"a\", \"x\") is None\n\n        assert r.lpos(\"a\", \"a\", count=0, maxlen=1) == [0]\n        assert r.lpos(\"a\", \"c\", count=0, maxlen=1) == []\n        assert r.lpos(\"a\", \"c\", count=0, maxlen=3) == [2]\n        assert r.lpos(\"a\", \"c\", count=0, maxlen=3, rank=-1) == [7, 6]\n        assert r.lpos(\"a\", \"c\", count=0, maxlen=7, rank=2) == [6]\n\n    def test_rpushx(self, r):\n        assert r.rpushx(\"a\", \"b\") == 0\n        assert r.lrange(\"a\", 0, -1) == []\n        r.rpush(\"a\", \"1\", \"2\", \"3\")\n        assert r.rpushx(\"a\", \"4\") == 4\n        assert r.lrange(\"a\", 0, -1) == [b\"1\", b\"2\", b\"3\", b\"4\"]\n\n    @pytest.mark.onlynoncluster\n    def test_lists_with_byte_keys(self, r):\n        r.rpush(b\"b\", b\"1\", b\"2\", b\"3\")\n        assert r.lrange(b\"b\", 0, -1) == [b\"1\", b\"2\", b\"3\"]\n        # LPOS command with byte keys\n        assert r.lpos(b\"b\", b\"2\") == 1\n        assert r.lpos(b\"b\", b\"2\", rank=1) == 1\n        assert r.lpos(b\"b\", b\"2\", rank=2) is None\n        # LCS command with byte keys\n        r.set(b\"key1\", b\"ohmytext\")\n        r.set(b\"key2\", b\"mynewtext\")\n        assert r.lcs(b\"key1\", b\"key2\") == b\"mytext\"\n        # TYPE command with byte keys\n        assert r.type(b\"b\") == b\"list\"\n        assert r.type(b\"key1\") == b\"string\"\n        # SCAN command with byte keys\n        r.set(b\"scan_key1\", b\"value1\")\n        r.set(b\"scan_key2\", b\"value2\")\n        cursor, keys = r.scan(match=b\"scan_key*\")\n        assert cursor == 0\n        assert set(keys) == {b\"scan_key1\", b\"scan_key2\"}\n        # PEXPIRETIME command with byte keys\n        r.set(b\"expire_key\", b\"value\")\n        r.pexpire(b\"expire_key\", 10000)\n        pexpiretime = r.pexpiretime(b\"expire_key\")\n        assert pexpiretime > 0\n        # LMOVE command with byte keys (src and dest)\n        r.rpush(b\"list_src\", b\"a\", b\"b\", b\"c\")\n        r.rpush(b\"list_dest\", b\"x\")\n        moved = r.lmove(b\"list_src\", b\"list_dest\", src=b\"LEFT\", dest=b\"RIGHT\")\n        assert moved == b\"a\"\n        assert r.lrange(b\"list_dest\", 0, -1) == [b\"x\", b\"a\"]\n        # SMOVE command with byte keys (src and dst)\n        r.sadd(b\"set_src\", b\"member1\", b\"member2\")\n        r.sadd(b\"set_dest\", b\"member3\")\n        assert r.smove(b\"set_src\", b\"set_dest\", b\"member1\") == 1\n        assert b\"member1\" in r.smembers(b\"set_dest\")\n\n    @pytest.mark.onlynoncluster\n    def test_lists_with_memoryview_keys(self, r):\n        # Create memoryview objects for key names\n        mv_b = memoryview(b\"b\")\n        mv_key1 = memoryview(b\"key1\")\n        mv_key2 = memoryview(b\"key2\")\n        mv_scan_key1 = memoryview(b\"scan_key1\")\n        mv_scan_key2 = memoryview(b\"scan_key2\")\n        mv_expire_key = memoryview(b\"expire_key\")\n        mv_list_src = memoryview(b\"list_src\")\n        mv_list_dest = memoryview(b\"list_dest\")\n        mv_set_src = memoryview(b\"set_src\")\n        mv_set_dest = memoryview(b\"set_dest\")\n\n        r.rpush(mv_b, b\"1\", b\"2\", b\"3\")\n        assert r.lrange(mv_b, 0, -1) == [b\"1\", b\"2\", b\"3\"]\n        # LPOS command with memoryview keys\n        assert r.lpos(mv_b, b\"2\") == 1\n        assert r.lpos(mv_b, b\"2\", rank=1) == 1\n        assert r.lpos(mv_b, b\"2\", rank=2) is None\n        # LCS command with memoryview keys\n        r.set(mv_key1, b\"ohmytext\")\n        r.set(mv_key2, b\"mynewtext\")\n        assert r.lcs(mv_key1, mv_key2) == b\"mytext\"\n        # TYPE command with memoryview keys\n        assert r.type(mv_b) == b\"list\"\n        assert r.type(mv_key1) == b\"string\"\n        # SCAN command with memoryview keys\n        r.set(mv_scan_key1, b\"value1\")\n        r.set(mv_scan_key2, b\"value2\")\n        cursor, keys = r.scan(match=memoryview(b\"scan_key*\"))\n        assert cursor == 0\n        assert set(keys) == {b\"scan_key1\", b\"scan_key2\"}\n        # PEXPIRETIME command with memoryview keys\n        r.set(mv_expire_key, b\"value\")\n        r.pexpire(mv_expire_key, 10000)\n        pexpiretime = r.pexpiretime(mv_expire_key)\n        assert pexpiretime > 0\n        # LMOVE command with memoryview keys (src and dest)\n        r.rpush(mv_list_src, b\"a\", b\"b\", b\"c\")\n        r.rpush(mv_list_dest, b\"x\")\n        moved = r.lmove(\n            mv_list_src,\n            mv_list_dest,\n            src=memoryview(b\"LEFT\"),\n            dest=memoryview(b\"RIGHT\"),\n        )\n        assert moved == b\"a\"\n        assert r.lrange(mv_list_dest, 0, -1) == [b\"x\", b\"a\"]\n        # SMOVE command with memoryview keys (src and dst)\n        r.sadd(mv_set_src, b\"member1\", b\"member2\")\n        r.sadd(mv_set_dest, b\"member3\")\n        assert r.smove(mv_set_src, mv_set_dest, b\"member1\") == 1\n        assert b\"member1\" in r.smembers(mv_set_dest)\n\n    # SCAN COMMANDS\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"2.8.0\")\n    def test_scan(self, r):\n        r.set(\"a\", 1)\n        r.set(\"b\", 2)\n        r.set(\"c\", 3)\n        cursor, keys = r.scan()\n        assert cursor == 0\n        assert set(keys) == {b\"a\", b\"b\", b\"c\"}\n        _, keys = r.scan(match=\"a\")\n        assert set(keys) == {b\"a\"}\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"6.0.0\")\n    def test_scan_type(self, r):\n        r.sadd(\"a-set\", 1)\n        r.hset(\"a-hash\", \"foo\", 2)\n        r.lpush(\"a-list\", \"aux\", 3)\n        _, keys = r.scan(match=\"a*\", _type=\"SET\")\n        assert set(keys) == {b\"a-set\"}\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"2.8.0\")\n    def test_scan_iter(self, r):\n        r.set(\"a\", 1)\n        r.set(\"b\", 2)\n        r.set(\"c\", 3)\n        keys = list(r.scan_iter())\n        assert set(keys) == {b\"a\", b\"b\", b\"c\"}\n        keys = list(r.scan_iter(match=\"a\"))\n        assert set(keys) == {b\"a\"}\n\n    @skip_if_server_version_lt(\"2.8.0\")\n    def test_sscan(self, r):\n        r.sadd(\"a\", 1, 2, 3)\n        cursor, members = r.sscan(\"a\")\n        assert cursor == 0\n        assert set(members) == {b\"1\", b\"2\", b\"3\"}\n        _, members = r.sscan(\"a\", match=b\"1\")\n        assert set(members) == {b\"1\"}\n\n    @skip_if_server_version_lt(\"2.8.0\")\n    def test_sscan_iter(self, r):\n        r.sadd(\"a\", 1, 2, 3)\n        members = list(r.sscan_iter(\"a\"))\n        assert set(members) == {b\"1\", b\"2\", b\"3\"}\n        members = list(r.sscan_iter(\"a\", match=b\"1\"))\n        assert set(members) == {b\"1\"}\n\n    @skip_if_server_version_lt(\"2.8.0\")\n    def test_hscan(self, r):\n        r.hset(\"a\", mapping={\"a\": 1, \"b\": 2, \"c\": 3})\n        cursor, dic = r.hscan(\"a\")\n        assert cursor == 0\n        assert dic == {b\"a\": b\"1\", b\"b\": b\"2\", b\"c\": b\"3\"}\n        _, dic = r.hscan(\"a\", match=\"a\")\n        assert dic == {b\"a\": b\"1\"}\n        _, dic = r.hscan(\"a_notset\")\n        assert dic == {}\n\n    @skip_if_server_version_lt(\"7.3.240\")\n    def test_hscan_novalues(self, r):\n        r.hset(\"a\", mapping={\"a\": 1, \"b\": 2, \"c\": 3})\n        cursor, keys = r.hscan(\"a\", no_values=True)\n        assert cursor == 0\n        assert sorted(keys) == [b\"a\", b\"b\", b\"c\"]\n        _, keys = r.hscan(\"a\", match=\"a\", no_values=True)\n        assert keys == [b\"a\"]\n        _, keys = r.hscan(\"a_notset\", no_values=True)\n        assert keys == []\n\n    @skip_if_server_version_lt(\"2.8.0\")\n    def test_hscan_iter(self, r):\n        r.hset(\"a\", mapping={\"a\": 1, \"b\": 2, \"c\": 3})\n        dic = dict(r.hscan_iter(\"a\"))\n        assert dic == {b\"a\": b\"1\", b\"b\": b\"2\", b\"c\": b\"3\"}\n        dic = dict(r.hscan_iter(\"a\", match=\"a\"))\n        assert dic == {b\"a\": b\"1\"}\n        dic = dict(r.hscan_iter(\"a_notset\"))\n        assert dic == {}\n\n    @skip_if_server_version_lt(\"7.3.240\")\n    def test_hscan_iter_novalues(self, r):\n        r.hset(\"a\", mapping={\"a\": 1, \"b\": 2, \"c\": 3})\n        keys = list(r.hscan_iter(\"a\", no_values=True))\n        assert keys == [b\"a\", b\"b\", b\"c\"]\n        keys = list(r.hscan_iter(\"a\", match=\"a\", no_values=True))\n        assert keys == [b\"a\"]\n        keys = list(r.hscan_iter(\"a_notset\", no_values=True))\n        assert keys == []\n\n    @skip_if_server_version_lt(\"2.8.0\")\n    def test_zscan(self, r):\n        r.zadd(\"a\", {\"a\": 1, \"b\": 2, \"c\": 3})\n        cursor, pairs = r.zscan(\"a\")\n        assert cursor == 0\n        assert set(pairs) == {(b\"a\", 1), (b\"b\", 2), (b\"c\", 3)}\n        _, pairs = r.zscan(\"a\", match=\"a\")\n        assert set(pairs) == {(b\"a\", 1)}\n\n    @skip_if_server_version_lt(\"2.8.0\")\n    def test_zscan_iter(self, r):\n        r.zadd(\"a\", {\"a\": 1, \"b\": 2, \"c\": 3})\n        pairs = list(r.zscan_iter(\"a\"))\n        assert set(pairs) == {(b\"a\", 1), (b\"b\", 2), (b\"c\", 3)}\n        pairs = list(r.zscan_iter(\"a\", match=\"a\"))\n        assert set(pairs) == {(b\"a\", 1)}\n\n    # SET COMMANDS\n    def test_sadd(self, r):\n        members = {b\"1\", b\"2\", b\"3\"}\n        r.sadd(\"a\", *members)\n        assert set(r.smembers(\"a\")) == members\n\n    def test_scard(self, r):\n        r.sadd(\"a\", \"1\", \"2\", \"3\")\n        assert r.scard(\"a\") == 3\n\n    @pytest.mark.onlynoncluster\n    def test_sdiff(self, r):\n        r.sadd(\"a\", \"1\", \"2\", \"3\")\n        assert r.sdiff(\"a\", \"b\") == {b\"1\", b\"2\", b\"3\"}\n        r.sadd(\"b\", \"2\", \"3\")\n        assert r.sdiff(\"a\", \"b\") == {b\"1\"}\n\n    @pytest.mark.onlynoncluster\n    def test_sdiffstore(self, r):\n        r.sadd(\"a\", \"1\", \"2\", \"3\")\n        assert r.sdiffstore(\"c\", \"a\", \"b\") == 3\n        assert r.smembers(\"c\") == {b\"1\", b\"2\", b\"3\"}\n        r.sadd(\"b\", \"2\", \"3\")\n        assert r.sdiffstore(\"c\", \"a\", \"b\") == 1\n        assert r.smembers(\"c\") == {b\"1\"}\n\n    @pytest.mark.onlynoncluster\n    def test_sinter(self, r):\n        r.sadd(\"a\", \"1\", \"2\", \"3\")\n        assert r.sinter(\"a\", \"b\") == set()\n        r.sadd(\"b\", \"2\", \"3\")\n        assert r.sinter(\"a\", \"b\") == {b\"2\", b\"3\"}\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_sintercard(self, r):\n        r.sadd(\"a\", 1, 2, 3)\n        r.sadd(\"b\", 1, 2, 3)\n        r.sadd(\"c\", 1, 3, 4)\n        assert r.sintercard(3, [\"a\", \"b\", \"c\"]) == 2\n        assert r.sintercard(3, [\"a\", \"b\", \"c\"], limit=1) == 1\n\n    @pytest.mark.onlynoncluster\n    def test_sinterstore(self, r):\n        r.sadd(\"a\", \"1\", \"2\", \"3\")\n        assert r.sinterstore(\"c\", \"a\", \"b\") == 0\n        assert r.smembers(\"c\") == set()\n        r.sadd(\"b\", \"2\", \"3\")\n        assert r.sinterstore(\"c\", \"a\", \"b\") == 2\n        assert r.smembers(\"c\") == {b\"2\", b\"3\"}\n\n    def test_sismember(self, r):\n        r.sadd(\"a\", \"1\", \"2\", \"3\")\n        assert r.sismember(\"a\", \"1\")\n        assert r.sismember(\"a\", \"2\")\n        assert r.sismember(\"a\", \"3\")\n        assert not r.sismember(\"a\", \"4\")\n\n    def test_smembers(self, r):\n        r.sadd(\"a\", \"1\", \"2\", \"3\")\n        assert set(r.smembers(\"a\")) == {b\"1\", b\"2\", b\"3\"}\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_smismember(self, r):\n        r.sadd(\"a\", \"1\", \"2\", \"3\")\n        result_list = [True, False, True, True]\n        assert r.smismember(\"a\", \"1\", \"4\", \"2\", \"3\") == result_list\n        assert r.smismember(\"a\", [\"1\", \"4\", \"2\", \"3\"]) == result_list\n\n    @pytest.mark.onlynoncluster\n    def test_smove(self, r):\n        r.sadd(\"a\", \"a1\", \"a2\")\n        r.sadd(\"b\", \"b1\", \"b2\")\n        assert r.smove(\"a\", \"b\", \"a1\")\n        assert r.smembers(\"a\") == {b\"a2\"}\n        assert r.smembers(\"b\") == {b\"b1\", b\"b2\", b\"a1\"}\n\n    def test_spop(self, r):\n        s = [b\"1\", b\"2\", b\"3\"]\n        r.sadd(\"a\", *s)\n        value = r.spop(\"a\")\n        assert value in s\n        assert set(r.smembers(\"a\")) == set(s) - {value}\n\n    @skip_if_server_version_lt(\"3.2.0\")\n    def test_spop_multi_value(self, r):\n        s = [b\"1\", b\"2\", b\"3\"]\n        r.sadd(\"a\", *s)\n        values = r.spop(\"a\", 2)\n        assert len(values) == 2\n\n        for value in values:\n            assert value in s\n        assert set(r.spop(\"a\", 1)) == set(s) - set(values)\n\n    def test_srandmember(self, r):\n        s = [b\"1\", b\"2\", b\"3\"]\n        r.sadd(\"a\", *s)\n        assert r.srandmember(\"a\") in s\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    def test_srandmember_multi_value(self, r):\n        s = [b\"1\", b\"2\", b\"3\"]\n        r.sadd(\"a\", *s)\n        randoms = r.srandmember(\"a\", number=2)\n        assert len(randoms) == 2\n        assert set(randoms).intersection(s) == set(randoms)\n\n    def test_srem(self, r):\n        r.sadd(\"a\", \"1\", \"2\", \"3\", \"4\")\n        assert r.srem(\"a\", \"5\") == 0\n        assert r.srem(\"a\", \"2\", \"4\") == 2\n        assert set(r.smembers(\"a\")) == {b\"1\", b\"3\"}\n\n    @pytest.mark.onlynoncluster\n    def test_sunion(self, r):\n        r.sadd(\"a\", \"1\", \"2\")\n        r.sadd(\"b\", \"2\", \"3\")\n        assert set(r.sunion(\"a\", \"b\")) == {b\"1\", b\"2\", b\"3\"}\n\n    @pytest.mark.onlynoncluster\n    def test_sunionstore(self, r):\n        r.sadd(\"a\", \"1\", \"2\")\n        r.sadd(\"b\", \"2\", \"3\")\n        assert r.sunionstore(\"c\", \"a\", \"b\") == 3\n        assert set(r.smembers(\"c\")) == {b\"1\", b\"2\", b\"3\"}\n\n    @skip_if_server_version_lt(\"1.0.0\")\n    @skip_if_redis_enterprise()\n    def test_debug_segfault(self, r):\n        with pytest.raises(NotImplementedError):\n            r.debug_segfault()\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"3.2.0\")\n    @skip_if_redis_enterprise()\n    def test_script_debug(self, r):\n        with pytest.raises(NotImplementedError):\n            r.script_debug()\n\n    # SORTED SET COMMANDS\n    def test_zadd(self, r):\n        mapping = {\"a1\": 1.0, \"a2\": 2.0, \"a3\": 3.0}\n        r.zadd(\"a\", mapping)\n        assert_resp_response(\n            r,\n            r.zrange(\"a\", 0, -1, withscores=True),\n            [(b\"a1\", 1.0), (b\"a2\", 2.0), (b\"a3\", 3.0)],\n            [[b\"a1\", 1.0], [b\"a2\", 2.0], [b\"a3\", 3.0]],\n        )\n\n        # error cases\n        with pytest.raises(exceptions.DataError):\n            r.zadd(\"a\", {})\n\n        # cannot use both nx and xx options\n        with pytest.raises(exceptions.DataError):\n            r.zadd(\"a\", mapping, nx=True, xx=True)\n\n        # cannot use the incr options with more than one value\n        with pytest.raises(exceptions.DataError):\n            r.zadd(\"a\", mapping, incr=True)\n\n    def test_zadd_nx(self, r):\n        assert r.zadd(\"a\", {\"a1\": 1}) == 1\n        assert r.zadd(\"a\", {\"a1\": 99, \"a2\": 2}, nx=True) == 1\n        assert_resp_response(\n            r,\n            r.zrange(\"a\", 0, -1, withscores=True),\n            [(b\"a1\", 1.0), (b\"a2\", 2.0)],\n            [[b\"a1\", 1.0], [b\"a2\", 2.0]],\n        )\n\n    def test_zadd_xx(self, r):\n        assert r.zadd(\"a\", {\"a1\": 1}) == 1\n        assert r.zadd(\"a\", {\"a1\": 99, \"a2\": 2}, xx=True) == 0\n        assert_resp_response(\n            r,\n            r.zrange(\"a\", 0, -1, withscores=True),\n            [(b\"a1\", 99.0)],\n            [[b\"a1\", 99.0]],\n        )\n\n    def test_zadd_ch(self, r):\n        assert r.zadd(\"a\", {\"a1\": 1}) == 1\n        assert r.zadd(\"a\", {\"a1\": 99, \"a2\": 2}, ch=True) == 2\n        assert_resp_response(\n            r,\n            r.zrange(\"a\", 0, -1, withscores=True),\n            [(b\"a2\", 2.0), (b\"a1\", 99.0)],\n            [[b\"a2\", 2.0], [b\"a1\", 99.0]],\n        )\n\n    def test_zadd_incr(self, r):\n        assert r.zadd(\"a\", {\"a1\": 1}) == 1\n        assert r.zadd(\"a\", {\"a1\": 4.5}, incr=True) == 5.5\n\n    def test_zadd_incr_with_xx(self, r):\n        # this asks zadd to incr 'a1' only if it exists, but it clearly\n        # doesn't. Redis returns a null value in this case and so should\n        # redis-py\n        assert r.zadd(\"a\", {\"a1\": 1}, xx=True, incr=True) is None\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_zadd_gt_lt(self, r):\n        r.zadd(\"a\", {\"a\": 2})\n        assert r.zadd(\"a\", {\"a\": 5}, gt=True, ch=True) == 1\n        assert r.zadd(\"a\", {\"a\": 1}, gt=True, ch=True) == 0\n        assert r.zadd(\"a\", {\"a\": 5}, lt=True, ch=True) == 0\n        assert r.zadd(\"a\", {\"a\": 1}, lt=True, ch=True) == 1\n\n        # cannot combine both nx and xx options and gt and lt options\n        with pytest.raises(exceptions.DataError):\n            r.zadd(\"a\", {\"a15\": 15}, nx=True, lt=True)\n        with pytest.raises(exceptions.DataError):\n            r.zadd(\"a\", {\"a15\": 15}, nx=True, gt=True)\n        with pytest.raises(exceptions.DataError):\n            r.zadd(\"a\", {\"a15\": 15}, lt=True, gt=True)\n        with pytest.raises(exceptions.DataError):\n            r.zadd(\"a\", {\"a15\": 15}, nx=True, xx=True)\n\n    def test_zcard(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        assert r.zcard(\"a\") == 3\n\n    def test_zcount(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        assert r.zcount(\"a\", \"-inf\", \"+inf\") == 3\n        assert r.zcount(\"a\", 1, 2) == 2\n        assert r.zcount(\"a\", \"(\" + str(1), 2) == 1\n        assert r.zcount(\"a\", 1, \"(\" + str(2)) == 1\n        assert r.zcount(\"a\", 10, 20) == 0\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_zdiff(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        r.zadd(\"b\", {\"a1\": 1, \"a2\": 2})\n        assert r.zdiff([\"a\", \"b\"]) == [b\"a3\"]\n        assert_resp_response(\n            r,\n            r.zdiff([\"a\", \"b\"], withscores=True),\n            [b\"a3\", b\"3\"],\n            [[b\"a3\", 3.0]],\n        )\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_zdiffstore(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        r.zadd(\"b\", {\"a1\": 1, \"a2\": 2})\n        assert r.zdiffstore(\"out\", [\"a\", \"b\"])\n        assert r.zrange(\"out\", 0, -1) == [b\"a3\"]\n        assert_resp_response(\n            r,\n            r.zrange(\"out\", 0, -1, withscores=True),\n            [(b\"a3\", 3.0)],\n            [[b\"a3\", 3.0]],\n        )\n\n    def test_zincrby(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        assert r.zincrby(\"a\", 1, \"a2\") == 3.0\n        assert r.zincrby(\"a\", 5, \"a3\") == 8.0\n        assert r.zscore(\"a\", \"a2\") == 3.0\n        assert r.zscore(\"a\", \"a3\") == 8.0\n\n    @skip_if_server_version_lt(\"2.8.9\")\n    def test_zlexcount(self, r):\n        r.zadd(\"a\", {\"a\": 0, \"b\": 0, \"c\": 0, \"d\": 0, \"e\": 0, \"f\": 0, \"g\": 0})\n        assert r.zlexcount(\"a\", \"-\", \"+\") == 7\n        assert r.zlexcount(\"a\", \"[b\", \"[f\") == 5\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_zinter(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 1})\n        r.zadd(\"b\", {\"a1\": 2, \"a2\": 2, \"a3\": 2})\n        r.zadd(\"c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert r.zinter([\"a\", \"b\", \"c\"]) == [b\"a3\", b\"a1\"]\n        # invalid aggregation\n        with pytest.raises(exceptions.DataError):\n            r.zinter([\"a\", \"b\", \"c\"], aggregate=\"foo\", withscores=True)\n        # aggregate with SUM\n        assert_resp_response(\n            r,\n            r.zinter([\"a\", \"b\", \"c\"], withscores=True),\n            [(b\"a3\", 8), (b\"a1\", 9)],\n            [[b\"a3\", 8], [b\"a1\", 9]],\n        )\n        # aggregate with MAX\n        assert_resp_response(\n            r,\n            r.zinter([\"a\", \"b\", \"c\"], aggregate=\"MAX\", withscores=True),\n            [(b\"a3\", 5), (b\"a1\", 6)],\n            [[b\"a3\", 5], [b\"a1\", 6]],\n        )\n        # aggregate with MIN\n        assert_resp_response(\n            r,\n            r.zinter([\"a\", \"b\", \"c\"], aggregate=\"MIN\", withscores=True),\n            [(b\"a1\", 1), (b\"a3\", 1)],\n            [[b\"a1\", 1], [b\"a3\", 1]],\n        )\n        # with weights\n        assert_resp_response(\n            r,\n            r.zinter({\"a\": 1, \"b\": 2, \"c\": 3}, withscores=True),\n            [(b\"a3\", 20), (b\"a1\", 23)],\n            [[b\"a3\", 20], [b\"a1\", 23]],\n        )\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_zintercard(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 1})\n        r.zadd(\"b\", {\"a1\": 2, \"a2\": 2, \"a3\": 2})\n        r.zadd(\"c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert r.zintercard(3, [\"a\", \"b\", \"c\"]) == 2\n        assert r.zintercard(3, [\"a\", \"b\", \"c\"], limit=1) == 1\n\n    @pytest.mark.onlynoncluster\n    def test_zinterstore_sum(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 1, \"a3\": 1})\n        r.zadd(\"b\", {\"a1\": 2, \"a2\": 2, \"a3\": 2})\n        r.zadd(\"c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert r.zinterstore(\"d\", [\"a\", \"b\", \"c\"]) == 2\n        assert_resp_response(\n            r,\n            r.zrange(\"d\", 0, -1, withscores=True),\n            [(b\"a3\", 8), (b\"a1\", 9)],\n            [[b\"a3\", 8], [b\"a1\", 9]],\n        )\n\n    @pytest.mark.onlynoncluster\n    def test_zinterstore_max(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 1, \"a3\": 1})\n        r.zadd(\"b\", {\"a1\": 2, \"a2\": 2, \"a3\": 2})\n        r.zadd(\"c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert r.zinterstore(\"d\", [\"a\", \"b\", \"c\"], aggregate=\"MAX\") == 2\n        assert_resp_response(\n            r,\n            r.zrange(\"d\", 0, -1, withscores=True),\n            [(b\"a3\", 5), (b\"a1\", 6)],\n            [[b\"a3\", 5], [b\"a1\", 6]],\n        )\n\n    @pytest.mark.onlynoncluster\n    def test_zinterstore_min(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        r.zadd(\"b\", {\"a1\": 2, \"a2\": 3, \"a3\": 5})\n        r.zadd(\"c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert r.zinterstore(\"d\", [\"a\", \"b\", \"c\"], aggregate=\"MIN\") == 2\n        assert_resp_response(\n            r,\n            r.zrange(\"d\", 0, -1, withscores=True),\n            [(b\"a1\", 1), (b\"a3\", 3)],\n            [[b\"a1\", 1], [b\"a3\", 3]],\n        )\n\n    @pytest.mark.onlynoncluster\n    def test_zinterstore_with_weight(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 1, \"a3\": 1})\n        r.zadd(\"b\", {\"a1\": 2, \"a2\": 2, \"a3\": 2})\n        r.zadd(\"c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert r.zinterstore(\"d\", {\"a\": 1, \"b\": 2, \"c\": 3}) == 2\n        assert_resp_response(\n            r,\n            r.zrange(\"d\", 0, -1, withscores=True),\n            [(b\"a3\", 20), (b\"a1\", 23)],\n            [[b\"a3\", 20], [b\"a1\", 23]],\n        )\n\n    @skip_if_server_version_lt(\"4.9.0\")\n    def test_zpopmax(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        assert_resp_response(r, r.zpopmax(\"a\"), [(b\"a3\", 3)], [b\"a3\", 3.0])\n        # with count\n        assert_resp_response(\n            r,\n            r.zpopmax(\"a\", count=2),\n            [(b\"a2\", 2), (b\"a1\", 1)],\n            [[b\"a2\", 2], [b\"a1\", 1]],\n        )\n\n    @skip_if_server_version_lt(\"4.9.0\")\n    def test_zpopmin(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        assert_resp_response(r, r.zpopmin(\"a\"), [(b\"a1\", 1)], [b\"a1\", 1.0])\n        # with count\n        assert_resp_response(\n            r,\n            r.zpopmin(\"a\", count=2),\n            [(b\"a2\", 2), (b\"a3\", 3)],\n            [[b\"a2\", 2], [b\"a3\", 3]],\n        )\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_zrandemember(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3, \"a4\": 4, \"a5\": 5})\n        assert r.zrandmember(\"a\") is not None\n        assert len(r.zrandmember(\"a\", 2)) == 2\n        # with scores\n        assert_resp_response(\n            r,\n            len(r.zrandmember(\"a\", 2, withscores=True)),\n            4,\n            2,\n        )\n        # without duplications\n        assert len(r.zrandmember(\"a\", 10)) == 5\n        # with duplications\n        assert len(r.zrandmember(\"a\", -10)) == 10\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"4.9.0\")\n    def test_bzpopmax(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 2})\n        r.zadd(\"b\", {\"b1\": 10, \"b2\": 20})\n        assert_resp_response(\n            r, r.bzpopmax([\"b\", \"a\"], timeout=1), (b\"b\", b\"b2\", 20), [b\"b\", b\"b2\", 20]\n        )\n        assert_resp_response(\n            r, r.bzpopmax([\"b\", \"a\"], timeout=1), (b\"b\", b\"b1\", 10), [b\"b\", b\"b1\", 10]\n        )\n        assert_resp_response(\n            r, r.bzpopmax([\"b\", \"a\"], timeout=1), (b\"a\", b\"a2\", 2), [b\"a\", b\"a2\", 2]\n        )\n        assert_resp_response(\n            r, r.bzpopmax([\"b\", \"a\"], timeout=1), (b\"a\", b\"a1\", 1), [b\"a\", b\"a1\", 1]\n        )\n        assert r.bzpopmax([\"b\", \"a\"], timeout=1) is None\n        r.zadd(\"c\", {\"c1\": 100})\n        assert_resp_response(\n            r, r.bzpopmax(\"c\", timeout=1), (b\"c\", b\"c1\", 100), [b\"c\", b\"c1\", 100]\n        )\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"4.9.0\")\n    def test_bzpopmin(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 2})\n        r.zadd(\"b\", {\"b1\": 10, \"b2\": 20})\n        assert_resp_response(\n            r, r.bzpopmin([\"b\", \"a\"], timeout=1), (b\"b\", b\"b1\", 10), [b\"b\", b\"b1\", 10]\n        )\n        assert_resp_response(\n            r, r.bzpopmin([\"b\", \"a\"], timeout=1), (b\"b\", b\"b2\", 20), [b\"b\", b\"b2\", 20]\n        )\n        assert_resp_response(\n            r, r.bzpopmin([\"b\", \"a\"], timeout=1), (b\"a\", b\"a1\", 1), [b\"a\", b\"a1\", 1]\n        )\n        assert_resp_response(\n            r, r.bzpopmin([\"b\", \"a\"], timeout=1), (b\"a\", b\"a2\", 2), [b\"a\", b\"a2\", 2]\n        )\n        assert r.bzpopmin([\"b\", \"a\"], timeout=1) is None\n        r.zadd(\"c\", {\"c1\": 100})\n        assert_resp_response(\n            r, r.bzpopmin(\"c\", timeout=1), (b\"c\", b\"c1\", 100), [b\"c\", b\"c1\", 100]\n        )\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_zmpop(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        assert_resp_response(\n            r,\n            r.zmpop(\"2\", [\"b\", \"a\"], min=True, count=2),\n            [b\"a\", [[b\"a1\", b\"1\"], [b\"a2\", b\"2\"]]],\n            [b\"a\", [[b\"a1\", 1.0], [b\"a2\", 2.0]]],\n        )\n        with pytest.raises(redis.DataError):\n            r.zmpop(\"2\", [\"b\", \"a\"], count=2)\n        r.zadd(\"b\", {\"b1\": 10, \"ab\": 9, \"b3\": 8})\n        assert_resp_response(\n            r,\n            r.zmpop(\"2\", [\"b\", \"a\"], max=True),\n            [b\"b\", [[b\"b1\", b\"10\"]]],\n            [b\"b\", [[b\"b1\", 10.0]]],\n        )\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_bzmpop(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        assert_resp_response(\n            r,\n            r.bzmpop(1, \"2\", [\"b\", \"a\"], min=True, count=2),\n            [b\"a\", [[b\"a1\", b\"1\"], [b\"a2\", b\"2\"]]],\n            [b\"a\", [[b\"a1\", 1.0], [b\"a2\", 2.0]]],\n        )\n        with pytest.raises(redis.DataError):\n            r.bzmpop(1, \"2\", [\"b\", \"a\"], count=2)\n        r.zadd(\"b\", {\"b1\": 10, \"ab\": 9, \"b3\": 8})\n        assert_resp_response(\n            r,\n            r.bzmpop(0, \"2\", [\"b\", \"a\"], max=True),\n            [b\"b\", [[b\"b1\", b\"10\"]]],\n            [b\"b\", [[b\"b1\", 10.0]]],\n        )\n        assert r.bzmpop(1, \"2\", [\"foo\", \"bar\"], max=True) is None\n\n    def test_zrange(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        assert r.zrange(\"a\", 0, 1) == [b\"a1\", b\"a2\"]\n        assert r.zrange(\"a\", 1, 2) == [b\"a2\", b\"a3\"]\n        assert r.zrange(\"a\", 0, 2) == [b\"a1\", b\"a2\", b\"a3\"]\n        assert r.zrange(\"a\", 0, 2, desc=True) == [b\"a3\", b\"a2\", b\"a1\"]\n\n        # withscores\n        assert_resp_response(\n            r,\n            r.zrange(\"a\", 0, 1, withscores=True),\n            [(b\"a1\", 1.0), (b\"a2\", 2.0)],\n            [[b\"a1\", 1.0], [b\"a2\", 2.0]],\n        )\n        assert_resp_response(\n            r,\n            r.zrange(\"a\", 1, 2, withscores=True),\n            [(b\"a2\", 2.0), (b\"a3\", 3.0)],\n            [[b\"a2\", 2.0], [b\"a3\", 3.0]],\n        )\n\n        # custom score cast function\n        assert_resp_response(\n            r,\n            r.zrange(\"a\", 0, 1, withscores=True, score_cast_func=safe_str),\n            [(b\"a1\", \"1\"), (b\"a2\", \"2\")],\n            [[b\"a1\", \"1.0\"], [b\"a2\", \"2.0\"]],\n        )\n\n    def test_zrange_errors(self, r):\n        with pytest.raises(exceptions.DataError):\n            r.zrange(\"a\", 0, 1, byscore=True, bylex=True)\n        with pytest.raises(exceptions.DataError):\n            r.zrange(\"a\", 0, 1, bylex=True, withscores=True)\n        with pytest.raises(exceptions.DataError):\n            r.zrange(\"a\", 0, 1, byscore=True, withscores=True, offset=4)\n        with pytest.raises(exceptions.DataError):\n            r.zrange(\"a\", 0, 1, byscore=True, withscores=True, num=2)\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_zrange_params(self, r):\n        # bylex\n        r.zadd(\"a\", {\"a\": 0, \"b\": 0, \"c\": 0, \"d\": 0, \"e\": 0, \"f\": 0, \"g\": 0})\n        assert r.zrange(\"a\", \"[aaa\", \"(g\", bylex=True) == [b\"b\", b\"c\", b\"d\", b\"e\", b\"f\"]\n        assert r.zrange(\"a\", \"[f\", \"+\", bylex=True) == [b\"f\", b\"g\"]\n        assert r.zrange(\"a\", \"+\", \"[f\", desc=True, bylex=True) == [b\"g\", b\"f\"]\n        assert r.zrange(\"a\", \"-\", \"+\", bylex=True, offset=3, num=2) == [b\"d\", b\"e\"]\n        assert r.zrange(\"a\", \"+\", \"-\", desc=True, bylex=True, offset=3, num=2) == [\n            b\"d\",\n            b\"c\",\n        ]\n\n        # byscore\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3, \"a4\": 4, \"a5\": 5})\n        assert r.zrange(\"a\", 2, 4, byscore=True, offset=1, num=2) == [b\"a3\", b\"a4\"]\n        assert r.zrange(\"a\", 4, 2, desc=True, byscore=True, offset=1, num=2) == [\n            b\"a3\",\n            b\"a2\",\n        ]\n        assert_resp_response(\n            r,\n            r.zrange(\"a\", 2, 4, byscore=True, withscores=True),\n            [(b\"a2\", 2.0), (b\"a3\", 3.0), (b\"a4\", 4.0)],\n            [[b\"a2\", 2.0], [b\"a3\", 3.0], [b\"a4\", 4.0]],\n        )\n        assert_resp_response(\n            r,\n            r.zrange(\n                \"a\", 4, 2, desc=True, byscore=True, withscores=True, score_cast_func=int\n            ),\n            [(b\"a4\", 4), (b\"a3\", 3), (b\"a2\", 2)],\n            [[b\"a4\", 4], [b\"a3\", 3], [b\"a2\", 2]],\n        )\n\n        # rev\n        assert r.zrange(\"a\", 0, 1, desc=True) == [b\"a5\", b\"a4\"]\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_zrangestore(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        assert r.zrangestore(\"b\", \"a\", 0, 1)\n        assert r.zrange(\"b\", 0, -1) == [b\"a1\", b\"a2\"]\n        assert r.zrangestore(\"b\", \"a\", 1, 2)\n        assert r.zrange(\"b\", 0, -1) == [b\"a2\", b\"a3\"]\n        assert_resp_response(\n            r,\n            r.zrange(\"b\", 0, -1, withscores=True),\n            [(b\"a2\", 2), (b\"a3\", 3)],\n            [[b\"a2\", 2], [b\"a3\", 3]],\n        )\n        # reversed order\n        assert r.zrangestore(\"b\", \"a\", 1, 2, desc=True)\n        assert r.zrange(\"b\", 0, -1) == [b\"a1\", b\"a2\"]\n        # by score\n        assert r.zrangestore(\"b\", \"a\", 2, 1, byscore=True, offset=0, num=1, desc=True)\n        assert r.zrange(\"b\", 0, -1) == [b\"a2\"]\n        # by lex\n        assert r.zrangestore(\"b\", \"a\", \"[a2\", \"(a3\", bylex=True, offset=0, num=1)\n        assert r.zrange(\"b\", 0, -1) == [b\"a2\"]\n\n    @skip_if_server_version_lt(\"2.8.9\")\n    def test_zrangebylex(self, r):\n        r.zadd(\"a\", {\"a\": 0, \"b\": 0, \"c\": 0, \"d\": 0, \"e\": 0, \"f\": 0, \"g\": 0})\n        assert r.zrangebylex(\"a\", \"-\", \"[c\") == [b\"a\", b\"b\", b\"c\"]\n        assert r.zrangebylex(\"a\", \"-\", \"(c\") == [b\"a\", b\"b\"]\n        assert r.zrangebylex(\"a\", \"[aaa\", \"(g\") == [b\"b\", b\"c\", b\"d\", b\"e\", b\"f\"]\n        assert r.zrangebylex(\"a\", \"[f\", \"+\") == [b\"f\", b\"g\"]\n        assert r.zrangebylex(\"a\", \"-\", \"+\", start=3, num=2) == [b\"d\", b\"e\"]\n\n    @skip_if_server_version_lt(\"2.9.9\")\n    def test_zrevrangebylex(self, r):\n        r.zadd(\"a\", {\"a\": 0, \"b\": 0, \"c\": 0, \"d\": 0, \"e\": 0, \"f\": 0, \"g\": 0})\n        assert r.zrevrangebylex(\"a\", \"[c\", \"-\") == [b\"c\", b\"b\", b\"a\"]\n        assert r.zrevrangebylex(\"a\", \"(c\", \"-\") == [b\"b\", b\"a\"]\n        assert r.zrevrangebylex(\"a\", \"(g\", \"[aaa\") == [b\"f\", b\"e\", b\"d\", b\"c\", b\"b\"]\n        assert r.zrevrangebylex(\"a\", \"+\", \"[f\") == [b\"g\", b\"f\"]\n        assert r.zrevrangebylex(\"a\", \"+\", \"-\", start=3, num=2) == [b\"d\", b\"c\"]\n\n    def test_zrangebyscore(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3, \"a4\": 4, \"a5\": 5})\n        assert r.zrangebyscore(\"a\", 2, 4) == [b\"a2\", b\"a3\", b\"a4\"]\n        # slicing with start/num\n        assert r.zrangebyscore(\"a\", 2, 4, start=1, num=2) == [b\"a3\", b\"a4\"]\n        # withscores\n        assert_resp_response(\n            r,\n            r.zrangebyscore(\"a\", 2, 4, withscores=True),\n            [(b\"a2\", 2.0), (b\"a3\", 3.0), (b\"a4\", 4.0)],\n            [[b\"a2\", 2.0], [b\"a3\", 3.0], [b\"a4\", 4.0]],\n        )\n        assert_resp_response(\n            r,\n            r.zrangebyscore(\"a\", 2, 4, withscores=True, score_cast_func=int),\n            [(b\"a2\", 2), (b\"a3\", 3), (b\"a4\", 4)],\n            [[b\"a2\", 2], [b\"a3\", 3], [b\"a4\", 4]],\n        )\n        # custom score cast function\n        assert_resp_response(\n            r,\n            r.zrangebyscore(\"a\", 2, 4, withscores=True, score_cast_func=safe_str),\n            [(b\"a2\", \"2\"), (b\"a3\", \"3\"), (b\"a4\", \"4\")],\n            [[b\"a2\", \"2.0\"], [b\"a3\", \"3.0\"], [b\"a4\", \"4.0\"]],\n        )\n\n    def test_zrank(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3, \"a4\": 4, \"a5\": 5})\n        assert r.zrank(\"a\", \"a1\") == 0\n        assert r.zrank(\"a\", \"a2\") == 1\n        assert r.zrank(\"a\", \"a6\") is None\n\n    @skip_if_server_version_lt(\"7.2.0\")\n    def test_zrank_withscore(self, r: redis.Redis):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3, \"a4\": 4, \"a5\": 5})\n        assert r.zrank(\"a\", \"a1\") == 0\n        assert r.zrank(\"a\", \"a2\") == 1\n        assert r.zrank(\"a\", \"a6\") is None\n        assert_resp_response(r, r.zrank(\"a\", \"a3\", withscore=True), [2, 3.0], [2, 3.0])\n        assert r.zrank(\"a\", \"a6\", withscore=True) is None\n\n        # custom score cast function\n        assert_resp_response(\n            r,\n            r.zrank(\"a\", \"a3\", withscore=True, score_cast_func=safe_str),\n            [2, \"3\"],\n            [2, \"3.0\"],\n        )\n\n    def test_zrem(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        assert r.zrem(\"a\", \"a2\") == 1\n        assert r.zrange(\"a\", 0, -1) == [b\"a1\", b\"a3\"]\n        assert r.zrem(\"a\", \"b\") == 0\n        assert r.zrange(\"a\", 0, -1) == [b\"a1\", b\"a3\"]\n\n    def test_zrem_multiple_keys(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        assert r.zrem(\"a\", \"a1\", \"a2\") == 2\n        assert r.zrange(\"a\", 0, 5) == [b\"a3\"]\n\n    @skip_if_server_version_lt(\"2.8.9\")\n    def test_zremrangebylex(self, r):\n        r.zadd(\"a\", {\"a\": 0, \"b\": 0, \"c\": 0, \"d\": 0, \"e\": 0, \"f\": 0, \"g\": 0})\n        assert r.zremrangebylex(\"a\", \"-\", \"[c\") == 3\n        assert r.zrange(\"a\", 0, -1) == [b\"d\", b\"e\", b\"f\", b\"g\"]\n        assert r.zremrangebylex(\"a\", \"[f\", \"+\") == 2\n        assert r.zrange(\"a\", 0, -1) == [b\"d\", b\"e\"]\n        assert r.zremrangebylex(\"a\", \"[h\", \"+\") == 0\n        assert r.zrange(\"a\", 0, -1) == [b\"d\", b\"e\"]\n\n    def test_zremrangebyrank(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3, \"a4\": 4, \"a5\": 5})\n        assert r.zremrangebyrank(\"a\", 1, 3) == 3\n        assert r.zrange(\"a\", 0, 5) == [b\"a1\", b\"a5\"]\n\n    def test_zremrangebyscore(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3, \"a4\": 4, \"a5\": 5})\n        assert r.zremrangebyscore(\"a\", 2, 4) == 3\n        assert r.zrange(\"a\", 0, -1) == [b\"a1\", b\"a5\"]\n        assert r.zremrangebyscore(\"a\", 2, 4) == 0\n        assert r.zrange(\"a\", 0, -1) == [b\"a1\", b\"a5\"]\n\n    def test_zrevrange(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        assert r.zrevrange(\"a\", 0, 1) == [b\"a3\", b\"a2\"]\n        assert r.zrevrange(\"a\", 1, 2) == [b\"a2\", b\"a1\"]\n\n        # withscores\n        assert_resp_response(\n            r,\n            r.zrevrange(\"a\", 0, 1, withscores=True),\n            [(b\"a3\", 3.0), (b\"a2\", 2.0)],\n            [[b\"a3\", 3.0], [b\"a2\", 2.0]],\n        )\n        assert_resp_response(\n            r,\n            r.zrevrange(\"a\", 1, 2, withscores=True),\n            [(b\"a2\", 2.0), (b\"a1\", 1.0)],\n            [[b\"a2\", 2.0], [b\"a1\", 1.0]],\n        )\n\n        # custom score cast function\n        # should be applied to resp2 and resp3\n        # responses\n        assert_resp_response(\n            r,\n            r.zrevrange(\"a\", 0, 1, withscores=True, score_cast_func=safe_str),\n            [(b\"a3\", \"3\"), (b\"a2\", \"2\")],\n            [[b\"a3\", \"3.0\"], [b\"a2\", \"2.0\"]],\n        )\n\n    def test_zrevrangebyscore(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3, \"a4\": 4, \"a5\": 5})\n        assert r.zrevrangebyscore(\"a\", 4, 2) == [b\"a4\", b\"a3\", b\"a2\"]\n        # slicing with start/num\n        assert r.zrevrangebyscore(\"a\", 4, 2, start=1, num=2) == [b\"a3\", b\"a2\"]\n\n        # withscores\n        assert_resp_response(\n            r,\n            r.zrevrangebyscore(\"a\", 4, 2, withscores=True),\n            [(b\"a4\", 4.0), (b\"a3\", 3.0), (b\"a2\", 2.0)],\n            [[b\"a4\", 4.0], [b\"a3\", 3.0], [b\"a2\", 2.0]],\n        )\n        # custom score type cast function\n        assert_resp_response(\n            r,\n            r.zrevrangebyscore(\"a\", 4, 2, withscores=True, score_cast_func=int),\n            [(b\"a4\", 4.0), (b\"a3\", 3.0), (b\"a2\", 2.0)],\n            [[b\"a4\", 4.0], [b\"a3\", 3.0], [b\"a2\", 2.0]],\n        )\n        # custom score cast function\n        assert_resp_response(\n            r,\n            r.zrevrangebyscore(\"a\", 4, 2, withscores=True, score_cast_func=safe_str),\n            [(b\"a4\", \"4\"), (b\"a3\", \"3\"), (b\"a2\", \"2\")],\n            [[b\"a4\", \"4.0\"], [b\"a3\", \"3.0\"], [b\"a2\", \"2.0\"]],\n        )\n\n    def test_zrevrank(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3, \"a4\": 4, \"a5\": 5})\n        assert r.zrevrank(\"a\", \"a1\") == 4\n        assert r.zrevrank(\"a\", \"a2\") == 3\n        assert r.zrevrank(\"a\", \"a6\") is None\n\n    @skip_if_server_version_lt(\"7.2.0\")\n    def test_zrevrank_withscore(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3, \"a4\": 4, \"a5\": 5})\n        assert r.zrevrank(\"a\", \"a1\") == 4\n        assert r.zrevrank(\"a\", \"a2\") == 3\n        assert r.zrevrank(\"a\", \"a6\") is None\n        assert_resp_response(\n            r, r.zrevrank(\"a\", \"a3\", withscore=True), [2, 3.0], [2, 3.0]\n        )\n        assert r.zrevrank(\"a\", \"a6\", withscore=True) is None\n\n        # custom score cast function\n        assert_resp_response(\n            r,\n            r.zrevrank(\"a\", \"a3\", withscore=True, score_cast_func=safe_str),\n            [2, \"3\"],\n            [2, \"3.0\"],\n        )\n\n    def test_zscore(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        assert r.zscore(\"a\", \"a1\") == 1.0\n        assert r.zscore(\"a\", \"a2\") == 2.0\n        assert r.zscore(\"a\", \"a4\") is None\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_zunion(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 1, \"a3\": 1})\n        r.zadd(\"b\", {\"a1\": 2, \"a2\": 2, \"a3\": 2})\n        r.zadd(\"c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        # sum\n        assert r.zunion([\"a\", \"b\", \"c\"]) == [b\"a2\", b\"a4\", b\"a3\", b\"a1\"]\n        assert_resp_response(\n            r,\n            r.zunion([\"a\", \"b\", \"c\"], withscores=True),\n            [(b\"a2\", 3), (b\"a4\", 4), (b\"a3\", 8), (b\"a1\", 9)],\n            [[b\"a2\", 3], [b\"a4\", 4], [b\"a3\", 8], [b\"a1\", 9]],\n        )\n        # max\n        assert_resp_response(\n            r,\n            r.zunion([\"a\", \"b\", \"c\"], aggregate=\"MAX\", withscores=True),\n            [(b\"a2\", 2), (b\"a4\", 4), (b\"a3\", 5), (b\"a1\", 6)],\n            [[b\"a2\", 2], [b\"a4\", 4], [b\"a3\", 5], [b\"a1\", 6]],\n        )\n        # min\n        assert_resp_response(\n            r,\n            r.zunion([\"a\", \"b\", \"c\"], aggregate=\"MIN\", withscores=True),\n            [(b\"a1\", 1), (b\"a2\", 1), (b\"a3\", 1), (b\"a4\", 4)],\n            [[b\"a1\", 1], [b\"a2\", 1], [b\"a3\", 1], [b\"a4\", 4]],\n        )\n        # with weight\n        assert_resp_response(\n            r,\n            r.zunion({\"a\": 1, \"b\": 2, \"c\": 3}, withscores=True),\n            [(b\"a2\", 5), (b\"a4\", 12), (b\"a3\", 20), (b\"a1\", 23)],\n            [[b\"a2\", 5], [b\"a4\", 12], [b\"a3\", 20], [b\"a1\", 23]],\n        )\n        # with custom score cast function\n        assert_resp_response(\n            r,\n            r.zunion([\"a\", \"b\", \"c\"], withscores=True, score_cast_func=safe_str),\n            [(b\"a2\", \"3\"), (b\"a4\", \"4\"), (b\"a3\", \"8\"), (b\"a1\", \"9\")],\n            [[b\"a2\", \"3.0\"], [b\"a4\", \"4.0\"], [b\"a3\", \"8.0\"], [b\"a1\", \"9.0\"]],\n        )\n\n    @pytest.mark.onlynoncluster\n    def test_zunionstore_sum(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 1, \"a3\": 1})\n        r.zadd(\"b\", {\"a1\": 2, \"a2\": 2, \"a3\": 2})\n        r.zadd(\"c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert r.zunionstore(\"d\", [\"a\", \"b\", \"c\"]) == 4\n        assert_resp_response(\n            r,\n            r.zrange(\"d\", 0, -1, withscores=True),\n            [(b\"a2\", 3), (b\"a4\", 4), (b\"a3\", 8), (b\"a1\", 9)],\n            [[b\"a2\", 3], [b\"a4\", 4], [b\"a3\", 8], [b\"a1\", 9]],\n        )\n\n    @pytest.mark.onlynoncluster\n    def test_zunionstore_max(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 1, \"a3\": 1})\n        r.zadd(\"b\", {\"a1\": 2, \"a2\": 2, \"a3\": 2})\n        r.zadd(\"c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert r.zunionstore(\"d\", [\"a\", \"b\", \"c\"], aggregate=\"MAX\") == 4\n        assert_resp_response(\n            r,\n            r.zrange(\"d\", 0, -1, withscores=True),\n            [(b\"a2\", 2), (b\"a4\", 4), (b\"a3\", 5), (b\"a1\", 6)],\n            [[b\"a2\", 2], [b\"a4\", 4], [b\"a3\", 5], [b\"a1\", 6]],\n        )\n\n    @pytest.mark.onlynoncluster\n    def test_zunionstore_min(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3})\n        r.zadd(\"b\", {\"a1\": 2, \"a2\": 2, \"a3\": 4})\n        r.zadd(\"c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert r.zunionstore(\"d\", [\"a\", \"b\", \"c\"], aggregate=\"MIN\") == 4\n        assert_resp_response(\n            r,\n            r.zrange(\"d\", 0, -1, withscores=True),\n            [(b\"a1\", 1), (b\"a2\", 2), (b\"a3\", 3), (b\"a4\", 4)],\n            [[b\"a1\", 1], [b\"a2\", 2], [b\"a3\", 3], [b\"a4\", 4]],\n        )\n\n    @pytest.mark.onlynoncluster\n    def test_zunionstore_with_weight(self, r):\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 1, \"a3\": 1})\n        r.zadd(\"b\", {\"a1\": 2, \"a2\": 2, \"a3\": 2})\n        r.zadd(\"c\", {\"a1\": 6, \"a3\": 5, \"a4\": 4})\n        assert r.zunionstore(\"d\", {\"a\": 1, \"b\": 2, \"c\": 3}) == 4\n        assert_resp_response(\n            r,\n            r.zrange(\"d\", 0, -1, withscores=True),\n            [(b\"a2\", 5), (b\"a4\", 12), (b\"a3\", 20), (b\"a1\", 23)],\n            [[b\"a2\", 5], [b\"a4\", 12], [b\"a3\", 20], [b\"a1\", 23]],\n        )\n\n    @skip_if_server_version_lt(\"6.1.240\")\n    def test_zmscore(self, r):\n        with pytest.raises(exceptions.DataError):\n            r.zmscore(\"invalid_key\", [])\n\n        assert r.zmscore(\"invalid_key\", [\"invalid_member\"]) == [None]\n\n        r.zadd(\"a\", {\"a1\": 1, \"a2\": 2, \"a3\": 3.5})\n        assert r.zmscore(\"a\", [\"a1\", \"a2\", \"a3\", \"a4\"]) == [1.0, 2.0, 3.5, None]\n\n    # HYPERLOGLOG TESTS\n    @skip_if_server_version_lt(\"2.8.9\")\n    def test_pfadd(self, r):\n        members = {b\"1\", b\"2\", b\"3\"}\n        assert r.pfadd(\"a\", *members) == 1\n        assert r.pfadd(\"a\", *members) == 0\n        assert r.pfcount(\"a\") == len(members)\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"2.8.9\")\n    def test_pfcount(self, r):\n        members = {b\"1\", b\"2\", b\"3\"}\n        r.pfadd(\"a\", *members)\n        assert r.pfcount(\"a\") == len(members)\n        members_b = {b\"2\", b\"3\", b\"4\"}\n        r.pfadd(\"b\", *members_b)\n        assert r.pfcount(\"b\") == len(members_b)\n        assert r.pfcount(\"a\", \"b\") == len(members_b.union(members))\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"2.8.9\")\n    def test_pfmerge(self, r):\n        mema = {b\"1\", b\"2\", b\"3\"}\n        memb = {b\"2\", b\"3\", b\"4\"}\n        memc = {b\"5\", b\"6\", b\"7\"}\n        r.pfadd(\"a\", *mema)\n        r.pfadd(\"b\", *memb)\n        r.pfadd(\"c\", *memc)\n        r.pfmerge(\"d\", \"c\", \"a\")\n        assert r.pfcount(\"d\") == 6\n        r.pfmerge(\"d\", \"b\")\n        assert r.pfcount(\"d\") == 7\n\n    # HASH COMMANDS\n    def test_hget_and_hset(self, r):\n        r.hset(\"a\", mapping={\"1\": 1, \"2\": 2, \"3\": 3})\n        assert r.hget(\"a\", \"1\") == b\"1\"\n        assert r.hget(\"a\", \"2\") == b\"2\"\n        assert r.hget(\"a\", \"3\") == b\"3\"\n\n        # field was updated, redis returns 0\n        assert r.hset(\"a\", \"2\", 5) == 0\n        assert r.hget(\"a\", \"2\") == b\"5\"\n\n        # field is new, redis returns 1\n        assert r.hset(\"a\", \"4\", 4) == 1\n        assert r.hget(\"a\", \"4\") == b\"4\"\n\n        # key inside of hash that doesn't exist returns null value\n        assert r.hget(\"a\", \"b\") is None\n\n        # keys with bool(key) == False\n        assert r.hset(\"a\", 0, 10) == 1\n        assert r.hset(\"a\", \"\", 10) == 1\n\n    def test_hset_with_multi_key_values(self, r):\n        r.hset(\"a\", mapping={\"1\": 1, \"2\": 2, \"3\": 3})\n        assert r.hget(\"a\", \"1\") == b\"1\"\n        assert r.hget(\"a\", \"2\") == b\"2\"\n        assert r.hget(\"a\", \"3\") == b\"3\"\n\n        r.hset(\"b\", \"foo\", \"bar\", mapping={\"1\": 1, \"2\": 2})\n        assert r.hget(\"b\", \"1\") == b\"1\"\n        assert r.hget(\"b\", \"2\") == b\"2\"\n        assert r.hget(\"b\", \"foo\") == b\"bar\"\n\n    def test_hset_with_key_values_passed_as_list(self, r):\n        r.hset(\"a\", items=[\"1\", 1, \"2\", 2, \"3\", 3])\n        assert r.hget(\"a\", \"1\") == b\"1\"\n        assert r.hget(\"a\", \"2\") == b\"2\"\n        assert r.hget(\"a\", \"3\") == b\"3\"\n\n        r.hset(\"b\", \"foo\", \"bar\", items=[\"1\", 1, \"2\", 2])\n        assert r.hget(\"b\", \"1\") == b\"1\"\n        assert r.hget(\"b\", \"2\") == b\"2\"\n        assert r.hget(\"b\", \"foo\") == b\"bar\"\n\n    def test_hset_without_data(self, r):\n        with pytest.raises(exceptions.DataError):\n            r.hset(\"x\")\n\n    def test_hdel(self, r):\n        r.hset(\"a\", mapping={\"1\": 1, \"2\": 2, \"3\": 3})\n        assert r.hdel(\"a\", \"2\") == 1\n        assert r.hget(\"a\", \"2\") is None\n        assert r.hdel(\"a\", \"1\", \"3\") == 2\n        assert r.hlen(\"a\") == 0\n\n    def test_hexists(self, r):\n        r.hset(\"a\", mapping={\"1\": 1, \"2\": 2, \"3\": 3})\n        assert r.hexists(\"a\", \"1\")\n        assert not r.hexists(\"a\", \"4\")\n\n    def test_hgetall(self, r):\n        h = {b\"a1\": b\"1\", b\"a2\": b\"2\", b\"a3\": b\"3\"}\n        r.hset(\"a\", mapping=h)\n        assert r.hgetall(\"a\") == h\n\n    def test_hincrby(self, r):\n        assert r.hincrby(\"a\", \"1\") == 1\n        assert r.hincrby(\"a\", \"1\", amount=2) == 3\n        assert r.hincrby(\"a\", \"1\", amount=-2) == 1\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    def test_hincrbyfloat(self, r):\n        assert r.hincrbyfloat(\"a\", \"1\") == 1.0\n        assert r.hincrbyfloat(\"a\", \"1\") == 2.0\n        assert r.hincrbyfloat(\"a\", \"1\", 1.2) == 3.2\n\n    def test_hkeys(self, r):\n        h = {b\"a1\": b\"1\", b\"a2\": b\"2\", b\"a3\": b\"3\"}\n        r.hset(\"a\", mapping=h)\n        local_keys = list(h.keys())\n        remote_keys = r.hkeys(\"a\")\n        assert sorted(local_keys) == sorted(remote_keys)\n\n    def test_hlen(self, r):\n        r.hset(\"a\", mapping={\"1\": 1, \"2\": 2, \"3\": 3})\n        assert r.hlen(\"a\") == 3\n\n    def test_hmget(self, r):\n        assert r.hset(\"a\", mapping={\"a\": 1, \"b\": 2, \"c\": 3})\n        assert r.hmget(\"a\", \"a\", \"b\", \"c\") == [b\"1\", b\"2\", b\"3\"]\n\n    def test_hmset(self, r):\n        h = {b\"a\": b\"1\", b\"b\": b\"2\", b\"c\": b\"3\"}\n        with pytest.warns(DeprecationWarning):\n            assert r.hmset(\"a\", h)\n        assert r.hgetall(\"a\") == h\n\n    def test_hsetnx(self, r):\n        # Initially set the hash field\n        assert r.hsetnx(\"a\", \"1\", 1)\n        assert r.hget(\"a\", \"1\") == b\"1\"\n        assert not r.hsetnx(\"a\", \"1\", 2)\n        assert r.hget(\"a\", \"1\") == b\"1\"\n\n    def test_hvals(self, r):\n        h = {b\"a1\": b\"1\", b\"a2\": b\"2\", b\"a3\": b\"3\"}\n        r.hset(\"a\", mapping=h)\n        local_vals = list(h.values())\n        remote_vals = r.hvals(\"a\")\n        assert sorted(local_vals) == sorted(remote_vals)\n\n    @skip_if_server_version_lt(\"3.2.0\")\n    def test_hstrlen(self, r):\n        r.hset(\"a\", mapping={\"1\": \"22\", \"2\": \"333\"})\n        assert r.hstrlen(\"a\", \"1\") == 2\n        assert r.hstrlen(\"a\", \"2\") == 3\n\n    # SORT\n    def test_sort_basic(self, r):\n        r.rpush(\"a\", \"3\", \"2\", \"1\", \"4\")\n        assert r.sort(\"a\") == [b\"1\", b\"2\", b\"3\", b\"4\"]\n\n    def test_sort_limited(self, r):\n        r.rpush(\"a\", \"3\", \"2\", \"1\", \"4\")\n        assert r.sort(\"a\", start=1, num=2) == [b\"2\", b\"3\"]\n\n    @pytest.mark.onlynoncluster\n    def test_sort_by(self, r):\n        r[\"score:1\"] = 8\n        r[\"score:2\"] = 3\n        r[\"score:3\"] = 5\n        r.rpush(\"a\", \"3\", \"2\", \"1\")\n        assert r.sort(\"a\", by=\"score:*\") == [b\"2\", b\"3\", b\"1\"]\n\n    @pytest.mark.onlynoncluster\n    def test_sort_get(self, r):\n        r[\"user:1\"] = \"u1\"\n        r[\"user:2\"] = \"u2\"\n        r[\"user:3\"] = \"u3\"\n        r.rpush(\"a\", \"2\", \"3\", \"1\")\n        assert r.sort(\"a\", get=\"user:*\") == [b\"u1\", b\"u2\", b\"u3\"]\n\n    @pytest.mark.onlynoncluster\n    def test_sort_get_multi(self, r):\n        r[\"user:1\"] = \"u1\"\n        r[\"user:2\"] = \"u2\"\n        r[\"user:3\"] = \"u3\"\n        r.rpush(\"a\", \"2\", \"3\", \"1\")\n        assert r.sort(\"a\", get=(\"user:*\", \"#\")) == [\n            b\"u1\",\n            b\"1\",\n            b\"u2\",\n            b\"2\",\n            b\"u3\",\n            b\"3\",\n        ]\n\n    @pytest.mark.onlynoncluster\n    def test_sort_get_groups_two(self, r):\n        r[\"user:1\"] = \"u1\"\n        r[\"user:2\"] = \"u2\"\n        r[\"user:3\"] = \"u3\"\n        r.rpush(\"a\", \"2\", \"3\", \"1\")\n        assert r.sort(\"a\", get=(\"user:*\", \"#\"), groups=True) == [\n            (b\"u1\", b\"1\"),\n            (b\"u2\", b\"2\"),\n            (b\"u3\", b\"3\"),\n        ]\n\n    @pytest.mark.onlynoncluster\n    def test_sort_groups_string_get(self, r):\n        r[\"user:1\"] = \"u1\"\n        r[\"user:2\"] = \"u2\"\n        r[\"user:3\"] = \"u3\"\n        r.rpush(\"a\", \"2\", \"3\", \"1\")\n        with pytest.raises(exceptions.DataError):\n            r.sort(\"a\", get=\"user:*\", groups=True)\n\n    @pytest.mark.onlynoncluster\n    def test_sort_groups_just_one_get(self, r):\n        r[\"user:1\"] = \"u1\"\n        r[\"user:2\"] = \"u2\"\n        r[\"user:3\"] = \"u3\"\n        r.rpush(\"a\", \"2\", \"3\", \"1\")\n        with pytest.raises(exceptions.DataError):\n            r.sort(\"a\", get=[\"user:*\"], groups=True)\n\n    def test_sort_groups_no_get(self, r):\n        r[\"user:1\"] = \"u1\"\n        r[\"user:2\"] = \"u2\"\n        r[\"user:3\"] = \"u3\"\n        r.rpush(\"a\", \"2\", \"3\", \"1\")\n        with pytest.raises(exceptions.DataError):\n            r.sort(\"a\", groups=True)\n\n    @pytest.mark.onlynoncluster\n    def test_sort_groups_three_gets(self, r):\n        r[\"user:1\"] = \"u1\"\n        r[\"user:2\"] = \"u2\"\n        r[\"user:3\"] = \"u3\"\n        r[\"door:1\"] = \"d1\"\n        r[\"door:2\"] = \"d2\"\n        r[\"door:3\"] = \"d3\"\n        r.rpush(\"a\", \"2\", \"3\", \"1\")\n        assert r.sort(\"a\", get=(\"user:*\", \"door:*\", \"#\"), groups=True) == [\n            (b\"u1\", b\"d1\", b\"1\"),\n            (b\"u2\", b\"d2\", b\"2\"),\n            (b\"u3\", b\"d3\", b\"3\"),\n        ]\n\n    def test_sort_desc(self, r):\n        r.rpush(\"a\", \"2\", \"3\", \"1\")\n        assert r.sort(\"a\", desc=True) == [b\"3\", b\"2\", b\"1\"]\n\n    def test_sort_alpha(self, r):\n        r.rpush(\"a\", \"e\", \"c\", \"b\", \"d\", \"a\")\n        assert r.sort(\"a\", alpha=True) == [b\"a\", b\"b\", b\"c\", b\"d\", b\"e\"]\n\n    @pytest.mark.onlynoncluster\n    def test_sort_store(self, r):\n        r.rpush(\"a\", \"2\", \"3\", \"1\")\n        assert r.sort(\"a\", store=\"sorted_values\") == 3\n        assert r.lrange(\"sorted_values\", 0, -1) == [b\"1\", b\"2\", b\"3\"]\n\n    @pytest.mark.onlynoncluster\n    def test_sort_all_options(self, r):\n        r[\"user:1:username\"] = \"zeus\"\n        r[\"user:2:username\"] = \"titan\"\n        r[\"user:3:username\"] = \"hermes\"\n        r[\"user:4:username\"] = \"hercules\"\n        r[\"user:5:username\"] = \"apollo\"\n        r[\"user:6:username\"] = \"athena\"\n        r[\"user:7:username\"] = \"hades\"\n        r[\"user:8:username\"] = \"dionysus\"\n\n        r[\"user:1:favorite_drink\"] = \"yuengling\"\n        r[\"user:2:favorite_drink\"] = \"rum\"\n        r[\"user:3:favorite_drink\"] = \"vodka\"\n        r[\"user:4:favorite_drink\"] = \"milk\"\n        r[\"user:5:favorite_drink\"] = \"pinot noir\"\n        r[\"user:6:favorite_drink\"] = \"water\"\n        r[\"user:7:favorite_drink\"] = \"gin\"\n        r[\"user:8:favorite_drink\"] = \"apple juice\"\n\n        r.rpush(\"gods\", \"5\", \"8\", \"3\", \"1\", \"2\", \"7\", \"6\", \"4\")\n        num = r.sort(\n            \"gods\",\n            start=2,\n            num=4,\n            by=\"user:*:username\",\n            get=\"user:*:favorite_drink\",\n            desc=True,\n            alpha=True,\n            store=\"sorted\",\n        )\n        assert num == 4\n        assert r.lrange(\"sorted\", 0, 10) == [b\"vodka\", b\"milk\", b\"gin\", b\"apple juice\"]\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    @pytest.mark.onlynoncluster\n    def test_sort_ro(self, r):\n        r[\"score:1\"] = 8\n        r[\"score:2\"] = 3\n        r[\"score:3\"] = 5\n        r.rpush(\"a\", \"3\", \"2\", \"1\")\n        assert r.sort_ro(\"a\", by=\"score:*\") == [b\"2\", b\"3\", b\"1\"]\n        r.rpush(\"b\", \"2\", \"3\", \"1\")\n        assert r.sort_ro(\"b\", desc=True) == [b\"3\", b\"2\", b\"1\"]\n\n    def test_sort_issue_924(self, r):\n        # Tests for issue https://github.com/andymccurdy/redis-py/issues/924\n        r.execute_command(\"SADD\", \"issue#924\", 1)\n        r.execute_command(\"SORT\", \"issue#924\")\n\n    @pytest.mark.onlynoncluster\n    @skip_if_redis_enterprise()\n    def test_cluster_addslots(self, mock_cluster_resp_ok):\n        assert mock_cluster_resp_ok.cluster(\"ADDSLOTS\", 1) is True\n\n    @pytest.mark.onlynoncluster\n    @skip_if_redis_enterprise()\n    def test_cluster_count_failure_reports(self, mock_cluster_resp_int):\n        assert isinstance(\n            mock_cluster_resp_int.cluster(\"COUNT-FAILURE-REPORTS\", \"node\"), int\n        )\n\n    @pytest.mark.onlynoncluster\n    @skip_if_redis_enterprise()\n    def test_cluster_countkeysinslot(self, mock_cluster_resp_int):\n        assert isinstance(mock_cluster_resp_int.cluster(\"COUNTKEYSINSLOT\", 2), int)\n\n    @pytest.mark.onlynoncluster\n    @skip_if_redis_enterprise()\n    def test_cluster_delslots(self, mock_cluster_resp_ok):\n        assert mock_cluster_resp_ok.cluster(\"DELSLOTS\", 1) is True\n\n    @pytest.mark.onlynoncluster\n    @skip_if_redis_enterprise()\n    def test_cluster_failover(self, mock_cluster_resp_ok):\n        assert mock_cluster_resp_ok.cluster(\"FAILOVER\", 1) is True\n\n    @pytest.mark.onlynoncluster\n    @skip_if_redis_enterprise()\n    def test_cluster_forget(self, mock_cluster_resp_ok):\n        assert mock_cluster_resp_ok.cluster(\"FORGET\", 1) is True\n\n    @pytest.mark.onlynoncluster\n    @skip_if_redis_enterprise()\n    def test_cluster_info(self, mock_cluster_resp_info):\n        assert isinstance(mock_cluster_resp_info.cluster(\"info\"), dict)\n\n    @pytest.mark.onlynoncluster\n    @skip_if_redis_enterprise()\n    def test_cluster_keyslot(self, mock_cluster_resp_int):\n        assert isinstance(mock_cluster_resp_int.cluster(\"keyslot\", \"asdf\"), int)\n\n    @pytest.mark.onlynoncluster\n    @skip_if_redis_enterprise()\n    def test_cluster_meet(self, mock_cluster_resp_ok):\n        assert mock_cluster_resp_ok.cluster(\"meet\", \"ip\", \"port\", 1) is True\n\n    @pytest.mark.onlynoncluster\n    @skip_if_redis_enterprise()\n    def test_cluster_nodes(self, mock_cluster_resp_nodes):\n        assert isinstance(mock_cluster_resp_nodes.cluster(\"nodes\"), dict)\n\n    @pytest.mark.onlynoncluster\n    @skip_if_redis_enterprise()\n    def test_cluster_replicate(self, mock_cluster_resp_ok):\n        assert mock_cluster_resp_ok.cluster(\"replicate\", \"nodeid\") is True\n\n    @pytest.mark.onlynoncluster\n    @skip_if_redis_enterprise()\n    def test_cluster_reset(self, mock_cluster_resp_ok):\n        assert mock_cluster_resp_ok.cluster(\"reset\", \"hard\") is True\n\n    @pytest.mark.onlynoncluster\n    @skip_if_redis_enterprise()\n    def test_cluster_saveconfig(self, mock_cluster_resp_ok):\n        assert mock_cluster_resp_ok.cluster(\"saveconfig\") is True\n\n    @pytest.mark.onlynoncluster\n    @skip_if_redis_enterprise()\n    def test_cluster_setslot(self, mock_cluster_resp_ok):\n        assert mock_cluster_resp_ok.cluster(\"setslot\", 1, \"IMPORTING\", \"nodeid\") is True\n\n    @pytest.mark.onlynoncluster\n    @skip_if_redis_enterprise()\n    def test_cluster_slaves(self, mock_cluster_resp_slaves):\n        assert isinstance(mock_cluster_resp_slaves.cluster(\"slaves\", \"nodeid\"), dict)\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"3.0.0\")\n    @skip_if_server_version_gte(\"7.0.0\")\n    @skip_if_redis_enterprise()\n    def test_readwrite(self, r):\n        assert r.readwrite()\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"3.0.0\")\n    def test_readonly_invalid_cluster_state(self, r):\n        with pytest.raises(exceptions.RedisError):\n            r.readonly()\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"3.0.0\")\n    @skip_if_redis_enterprise()\n    def test_readonly(self, mock_cluster_resp_ok):\n        assert mock_cluster_resp_ok.readonly() is True\n\n    # GEO COMMANDS\n    @skip_if_server_version_lt(\"3.2.0\")\n    def test_geoadd(self, r):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n        assert r.geoadd(\"barcelona\", values) == 2\n        assert r.zcard(\"barcelona\") == 2\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_geoadd_nx(self, r):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n        assert r.geoadd(\"a\", values) == 2\n        values = (\n            (2.1909389952632, 41.433791470673, \"place1\")\n            + (2.1873744593677, 41.406342043777, \"place2\")\n            + (2.1804738294738, 41.405647879212, \"place3\")\n        )\n        assert r.geoadd(\"a\", values, nx=True) == 1\n        assert r.zrange(\"a\", 0, -1) == [b\"place3\", b\"place2\", b\"place1\"]\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_geoadd_xx(self, r):\n        values = (2.1909389952632, 41.433791470673, \"place1\")\n        assert r.geoadd(\"a\", values) == 1\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n        assert r.geoadd(\"a\", values, xx=True) == 0\n        assert r.zrange(\"a\", 0, -1) == [b\"place1\"]\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_geoadd_ch(self, r):\n        values = (2.1909389952632, 41.433791470673, \"place1\")\n        assert r.geoadd(\"a\", values) == 1\n        values = (2.1909389952632, 31.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n        assert r.geoadd(\"a\", values, ch=True) == 2\n        assert r.zrange(\"a\", 0, -1) == [b\"place1\", b\"place2\"]\n\n    @skip_if_server_version_lt(\"3.2.0\")\n    def test_geoadd_invalid_params(self, r):\n        with pytest.raises(exceptions.RedisError):\n            r.geoadd(\"barcelona\", (1, 2))\n\n    @skip_if_server_version_lt(\"3.2.0\")\n    def test_geodist(self, r):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n        assert r.geoadd(\"barcelona\", values) == 2\n        assert r.geodist(\"barcelona\", \"place1\", \"place2\") == 3067.4157\n\n    @skip_if_server_version_lt(\"3.2.0\")\n    def test_geodist_units(self, r):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n        r.geoadd(\"barcelona\", values)\n        assert r.geodist(\"barcelona\", \"place1\", \"place2\", \"km\") == 3.0674\n\n    @skip_if_server_version_lt(\"3.2.0\")\n    def test_geodist_missing_one_member(self, r):\n        values = (2.1909389952632, 41.433791470673, \"place1\")\n        r.geoadd(\"barcelona\", values)\n        assert r.geodist(\"barcelona\", \"place1\", \"missing_member\", \"km\") is None\n\n    @skip_if_server_version_lt(\"3.2.0\")\n    def test_geodist_invalid_units(self, r):\n        with pytest.raises(exceptions.RedisError):\n            assert r.geodist(\"x\", \"y\", \"z\", \"inches\")\n\n    @skip_if_server_version_lt(\"3.2.0\")\n    def test_geohash(self, r):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n        r.geoadd(\"barcelona\", values)\n        assert_resp_response(\n            r,\n            r.geohash(\"barcelona\", \"place1\", \"place2\", \"place3\"),\n            [\"sp3e9yg3kd0\", \"sp3e9cbc3t0\", None],\n            [b\"sp3e9yg3kd0\", b\"sp3e9cbc3t0\", None],\n        )\n\n    @skip_unless_arch_bits(64)\n    @skip_if_server_version_lt(\"3.2.0\")\n    def test_geopos(self, r):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n        r.geoadd(\"barcelona\", values)\n        # redis uses 52 bits precision, hereby small errors may be introduced.\n        assert_resp_response(\n            r,\n            r.geopos(\"barcelona\", \"place1\", \"place2\"),\n            [\n                (2.19093829393386841, 41.43379028184083523),\n                (2.18737632036209106, 41.40634178640635099),\n            ],\n            [\n                [2.19093829393386841, 41.43379028184083523],\n                [2.18737632036209106, 41.40634178640635099],\n            ],\n        )\n\n    @skip_if_server_version_lt(\"4.0.0\")\n    def test_geopos_no_value(self, r):\n        assert r.geopos(\"barcelona\", \"place1\", \"place2\") == [None, None]\n\n    @skip_if_server_version_lt(\"3.2.0\")\n    @skip_if_server_version_gte(\"4.0.0\")\n    def test_old_geopos_no_value(self, r):\n        assert r.geopos(\"barcelona\", \"place1\", \"place2\") == []\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_geosearch(self, r):\n        values = (\n            (2.1909389952632, 41.433791470673, \"place1\")\n            + (2.1873744593677, 41.406342043777, b\"\\x80place2\")\n            + (2.583333, 41.316667, \"place3\")\n        )\n        r.geoadd(\"barcelona\", values)\n        assert r.geosearch(\n            \"barcelona\", longitude=2.191, latitude=41.433, radius=1000\n        ) == [b\"place1\"]\n        assert r.geosearch(\n            \"barcelona\", longitude=2.187, latitude=41.406, radius=1000\n        ) == [b\"\\x80place2\"]\n        assert r.geosearch(\n            \"barcelona\", longitude=2.191, latitude=41.433, height=1000, width=1000\n        ) == [b\"place1\"]\n        assert r.geosearch(\"barcelona\", member=\"place3\", radius=100, unit=\"km\") == [\n            b\"\\x80place2\",\n            b\"place1\",\n            b\"place3\",\n        ]\n        # test count\n        assert r.geosearch(\n            \"barcelona\", member=\"place3\", radius=100, unit=\"km\", count=2\n        ) == [b\"place3\", b\"\\x80place2\"]\n        assert r.geosearch(\n            \"barcelona\", member=\"place3\", radius=100, unit=\"km\", count=1, any=1\n        )[0] in [b\"place1\", b\"place3\", b\"\\x80place2\"]\n\n    @skip_unless_arch_bits(64)\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_geosearch_member(self, r):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            b\"\\x80place2\",\n        )\n\n        r.geoadd(\"barcelona\", values)\n        assert r.geosearch(\"barcelona\", member=\"place1\", radius=4000) == [\n            b\"\\x80place2\",\n            b\"place1\",\n        ]\n        assert r.geosearch(\"barcelona\", member=\"place1\", radius=10) == [b\"place1\"]\n\n        assert r.geosearch(\n            \"barcelona\",\n            member=\"place1\",\n            radius=4000,\n            withdist=True,\n            withcoord=True,\n            withhash=True,\n        ) == [\n            [\n                b\"\\x80place2\",\n                3067.4157,\n                3471609625421029,\n                (2.187376320362091, 41.40634178640635),\n            ],\n            [\n                b\"place1\",\n                0.0,\n                3471609698139488,\n                (2.1909382939338684, 41.433790281840835),\n            ],\n        ]\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_geosearch_sort(self, r):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n        r.geoadd(\"barcelona\", values)\n        assert r.geosearch(\n            \"barcelona\", longitude=2.191, latitude=41.433, radius=3000, sort=\"ASC\"\n        ) == [b\"place1\", b\"place2\"]\n        assert r.geosearch(\n            \"barcelona\", longitude=2.191, latitude=41.433, radius=3000, sort=\"DESC\"\n        ) == [b\"place2\", b\"place1\"]\n\n    @skip_unless_arch_bits(64)\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_geosearch_with(self, r):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n        r.geoadd(\"barcelona\", values)\n\n        # test a bunch of combinations to test the parse response\n        # function.\n        assert r.geosearch(\n            \"barcelona\",\n            longitude=2.191,\n            latitude=41.433,\n            radius=1,\n            unit=\"km\",\n            withdist=True,\n            withcoord=True,\n            withhash=True,\n        ) == [\n            [\n                b\"place1\",\n                0.0881,\n                3471609698139488,\n                (2.19093829393386841, 41.43379028184083523),\n            ]\n        ]\n        assert r.geosearch(\n            \"barcelona\",\n            longitude=2.191,\n            latitude=41.433,\n            radius=1,\n            unit=\"km\",\n            withdist=True,\n            withcoord=True,\n        ) == [[b\"place1\", 0.0881, (2.19093829393386841, 41.43379028184083523)]]\n        assert r.geosearch(\n            \"barcelona\",\n            longitude=2.191,\n            latitude=41.433,\n            radius=1,\n            unit=\"km\",\n            withhash=True,\n            withcoord=True,\n        ) == [\n            [b\"place1\", 3471609698139488, (2.19093829393386841, 41.43379028184083523)]\n        ]\n        # test no values.\n        assert (\n            r.geosearch(\n                \"barcelona\",\n                longitude=2,\n                latitude=1,\n                radius=1,\n                unit=\"km\",\n                withdist=True,\n                withcoord=True,\n                withhash=True,\n            )\n            == []\n        )\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_geosearch_negative(self, r):\n        # not specifying member nor longitude and latitude\n        with pytest.raises(exceptions.DataError):\n            assert r.geosearch(\"barcelona\")\n        # specifying member and longitude and latitude\n        with pytest.raises(exceptions.DataError):\n            assert r.geosearch(\"barcelona\", member=\"Paris\", longitude=2, latitude=1)\n        # specifying one of longitude and latitude\n        with pytest.raises(exceptions.DataError):\n            assert r.geosearch(\"barcelona\", longitude=2)\n        with pytest.raises(exceptions.DataError):\n            assert r.geosearch(\"barcelona\", latitude=2)\n\n        # not specifying radius nor width and height\n        with pytest.raises(exceptions.DataError):\n            assert r.geosearch(\"barcelona\", member=\"Paris\")\n        # specifying radius and width and height\n        with pytest.raises(exceptions.DataError):\n            assert r.geosearch(\"barcelona\", member=\"Paris\", radius=3, width=2, height=1)\n        # specifying one of width and height\n        with pytest.raises(exceptions.DataError):\n            assert r.geosearch(\"barcelona\", member=\"Paris\", width=2)\n        with pytest.raises(exceptions.DataError):\n            assert r.geosearch(\"barcelona\", member=\"Paris\", height=2)\n\n        # invalid sort\n        with pytest.raises(exceptions.DataError):\n            assert r.geosearch(\n                \"barcelona\", member=\"Paris\", width=2, height=2, sort=\"wrong\"\n            )\n\n        # invalid unit\n        with pytest.raises(exceptions.DataError):\n            assert r.geosearch(\n                \"barcelona\", member=\"Paris\", width=2, height=2, unit=\"miles\"\n            )\n\n        # use any without count\n        with pytest.raises(exceptions.DataError):\n            assert r.geosearch(\"barcelona\", member=\"place3\", radius=100, any=1)\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_geosearchstore(self, r):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n\n        r.geoadd(\"barcelona\", values)\n        r.geosearchstore(\n            \"places_barcelona\",\n            \"barcelona\",\n            longitude=2.191,\n            latitude=41.433,\n            radius=1000,\n        )\n        assert r.zrange(\"places_barcelona\", 0, -1) == [b\"place1\"]\n\n    @pytest.mark.onlynoncluster\n    @skip_unless_arch_bits(64)\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_geosearchstore_dist(self, r):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n\n        r.geoadd(\"barcelona\", values)\n        r.geosearchstore(\n            \"places_barcelona\",\n            \"barcelona\",\n            longitude=2.191,\n            latitude=41.433,\n            radius=1000,\n            storedist=True,\n        )\n        # instead of save the geo score, the distance is saved.\n        assert r.zscore(\"places_barcelona\", \"place1\") == 88.05060698409301\n\n    @skip_if_server_version_lt(\"3.2.0\")\n    def test_georadius_Issue2609(self, r):\n        # test for issue #2609 (Geo search functions don't work with execute_command)\n        r.geoadd(name=\"my-key\", values=[1, 2, \"data\"])\n        assert r.execute_command(\"GEORADIUS\", \"my-key\", 1, 2, 400, \"m\") == [b\"data\"]\n\n    @skip_if_server_version_lt(\"3.2.0\")\n    def test_georadius(self, r):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            b\"\\x80place2\",\n        )\n\n        r.geoadd(\"barcelona\", values)\n        assert r.georadius(\"barcelona\", 2.191, 41.433, 1000) == [b\"place1\"]\n        assert r.georadius(\"barcelona\", 2.187, 41.406, 1000) == [b\"\\x80place2\"]\n\n    @skip_if_server_version_lt(\"3.2.0\")\n    def test_georadius_no_values(self, r):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n\n        r.geoadd(\"barcelona\", values)\n        assert r.georadius(\"barcelona\", 1, 2, 1000) == []\n\n    @skip_if_server_version_lt(\"3.2.0\")\n    def test_georadius_units(self, r):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n\n        r.geoadd(\"barcelona\", values)\n        assert r.georadius(\"barcelona\", 2.191, 41.433, 1, unit=\"km\") == [b\"place1\"]\n\n    @skip_unless_arch_bits(64)\n    @skip_if_server_version_lt(\"3.2.0\")\n    def test_georadius_with(self, r):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n\n        r.geoadd(\"barcelona\", values)\n\n        # test a bunch of combinations to test the parse response\n        # function.\n        assert r.georadius(\n            \"barcelona\",\n            2.191,\n            41.433,\n            1,\n            unit=\"km\",\n            withdist=True,\n            withcoord=True,\n            withhash=True,\n        ) == [\n            [\n                b\"place1\",\n                0.0881,\n                3471609698139488,\n                (2.19093829393386841, 41.43379028184083523),\n            ]\n        ]\n\n        assert r.georadius(\n            \"barcelona\", 2.191, 41.433, 1, unit=\"km\", withdist=True, withcoord=True\n        ) == [[b\"place1\", 0.0881, (2.19093829393386841, 41.43379028184083523)]]\n\n        assert r.georadius(\n            \"barcelona\", 2.191, 41.433, 1, unit=\"km\", withhash=True, withcoord=True\n        ) == [\n            [b\"place1\", 3471609698139488, (2.19093829393386841, 41.43379028184083523)]\n        ]\n\n        # test no values.\n        assert (\n            r.georadius(\n                \"barcelona\",\n                2,\n                1,\n                1,\n                unit=\"km\",\n                withdist=True,\n                withcoord=True,\n                withhash=True,\n            )\n            == []\n        )\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_georadius_count(self, r):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n\n        r.geoadd(\"barcelona\", values)\n        assert r.georadius(\"barcelona\", 2.191, 41.433, 3000, count=1) == [b\"place1\"]\n        assert r.georadius(\"barcelona\", 2.191, 41.433, 3000, count=1, any=True) == [\n            b\"place2\"\n        ]\n\n    @skip_if_server_version_lt(\"3.2.0\")\n    def test_georadius_sort(self, r):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n\n        r.geoadd(\"barcelona\", values)\n        assert r.georadius(\"barcelona\", 2.191, 41.433, 3000, sort=\"ASC\") == [\n            b\"place1\",\n            b\"place2\",\n        ]\n        assert r.georadius(\"barcelona\", 2.191, 41.433, 3000, sort=\"DESC\") == [\n            b\"place2\",\n            b\"place1\",\n        ]\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"3.2.0\")\n    def test_georadius_store(self, r):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n\n        r.geoadd(\"barcelona\", values)\n        r.georadius(\"barcelona\", 2.191, 41.433, 1000, store=\"places_barcelona\")\n        assert r.zrange(\"places_barcelona\", 0, -1) == [b\"place1\"]\n\n    @pytest.mark.onlynoncluster\n    @skip_unless_arch_bits(64)\n    @skip_if_server_version_lt(\"3.2.0\")\n    def test_georadius_store_dist(self, r):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            \"place2\",\n        )\n\n        r.geoadd(\"barcelona\", values)\n        r.georadius(\"barcelona\", 2.191, 41.433, 1000, store_dist=\"places_barcelona\")\n        # instead of save the geo score, the distance is saved.\n        assert r.zscore(\"places_barcelona\", \"place1\") == 88.05060698409301\n\n    @skip_unless_arch_bits(64)\n    @skip_if_server_version_lt(\"3.2.0\")\n    def test_georadiusmember(self, r):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            b\"\\x80place2\",\n        )\n\n        r.geoadd(\"barcelona\", values)\n        assert r.georadiusbymember(\"barcelona\", \"place1\", 4000) == [\n            b\"\\x80place2\",\n            b\"place1\",\n        ]\n        assert r.georadiusbymember(\"barcelona\", \"place1\", 10) == [b\"place1\"]\n\n        assert r.georadiusbymember(\n            \"barcelona\", \"place1\", 4000, withdist=True, withcoord=True, withhash=True\n        ) == [\n            [\n                b\"\\x80place2\",\n                3067.4157,\n                3471609625421029,\n                (2.187376320362091, 41.40634178640635),\n            ],\n            [\n                b\"place1\",\n                0.0,\n                3471609698139488,\n                (2.1909382939338684, 41.433790281840835),\n            ],\n        ]\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_georadiusmember_count(self, r):\n        values = (2.1909389952632, 41.433791470673, \"place1\") + (\n            2.1873744593677,\n            41.406342043777,\n            b\"\\x80place2\",\n        )\n        r.geoadd(\"barcelona\", values)\n        assert r.georadiusbymember(\"barcelona\", \"place1\", 4000, count=1, any=True) == [\n            b\"\\x80place2\"\n        ]\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    def test_xack(self, r):\n        stream = \"stream\"\n        group = \"group\"\n        consumer = \"consumer\"\n        # xack on a stream that doesn't exist\n        assert r.xack(stream, group, \"0-0\") == 0\n\n        m1 = r.xadd(stream, {\"one\": \"one\"})\n        m2 = r.xadd(stream, {\"two\": \"two\"})\n        m3 = r.xadd(stream, {\"three\": \"three\"})\n\n        # xack on a group that doesn't exist\n        assert r.xack(stream, group, m1) == 0\n\n        r.xgroup_create(stream, group, 0)\n        r.xreadgroup(group, consumer, streams={stream: \">\"})\n        # xack returns the number of ack'd elements\n        assert r.xack(stream, group, m1) == 1\n        assert r.xack(stream, group, m2, m3) == 2\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    def test_xadd(self, r):\n        stream = \"stream\"\n        message_id = r.xadd(stream, {\"foo\": \"bar\"})\n        assert re.match(rb\"[0-9]+\\-[0-9]+\", message_id)\n\n        # explicit message id\n        message_id = b\"9999999999999999999-0\"\n        assert message_id == r.xadd(stream, {\"foo\": \"bar\"}, id=message_id)\n\n        # with maxlen, the list evicts the first message\n        r.xadd(stream, {\"foo\": \"bar\"}, maxlen=2, approximate=False)\n        assert r.xlen(stream) == 2\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_xadd_nomkstream(self, r):\n        # nomkstream option\n        stream = \"stream\"\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"some\": \"other\"}, nomkstream=False)\n        assert r.xlen(stream) == 2\n        r.xadd(stream, {\"some\": \"other\"}, nomkstream=True)\n        assert r.xlen(stream) == 3\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_xadd_minlen_and_limit(self, r):\n        stream = \"stream\"\n\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n\n        # Future self: No limits without approximate, according to the api\n        with pytest.raises(redis.ResponseError):\n            assert r.xadd(stream, {\"foo\": \"bar\"}, maxlen=3, approximate=False, limit=2)\n\n        # limit can not be provided without maxlen or minid\n        with pytest.raises(redis.ResponseError):\n            assert r.xadd(stream, {\"foo\": \"bar\"}, limit=2)\n\n        # maxlen with a limit\n        assert r.xadd(stream, {\"foo\": \"bar\"}, maxlen=3, approximate=True, limit=2)\n        r.delete(stream)\n\n        # maxlen and minid can not be provided together\n        with pytest.raises(redis.DataError):\n            assert r.xadd(stream, {\"foo\": \"bar\"}, maxlen=3, minid=\"sometestvalue\")\n\n        # minid with a limit\n        m1 = r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n        assert r.xadd(stream, {\"foo\": \"bar\"}, approximate=True, minid=m1, limit=3)\n\n        # pure minid\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n        m4 = r.xadd(stream, {\"foo\": \"bar\"})\n        assert r.xadd(stream, {\"foo\": \"bar\"}, approximate=False, minid=m4)\n\n        # minid approximate\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n        m3 = r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n        assert r.xadd(stream, {\"foo\": \"bar\"}, approximate=True, minid=m3)\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_xadd_explicit_ms(self, r: redis.Redis):\n        stream = \"stream\"\n        message_id = r.xadd(stream, {\"foo\": \"bar\"}, \"9999999999999999999-*\")\n        ms = message_id[: message_id.index(b\"-\")]\n        assert ms == b\"9999999999999999999\"\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_xautoclaim(self, r):\n        stream = \"stream\"\n        group = \"group\"\n        consumer1 = \"consumer1\"\n        consumer2 = \"consumer2\"\n\n        message_id1 = r.xadd(stream, {\"john\": \"wick\"})\n        message_id2 = r.xadd(stream, {\"johny\": \"deff\"})\n        message = get_stream_message(r, stream, message_id1)\n        r.xgroup_create(stream, group, 0)\n\n        # trying to claim a message that isn't already pending doesn't\n        # do anything\n        response = r.xautoclaim(stream, group, consumer2, min_idle_time=0)\n        assert response == [b\"0-0\", [], []]\n\n        # read the group as consumer1 to initially claim the messages\n        r.xreadgroup(group, consumer1, streams={stream: \">\"})\n\n        # claim one message as consumer2\n        response = r.xautoclaim(stream, group, consumer2, min_idle_time=0, count=1)\n        assert response[1] == [message]\n\n        # reclaim the messages as consumer1, but use the justid argument\n        # which only returns message ids\n        assert r.xautoclaim(\n            stream, group, consumer1, min_idle_time=0, start_id=0, justid=True\n        ) == [message_id1, message_id2]\n        assert r.xautoclaim(\n            stream, group, consumer1, min_idle_time=0, start_id=message_id2, justid=True\n        ) == [message_id2]\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_xautoclaim_negative(self, r):\n        stream = \"stream\"\n        group = \"group\"\n        consumer = \"consumer\"\n        with pytest.raises(redis.DataError):\n            r.xautoclaim(stream, group, consumer, min_idle_time=-1)\n        with pytest.raises(ValueError):\n            r.xautoclaim(stream, group, consumer, min_idle_time=\"wrong\")\n        with pytest.raises(redis.DataError):\n            r.xautoclaim(stream, group, consumer, min_idle_time=0, count=-1)\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    def test_xclaim(self, r):\n        stream = \"stream\"\n        group = \"group\"\n        consumer1 = \"consumer1\"\n        consumer2 = \"consumer2\"\n        message_id = r.xadd(stream, {\"john\": \"wick\"})\n        message = get_stream_message(r, stream, message_id)\n        r.xgroup_create(stream, group, 0)\n\n        # trying to claim a message that isn't already pending doesn't\n        # do anything\n        response = r.xclaim(\n            stream, group, consumer2, min_idle_time=0, message_ids=(message_id,)\n        )\n        assert response == []\n\n        # read the group as consumer1 to initially claim the messages\n        r.xreadgroup(group, consumer1, streams={stream: \">\"})\n\n        # claim the message as consumer2\n        response = r.xclaim(\n            stream, group, consumer2, min_idle_time=0, message_ids=(message_id,)\n        )\n        assert response[0] == message\n\n        # reclaim the message as consumer1, but use the justid argument\n        # which only returns message ids\n        assert r.xclaim(\n            stream,\n            group,\n            consumer1,\n            min_idle_time=0,\n            message_ids=(message_id,),\n            justid=True,\n        ) == [message_id]\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_xclaim_trimmed(self, r):\n        # xclaim should not raise an exception if the item is not there\n        stream = \"stream\"\n        group = \"group\"\n\n        r.xgroup_create(stream, group, id=\"$\", mkstream=True)\n\n        # add a couple of new items\n        sid1 = r.xadd(stream, {\"item\": 0})\n        sid2 = r.xadd(stream, {\"item\": 0})\n\n        # read them from consumer1\n        r.xreadgroup(group, \"consumer1\", {stream: \">\"})\n\n        # add a 3rd and trim the stream down to 2 items\n        r.xadd(stream, {\"item\": 3}, maxlen=2, approximate=False)\n\n        # xclaim them from consumer2\n        # the item that is still in the stream should be returned\n        item = r.xclaim(stream, group, \"consumer2\", 0, [sid1, sid2])\n        assert len(item) == 1\n        assert item[0][0] == sid2\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    def test_xdel(self, r):\n        stream = \"stream\"\n\n        # deleting from an empty stream doesn't do anything\n        assert r.xdel(stream, 1) == 0\n\n        m1 = r.xadd(stream, {\"foo\": \"bar\"})\n        m2 = r.xadd(stream, {\"foo\": \"bar\"})\n        m3 = r.xadd(stream, {\"foo\": \"bar\"})\n\n        # xdel returns the number of deleted elements\n        assert r.xdel(stream, m1) == 1\n        assert r.xdel(stream, m2, m3) == 2\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_xgroup_create(self, r):\n        # tests xgroup_create and xinfo_groups\n        stream = \"stream\"\n        group = \"group\"\n        r.xadd(stream, {\"foo\": \"bar\"})\n\n        # no group is setup yet, no info to obtain\n        assert r.xinfo_groups(stream) == []\n\n        assert r.xgroup_create(stream, group, 0)\n        expected = [\n            {\n                \"name\": group.encode(),\n                \"consumers\": 0,\n                \"pending\": 0,\n                \"last-delivered-id\": b\"0-0\",\n                \"entries-read\": None,\n                \"lag\": 1,\n            }\n        ]\n        assert r.xinfo_groups(stream) == expected\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_xgroup_create_mkstream(self, r):\n        # tests xgroup_create and xinfo_groups\n        stream = \"stream\"\n        group = \"group\"\n\n        # an error is raised if a group is created on a stream that\n        # doesn't already exist\n        with pytest.raises(exceptions.ResponseError):\n            r.xgroup_create(stream, group, 0)\n\n        # however, with mkstream=True, the underlying stream is created\n        # automatically\n        assert r.xgroup_create(stream, group, 0, mkstream=True)\n        expected = [\n            {\n                \"name\": group.encode(),\n                \"consumers\": 0,\n                \"pending\": 0,\n                \"last-delivered-id\": b\"0-0\",\n                \"entries-read\": None,\n                \"lag\": 0,\n            }\n        ]\n        assert r.xinfo_groups(stream) == expected\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    @skip_if_server_version_gte(\"8.2.2\")\n    def test_xgroup_create_entriesread(self, r: redis.Redis):\n        stream = \"stream\"\n        group = \"group\"\n        r.xadd(stream, {\"foo\": \"bar\"})\n\n        # no group is setup yet, no info to obtain\n        assert r.xinfo_groups(stream) == []\n\n        assert r.xgroup_create(stream, group, 0, entries_read=7)\n        expected = [\n            {\n                \"name\": group.encode(),\n                \"consumers\": 0,\n                \"pending\": 0,\n                \"last-delivered-id\": b\"0-0\",\n                \"entries-read\": 7,\n                \"lag\": 1,\n            }\n        ]\n        assert r.xinfo_groups(stream) == expected\n\n    @skip_if_server_version_lt(\"8.2.2\")\n    def test_xgroup_create_entriesread_fixed_max_entries_read(self, r: redis.Redis):\n        stream = \"stream\"\n        group = \"group\"\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo1\": \"bar1\"})\n        r.xadd(stream, {\"foo2\": \"bar2\"})\n\n        # no group is setup yet, no info to obtain\n        assert r.xinfo_groups(stream) == []\n\n        assert r.xgroup_create(stream, group, 0, entries_read=7)\n        # validate the entries-read is max the number of entries\n        # in the stream and lag shows the entries between\n        # last_delivered_id and entries_added\n        expected = [\n            {\n                \"name\": group.encode(),\n                \"consumers\": 0,\n                \"pending\": 0,\n                \"last-delivered-id\": b\"0-0\",\n                \"entries-read\": 3,\n                \"lag\": 3,\n            }\n        ]\n        assert r.xinfo_groups(stream) == expected\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    def test_xgroup_delconsumer(self, r):\n        stream = \"stream\"\n        group = \"group\"\n        consumer = \"consumer\"\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xgroup_create(stream, group, 0)\n\n        # a consumer that hasn't yet read any messages doesn't do anything\n        assert r.xgroup_delconsumer(stream, group, consumer) == 0\n\n        # read all messages from the group\n        r.xreadgroup(group, consumer, streams={stream: \">\"})\n\n        # deleting the consumer should return 2 pending messages\n        assert r.xgroup_delconsumer(stream, group, consumer) == 2\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_xgroup_createconsumer(self, r):\n        stream = \"stream\"\n        group = \"group\"\n        consumer = \"consumer\"\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xgroup_create(stream, group, 0)\n        assert r.xgroup_createconsumer(stream, group, consumer) == 1\n\n        # read all messages from the group\n        r.xreadgroup(group, consumer, streams={stream: \">\"})\n\n        # deleting the consumer should return 2 pending messages\n        assert r.xgroup_delconsumer(stream, group, consumer) == 2\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    def test_xgroup_destroy(self, r):\n        stream = \"stream\"\n        group = \"group\"\n        r.xadd(stream, {\"foo\": \"bar\"})\n\n        # destroying a nonexistent group returns False\n        assert not r.xgroup_destroy(stream, group)\n\n        r.xgroup_create(stream, group, 0)\n        assert r.xgroup_destroy(stream, group)\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    @skip_if_server_version_gte(\"8.2.2\")\n    def test_xgroup_setid(self, r):\n        stream = \"stream\"\n        group = \"group\"\n        message_id = r.xadd(stream, {\"foo\": \"bar\"})\n\n        r.xgroup_create(stream, group, 0)\n        # advance the last_delivered_id to the message_id\n        r.xgroup_setid(stream, group, message_id, entries_read=2)\n        expected = [\n            {\n                \"name\": group.encode(),\n                \"consumers\": 0,\n                \"pending\": 0,\n                \"last-delivered-id\": message_id,\n                \"entries-read\": 2,\n                \"lag\": -1,\n            }\n        ]\n        assert r.xinfo_groups(stream) == expected\n\n    @skip_if_server_version_lt(\"8.2.2\")\n    def test_xgroup_setid_fixed_max_entries_read(self, r):\n        stream = \"stream\"\n        group = \"group\"\n        message_id = r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo1\": \"bar1\"})\n\n        r.xgroup_create(stream, group, 0)\n        # advance the last_delivered_id to the message_id\n        r.xgroup_setid(stream, group, message_id, entries_read=5)\n        expected = [\n            {\n                \"name\": group.encode(),\n                \"consumers\": 0,\n                \"pending\": 0,\n                \"last-delivered-id\": message_id,\n                \"entries-read\": 2,\n                \"lag\": 0,\n            }\n        ]\n        assert r.xinfo_groups(stream) == expected\n\n    @skip_if_server_version_lt(\"7.2.0\")\n    def test_xinfo_consumers(self, r):\n        stream = \"stream\"\n        group = \"group\"\n        consumer1 = \"consumer1\"\n        consumer2 = \"consumer2\"\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n\n        r.xgroup_create(stream, group, 0)\n        r.xreadgroup(group, consumer1, streams={stream: \">\"}, count=1)\n        r.xreadgroup(group, consumer2, streams={stream: \">\"})\n        info = r.xinfo_consumers(stream, group)\n        assert len(info) == 2\n        expected = [\n            {\"name\": consumer1.encode(), \"pending\": 1},\n            {\"name\": consumer2.encode(), \"pending\": 2},\n        ]\n\n        # we can't determine the idle and inactive time, so just make sure it's an int\n        assert isinstance(info[0].pop(\"idle\"), int)\n        assert isinstance(info[1].pop(\"idle\"), int)\n        assert isinstance(info[0].pop(\"inactive\"), int)\n        assert isinstance(info[1].pop(\"inactive\"), int)\n        assert info == expected\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_xinfo_stream(self, r):\n        stream = \"stream\"\n        m1 = r.xadd(stream, {\"foo\": \"bar\"})\n        m2 = r.xadd(stream, {\"foo\": \"bar\"})\n        info = r.xinfo_stream(stream)\n\n        assert info[\"length\"] == 2\n        assert info[\"first-entry\"] == get_stream_message(r, stream, m1)\n        assert info[\"last-entry\"] == get_stream_message(r, stream, m2)\n        assert info[\"max-deleted-entry-id\"] == b\"0-0\"\n        assert info[\"entries-added\"] == 2\n        assert info[\"recorded-first-entry-id\"] == m1\n\n        r.xtrim(stream, 0)\n        info = r.xinfo_stream(stream)\n        assert info[\"length\"] == 0\n        assert info[\"first-entry\"] is None\n        assert info[\"last-entry\"] is None\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    def test_xinfo_stream_full(self, r):\n        stream = \"stream\"\n        group = \"group\"\n        m1 = r.xadd(stream, {\"foo\": \"bar\"})\n        info = r.xinfo_stream(stream, full=True)\n        assert info[\"length\"] == 1\n        assert len(info[\"groups\"]) == 0\n\n        r.xgroup_create(stream, group, 0)\n        info = r.xinfo_stream(stream, full=True)\n        assert info[\"length\"] == 1\n        assert_resp_response_in(\n            r,\n            m1,\n            info[\"entries\"],\n            info[\"entries\"].keys(),\n        )\n        assert len(info[\"groups\"]) == 1\n\n        r.xreadgroup(group, \"consumer\", streams={stream: \">\"})\n        info = r.xinfo_stream(stream, full=True)\n        consumer = info[\"groups\"][0][\"consumers\"][0]\n        assert isinstance(consumer, dict)\n\n    @skip_if_server_version_lt(\"8.5.0\")\n    def test_xinfo_stream_idempotent_fields(self, r):\n        stream = \"stream\"\n\n        # Create stream with regular entry\n        r.xadd(stream, {\"foo\": \"bar\"})\n        info = r.xinfo_stream(stream)\n\n        # Verify new idempotent producer fields are present with default values\n        assert \"idmp-duration\" in info\n        assert \"idmp-maxsize\" in info\n        assert \"pids-tracked\" in info\n        assert \"iids-tracked\" in info\n        assert \"iids-added\" in info\n        assert \"iids-duplicates\" in info\n\n        # Default values (before any idempotent entries)\n        assert info[\"pids-tracked\"] == 0\n        assert info[\"iids-tracked\"] == 0\n        assert info[\"iids-added\"] == 0\n        assert info[\"iids-duplicates\"] == 0\n\n        # Add idempotent entry\n        r.xadd(stream, {\"field1\": \"value1\"}, idmpauto=\"producer1\")\n        info = r.xinfo_stream(stream)\n\n        # After adding idempotent entry\n        assert info[\"pids-tracked\"] == 1  # One producer tracked\n        assert info[\"iids-tracked\"] == 1  # One iid tracked\n        assert info[\"iids-added\"] == 1  # One idempotent entry added\n        assert info[\"iids-duplicates\"] == 0  # No duplicates yet\n\n        # Add duplicate entry\n        r.xadd(stream, {\"field1\": \"value1\"}, idmpauto=\"producer1\")\n        info = r.xinfo_stream(stream)\n\n        # After duplicate\n        assert info[\"pids-tracked\"] == 1  # Still one producer\n        assert info[\"iids-tracked\"] == 1  # Still one iid (duplicate doesn't add new)\n        assert info[\"iids-added\"] == 1  # Still one unique entry\n        assert info[\"iids-duplicates\"] == 1  # One duplicate detected\n\n        # Add entry from different producer\n        r.xadd(stream, {\"field2\": \"value2\"}, idmpauto=\"producer2\")\n        info = r.xinfo_stream(stream)\n\n        # After second producer\n        assert info[\"pids-tracked\"] == 2  # Two producers tracked\n        assert info[\"iids-tracked\"] == 2  # Two iids tracked\n        assert info[\"iids-added\"] == 2  # Two unique entries\n        assert info[\"iids-duplicates\"] == 1  # Still one duplicate\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    def test_xlen(self, r):\n        stream = \"stream\"\n        assert r.xlen(stream) == 0\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n        assert r.xlen(stream) == 2\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    def test_xpending(self, r):\n        stream = \"stream\"\n        group = \"group\"\n        consumer1 = \"consumer1\"\n        consumer2 = \"consumer2\"\n        m1 = r.xadd(stream, {\"foo\": \"bar\"})\n        m2 = r.xadd(stream, {\"foo\": \"bar\"})\n        r.xgroup_create(stream, group, 0)\n\n        # xpending on a group that has no consumers yet\n        expected = {\"pending\": 0, \"min\": None, \"max\": None, \"consumers\": []}\n        assert r.xpending(stream, group) == expected\n\n        # read 1 message from the group with each consumer\n        r.xreadgroup(group, consumer1, streams={stream: \">\"}, count=1)\n        r.xreadgroup(group, consumer2, streams={stream: \">\"}, count=1)\n\n        expected = {\n            \"pending\": 2,\n            \"min\": m1,\n            \"max\": m2,\n            \"consumers\": [\n                {\"name\": consumer1.encode(), \"pending\": 1},\n                {\"name\": consumer2.encode(), \"pending\": 1},\n            ],\n        }\n        assert r.xpending(stream, group) == expected\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    def test_xpending_range(self, r):\n        stream = \"stream\"\n        group = \"group\"\n        consumer1 = \"consumer1\"\n        consumer2 = \"consumer2\"\n        m1 = r.xadd(stream, {\"foo\": \"bar\"})\n        m2 = r.xadd(stream, {\"foo\": \"bar\"})\n        r.xgroup_create(stream, group, 0)\n\n        # xpending range on a group that has no consumers yet\n        assert r.xpending_range(stream, group, min=\"-\", max=\"+\", count=5) == []\n\n        # read 1 message from the group with each consumer\n        r.xreadgroup(group, consumer1, streams={stream: \">\"}, count=1)\n        r.xreadgroup(group, consumer2, streams={stream: \">\"}, count=1)\n\n        response = r.xpending_range(stream, group, min=\"-\", max=\"+\", count=5)\n        assert len(response) == 2\n        assert response[0][\"message_id\"] == m1\n        assert response[0][\"consumer\"] == consumer1.encode()\n        assert response[1][\"message_id\"] == m2\n        assert response[1][\"consumer\"] == consumer2.encode()\n\n        # test with consumer name\n        response = r.xpending_range(\n            stream, group, min=\"-\", max=\"+\", count=5, consumername=consumer1\n        )\n        assert response[0][\"message_id\"] == m1\n        assert response[0][\"consumer\"] == consumer1.encode()\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_xpending_range_idle(self, r):\n        stream = \"stream\"\n        group = \"group\"\n        consumer1 = \"consumer1\"\n        consumer2 = \"consumer2\"\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xgroup_create(stream, group, 0)\n\n        # read 1 message from the group with each consumer\n        r.xreadgroup(group, consumer1, streams={stream: \">\"}, count=1)\n        r.xreadgroup(group, consumer2, streams={stream: \">\"}, count=1)\n\n        response = r.xpending_range(stream, group, min=\"-\", max=\"+\", count=5)\n        assert len(response) == 2\n        response = r.xpending_range(stream, group, min=\"-\", max=\"+\", count=5, idle=1000)\n        assert len(response) == 0\n\n    def test_xpending_range_negative(self, r):\n        stream = \"stream\"\n        group = \"group\"\n        with pytest.raises(redis.DataError):\n            r.xpending_range(stream, group, min=\"-\", max=\"+\", count=None)\n        with pytest.raises(ValueError):\n            r.xpending_range(stream, group, min=\"-\", max=\"+\", count=\"one\")\n        with pytest.raises(redis.DataError):\n            r.xpending_range(stream, group, min=\"-\", max=\"+\", count=-1)\n        with pytest.raises(ValueError):\n            r.xpending_range(stream, group, min=\"-\", max=\"+\", count=5, idle=\"one\")\n        with pytest.raises(redis.exceptions.ResponseError):\n            r.xpending_range(stream, group, min=\"-\", max=\"+\", count=5, idle=1.5)\n        with pytest.raises(redis.DataError):\n            r.xpending_range(stream, group, min=\"-\", max=\"+\", count=5, idle=-1)\n        with pytest.raises(redis.DataError):\n            r.xpending_range(stream, group, min=None, max=None, count=None, idle=0)\n        with pytest.raises(redis.DataError):\n            r.xpending_range(\n                stream, group, min=None, max=None, count=None, consumername=0\n            )\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    def test_xrange(self, r):\n        stream = \"stream\"\n        m1 = r.xadd(stream, {\"foo\": \"bar\"})\n        m2 = r.xadd(stream, {\"foo\": \"bar\"})\n        m3 = r.xadd(stream, {\"foo\": \"bar\"})\n        m4 = r.xadd(stream, {\"foo\": \"bar\"})\n\n        def get_ids(results):\n            return [result[0] for result in results]\n\n        results = r.xrange(stream, min=m1)\n        assert get_ids(results) == [m1, m2, m3, m4]\n\n        results = r.xrange(stream, min=m2, max=m3)\n        assert get_ids(results) == [m2, m3]\n\n        results = r.xrange(stream, max=m3)\n        assert get_ids(results) == [m1, m2, m3]\n\n        results = r.xrange(stream, max=m2, count=1)\n        assert get_ids(results) == [m1]\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    def test_xread(self, r):\n        stream = \"stream\"\n        m1 = r.xadd(stream, {\"foo\": \"bar\"})\n        m2 = r.xadd(stream, {\"bing\": \"baz\"})\n\n        stream_name = stream.encode()\n        expected_entries = [\n            get_stream_message(r, stream, m1),\n            get_stream_message(r, stream, m2),\n        ]\n        # xread starting at 0 returns both messages\n        assert_resp_response(\n            r,\n            r.xread(streams={stream: 0}),\n            [[stream_name, expected_entries]],\n            {stream_name: [expected_entries]},\n        )\n\n        expected_entries = [get_stream_message(r, stream, m1)]\n        # xread starting at 0 and count=1 returns only the first message\n        assert_resp_response(\n            r,\n            r.xread(streams={stream: 0}, count=1),\n            [[stream_name, expected_entries]],\n            {stream_name: [expected_entries]},\n        )\n\n        expected_entries = [get_stream_message(r, stream, m2)]\n        # xread starting at m1 returns only the second message\n        assert_resp_response(\n            r,\n            r.xread(streams={stream: m1}),\n            [[stream_name, expected_entries]],\n            {stream_name: [expected_entries]},\n        )\n\n        # xread starting at the last message returns an empty list\n        assert_resp_response(r, r.xread(streams={stream: m2}), [], {})\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    def test_xreadgroup(self, r):\n        stream = \"stream\"\n        group = \"group\"\n        consumer = \"consumer\"\n        m1 = r.xadd(stream, {\"foo\": \"bar\"})\n        m2 = r.xadd(stream, {\"bing\": \"baz\"})\n        r.xgroup_create(stream, group, 0)\n\n        stream_name = stream.encode()\n        expected_entries = [\n            get_stream_message(r, stream, m1),\n            get_stream_message(r, stream, m2),\n        ]\n\n        # xread starting at 0 returns both messages\n        assert_resp_response(\n            r,\n            r.xreadgroup(group, consumer, streams={stream: \">\"}),\n            [[stream_name, expected_entries]],\n            {stream_name: [expected_entries]},\n        )\n\n        r.xgroup_destroy(stream, group)\n        r.xgroup_create(stream, group, 0)\n\n        expected_entries = [get_stream_message(r, stream, m1)]\n\n        # xread with count=1 returns only the first message\n        assert_resp_response(\n            r,\n            r.xreadgroup(group, consumer, streams={stream: \">\"}, count=1),\n            [[stream_name, expected_entries]],\n            {stream_name: [expected_entries]},\n        )\n\n        r.xgroup_destroy(stream, group)\n\n        # create the group using $ as the last id meaning subsequent reads\n        # will only find messages added after this\n        r.xgroup_create(stream, group, \"$\")\n\n        # xread starting after the last message returns an empty message list\n        assert_resp_response(\n            r, r.xreadgroup(group, consumer, streams={stream: \">\"}), [], {}\n        )\n\n        # xreadgroup with noack does not have any items in the PEL\n        r.xgroup_destroy(stream, group)\n        r.xgroup_create(stream, group, \"0\")\n        res = r.xreadgroup(group, consumer, streams={stream: \">\"}, noack=True)\n        empty_res = r.xreadgroup(group, consumer, streams={stream: \"0\"})\n        if is_resp2_connection(r):\n            assert len(res[0][1]) == 2\n            # now there should be nothing pending\n            assert len(empty_res[0][1]) == 0\n        else:\n            assert len(res[stream_name][0]) == 2\n            # now there should be nothing pending\n            assert len(empty_res[stream_name][0]) == 0\n\n        r.xgroup_destroy(stream, group)\n        r.xgroup_create(stream, group, \"0\")\n        # delete all the messages in the stream\n        expected_entries = [(m1, {}), (m2, {})]\n        r.xreadgroup(group, consumer, streams={stream: \">\"})\n        r.xtrim(stream, 0)\n        assert_resp_response(\n            r,\n            r.xreadgroup(group, consumer, streams={stream: \"0\"}),\n            [[stream_name, expected_entries]],\n            {stream_name: [expected_entries]},\n        )\n\n    def _validate_xreadgroup_with_claim_min_idle_time_response(\n        self, r, response, expected_entries\n    ):\n        # validate the number of streams\n        assert len(response) == len(expected_entries)\n\n        expected_streams = expected_entries.keys()\n        for str_index, expected_stream in enumerate(expected_streams):\n            expected_entries_per_stream = expected_entries[expected_stream]\n\n            if is_resp2_connection(r):\n                actual_entries_per_stream = response[str_index][1]\n                actual_stream = response[str_index][0]\n                assert actual_stream == expected_stream\n            else:\n                actual_entries_per_stream = response[expected_stream][0]\n\n            # validate the number of entries\n            assert len(actual_entries_per_stream) == len(expected_entries_per_stream)\n\n            for i, entry in enumerate(actual_entries_per_stream):\n                message = (entry[0], entry[1])\n                message_idle = int(entry[2])\n                message_delivered_count = int(entry[3])\n\n                expected_idle = expected_entries_per_stream[i][\"min_idle_time\"]\n\n                assert message == expected_entries_per_stream[i][\"msg\"]\n                if expected_idle == 0:\n                    assert message_idle == 0\n                    assert message_delivered_count == 0\n                else:\n                    assert message_idle >= expected_idle\n                    assert message_delivered_count > 0\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_xreadgroup_with_claim_min_idle_time(self, r):\n        stream = \"stream\"\n        group = \"group\"\n        consumer_1 = \"c1\"\n        stream_name = stream.encode()\n\n        try:\n            # before test cleanup\n            r.xgroup_destroy(stream, group)\n        except ResponseError:\n            pass\n\n        m1 = r.xadd(stream, {\"key_m1\": \"val_m1\"})\n        m2 = r.xadd(stream, {\"key_m2\": \"val_m2\"})\n\n        r.xgroup_create(stream, group, 0)\n\n        # read all the messages - this will save the msgs in PEL\n        r.xreadgroup(group, consumer_1, streams={stream: \">\"})\n\n        # wait for 100ms - so that the messages would have been in the PEL for long enough\n        time.sleep(0.1)\n\n        m3 = r.xadd(stream, {\"key_m3\": \"val_m3\"})\n        m4 = r.xadd(stream, {\"key_m4\": \"val_m4\"})\n\n        expected_entries = {\n            stream_name: [\n                {\"msg\": get_stream_message(r, stream, m1), \"min_idle_time\": 100},\n                {\"msg\": get_stream_message(r, stream, m2), \"min_idle_time\": 100},\n                {\"msg\": get_stream_message(r, stream, m3), \"min_idle_time\": 0},\n                {\"msg\": get_stream_message(r, stream, m4), \"min_idle_time\": 0},\n            ]\n        }\n\n        res = r.xreadgroup(\n            group, consumer_1, streams={stream: \">\"}, claim_min_idle_time=100\n        )\n\n        self._validate_xreadgroup_with_claim_min_idle_time_response(\n            r, res, expected_entries\n        )\n\n        # validate PEL still holds the messages until they are acknowledged\n        response = r.xpending_range(stream, group, min=\"-\", max=\"+\", count=5)\n        assert len(response) == 4\n\n        # add 2 more messages\n        m5 = r.xadd(stream, {\"key_m5\": \"val_m5\"})\n        m6 = r.xadd(stream, {\"key_m6\": \"val_m6\"})\n\n        expected_entries = [\n            get_stream_message(r, stream, m5),\n            get_stream_message(r, stream, m6),\n        ]\n        # read all the messages - this will save the msgs in PEL\n        res = r.xreadgroup(group, consumer_1, streams={stream: \">\"})\n        assert_resp_response(\n            r,\n            res,\n            [[stream_name, expected_entries]],\n            {stream_name: [expected_entries]},\n        )\n\n        # add 2 more messages\n        m7 = r.xadd(stream, {\"key_m7\": \"val_m7\"})\n        m8 = r.xadd(stream, {\"key_m8\": \"val_m8\"})\n        # read the messages with claim_min_idle_time=1000\n        # only m7 and m8 should be returned\n        # because the other messages have not been in the PEL for long enough\n        expected_entries = {\n            stream_name: [\n                {\"msg\": get_stream_message(r, stream, m7), \"min_idle_time\": 0},\n                {\"msg\": get_stream_message(r, stream, m8), \"min_idle_time\": 0},\n            ]\n        }\n        res = r.xreadgroup(\n            group, consumer_1, streams={stream: \">\"}, claim_min_idle_time=100\n        )\n        self._validate_xreadgroup_with_claim_min_idle_time_response(\n            r, res, expected_entries\n        )\n\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_xreadgroup_with_claim_min_idle_time_multiple_streams_same_slots(self, r):\n        stream_1 = \"stream1:{same_slot:42}\"\n        stream_2 = \"stream2:{same_slot:42}\"\n        group = \"group\"\n        consumer_1 = \"c1\"\n\n        stream_1_name = stream_1.encode()\n        stream_2_name = stream_2.encode()\n\n        # before test cleanup\n        try:\n            r.xgroup_destroy(stream_1, group)\n        except ResponseError:\n            pass\n        try:\n            r.xgroup_destroy(stream_2, group)\n        except ResponseError:\n            pass\n\n        m1_s1 = r.xadd(stream_1, {\"key_m1_s1\": \"val_m1\"})\n        m2_s1 = r.xadd(stream_1, {\"key_m2_s1\": \"val_m2\"})\n        r.xgroup_create(stream_1, group, 0)\n\n        m1_s2 = r.xadd(stream_2, {\"key_m1_s2\": \"val_m1\"})\n        m2_s2 = r.xadd(stream_2, {\"key_m2_s2\": \"val_m2\"})\n        r.xgroup_create(stream_2, group, 0)\n\n        # read all the messages - this will save the msgs in PEL\n        r.xreadgroup(group, consumer_1, streams={stream_1: \">\", stream_2: \">\"})\n\n        # wait for 100ms - so that the messages would have been in the PEL for long enough\n        time.sleep(0.1)\n\n        m3_s1 = r.xadd(stream_1, {\"key_m3_s1\": \"val_m3\"})\n        m4_s2 = r.xadd(stream_2, {\"key_m4_s2\": \"val_m4\"})\n\n        expected_entries = {\n            stream_1_name: [\n                {\"msg\": get_stream_message(r, stream_1, m1_s1), \"min_idle_time\": 100},\n                {\"msg\": get_stream_message(r, stream_1, m2_s1), \"min_idle_time\": 100},\n                {\"msg\": get_stream_message(r, stream_1, m3_s1), \"min_idle_time\": 0},\n            ],\n            stream_2_name: [\n                {\"msg\": get_stream_message(r, stream_2, m1_s2), \"min_idle_time\": 100},\n                {\"msg\": get_stream_message(r, stream_2, m2_s2), \"min_idle_time\": 100},\n                {\"msg\": get_stream_message(r, stream_2, m4_s2), \"min_idle_time\": 0},\n            ],\n        }\n\n        res = r.xreadgroup(\n            group,\n            consumer_1,\n            streams={stream_1: \">\", stream_2: \">\"},\n            claim_min_idle_time=100,\n        )\n\n        self._validate_xreadgroup_with_claim_min_idle_time_response(\n            r, res, expected_entries\n        )\n\n        # validate PEL still holds the messages until they are acknowledged\n        response = r.xpending_range(stream_1, group, min=\"-\", max=\"+\", count=5)\n        assert len(response) == 3\n        response = r.xpending_range(stream_2, group, min=\"-\", max=\"+\", count=5)\n        assert len(response) == 3\n\n        # add 2 more messages\n        r.xadd(stream_1, {\"key_m5\": \"val_m5\"})\n        r.xadd(stream_2, {\"key_m6\": \"val_m6\"})\n\n        # read all the messages - this will save the msgs in PEL\n        r.xreadgroup(group, consumer_1, streams={stream_1: \">\", stream_2: \">\"})\n\n        # add 2 more messages\n        m7 = r.xadd(stream_1, {\"key_m7\": \"val_m7\"})\n        m8 = r.xadd(stream_2, {\"key_m8\": \"val_m8\"})\n        # read the messages with claim_min_idle_time=1000\n        # only m7 and m8 should be returned\n        # because the other messages have not been in the PEL for long enough\n        expected_entries = {\n            stream_1_name: [\n                {\"msg\": get_stream_message(r, stream_1, m7), \"min_idle_time\": 0}\n            ],\n            stream_2_name: [\n                {\"msg\": get_stream_message(r, stream_2, m8), \"min_idle_time\": 0}\n            ],\n        }\n        res = r.xreadgroup(\n            group,\n            consumer_1,\n            streams={stream_1: \">\", stream_2: \">\"},\n            claim_min_idle_time=100,\n        )\n        self._validate_xreadgroup_with_claim_min_idle_time_response(\n            r, res, expected_entries\n        )\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_xreadgroup_with_claim_min_idle_time_multiple_streams(self, r):\n        stream_1 = \"stream1\"\n        stream_2 = \"stream2\"\n        group = \"group\"\n        consumer_1 = \"c1\"\n\n        stream_1_name = stream_1.encode()\n        stream_2_name = stream_2.encode()\n\n        # before test cleanup\n        try:\n            r.xgroup_destroy(stream_1, group)\n        except ResponseError:\n            pass\n        try:\n            r.xgroup_destroy(stream_2, group)\n        except ResponseError:\n            pass\n\n        m1_s1 = r.xadd(stream_1, {\"key_m1_s1\": \"val_m1\"})\n        m2_s1 = r.xadd(stream_1, {\"key_m2_s1\": \"val_m2\"})\n        r.xgroup_create(stream_1, group, 0)\n\n        m1_s2 = r.xadd(stream_2, {\"key_m1_s2\": \"val_m1\"})\n        m2_s2 = r.xadd(stream_2, {\"key_m2_s2\": \"val_m2\"})\n        r.xgroup_create(stream_2, group, 0)\n\n        # read all the messages - this will save the msgs in PEL\n        r.xreadgroup(group, consumer_1, streams={stream_1: \">\", stream_2: \">\"})\n\n        # wait for 100ms - so that the messages would have been in the PEL for long enough\n        time.sleep(0.1)\n\n        m3_s1 = r.xadd(stream_1, {\"key_m3_s1\": \"val_m3\"})\n        m4_s2 = r.xadd(stream_2, {\"key_m4_s2\": \"val_m4\"})\n\n        expected_entries = {\n            stream_1_name: [\n                {\"msg\": get_stream_message(r, stream_1, m1_s1), \"min_idle_time\": 100},\n                {\"msg\": get_stream_message(r, stream_1, m2_s1), \"min_idle_time\": 100},\n                {\"msg\": get_stream_message(r, stream_1, m3_s1), \"min_idle_time\": 0},\n            ],\n            stream_2_name: [\n                {\"msg\": get_stream_message(r, stream_2, m1_s2), \"min_idle_time\": 100},\n                {\"msg\": get_stream_message(r, stream_2, m2_s2), \"min_idle_time\": 100},\n                {\"msg\": get_stream_message(r, stream_2, m4_s2), \"min_idle_time\": 0},\n            ],\n        }\n\n        res = r.xreadgroup(\n            group,\n            consumer_1,\n            streams={stream_1: \">\", stream_2: \">\"},\n            claim_min_idle_time=100,\n        )\n\n        self._validate_xreadgroup_with_claim_min_idle_time_response(\n            r, res, expected_entries\n        )\n\n        # validate PEL still holds the messages until they are acknowledged\n        response = r.xpending_range(stream_1, group, min=\"-\", max=\"+\", count=5)\n        assert len(response) == 3\n        response = r.xpending_range(stream_2, group, min=\"-\", max=\"+\", count=5)\n        assert len(response) == 3\n\n        # add 2 more messages\n        r.xadd(stream_1, {\"key_m5\": \"val_m5\"})\n        r.xadd(stream_2, {\"key_m6\": \"val_m6\"})\n\n        # read all the messages - this will save the msgs in PEL\n        r.xreadgroup(group, consumer_1, streams={stream_1: \">\", stream_2: \">\"})\n\n        # add 2 more messages\n        m7 = r.xadd(stream_1, {\"key_m7\": \"val_m7\"})\n        m8 = r.xadd(stream_2, {\"key_m8\": \"val_m8\"})\n        # read the messages with claim_min_idle_time=1000\n        # only m7 and m8 should be returned\n        # because the other messages have not been in the PEL for long enough\n        expected_entries = {\n            stream_1_name: [\n                {\"msg\": get_stream_message(r, stream_1, m7), \"min_idle_time\": 0}\n            ],\n            stream_2_name: [\n                {\"msg\": get_stream_message(r, stream_2, m8), \"min_idle_time\": 0}\n            ],\n        }\n        res = r.xreadgroup(\n            group,\n            consumer_1,\n            streams={stream_1: \">\", stream_2: \">\"},\n            claim_min_idle_time=100,\n        )\n        self._validate_xreadgroup_with_claim_min_idle_time_response(\n            r, res, expected_entries\n        )\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    def test_xrevrange(self, r):\n        stream = \"stream\"\n        m1 = r.xadd(stream, {\"foo\": \"bar\"})\n        m2 = r.xadd(stream, {\"foo\": \"bar\"})\n        m3 = r.xadd(stream, {\"foo\": \"bar\"})\n        m4 = r.xadd(stream, {\"foo\": \"bar\"})\n\n        def get_ids(results):\n            return [result[0] for result in results]\n\n        results = r.xrevrange(stream, max=m4)\n        assert get_ids(results) == [m4, m3, m2, m1]\n\n        results = r.xrevrange(stream, max=m3, min=m2)\n        assert get_ids(results) == [m3, m2]\n\n        results = r.xrevrange(stream, min=m3)\n        assert get_ids(results) == [m4, m3]\n\n        results = r.xrevrange(stream, min=m2, count=1)\n        assert get_ids(results) == [m4]\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    def test_xtrim(self, r):\n        stream = \"stream\"\n\n        # trimming an empty key doesn't do anything\n        assert r.xtrim(stream, 1000) == 0\n\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n\n        # trimming an amount large than the number of messages\n        # doesn't do anything\n        assert r.xtrim(stream, 5, approximate=False) == 0\n\n        # 1 message is trimmed\n        assert r.xtrim(stream, 3, approximate=False) == 1\n\n    @skip_if_server_version_lt(\"6.2.4\")\n    def test_xtrim_minlen_and_length_args(self, r):\n        stream = \"stream\"\n\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n\n        # Future self: No limits without approximate, according to the api\n        with pytest.raises(redis.ResponseError):\n            assert r.xtrim(stream, 3, approximate=False, limit=2)\n\n        # maxlen with a limit\n        assert r.xtrim(stream, 3, approximate=True, limit=2) == 0\n        r.delete(stream)\n\n        with pytest.raises(redis.DataError):\n            assert r.xtrim(stream, maxlen=3, minid=\"sometestvalue\")\n\n        # minid with a limit\n        m1 = r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n        assert r.xtrim(stream, None, approximate=True, minid=m1, limit=3) == 0\n\n        # pure minid\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n        m4 = r.xadd(stream, {\"foo\": \"bar\"})\n        assert r.xtrim(stream, None, approximate=False, minid=m4) == 7\n\n        # minid approximate\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n        m3 = r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n        assert r.xtrim(stream, None, approximate=True, minid=m3) == 0\n\n    @skip_if_server_version_lt(\"8.1.224\")\n    def test_xdelex(self, r):\n        stream = \"stream\"\n\n        m1 = r.xadd(stream, {\"foo\": \"bar\"})\n        m2 = r.xadd(stream, {\"foo\": \"bar\"})\n        m3 = r.xadd(stream, {\"foo\": \"bar\"})\n        m4 = r.xadd(stream, {\"foo\": \"bar\"})\n\n        # Test XDELEX with default ref_policy (KEEPREF)\n        result = r.xdelex(stream, m1)\n        assert result == [1]\n\n        # Test XDELEX with explicit KEEPREF\n        result = r.xdelex(stream, m2, ref_policy=\"KEEPREF\")\n        assert result == [1]\n\n        # Test XDELEX with DELREF\n        result = r.xdelex(stream, m3, ref_policy=\"DELREF\")\n        assert result == [1]\n\n        # Test XDELEX with ACKED\n        result = r.xdelex(stream, m4, ref_policy=\"ACKED\")\n        assert result == [1]\n\n        # Test with non-existent ID\n        result = r.xdelex(stream, \"999999-0\", ref_policy=\"KEEPREF\")\n        assert result == [-1]\n\n        # Test with multiple IDs\n        m5 = r.xadd(stream, {\"foo\": \"bar\"})\n        m6 = r.xadd(stream, {\"foo\": \"bar\"})\n        result = r.xdelex(stream, m5, m6, ref_policy=\"KEEPREF\")\n        assert result == [1, 1]  # Both entries deleted\n\n        # Test error cases\n        with pytest.raises(redis.DataError):\n            r.xdelex(stream, \"123-0\", ref_policy=\"INVALID\")\n\n        with pytest.raises(redis.DataError):\n            r.xdelex(stream)  # No IDs provided\n\n    @skip_if_server_version_lt(\"8.1.224\")\n    def test_xackdel(self, r):\n        stream = \"stream\"\n        group = \"group\"\n        consumer = \"consumer\"\n\n        m1 = r.xadd(stream, {\"foo\": \"bar\"})\n        m2 = r.xadd(stream, {\"foo\": \"bar\"})\n        m3 = r.xadd(stream, {\"foo\": \"bar\"})\n        m4 = r.xadd(stream, {\"foo\": \"bar\"})\n        r.xgroup_create(stream, group, 0)\n\n        r.xreadgroup(group, consumer, streams={stream: \">\"})\n\n        # Test XACKDEL with default ref_policy (KEEPREF)\n        result = r.xackdel(stream, group, m1)\n        assert result == [1]\n\n        # Test XACKDEL with explicit KEEPREF\n        result = r.xackdel(stream, group, m2, ref_policy=\"KEEPREF\")\n        assert result == [1]\n\n        # Test XACKDEL with DELREF\n        result = r.xackdel(stream, group, m3, ref_policy=\"DELREF\")\n        assert result == [1]\n\n        # Test XACKDEL with ACKED\n        result = r.xackdel(stream, group, m4, ref_policy=\"ACKED\")\n        assert result == [1]\n\n        # Test with non-existent ID\n        result = r.xackdel(stream, group, \"999999-0\", ref_policy=\"KEEPREF\")\n        assert result == [-1]\n\n        # Test error cases\n        with pytest.raises(redis.DataError):\n            r.xackdel(stream, group, m1, ref_policy=\"INVALID\")\n\n        with pytest.raises(redis.DataError):\n            r.xackdel(stream, group)  # No IDs provided\n\n    @skip_if_server_version_lt(\"8.1.224\")\n    def test_xtrim_with_options(self, r):\n        stream = \"stream\"\n\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n\n        # Test XTRIM with KEEPREF ref_policy\n        assert r.xtrim(stream, maxlen=2, approximate=False, ref_policy=\"KEEPREF\") == 2\n\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n\n        # Test XTRIM with DELREF ref_policy\n        assert r.xtrim(stream, maxlen=2, approximate=False, ref_policy=\"DELREF\") == 2\n\n        r.xadd(stream, {\"foo\": \"bar\"})\n        r.xadd(stream, {\"foo\": \"bar\"})\n\n        # Test XTRIM with ACKED ref_policy\n        assert r.xtrim(stream, maxlen=2, approximate=False, ref_policy=\"ACKED\") == 2\n\n        # Test error case\n        with pytest.raises(redis.DataError):\n            r.xtrim(stream, maxlen=2, ref_policy=\"INVALID\")\n\n    @skip_if_server_version_lt(\"8.1.224\")\n    def test_xadd_with_options(self, r):\n        stream = \"stream\"\n\n        # Test XADD with KEEPREF ref_policy\n        r.xadd(\n            stream, {\"foo\": \"bar\"}, maxlen=2, approximate=False, ref_policy=\"KEEPREF\"\n        )\n        r.xadd(\n            stream, {\"foo\": \"bar\"}, maxlen=2, approximate=False, ref_policy=\"KEEPREF\"\n        )\n        r.xadd(\n            stream, {\"foo\": \"bar\"}, maxlen=2, approximate=False, ref_policy=\"KEEPREF\"\n        )\n        assert r.xlen(stream) == 2\n\n        # Test XADD with DELREF ref_policy\n        r.xadd(stream, {\"foo\": \"bar\"}, maxlen=2, approximate=False, ref_policy=\"DELREF\")\n        assert r.xlen(stream) == 2\n\n        # Test XADD with ACKED ref_policy\n        r.xadd(stream, {\"foo\": \"bar\"}, maxlen=2, approximate=False, ref_policy=\"ACKED\")\n        assert r.xlen(stream) == 2\n\n        # Test error case\n        with pytest.raises(redis.DataError):\n            r.xadd(stream, {\"foo\": \"bar\"}, ref_policy=\"INVALID\")\n\n    @skip_if_server_version_lt(\"8.5.0\")\n    def test_xadd_idmpauto(self, r):\n        stream = \"stream\"\n\n        # XADD with IDMPAUTO - first write\n        message_id1 = r.xadd(stream, {\"field1\": \"value1\"}, idmpauto=\"producer1\")\n\n        # Test XADD with IDMPAUTO - duplicate write returns same ID\n        message_id2 = r.xadd(stream, {\"field1\": \"value1\"}, idmpauto=\"producer1\")\n        assert message_id1 == message_id2\n\n        # Test XADD with IDMPAUTO - different content creates new entry\n        message_id3 = r.xadd(stream, {\"field1\": \"value2\"}, idmpauto=\"producer1\")\n        assert message_id3 != message_id1\n\n        # Test XADD with IDMPAUTO - different producer creates new entry\n        message_id4 = r.xadd(stream, {\"field1\": \"value1\"}, idmpauto=\"producer2\")\n        assert message_id4 != message_id1\n\n        # Verify stream has 3 entries (2 unique from producer1, 1 from producer2)\n        assert r.xlen(stream) == 3\n\n    @skip_if_server_version_lt(\"8.5.0\")\n    def test_xadd_idmp(self, r):\n        stream = \"stream\"\n\n        # XADD with IDMP - first write\n        message_id1 = r.xadd(stream, {\"field1\": \"value1\"}, idmp=(\"producer1\", b\"msg1\"))\n\n        # Test XADD with IDMP - duplicate write returns same ID\n        message_id2 = r.xadd(stream, {\"field1\": \"value1\"}, idmp=(\"producer1\", b\"msg1\"))\n        assert message_id1 == message_id2\n\n        # Test XADD with IDMP - different iid creates new entry\n        message_id3 = r.xadd(stream, {\"field1\": \"value1\"}, idmp=(\"producer1\", b\"msg2\"))\n        assert message_id3 != message_id1\n\n        # Test XADD with IDMP - different producer creates new entry\n        message_id4 = r.xadd(stream, {\"field1\": \"value1\"}, idmp=(\"producer2\", b\"msg1\"))\n        assert message_id4 != message_id1\n\n        # Test XADD with IDMP - shorter binary iid\n        r.xadd(stream, {\"field1\": \"value1\"}, idmp=(\"producer1\", b\"\\x01\"))\n\n        # Verify stream has 4 entries\n        assert r.xlen(stream) == 4\n\n    @skip_if_server_version_lt(\"8.5.0\")\n    def test_xadd_idmp_validation(self, r):\n        stream = \"stream\"\n\n        # Test error: both idmpauto and idmp specified\n        with pytest.raises(redis.DataError):\n            r.xadd(\n                stream,\n                {\"foo\": \"bar\"},\n                idmpauto=\"producer1\",\n                idmp=(\"producer1\", b\"msg1\"),\n            )\n\n        # Test error: idmpauto with explicit id\n        with pytest.raises(redis.DataError):\n            r.xadd(stream, {\"foo\": \"bar\"}, id=\"1234567890-0\", idmpauto=\"producer1\")\n\n        # Test error: idmp with explicit id\n        with pytest.raises(redis.DataError):\n            r.xadd(\n                stream, {\"foo\": \"bar\"}, id=\"1234567890-0\", idmp=(\"producer1\", b\"msg1\")\n            )\n\n        # Test error: idmp not a tuple\n        with pytest.raises(redis.DataError):\n            r.xadd(stream, {\"foo\": \"bar\"}, idmp=\"invalid\")\n\n        # Test error: idmp tuple with wrong number of elements\n        with pytest.raises(redis.DataError):\n            r.xadd(stream, {\"foo\": \"bar\"}, idmp=(\"producer1\",))\n\n        # Test error: idmp tuple with wrong number of elements\n        with pytest.raises(redis.DataError):\n            r.xadd(stream, {\"foo\": \"bar\"}, idmp=(\"producer1\", b\"msg1\", \"extra\"))\n\n    @skip_if_server_version_lt(\"8.5.0\")\n    def test_xcfgset_idmp_duration(self, r):\n        stream = \"stream\"\n\n        # Create stream first\n        r.xadd(stream, {\"foo\": \"bar\"})\n\n        # Test XCFGSET with IDMP-DURATION only\n        assert r.xcfgset(stream, idmp_duration=120) == b\"OK\"\n\n        # Test with minimum value\n        assert r.xcfgset(stream, idmp_duration=1) == b\"OK\"\n\n        # Test with maximum value\n        assert r.xcfgset(stream, idmp_duration=300) == b\"OK\"\n\n    @skip_if_server_version_lt(\"8.5.0\")\n    def test_xcfgset_idmp_maxsize(self, r):\n        stream = \"stream\"\n\n        # Create stream first\n        r.xadd(stream, {\"foo\": \"bar\"})\n\n        # Test XCFGSET with IDMP-MAXSIZE only\n        assert r.xcfgset(stream, idmp_maxsize=5000) == b\"OK\"\n\n        # Test with minimum value\n        assert r.xcfgset(stream, idmp_maxsize=1) == b\"OK\"\n\n        # Test with maximum value\n        assert r.xcfgset(stream, idmp_maxsize=10000) == b\"OK\"\n\n    @skip_if_server_version_lt(\"8.5.0\")\n    def test_xcfgset_both_parameters(self, r):\n        stream = \"stream\"\n\n        # Create stream first\n        r.xadd(stream, {\"foo\": \"bar\"})\n\n        # Test XCFGSET with both IDMP-DURATION and IDMP-MAXSIZE\n        assert r.xcfgset(stream, idmp_duration=120, idmp_maxsize=5000) == b\"OK\"\n\n        # Test with different values\n        assert r.xcfgset(stream, idmp_duration=60, idmp_maxsize=10000) == b\"OK\"\n\n    @skip_if_server_version_lt(\"8.5.0\")\n    def test_xcfgset_validation(self, r):\n        stream = \"stream\"\n\n        # Test error: no parameters provided\n        with pytest.raises(redis.DataError):\n            r.xcfgset(stream)\n\n        # Test error: idmp_duration too small\n        with pytest.raises(redis.DataError):\n            r.xcfgset(stream, idmp_duration=0)\n\n        # Test error: idmp_duration too large\n        with pytest.raises(redis.DataError):\n            r.xcfgset(stream, idmp_duration=301)\n\n        # Test error: idmp_duration not an integer\n        with pytest.raises(redis.DataError):\n            r.xcfgset(stream, idmp_duration=\"invalid\")\n\n        # Test error: idmp_maxsize too small\n        with pytest.raises(redis.DataError):\n            r.xcfgset(stream, idmp_maxsize=0)\n\n        # Test error: idmp_maxsize too large\n        with pytest.raises(redis.DataError):\n            r.xcfgset(stream, idmp_maxsize=1000001)\n\n        # Test error: idmp_maxsize not an integer\n        with pytest.raises(redis.DataError):\n            r.xcfgset(stream, idmp_maxsize=\"invalid\")\n\n    def test_bitfield_operations(self, r):\n        # comments show affected bits\n        bf = r.bitfield(\"a\")\n        resp = (\n            bf.set(\"u8\", 8, 255)  # 00000000 11111111\n            .get(\"u8\", 0)  # 00000000\n            .get(\"u4\", 8)  # 1111\n            .get(\"u4\", 12)  # 1111\n            .get(\"u4\", 13)  # 111 0\n            .execute()\n        )\n        assert resp == [0, 0, 15, 15, 14]\n\n        # .set() returns the previous value...\n        resp = (\n            bf.set(\"u8\", 4, 1)  # 0000 0001\n            .get(\"u16\", 0)  # 00000000 00011111\n            .set(\"u16\", 0, 0)  # 00000000 00000000\n            .execute()\n        )\n        assert resp == [15, 31, 31]\n\n        # incrby adds to the value\n        resp = (\n            bf.incrby(\"u8\", 8, 254)  # 00000000 11111110\n            .incrby(\"u8\", 8, 1)  # 00000000 11111111\n            .get(\"u16\", 0)  # 00000000 11111111\n            .execute()\n        )\n        assert resp == [254, 255, 255]\n\n        # Verify overflow protection works as a method:\n        r.delete(\"a\")\n        resp = (\n            bf.set(\"u8\", 8, 254)  # 00000000 11111110\n            .overflow(\"fail\")\n            .incrby(\"u8\", 8, 2)  # incrby 2 would overflow, None returned\n            .incrby(\"u8\", 8, 1)  # 00000000 11111111\n            .incrby(\"u8\", 8, 1)  # incrby 1 would overflow, None returned\n            .get(\"u16\", 0)  # 00000000 11111111\n            .execute()\n        )\n        assert resp == [0, None, 255, None, 255]\n\n        # Verify overflow protection works as arg to incrby:\n        r.delete(\"a\")\n        resp = (\n            bf.set(\"u8\", 8, 255)  # 00000000 11111111\n            .incrby(\"u8\", 8, 1)  # 00000000 00000000  wrap default\n            .set(\"u8\", 8, 255)  # 00000000 11111111\n            .incrby(\"u8\", 8, 1, \"FAIL\")  # 00000000 11111111  fail\n            .incrby(\"u8\", 8, 1)  # 00000000 11111111  still fail\n            .get(\"u16\", 0)  # 00000000 11111111\n            .execute()\n        )\n        assert resp == [0, 0, 0, None, None, 255]\n\n        # test default default_overflow\n        r.delete(\"a\")\n        bf = r.bitfield(\"a\", default_overflow=\"FAIL\")\n        resp = (\n            bf.set(\"u8\", 8, 255)  # 00000000 11111111\n            .incrby(\"u8\", 8, 1)  # 00000000 11111111  fail default\n            .get(\"u16\", 0)  # 00000000 11111111\n            .execute()\n        )\n        assert resp == [0, None, 255]\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    def test_bitfield_ro(self, r: redis.Redis):\n        bf = r.bitfield(\"a\")\n        resp = bf.set(\"u8\", 8, 255).execute()\n        assert resp == [0]\n\n        resp = r.bitfield_ro(\"a\", \"u8\", 0)\n        assert resp == [0]\n\n        items = [(\"u4\", 8), (\"u4\", 12), (\"u4\", 13)]\n        resp = r.bitfield_ro(\"a\", \"u8\", 0, items)\n        assert resp == [0, 15, 15, 14]\n\n    @skip_if_server_version_lt(\"4.0.0\")\n    def test_memory_help(self, r):\n        with pytest.raises(NotImplementedError):\n            r.memory_help()\n\n    @skip_if_server_version_lt(\"4.0.0\")\n    def test_memory_doctor(self, r):\n        with pytest.raises(NotImplementedError):\n            r.memory_doctor()\n\n    @skip_if_server_version_lt(\"4.0.0\")\n    @skip_if_redis_enterprise()\n    def test_memory_malloc_stats(self, r):\n        if skip_if_redis_enterprise().args[0] is True:\n            with pytest.raises(redis.exceptions.ResponseError):\n                assert r.memory_malloc_stats()\n            return\n\n        assert r.memory_malloc_stats()\n\n    @skip_if_server_version_lt(\"4.0.0\")\n    @skip_if_redis_enterprise()\n    def test_memory_stats(self, r):\n        # put a key into the current db to make sure that \"db.<current-db>\"\n        # has data\n        r.set(\"foo\", \"bar\")\n\n        if skip_if_redis_enterprise().args[0] is True:\n            with pytest.raises(redis.exceptions.ResponseError):\n                stats = r.memory_stats()\n            return\n\n        stats = r.memory_stats()\n        assert isinstance(stats, dict)\n        for key, value in stats.items():\n            if key.startswith(\"db.\"):\n                assert not isinstance(value, list)\n\n    @skip_if_server_version_lt(\"4.0.0\")\n    def test_memory_usage(self, r):\n        r.set(\"foo\", \"bar\")\n        assert isinstance(r.memory_usage(\"foo\"), int)\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_latency_histogram_not_implemented(self, r: redis.Redis):\n        with pytest.raises(NotImplementedError):\n            r.latency_histogram()\n\n    def test_latency_graph_not_implemented(self, r: redis.Redis):\n        with pytest.raises(NotImplementedError):\n            r.latency_graph()\n\n    def test_latency_doctor_not_implemented(self, r: redis.Redis):\n        with pytest.raises(NotImplementedError):\n            r.latency_doctor()\n\n    def test_latency_history(self, r: redis.Redis):\n        assert r.latency_history(\"command\") == []\n\n    def test_latency_latest(self, r: redis.Redis):\n        assert r.latency_latest() == []\n\n    def test_latency_reset(self, r: redis.Redis):\n        assert r.latency_reset() == 0\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"4.0.0\")\n    @skip_if_redis_enterprise()\n    def test_module_list(self, r):\n        assert isinstance(r.module_list(), list)\n        for x in r.module_list():\n            assert isinstance(x, dict)\n\n    @skip_if_server_version_lt(\"2.8.13\")\n    @skip_if_redis_enterprise()\n    def test_command_count(self, r):\n        res = r.command_count()\n        assert isinstance(res, int)\n        assert res >= 100\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_command_docs(self, r):\n        with pytest.raises(NotImplementedError):\n            r.command_docs(\"set\")\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    @skip_if_redis_enterprise()\n    def test_command_list(self, r: redis.Redis):\n        assert len(r.command_list()) > 300\n        assert len(r.command_list(module=\"fakemod\")) == 0\n        assert len(r.command_list(category=\"list\")) > 15\n        assert b\"lpop\" in r.command_list(pattern=\"l*\")\n        with pytest.raises(redis.ResponseError):\n            r.command_list(category=\"list\", pattern=\"l*\")\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"2.8.13\")\n    @skip_if_redis_enterprise()\n    def test_command_getkeys(self, r):\n        res = r.command_getkeys(\"MSET\", \"a\", \"b\", \"c\", \"d\", \"e\", \"f\")\n        assert_resp_response(r, res, [\"a\", \"c\", \"e\"], [b\"a\", b\"c\", b\"e\"])\n        res = r.command_getkeys(\n            \"EVAL\",\n            '\"not consulted\"',\n            \"3\",\n            \"key1\",\n            \"key2\",\n            \"key3\",\n            \"arg1\",\n            \"arg2\",\n            \"arg3\",\n            \"argN\",\n        )\n        assert_resp_response(\n            r, res, [\"key1\", \"key2\", \"key3\"], [b\"key1\", b\"key2\", b\"key3\"]\n        )\n\n    @skip_if_server_version_lt(\"2.8.13\")\n    def test_command(self, r):\n        res = r.command()\n        assert len(res) >= 100\n        cmds = list(res.keys())\n        assert \"set\" in cmds\n        assert \"get\" in cmds\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"7.0.0\")\n    @skip_if_redis_enterprise()\n    def test_command_getkeysandflags(self, r: redis.Redis):\n        res = r.command_getkeysandflags(\"LMOVE\", \"mylist1\", \"mylist2\", \"left\", \"left\")\n        assert res == [\n            [b\"mylist1\", [b\"RW\", b\"access\", b\"delete\"]],\n            [b\"mylist2\", [b\"RW\", b\"insert\"]],\n        ]\n\n    @pytest.mark.redismod\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"4.0.0\")\n    @skip_if_redis_enterprise()\n    def test_module(self, stack_r):\n        with pytest.raises(redis.exceptions.ModuleError) as excinfo:\n            stack_r.module_load(\"/some/fake/path\")\n            assert \"Error loading the extension.\" in str(excinfo.value)\n\n        with pytest.raises(redis.exceptions.ModuleError) as excinfo:\n            stack_r.module_load(\"/some/fake/path\", \"arg1\", \"arg2\", \"arg3\", \"arg4\")\n            assert \"Error loading the extension.\" in str(excinfo.value)\n\n    @pytest.mark.redismod\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"7.0.0\")\n    @skip_if_redis_enterprise()\n    def test_module_loadex(self, stack_r: redis.Redis):\n        with pytest.raises(redis.exceptions.ModuleError) as excinfo:\n            stack_r.module_loadex(\"/some/fake/path\")\n            assert \"Error loading the extension.\" in str(excinfo.value)\n\n        with pytest.raises(redis.exceptions.ModuleError) as excinfo:\n            stack_r.module_loadex(\n                \"/some/fake/path\", [\"name\", \"value\"], [\"arg1\", \"arg2\"]\n            )\n            assert \"Error loading the extension.\" in str(excinfo.value)\n\n    @skip_if_server_version_lt(\"2.6.0\")\n    def test_restore(self, r):\n        # standard restore\n        key = \"foo\"\n        r.set(key, \"bar\")\n        dumpdata = r.dump(key)\n        r.delete(key)\n        assert r.restore(key, 0, dumpdata)\n        assert r.get(key) == b\"bar\"\n\n        # overwrite restore\n        with pytest.raises(redis.exceptions.ResponseError):\n            assert r.restore(key, 0, dumpdata)\n        r.set(key, \"a new value!\")\n        assert r.restore(key, 0, dumpdata, replace=True)\n        assert r.get(key) == b\"bar\"\n\n        # ttl check\n        key2 = \"another\"\n        r.set(key2, \"blee!\")\n        dumpdata = r.dump(key2)\n        r.delete(key2)\n        assert r.restore(key2, 0, dumpdata)\n        assert r.ttl(key2) == -1\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    def test_restore_idletime(self, r):\n        key = \"yayakey\"\n        r.set(key, \"blee!\")\n        dumpdata = r.dump(key)\n        r.delete(key)\n        assert r.restore(key, 0, dumpdata, idletime=5)\n        assert r.get(key) == b\"blee!\"\n\n    @skip_if_server_version_lt(\"5.0.0\")\n    def test_restore_frequency(self, r):\n        key = \"yayakey\"\n        r.set(key, \"blee!\")\n        dumpdata = r.dump(key)\n        r.delete(key)\n        assert r.restore(key, 0, dumpdata, frequency=5)\n        assert r.get(key) == b\"blee!\"\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"5.0.0\")\n    @skip_if_redis_enterprise()\n    def test_replicaof(self, r):\n        with pytest.raises(redis.ResponseError):\n            assert r.replicaof(\"NO ONE\")\n        assert r.replicaof(\"NO\", \"ONE\")\n\n    def test_shutdown(self, r: redis.Redis):\n        r.execute_command = mock.MagicMock()\n        r.execute_command(\"SHUTDOWN\", \"NOSAVE\")\n        r.execute_command.assert_called_once_with(\"SHUTDOWN\", \"NOSAVE\")\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_shutdown_with_params(self, r: redis.Redis):\n        r.execute_command = mock.MagicMock()\n        r.execute_command(\"SHUTDOWN\", \"SAVE\", \"NOW\", \"FORCE\")\n        r.execute_command.assert_called_once_with(\"SHUTDOWN\", \"SAVE\", \"NOW\", \"FORCE\")\n        r.execute_command(\"SHUTDOWN\", \"ABORT\")\n        r.execute_command.assert_called_with(\"SHUTDOWN\", \"ABORT\")\n\n    @pytest.mark.replica\n    @pytest.mark.xfail(strict=False)\n    @skip_if_server_version_lt(\"2.8.0\")\n    @skip_if_redis_enterprise()\n    def test_sync(self, r):\n        r.flushdb()\n        time.sleep(1)\n        r2 = redis.Redis(port=6380, decode_responses=False)\n        res = r2.sync()\n        assert b\"REDIS\" in res\n\n    @pytest.mark.replica\n    @skip_if_server_version_lt(\"2.8.0\")\n    @skip_if_redis_enterprise()\n    def test_psync(self, r):\n        r2 = redis.Redis(port=6380, decode_responses=False)\n        res = r2.psync(r2.client_id(), 1)\n        assert b\"FULLRESYNC\" in res\n\n    @pytest.mark.onlynoncluster\n    def test_interrupted_command(self, r: redis.Redis):\n        \"\"\"\n        Regression test for issue #1128:  An Un-handled BaseException\n        will leave the socket with un-read response to a previous\n        command.\n        \"\"\"\n\n        ok = False\n\n        def helper():\n            with pytest.raises(CancelledError):\n                # blocking pop\n                with patch.object(\n                    r.connection._parser, \"read_response\", side_effect=CancelledError\n                ):\n                    r.brpop([\"nonexist\"])\n            # if all is well, we can continue.\n            r.set(\"status\", \"down\")  # should not hang\n            nonlocal ok\n            ok = True\n\n        thread = threading.Thread(target=helper)\n        thread.start()\n        thread.join(0.1)\n        try:\n            assert not thread.is_alive()\n            assert ok\n        finally:\n            # disconnect here so that fixture cleanup can proceed\n            r.connection.disconnect()\n\n\n@pytest.mark.onlynoncluster\nclass TestBinarySave:\n    def test_binary_get_set(self, r):\n        assert r.set(\" foo bar \", \"123\")\n        assert r.get(\" foo bar \") == b\"123\"\n\n        assert r.set(\" foo\\r\\nbar\\r\\n \", \"456\")\n        assert r.get(\" foo\\r\\nbar\\r\\n \") == b\"456\"\n\n        assert r.set(\" \\r\\n\\t\\x07\\x13 \", \"789\")\n        assert r.get(\" \\r\\n\\t\\x07\\x13 \") == b\"789\"\n\n        assert sorted(r.keys(\"*\")) == [\n            b\" \\r\\n\\t\\x07\\x13 \",\n            b\" foo\\r\\nbar\\r\\n \",\n            b\" foo bar \",\n        ]\n\n        assert r.delete(\" foo bar \")\n        assert r.delete(\" foo\\r\\nbar\\r\\n \")\n        assert r.delete(\" \\r\\n\\t\\x07\\x13 \")\n\n    def test_binary_lists(self, r):\n        mapping = {\n            b\"foo bar\": [b\"1\", b\"2\", b\"3\"],\n            b\"foo\\r\\nbar\\r\\n\": [b\"4\", b\"5\", b\"6\"],\n            b\"foo\\tbar\\x07\": [b\"7\", b\"8\", b\"9\"],\n        }\n        # fill in lists\n        for key, value in mapping.items():\n            r.rpush(key, *value)\n\n        # check that KEYS returns all the keys as they are\n        assert sorted(r.keys(\"*\")) == sorted(mapping.keys())\n\n        # check that it is possible to get list content by key name\n        for key, value in mapping.items():\n            assert r.lrange(key, 0, -1) == value\n\n    def test_22_info(self, r):\n        \"\"\"\n        Older Redis versions contained 'allocation_stats' in INFO that\n        was the cause of a number of bugs when parsing.\n        \"\"\"\n        info = (\n            \"allocation_stats:6=1,7=1,8=7141,9=180,10=92,11=116,12=5330,\"\n            \"13=123,14=3091,15=11048,16=225842,17=1784,18=814,19=12020,\"\n            \"20=2530,21=645,22=15113,23=8695,24=142860,25=318,26=3303,\"\n            \"27=20561,28=54042,29=37390,30=1884,31=18071,32=31367,33=160,\"\n            \"34=169,35=201,36=10155,37=1045,38=15078,39=22985,40=12523,\"\n            \"41=15588,42=265,43=1287,44=142,45=382,46=945,47=426,48=171,\"\n            \"49=56,50=516,51=43,52=41,53=46,54=54,55=75,56=647,57=332,\"\n            \"58=32,59=39,60=48,61=35,62=62,63=32,64=221,65=26,66=30,\"\n            \"67=36,68=41,69=44,70=26,71=144,72=169,73=24,74=37,75=25,\"\n            \"76=42,77=21,78=126,79=374,80=27,81=40,82=43,83=47,84=46,\"\n            \"85=114,86=34,87=37,88=7240,89=34,90=38,91=18,92=99,93=20,\"\n            \"94=18,95=17,96=15,97=22,98=18,99=69,100=17,101=22,102=15,\"\n            \"103=29,104=39,105=30,106=70,107=22,108=21,109=26,110=52,\"\n            \"111=45,112=33,113=67,114=41,115=44,116=48,117=53,118=54,\"\n            \"119=51,120=75,121=44,122=57,123=44,124=66,125=56,126=52,\"\n            \"127=81,128=108,129=70,130=50,131=51,132=53,133=45,134=62,\"\n            \"135=12,136=13,137=7,138=15,139=21,140=11,141=20,142=6,143=7,\"\n            \"144=11,145=6,146=16,147=19,148=1112,149=1,151=83,154=1,\"\n            \"155=1,156=1,157=1,160=1,161=1,162=2,166=1,169=1,170=1,171=2,\"\n            \"172=1,174=1,176=2,177=9,178=34,179=73,180=30,181=1,185=3,\"\n            \"187=1,188=1,189=1,192=1,196=1,198=1,200=1,201=1,204=1,205=1,\"\n            \"207=1,208=1,209=1,214=2,215=31,216=78,217=28,218=5,219=2,\"\n            \"220=1,222=1,225=1,227=1,234=1,242=1,250=1,252=1,253=1,\"\n            \">=256=203\"\n        )\n        parsed = parse_info(info)\n        assert \"allocation_stats\" in parsed\n        assert \"6\" in parsed[\"allocation_stats\"]\n        assert \">=256\" in parsed[\"allocation_stats\"]\n\n    @skip_if_redis_enterprise()\n    def test_large_responses(self, r):\n        \"The PythonParser has some special cases for return values > 1MB\"\n        # load up 5MB of data into a key\n        data = \"\".join([ascii_letters] * (5000000 // len(ascii_letters)))\n        r[\"a\"] = data\n        assert r[\"a\"] == data.encode()\n\n    def test_floating_point_encoding(self, r):\n        \"\"\"\n        High precision floating point values sent to the server should keep\n        precision.\n        \"\"\"\n        timestamp = 1349673917.939762\n        r.zadd(\"a\", {\"a1\": timestamp})\n        assert r.zscore(\"a\", \"a1\") == timestamp\n"
  },
  {
    "path": "tests/test_connect.py",
    "content": "import re\nimport socket\nimport socketserver\nimport ssl\nimport threading\n\nimport pytest\nfrom redis.connection import Connection, SSLConnection, UnixDomainSocketConnection\nfrom redis.exceptions import RedisError\n\nfrom .ssl_utils import CertificateType, get_tls_certificates\n\n_CLIENT_NAME = \"test-suite-client\"\n_CMD_SEP = b\"\\r\\n\"\n_SUCCESS_RESP = b\"+OK\" + _CMD_SEP\n_ERROR_RESP = b\"-ERR\" + _CMD_SEP\n_SUPPORTED_CMDS = {f\"CLIENT SETNAME {_CLIENT_NAME}\": _SUCCESS_RESP}\n\n\n@pytest.fixture\ndef tcp_address():\n    with socket.socket() as sock:\n        sock.bind((\"127.0.0.1\", 0))\n        return sock.getsockname()\n\n\n@pytest.fixture\ndef uds_address(tmpdir):\n    return tmpdir / \"uds.sock\"\n\n\ndef test_tcp_connect(tcp_address):\n    host, port = tcp_address\n    conn = Connection(host=host, port=port, client_name=_CLIENT_NAME, socket_timeout=10)\n    _assert_connect(conn, tcp_address)\n\n\ndef test_uds_connect(uds_address):\n    path = str(uds_address)\n    conn = UnixDomainSocketConnection(path, client_name=_CLIENT_NAME, socket_timeout=10)\n    _assert_connect(conn, path)\n\n\n@pytest.mark.ssl\n@pytest.mark.parametrize(\n    \"ssl_min_version\",\n    [\n        ssl.TLSVersion.TLSv1_2,\n        pytest.param(\n            ssl.TLSVersion.TLSv1_3,\n            marks=pytest.mark.skipif(not ssl.HAS_TLSv1_3, reason=\"requires TLSv1.3\"),\n        ),\n    ],\n)\ndef test_tcp_ssl_connect(tcp_address, ssl_min_version):\n    host, port = tcp_address\n\n    # in order to have working hostname verification, we need to use \"localhost\"\n    # as redis host as the server certificate is self-signed and only valid for \"localhost\"\n    host = \"localhost\"\n    server_certs = get_tls_certificates(cert_type=CertificateType.server)\n\n    conn = SSLConnection(\n        host=host,\n        port=port,\n        ssl_check_hostname=True,\n        client_name=_CLIENT_NAME,\n        ssl_ca_certs=server_certs.ca_certfile,\n        socket_timeout=10,\n        ssl_min_version=ssl_min_version,\n    )\n    _assert_connect(\n        conn, tcp_address, certfile=server_certs.certfile, keyfile=server_certs.keyfile\n    )\n\n\n@pytest.mark.ssl\n@pytest.mark.parametrize(\n    \"ssl_ciphers\",\n    [\n        \"AES256-SHA:DHE-RSA-AES256-SHA:AES128-SHA:DHE-RSA-AES128-SHA\",\n        \"ECDHE-ECDSA-AES256-GCM-SHA384\",\n        \"ECDHE-RSA-AES128-GCM-SHA256\",\n    ],\n)\ndef test_tcp_ssl_tls12_custom_ciphers(tcp_address, ssl_ciphers):\n    host, port = tcp_address\n\n    # in order to have working hostname verification, we need to use \"localhost\"\n    # as redis host as the server certificate is self-signed and only valid for \"localhost\"\n    host = \"localhost\"\n\n    server_certs = get_tls_certificates(cert_type=CertificateType.server)\n\n    conn = SSLConnection(\n        host=host,\n        port=port,\n        client_name=_CLIENT_NAME,\n        ssl_ca_certs=server_certs.ca_certfile,\n        socket_timeout=10,\n        ssl_min_version=ssl.TLSVersion.TLSv1_2,\n        ssl_ciphers=ssl_ciphers,\n    )\n    _assert_connect(\n        conn, tcp_address, certfile=server_certs.certfile, keyfile=server_certs.keyfile\n    )\n\n\n\"\"\"\nAddresses bug CAE-333 which uncovered that the init method of the base\nclass did override the initialization of the socket_timeout parameter.\n\"\"\"\n\n\ndef test_unix_socket_with_timeout():\n    conn = UnixDomainSocketConnection(socket_timeout=1000)\n\n    # Check if the base class defaults were taken over.\n    assert conn.db == 0\n\n    # Verify if the timeout and the path is set correctly.\n    assert conn.socket_timeout == 1000\n    assert conn.path == \"\"\n\n\n@pytest.mark.ssl\n@pytest.mark.skipif(not ssl.HAS_TLSv1_3, reason=\"requires TLSv1.3\")\ndef test_tcp_ssl_version_mismatch(tcp_address):\n    host, port = tcp_address\n    certfile, keyfile, _ = get_tls_certificates(cert_type=CertificateType.server)\n    conn = SSLConnection(\n        host=host,\n        port=port,\n        client_name=_CLIENT_NAME,\n        ssl_ca_certs=certfile,\n        socket_timeout=3,\n        ssl_min_version=ssl.TLSVersion.TLSv1_3,\n    )\n    with pytest.raises(RedisError):\n        _assert_connect(\n            conn,\n            tcp_address,\n            certfile=certfile,\n            keyfile=keyfile,\n            maximum_ssl_version=ssl.TLSVersion.TLSv1_2,\n        )\n\n\ndef _assert_connect(conn, server_address, **tcp_kw):\n    if isinstance(server_address, str):\n        if not _RedisUDSServer:\n            pytest.skip(\"Unix domain sockets are not supported on this platform\")\n        server = _RedisUDSServer(server_address, _RedisRequestHandler)\n    else:\n        server = _RedisTCPServer(server_address, _RedisRequestHandler, **tcp_kw)\n    with server as aserver:\n        t = threading.Thread(target=aserver.serve_forever)\n        t.start()\n        try:\n            aserver.wait_online()\n            conn.connect()\n            conn.disconnect()\n        finally:\n            aserver.stop()\n            t.join(timeout=5)\n\n\nclass _RedisTCPServer(socketserver.TCPServer):\n    def __init__(\n        self,\n        *args,\n        certfile=None,\n        keyfile=None,\n        minimum_ssl_version=ssl.TLSVersion.TLSv1_2,\n        maximum_ssl_version=ssl.TLSVersion.TLSv1_3,\n        **kw,\n    ) -> None:\n        self._ready_event = threading.Event()\n        self._stop_requested = False\n        self._certfile = certfile\n        self._keyfile = keyfile\n        self._minimum_ssl_version = minimum_ssl_version\n        self._maximum_ssl_version = maximum_ssl_version\n        super().__init__(*args, **kw)\n\n    def service_actions(self):\n        self._ready_event.set()\n\n    def wait_online(self):\n        self._ready_event.wait()\n\n    def stop(self):\n        self._stop_requested = True\n        self.shutdown()\n\n    def is_serving(self):\n        return not self._stop_requested\n\n    def get_request(self):\n        if self._certfile is None:\n            return super().get_request()\n        newsocket, fromaddr = self.socket.accept()\n        context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)\n        context.load_cert_chain(certfile=self._certfile, keyfile=self._keyfile)\n        context.minimum_version = self._minimum_ssl_version\n        context.maximum_version = self._maximum_ssl_version\n        connstream = context.wrap_socket(newsocket, server_side=True)\n        return connstream, fromaddr\n\n\nif hasattr(socketserver, \"UnixStreamServer\"):\n\n    class _RedisUDSServer(socketserver.UnixStreamServer):\n        def __init__(self, *args, **kw) -> None:\n            self._ready_event = threading.Event()\n            self._stop_requested = False\n            super().__init__(*args, **kw)\n\n        def service_actions(self):\n            self._ready_event.set()\n\n        def wait_online(self):\n            self._ready_event.wait()\n\n        def stop(self):\n            self._stop_requested = True\n            self.shutdown()\n\n        def is_serving(self):\n            return not self._stop_requested\n\nelse:\n    _RedisUDSServer = None\n\n\nclass _RedisRequestHandler(socketserver.StreamRequestHandler):\n    def setup(self):\n        pass\n\n    def finish(self):\n        pass\n\n    def handle(self):\n        buffer = b\"\"\n        command = None\n        command_ptr = None\n        fragment_length = None\n        while self.server.is_serving() or buffer:\n            try:\n                buffer += self.request.recv(1024)\n            except socket.timeout:\n                continue\n            if not buffer:\n                continue\n            parts = re.split(_CMD_SEP, buffer)\n            buffer = parts[-1]\n            for fragment in parts[:-1]:\n                fragment = fragment.decode()\n\n                if fragment.startswith(\"*\") and command is None:\n                    command = [None for _ in range(int(fragment[1:]))]\n                    command_ptr = 0\n                    fragment_length = None\n                    continue\n\n                if fragment.startswith(\"$\") and command[command_ptr] is None:\n                    fragment_length = int(fragment[1:])\n                    continue\n\n                assert len(fragment) == fragment_length\n                command[command_ptr] = fragment\n                command_ptr += 1\n\n                if command_ptr < len(command):\n                    continue\n\n                command = \" \".join(command)\n                resp = _SUPPORTED_CMDS.get(command, _ERROR_RESP)\n                self.request.sendall(resp)\n                command = None\n"
  },
  {
    "path": "tests/test_connection.py",
    "content": "import copy\nimport platform\nimport socket\nimport sys\nimport threading\nimport types\nfrom errno import ECONNREFUSED\nfrom typing import Any\nfrom unittest import mock\nfrom unittest.mock import call, patch, MagicMock, Mock\n\nimport pytest\nimport redis\nfrom redis import ConnectionPool, Redis\nfrom redis._parsers import _HiredisParser, _RESP2Parser, _RESP3Parser\nfrom redis._parsers.socket import SENTINEL\nfrom redis.backoff import NoBackoff\nfrom redis.cache import (\n    CacheConfig,\n    CacheEntry,\n    CacheEntryStatus,\n    CacheInterface,\n    CacheKey,\n    CacheProxy,\n    DefaultCache,\n    LRUPolicy,\n)\nfrom redis.connection import (\n    CacheProxyConnection,\n    Connection,\n    SSLConnection,\n    UnixDomainSocketConnection,\n    parse_url,\n    BlockingConnectionPool,\n)\nfrom redis.credentials import UsernamePasswordCredentialProvider\nfrom redis.event import (\n    EventDispatcher,\n)\nfrom redis.exceptions import ConnectionError, InvalidResponse, RedisError, TimeoutError\nfrom redis.observability.attributes import (\n    DB_CLIENT_CONNECTION_POOL_NAME,\n    DB_CLIENT_CONNECTION_STATE,\n    ConnectionState,\n)\nfrom redis.retry import Retry\nfrom redis.utils import HIREDIS_AVAILABLE\n\nfrom .conftest import skip_if_server_version_lt\nfrom .mocks import MockSocket\n\n\n@pytest.mark.skipif(HIREDIS_AVAILABLE, reason=\"PythonParser only\")\n@pytest.mark.onlynoncluster\ndef test_invalid_response(r):\n    raw = b\"x\"\n    parser = r.connection._parser\n    with mock.patch.object(parser._buffer, \"readline\", return_value=raw):\n        with pytest.raises(InvalidResponse, match=f\"Protocol Error: {raw!r}\"):\n            parser.read_response()\n\n\n@skip_if_server_version_lt(\"4.0.0\")\n@pytest.mark.redismod\ndef test_loading_external_modules(r):\n    def inner():\n        pass\n\n    r.load_external_module(\"myfuncname\", inner)\n    assert getattr(r, \"myfuncname\") == inner\n    assert isinstance(getattr(r, \"myfuncname\"), types.FunctionType)\n\n    # and call it\n    from redis.commands import RedisModuleCommands\n\n    j = RedisModuleCommands.json\n    r.load_external_module(\"sometestfuncname\", j)\n\n    # d = {'hello': 'world!'}\n    # mod = j(r)\n    # mod.set(\"fookey\", \".\", d)\n    # assert mod.get('fookey') == d\n\n\nclass TestConnection:\n    def test_disconnect(self):\n        conn = Connection()\n        mock_sock = mock.Mock()\n        conn._sock = mock_sock\n        conn.disconnect()\n        mock_sock.shutdown.assert_called_once()\n        mock_sock.close.assert_called_once()\n        assert conn._sock is None\n\n    def test_disconnect__shutdown_OSError(self):\n        \"\"\"An OSError on socket shutdown will still close the socket.\"\"\"\n        conn = Connection()\n        mock_sock = mock.Mock()\n        conn._sock = mock_sock\n        conn._sock.shutdown.side_effect = OSError\n        conn.disconnect()\n        mock_sock.shutdown.assert_called_once()\n        mock_sock.close.assert_called_once()\n        assert conn._sock is None\n\n    def test_disconnect__close_OSError(self):\n        \"\"\"An OSError on socket close will still clear out the socket.\"\"\"\n        conn = Connection()\n        mock_sock = mock.Mock()\n        conn._sock = mock_sock\n        conn._sock.close.side_effect = OSError\n        conn.disconnect()\n        mock_sock.shutdown.assert_called_once()\n        mock_sock.close.assert_called_once()\n        assert conn._sock is None\n\n    def clear(self, conn):\n        conn.retry_on_error.clear()\n\n    def test_retry_connect_on_timeout_error(self):\n        \"\"\"Test that the _connect function is retried in case of a timeout\"\"\"\n        conn = Connection(retry_on_timeout=True, retry=Retry(NoBackoff(), 3))\n        origin_connect = conn._connect\n        conn._connect = mock.Mock()\n\n        def mock_connect():\n            # connect only on the last retry\n            if conn._connect.call_count <= 2:\n                raise socket.timeout\n            else:\n                return origin_connect()\n\n        conn._connect.side_effect = mock_connect\n        conn.connect()\n        assert conn._connect.call_count == 3\n        self.clear(conn)\n\n    def test_connect_without_retry_on_non_retryable_error(self):\n        \"\"\"Test that the _connect function is not being retried in case of a non-retryable error\"\"\"\n        with patch.object(Connection, \"_connect\") as _connect:\n            _connect.side_effect = RedisError(\"\")\n            conn = Connection(retry_on_timeout=True, retry=Retry(NoBackoff(), 2))\n            with pytest.raises(RedisError):\n                conn.connect()\n            assert _connect.call_count == 1\n            self.clear(conn)\n\n    def test_connect_with_retries(self):\n        \"\"\"\n        Validate that retries occur for the entire connect+handshake flow when OSError\n        happens during the handshake phase.\n        \"\"\"\n        with patch.object(socket.socket, \"sendall\") as sendall:\n            sendall.side_effect = OSError(ECONNREFUSED)\n            conn = Connection(retry_on_timeout=True, retry=Retry(NoBackoff(), 2))\n            with pytest.raises(ConnectionError):\n                conn.connect()\n            # the handshake commands are the failing ones\n            # validate that we don't execute too many commands on each retry\n            # 3 retries --> 3 commands\n            assert sendall.call_count == 3\n\n    def test_connect_timeout_error_without_retry(self):\n        \"\"\"Test that the _connect function is not being retried if retry_on_timeout is\n        set to False\"\"\"\n        conn = Connection(retry_on_timeout=False)\n        conn._connect = mock.Mock()\n        conn._connect.side_effect = socket.timeout\n\n        with pytest.raises(TimeoutError, match=\"Timeout connecting to server\"):\n            conn.connect()\n        assert conn._connect.call_count == 1\n        self.clear(conn)\n\n\n@pytest.mark.onlynoncluster\n@pytest.mark.parametrize(\n    \"parser_class\",\n    [_RESP2Parser, _RESP3Parser, _HiredisParser],\n    ids=[\"RESP2Parser\", \"RESP3Parser\", \"HiredisParser\"],\n)\ndef test_connection_parse_response_resume(r: redis.Redis, parser_class):\n    \"\"\"\n    This test verifies that the Connection parser,\n    be that PythonParser or HiredisParser,\n    can be interrupted at IO time and then resume parsing.\n    \"\"\"\n    if parser_class is _HiredisParser and not HIREDIS_AVAILABLE:\n        pytest.skip(\"Hiredis not available)\")\n    args = dict(r.connection_pool.connection_kwargs)\n    args[\"parser_class\"] = parser_class\n    conn = Connection(**args)\n    conn.connect()\n    message = (\n        b\"*3\\r\\n$7\\r\\nmessage\\r\\n$8\\r\\nchannel1\\r\\n\"\n        b\"$25\\r\\nhi\\r\\nthere\\r\\n+how\\r\\nare\\r\\nyou\\r\\n\"\n    )\n    mock_socket = MockSocket(message, interrupt_every=2)\n\n    if isinstance(conn._parser, _RESP2Parser) or isinstance(conn._parser, _RESP3Parser):\n        conn._parser._buffer._sock = mock_socket\n    else:\n        conn._parser._sock = mock_socket\n    for i in range(100):\n        try:\n            response = conn.read_response(disconnect_on_error=False)\n            break\n        except MockSocket.TestError:\n            pass\n\n    else:\n        pytest.fail(\"didn't receive a response\")\n    assert response\n    assert i > 0\n\n\n@pytest.mark.onlynoncluster\n@pytest.mark.parametrize(\n    \"Class\",\n    [\n        Connection,\n        SSLConnection,\n        UnixDomainSocketConnection,\n    ],\n)\ndef test_pack_command(Class):\n    \"\"\"\n    This test verifies that the pack_command works\n    on all supported connections. #2581\n    \"\"\"\n    cmd = (\n        \"HSET\",\n        \"foo\",\n        \"key\",\n        \"value1\",\n        b\"key_b\",\n        b\"bytes str\",\n        b\"key_i\",\n        67,\n        \"key_f\",\n        3.14159265359,\n    )\n    expected = (\n        b\"*10\\r\\n$4\\r\\nHSET\\r\\n$3\\r\\nfoo\\r\\n$3\\r\\nkey\\r\\n$6\\r\\nvalue1\\r\\n\"\n        b\"$5\\r\\nkey_b\\r\\n$9\\r\\nbytes str\\r\\n$5\\r\\nkey_i\\r\\n$2\\r\\n67\\r\\n$5\"\n        b\"\\r\\nkey_f\\r\\n$13\\r\\n3.14159265359\\r\\n\"\n    )\n\n    actual = Class().pack_command(*cmd)[0]\n    assert actual == expected, f\"actual = {actual}, expected = {expected}\"\n\n\n@pytest.mark.onlynoncluster\ndef test_create_single_connection_client_from_url():\n    client = redis.Redis.from_url(\n        \"redis://localhost:6379/0?\", single_connection_client=True\n    )\n    assert client.connection is not None\n\n\n@pytest.mark.parametrize(\"from_url\", (True, False), ids=(\"from_url\", \"from_args\"))\ndef test_pool_auto_close(request, from_url):\n    \"\"\"Verify that basic Redis instances have auto_close_connection_pool set to True\"\"\"\n\n    url: str = request.config.getoption(\"--redis-url\")\n    url_args = parse_url(url)\n\n    def get_redis_connection():\n        if from_url:\n            return Redis.from_url(url)\n        return Redis(**url_args)\n\n    r1 = get_redis_connection()\n    assert r1.auto_close_connection_pool is True\n    r1.close()\n\n\n@pytest.mark.skipif(sys.version_info == (3, 9), reason=\"Flacky test on Python 3.9\")\n@pytest.mark.parametrize(\"from_url\", (True, False), ids=(\"from_url\", \"from_args\"))\ndef test_redis_connection_pool(request, from_url):\n    \"\"\"Verify that basic Redis instances using `connection_pool`\n    have auto_close_connection_pool set to False\"\"\"\n\n    url: str = request.config.getoption(\"--redis-url\")\n    url_args = parse_url(url)\n\n    pool = None\n\n    def get_redis_connection():\n        nonlocal pool\n        if from_url:\n            pool = ConnectionPool.from_url(url)\n        else:\n            pool = ConnectionPool(**url_args)\n        return Redis(connection_pool=pool)\n\n    called = 0\n\n    def mock_disconnect(_):\n        nonlocal called\n        called += 1\n\n    with patch.object(ConnectionPool, \"disconnect\", mock_disconnect):\n        with get_redis_connection() as r1:\n            assert r1.auto_close_connection_pool is False\n\n    assert called == 0\n    pool.disconnect()\n\n\n@pytest.mark.parametrize(\"from_url\", (True, False), ids=(\"from_url\", \"from_args\"))\ndef test_redis_from_pool(request, from_url):\n    \"\"\"Verify that basic Redis instances created using `from_pool()`\n    have auto_close_connection_pool set to True\"\"\"\n\n    url: str = request.config.getoption(\"--redis-url\")\n    url_args = parse_url(url)\n\n    pool = None\n\n    def get_redis_connection():\n        nonlocal pool\n        if from_url:\n            pool = ConnectionPool.from_url(url)\n        else:\n            pool = ConnectionPool(**url_args)\n        return Redis.from_pool(pool)\n\n    called = 0\n\n    def mock_disconnect(_):\n        nonlocal called\n        called += 1\n\n    with patch.object(ConnectionPool, \"disconnect\", mock_disconnect):\n        with get_redis_connection() as r1:\n            assert r1.auto_close_connection_pool is True\n\n    assert called == 1\n    pool.disconnect()\n\n\n@pytest.mark.parametrize(\n    \"conn, error, expected_message\",\n    [\n        (SSLConnection(), OSError(), \"Error connecting to localhost:6379.\"),\n        (SSLConnection(), OSError(12), \"Error 12 connecting to localhost:6379.\"),\n        (\n            SSLConnection(),\n            OSError(12, \"Some Error\"),\n            \"Error 12 connecting to localhost:6379. Some Error.\",\n        ),\n        (\n            UnixDomainSocketConnection(path=\"unix:///tmp/redis.sock\"),\n            OSError(),\n            \"Error connecting to unix:///tmp/redis.sock.\",\n        ),\n        (\n            UnixDomainSocketConnection(path=\"unix:///tmp/redis.sock\"),\n            OSError(12),\n            \"Error 12 connecting to unix:///tmp/redis.sock.\",\n        ),\n        (\n            UnixDomainSocketConnection(path=\"unix:///tmp/redis.sock\"),\n            OSError(12, \"Some Error\"),\n            \"Error 12 connecting to unix:///tmp/redis.sock. Some Error.\",\n        ),\n    ],\n)\ndef test_format_error_message(conn, error, expected_message):\n    \"\"\"Test that the _error_message function formats errors correctly\"\"\"\n    error_message = conn._error_message(error)\n    assert error_message == expected_message\n\n\ndef test_network_connection_failure():\n    # Match only the stable part of the error message across OS\n    exp_err = rf\"Error {ECONNREFUSED} connecting to localhost:9999\\.\"\n    with pytest.raises(ConnectionError, match=exp_err):\n        redis = Redis(port=9999)\n        redis.set(\"a\", \"b\")\n\n\n@pytest.mark.skipif(\n    not hasattr(socket, \"AF_UNIX\"),\n    reason=\"Unix domain sockets not supported on this platform\",\n)\ndef test_unix_socket_connection_failure():\n    exp_err = \"Error 2 connecting to unix:///tmp/a.sock. No such file or directory.\"\n    with pytest.raises(ConnectionError, match=exp_err):\n        redis = Redis(unix_socket_path=\"unix:///tmp/a.sock\")\n        redis.set(\"a\", \"b\")\n\n\nclass TestUnitConnectionPool:\n    @pytest.mark.parametrize(\n        \"max_conn\", (-1, \"str\"), ids=(\"non-positive\", \"wrong type\")\n    )\n    def test_throws_error_on_incorrect_max_connections(self, max_conn):\n        with pytest.raises(\n            ValueError, match='\"max_connections\" must be a positive integer'\n        ):\n            ConnectionPool(\n                max_connections=max_conn,\n            )\n\n    def test_throws_error_on_cache_enable_in_resp2(self):\n        with pytest.raises(\n            RedisError, match=\"Client caching is only supported with RESP version 3\"\n        ):\n            ConnectionPool(protocol=2, cache_config=CacheConfig())\n\n    def test_throws_error_on_incorrect_cache_implementation(self):\n        with pytest.raises(ValueError, match=\"Cache must implement CacheInterface\"):\n            ConnectionPool(protocol=3, cache=\"wrong\")\n\n    def test_returns_custom_cache_implementation(self, mock_cache):\n        connection_pool = ConnectionPool(protocol=3, cache=mock_cache)\n\n        assert mock_cache == connection_pool.cache\n        connection_pool.disconnect()\n\n    def test_creates_cache_with_custom_cache_factory(\n        self, mock_cache_factory, mock_cache\n    ):\n        mock_cache_factory.get_cache.return_value = mock_cache\n\n        connection_pool = ConnectionPool(\n            protocol=3,\n            cache_config=CacheConfig(max_size=5),\n            cache_factory=mock_cache_factory,\n        )\n\n        # Cache is wrapped in CacheProxy for observability\n        assert isinstance(connection_pool.cache, CacheProxy)\n        assert connection_pool.cache._cache == mock_cache\n        connection_pool.disconnect()\n\n    def test_creates_cache_with_given_configuration(self, mock_cache):\n        connection_pool = ConnectionPool(\n            protocol=3, cache_config=CacheConfig(max_size=100)\n        )\n\n        assert isinstance(connection_pool.cache, CacheInterface)\n        assert connection_pool.cache.config.get_max_size() == 100\n        assert isinstance(connection_pool.cache.eviction_policy, LRUPolicy)\n        connection_pool.disconnect()\n\n    def test_make_connection_proxy_connection_on_given_cache(self):\n        connection_pool = ConnectionPool(protocol=3, cache_config=CacheConfig())\n\n        assert isinstance(connection_pool.make_connection(), CacheProxyConnection)\n        connection_pool.disconnect()\n\n\nclass TestUnitCacheProxyConnection:\n    def test_clears_cache_on_disconnect(self, mock_connection, cache_conf):\n        cache = DefaultCache(CacheConfig(max_size=10))\n        cache_key = CacheKey(\n            command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\")\n        )\n\n        cache.set(\n            CacheEntry(\n                cache_key=cache_key,\n                cache_value=b\"bar\",\n                status=CacheEntryStatus.VALID,\n                connection_ref=mock_connection,\n            )\n        )\n        assert cache.get(cache_key).cache_value == b\"bar\"\n\n        mock_connection.disconnect.return_value = None\n        mock_connection.retry = \"mock\"\n        mock_connection.host = \"mock\"\n        mock_connection.port = \"mock\"\n        mock_connection.db = 0\n        mock_connection.credential_provider = UsernamePasswordCredentialProvider()\n        mock_connection._event_dispatcher = EventDispatcher()\n\n        proxy_connection = CacheProxyConnection(\n            mock_connection, cache, threading.RLock()\n        )\n        proxy_connection.disconnect()\n\n        assert len(cache.collection) == 0\n\n    @pytest.mark.skipif(\n        platform.python_implementation() == \"PyPy\",\n        reason=\"Pypy doesn't support side_effect\",\n    )\n    def test_read_response_returns_cached_reply(self, mock_cache, mock_connection):\n        mock_connection.retry = \"mock\"\n        mock_connection.host = \"mock\"\n        mock_connection.port = \"mock\"\n        mock_connection.db = 0\n        mock_connection.credential_provider = UsernamePasswordCredentialProvider()\n        mock_connection._event_dispatcher = EventDispatcher()\n\n        mock_cache.is_cachable.return_value = True\n        mock_cache.get.side_effect = [\n            None,\n            None,\n            CacheEntry(\n                cache_key=CacheKey(\n                    command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\")\n                ),\n                cache_value=CacheProxyConnection.DUMMY_CACHE_VALUE,\n                status=CacheEntryStatus.IN_PROGRESS,\n                connection_ref=mock_connection,\n            ),\n            CacheEntry(\n                cache_key=CacheKey(\n                    command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\")\n                ),\n                cache_value=b\"bar\",\n                status=CacheEntryStatus.VALID,\n                connection_ref=mock_connection,\n            ),\n            CacheEntry(\n                cache_key=CacheKey(\n                    command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\")\n                ),\n                cache_value=b\"bar\",\n                status=CacheEntryStatus.VALID,\n                connection_ref=mock_connection,\n            ),\n            CacheEntry(\n                cache_key=CacheKey(\n                    command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\")\n                ),\n                cache_value=b\"bar\",\n                status=CacheEntryStatus.VALID,\n                connection_ref=mock_connection,\n            ),\n        ]\n        mock_connection.send_command.return_value = Any\n        mock_connection.read_response.return_value = b\"bar\"\n        mock_connection.can_read.return_value = False\n\n        proxy_connection = CacheProxyConnection(\n            mock_connection, mock_cache, threading.RLock()\n        )\n        proxy_connection.send_command(*[\"GET\", \"foo\"], **{\"keys\": [\"foo\"]})\n        assert proxy_connection.read_response() == b\"bar\"\n        assert proxy_connection._current_command_cache_key is None\n        assert proxy_connection.read_response() == b\"bar\"\n\n        mock_cache.set.assert_has_calls(\n            [\n                call(\n                    CacheEntry(\n                        cache_key=CacheKey(\n                            command=\"GET\",\n                            redis_keys=(\"foo\",),\n                            redis_args=(\"GET\", \"foo\"),\n                        ),\n                        cache_value=CacheProxyConnection.DUMMY_CACHE_VALUE,\n                        status=CacheEntryStatus.IN_PROGRESS,\n                        connection_ref=mock_connection,\n                    )\n                ),\n                call(\n                    CacheEntry(\n                        cache_key=CacheKey(\n                            command=\"GET\",\n                            redis_keys=(\"foo\",),\n                            redis_args=(\"GET\", \"foo\"),\n                        ),\n                        cache_value=b\"bar\",\n                        status=CacheEntryStatus.VALID,\n                        connection_ref=mock_connection,\n                    )\n                ),\n            ]\n        )\n\n        mock_cache.get.assert_has_calls(\n            [\n                call(\n                    CacheKey(\n                        command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\")\n                    )\n                ),\n                call(\n                    CacheKey(\n                        command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\")\n                    )\n                ),\n                call(\n                    CacheKey(\n                        command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\")\n                    )\n                ),\n            ]\n        )\n\n    @pytest.mark.skipif(\n        platform.python_implementation() == \"PyPy\",\n        reason=\"Pypy doesn't support side_effect\",\n    )\n    def test_triggers_invalidation_processing_on_another_connection(\n        self, mock_cache, mock_connection\n    ):\n        mock_connection.retry = \"mock\"\n        mock_connection.host = \"mock\"\n        mock_connection.port = \"mock\"\n        mock_connection.db = 0\n        mock_connection.credential_provider = UsernamePasswordCredentialProvider()\n        mock_connection._event_dispatcher = Mock(spec=EventDispatcher)\n\n        another_conn = copy.deepcopy(mock_connection)\n        another_conn.can_read.side_effect = [True, False]\n        another_conn.read_response.return_value = None\n        cache_entry = CacheEntry(\n            cache_key=CacheKey(\n                command=\"GET\", redis_keys=(\"foo\",), redis_args=(\"GET\", \"foo\")\n            ),\n            cache_value=b\"bar\",\n            status=CacheEntryStatus.VALID,\n            connection_ref=another_conn,\n        )\n        mock_cache.is_cachable.return_value = True\n        mock_cache.get.return_value = cache_entry\n        mock_connection.can_read.return_value = False\n\n        proxy_connection = CacheProxyConnection(\n            mock_connection, mock_cache, threading.RLock()\n        )\n        proxy_connection.send_command(*[\"GET\", \"foo\"], **{\"keys\": [\"foo\"]})\n\n        assert proxy_connection.read_response() == b\"bar\"\n        assert another_conn.can_read.call_count == 2\n        another_conn.read_response.assert_called_once()\n\n    def test_read_response_propagates_timeout_parameter(self, mock_connection):\n        \"\"\"Test that timeout parameter is propagated to underlying connection.\"\"\"\n        mock_connection.retry = \"mock\"\n        mock_connection.host = \"mock\"\n        mock_connection.port = \"mock\"\n        mock_connection.db = 0\n        mock_connection._event_dispatcher = EventDispatcher()\n        mock_connection.credential_provider = UsernamePasswordCredentialProvider()\n        mock_connection.read_response.return_value = b\"OK\"\n\n        cache = DefaultCache(CacheConfig(max_size=10))\n        proxy_connection = CacheProxyConnection(\n            mock_connection, cache, threading.RLock()\n        )\n\n        # Test with specific timeout value\n        proxy_connection.read_response(timeout=0.5)\n        mock_connection.read_response.assert_called_with(\n            disable_decoding=False,\n            timeout=0.5,\n            disconnect_on_error=True,\n            push_request=False,\n        )\n\n    def test_read_response_timeout_default_is_sentinel(self, mock_connection):\n        \"\"\"Test that default timeout value is SENTINEL.\"\"\"\n        mock_connection.retry = \"mock\"\n        mock_connection.host = \"mock\"\n        mock_connection.port = \"mock\"\n        mock_connection.db = 0\n        mock_connection._event_dispatcher = EventDispatcher()\n        mock_connection.credential_provider = UsernamePasswordCredentialProvider()\n        mock_connection.read_response.return_value = b\"OK\"\n\n        cache = DefaultCache(CacheConfig(max_size=10))\n        proxy_connection = CacheProxyConnection(\n            mock_connection, cache, threading.RLock()\n        )\n\n        # Test default timeout is SENTINEL\n        proxy_connection.read_response()\n        mock_connection.read_response.assert_called_with(\n            disable_decoding=False,\n            timeout=SENTINEL,\n            disconnect_on_error=True,\n            push_request=False,\n        )\n\n    def test_read_response_timeout_none_passed_through(self, mock_connection):\n        \"\"\"Test that timeout=None is passed through for blocking behavior.\"\"\"\n        mock_connection.retry = \"mock\"\n        mock_connection.host = \"mock\"\n        mock_connection.port = \"mock\"\n        mock_connection.db = 0\n        mock_connection._event_dispatcher = EventDispatcher()\n        mock_connection.credential_provider = UsernamePasswordCredentialProvider()\n        mock_connection.read_response.return_value = b\"OK\"\n\n        cache = DefaultCache(CacheConfig(max_size=10))\n        proxy_connection = CacheProxyConnection(\n            mock_connection, cache, threading.RLock()\n        )\n\n        # Test timeout=None is passed through\n        proxy_connection.read_response(timeout=None)\n        mock_connection.read_response.assert_called_with(\n            disable_decoding=False,\n            timeout=None,\n            disconnect_on_error=True,\n            push_request=False,\n        )\n\n    def test_read_response_timeout_zero_passed_through(self, mock_connection):\n        \"\"\"Test that timeout=0 is passed through for non-blocking behavior.\"\"\"\n        mock_connection.retry = \"mock\"\n        mock_connection.host = \"mock\"\n        mock_connection.port = \"mock\"\n        mock_connection.db = 0\n        mock_connection._event_dispatcher = EventDispatcher()\n        mock_connection.credential_provider = UsernamePasswordCredentialProvider()\n        mock_connection.read_response.return_value = b\"OK\"\n\n        cache = DefaultCache(CacheConfig(max_size=10))\n        proxy_connection = CacheProxyConnection(\n            mock_connection, cache, threading.RLock()\n        )\n\n        # Test timeout=0 is passed through\n        proxy_connection.read_response(timeout=0)\n        mock_connection.read_response.assert_called_with(\n            disable_decoding=False,\n            timeout=0,\n            disconnect_on_error=True,\n            push_request=False,\n        )\n\n    def test_read_response_all_params_with_timeout(self, mock_connection):\n        \"\"\"Test that all parameters including timeout are correctly passed.\"\"\"\n        mock_connection.retry = \"mock\"\n        mock_connection.host = \"mock\"\n        mock_connection.port = \"mock\"\n        mock_connection.db = 0\n        mock_connection._event_dispatcher = EventDispatcher()\n        mock_connection.credential_provider = UsernamePasswordCredentialProvider()\n        mock_connection.read_response.return_value = b\"OK\"\n\n        cache = DefaultCache(CacheConfig(max_size=10))\n        proxy_connection = CacheProxyConnection(\n            mock_connection, cache, threading.RLock()\n        )\n\n        # Test all parameters together\n        proxy_connection.read_response(\n            disable_decoding=True,\n            timeout=1.5,\n            disconnect_on_error=False,\n            push_request=True,\n        )\n        mock_connection.read_response.assert_called_with(\n            disable_decoding=True,\n            timeout=1.5,\n            disconnect_on_error=False,\n            push_request=True,\n        )\n\n\nclass TestConnectionPoolGetConnectionCount:\n    \"\"\"Tests for ConnectionPool.get_connection_count() method.\"\"\"\n\n    def test_get_connection_count_returns_idle_and_used_counts(self):\n        \"\"\"Test that get_connection_count returns both idle and used connection counts.\"\"\"\n        pool = ConnectionPool(max_connections=10)\n\n        # Initially, no connections exist\n        counts = pool.get_connection_count()\n        assert len(counts) == 2\n\n        # Check idle connections count\n        idle_count, idle_attrs = counts[0]\n        assert idle_count == 0\n        assert DB_CLIENT_CONNECTION_POOL_NAME in idle_attrs\n        assert idle_attrs[DB_CLIENT_CONNECTION_STATE] == ConnectionState.IDLE.value\n\n        # Check used connections count\n        used_count, used_attrs = counts[1]\n        assert used_count == 0\n        assert DB_CLIENT_CONNECTION_POOL_NAME in used_attrs\n        assert used_attrs[DB_CLIENT_CONNECTION_STATE] == ConnectionState.USED.value\n\n        pool.disconnect()\n\n    def test_get_connection_count_with_connections_in_use(self):\n        \"\"\"Test get_connection_count when connections are in use.\"\"\"\n\n        pool = ConnectionPool(max_connections=10)\n\n        # Create mock connections\n        mock_conn1 = MagicMock()\n        mock_conn1.pid = pool.pid\n\n        mock_conn2 = MagicMock()\n        mock_conn2.pid = pool.pid\n\n        # Simulate connections in use\n        pool._in_use_connections.add(mock_conn1)\n        pool._in_use_connections.add(mock_conn2)\n\n        counts = pool.get_connection_count()\n\n        idle_count, idle_attrs = counts[0]\n        used_count, used_attrs = counts[1]\n\n        assert idle_count == 0\n        assert used_count == 2\n        assert idle_attrs[DB_CLIENT_CONNECTION_STATE] == ConnectionState.IDLE.value\n        assert used_attrs[DB_CLIENT_CONNECTION_STATE] == ConnectionState.USED.value\n\n        pool.disconnect()\n\n    def test_get_connection_count_with_available_connections(self):\n        \"\"\"Test get_connection_count when connections are available (idle).\"\"\"\n\n        pool = ConnectionPool(max_connections=10)\n\n        # Create mock connections\n        mock_conn1 = MagicMock()\n        mock_conn1.pid = pool.pid\n\n        mock_conn2 = MagicMock()\n        mock_conn2.pid = pool.pid\n\n        mock_conn3 = MagicMock()\n        mock_conn3.pid = pool.pid\n\n        # Simulate available connections\n        pool._available_connections.append(mock_conn1)\n        pool._available_connections.append(mock_conn2)\n        pool._available_connections.append(mock_conn3)\n\n        counts = pool.get_connection_count()\n\n        idle_count, idle_attrs = counts[0]\n        used_count, used_attrs = counts[1]\n\n        assert idle_count == 3\n        assert used_count == 0\n        assert idle_attrs[DB_CLIENT_CONNECTION_STATE] == ConnectionState.IDLE.value\n        assert used_attrs[DB_CLIENT_CONNECTION_STATE] == ConnectionState.USED.value\n\n        pool.disconnect()\n\n    def test_get_connection_count_mixed_connections(self):\n        \"\"\"Test get_connection_count with both idle and used connections.\"\"\"\n\n        pool = ConnectionPool(max_connections=10)\n\n        # Create mock connections\n        mock_idle = MagicMock()\n        mock_idle.pid = pool.pid\n\n        mock_used1 = MagicMock()\n        mock_used1.pid = pool.pid\n\n        mock_used2 = MagicMock()\n        mock_used2.pid = pool.pid\n\n        # Simulate mixed state\n        pool._available_connections.append(mock_idle)\n        pool._in_use_connections.add(mock_used1)\n        pool._in_use_connections.add(mock_used2)\n\n        counts = pool.get_connection_count()\n\n        idle_count, _ = counts[0]\n        used_count, _ = counts[1]\n\n        assert idle_count == 1\n        assert used_count == 2\n\n        pool.disconnect()\n\n    def test_get_connection_count_includes_pool_name_in_attributes(self):\n        \"\"\"Test that get_connection_count includes pool name in attributes.\"\"\"\n        from redis.observability.attributes import get_pool_name\n\n        pool = ConnectionPool(max_connections=10)\n\n        counts = pool.get_connection_count()\n\n        _, idle_attrs = counts[0]\n        _, used_attrs = counts[1]\n\n        # Both should have the pool name\n        assert DB_CLIENT_CONNECTION_POOL_NAME in idle_attrs\n        assert DB_CLIENT_CONNECTION_POOL_NAME in used_attrs\n\n        # Pool name should match the format from get_pool_name() (host:port_uniqueID)\n        expected_pool_name = get_pool_name(pool)\n        assert idle_attrs[DB_CLIENT_CONNECTION_POOL_NAME] == expected_pool_name\n        assert used_attrs[DB_CLIENT_CONNECTION_POOL_NAME] == expected_pool_name\n\n        # Verify the pool name has the expected format (host:port_uniqueID)\n        assert \"unknown:6379_\" in expected_pool_name\n\n        # Verify the unique ID is 8 hex characters (matching go-redis)\n        parts = expected_pool_name.split(\"_\")\n        assert len(parts) == 2, (\n            f\"Pool name should have format host:port_id, got: {expected_pool_name}\"\n        )\n        unique_id = parts[1]\n        assert len(unique_id) == 8, (\n            f\"Unique ID should be 8 characters, got: {unique_id}\"\n        )\n\n        pool.disconnect()\n\n\nclass TestBlockingConnectionPoolGetConnectionCount:\n    \"\"\"Tests for BlockingConnectionPool.get_connection_count() method.\"\"\"\n\n    def test_get_connection_count_returns_idle_and_used_counts(self):\n        \"\"\"Test that BlockingConnectionPool.get_connection_count returns both counts.\"\"\"\n\n        pool = BlockingConnectionPool(max_connections=10)\n\n        # Initially, no connections exist\n        counts = pool.get_connection_count()\n        assert len(counts) == 2\n\n        idle_count, idle_attrs = counts[0]\n        used_count, used_attrs = counts[1]\n\n        assert idle_count == 0\n        assert used_count == 0\n        assert idle_attrs[DB_CLIENT_CONNECTION_STATE] == ConnectionState.IDLE.value\n        assert used_attrs[DB_CLIENT_CONNECTION_STATE] == ConnectionState.USED.value\n\n        pool.disconnect()\n\n    def test_get_connection_count_with_connections_in_queue(self):\n        \"\"\"Test get_connection_count when connections are in the queue (idle).\"\"\"\n\n        pool = BlockingConnectionPool(max_connections=10)\n\n        # Create mock connections and add to queue\n        mock_conn1 = MagicMock()\n        mock_conn1.pid = pool.pid\n\n        mock_conn2 = MagicMock()\n        mock_conn2.pid = pool.pid\n\n        # Add connections to the pool's internal list and queue\n        pool._connections.append(mock_conn1)\n        pool._connections.append(mock_conn2)\n\n        # Clear the queue and add our connections\n        while not pool.pool.empty():\n            try:\n                pool.pool.get_nowait()\n            except Exception:\n                break\n\n        pool.pool.put_nowait(mock_conn1)\n        pool.pool.put_nowait(mock_conn2)\n\n        counts = pool.get_connection_count()\n\n        idle_count, _ = counts[0]\n        used_count, _ = counts[1]\n\n        assert idle_count == 2\n        assert used_count == 0\n\n        pool.disconnect()\n"
  },
  {
    "path": "tests/test_connection_pool.py",
    "content": "import os\nimport re\nimport time\nfrom contextlib import closing\nfrom threading import Thread\nfrom unittest import mock\nfrom unittest.mock import MagicMock\n\nimport pytest\nimport redis\nfrom redis.cache import CacheConfig\nfrom redis.connection import CacheProxyConnection, Connection, to_bool\nfrom redis.event import (\n    AfterConnectionReleasedEvent,\n    EventDispatcher,\n    EventListenerInterface,\n)\nfrom redis.utils import SSL_AVAILABLE\n\nfrom .conftest import (\n    _get_client,\n    skip_if_redis_enterprise,\n    skip_if_resp_version,\n    skip_if_server_version_lt,\n)\nfrom .test_pubsub import wait_for_message\n\nif SSL_AVAILABLE:\n    import ssl\n\n\nclass DummyConnection:\n    description_format = \"DummyConnection<>\"\n\n    def __init__(self, **kwargs):\n        self.kwargs = kwargs\n        self.pid = os.getpid()\n        self._sock = None\n\n    def connect(self):\n        self._sock = mock.Mock()\n\n    def disconnect(self):\n        self._sock = None\n\n    def can_read(self):\n        return False\n\n    def should_reconnect(self):\n        return False\n\n    def re_auth(self):\n        pass\n\n\nclass TestConnectionPool:\n    def get_pool(\n        self,\n        connection_kwargs=None,\n        max_connections=None,\n        connection_class=redis.Connection,\n    ):\n        connection_kwargs = connection_kwargs or {}\n        pool = redis.ConnectionPool(\n            connection_class=connection_class,\n            max_connections=max_connections,\n            **connection_kwargs,\n        )\n        return pool\n\n    def test_connection_creation(self):\n        connection_kwargs = {\n            \"foo\": \"bar\",\n            \"biz\": \"baz\",\n        }\n        pool = self.get_pool(\n            connection_kwargs=connection_kwargs, connection_class=DummyConnection\n        )\n\n        connection = pool.get_connection()\n        assert isinstance(connection, DummyConnection)\n        assert connection.kwargs == connection_kwargs\n\n    def test_closing(self):\n        connection_kwargs = {\"foo\": \"bar\", \"biz\": \"baz\"}\n        pool = redis.ConnectionPool(\n            connection_class=DummyConnection,\n            max_connections=None,\n            **connection_kwargs,\n        )\n        with closing(pool):\n            pass\n\n    def test_multiple_connections(self, master_host):\n        connection_kwargs = {\"host\": master_host[0], \"port\": master_host[1]}\n        pool = self.get_pool(connection_kwargs=connection_kwargs)\n        c1 = pool.get_connection()\n        c2 = pool.get_connection()\n        assert c1 != c2\n\n    def test_max_connections(self, master_host):\n        # Use DummyConnection to avoid actual connection to Redis\n        # This prevents authentication issues and makes the test more reliable\n        # while still properly testing the MaxConnectionsError behavior\n        pool = self.get_pool(max_connections=2, connection_class=DummyConnection)\n        pool.get_connection()\n        pool.get_connection()\n        with pytest.raises(redis.MaxConnectionsError):\n            pool.get_connection()\n\n    def test_reuse_previously_released_connection(self, master_host):\n        connection_kwargs = {\"host\": master_host[0], \"port\": master_host[1]}\n        pool = self.get_pool(connection_kwargs=connection_kwargs)\n        c1 = pool.get_connection()\n        pool.release(c1)\n        c2 = pool.get_connection()\n        assert c1 == c2\n\n    def test_release_not_owned_connection(self, master_host):\n        connection_kwargs = {\"host\": master_host[0], \"port\": master_host[1]}\n        pool1 = self.get_pool(connection_kwargs=connection_kwargs)\n        c1 = pool1.get_connection()\n        pool2 = self.get_pool(\n            connection_kwargs={\"host\": master_host[0], \"port\": master_host[1]}\n        )\n        c2 = pool2.get_connection()\n        pool2.release(c2)\n\n        assert len(pool2._available_connections) == 1\n\n        pool2.release(c1)\n        assert len(pool2._available_connections) == 1\n\n    def test_repr_contains_db_info_tcp(self):\n        connection_kwargs = {\n            \"host\": \"localhost\",\n            \"port\": 6379,\n            \"db\": 1,\n            \"client_name\": \"test-client\",\n        }\n        pool = self.get_pool(\n            connection_kwargs=connection_kwargs, connection_class=redis.Connection\n        )\n        expected = \"host=localhost,port=6379,db=1,client_name=test-client\"\n        assert expected in repr(pool)\n\n    def test_repr_contains_db_info_unix(self):\n        connection_kwargs = {\"path\": \"/abc\", \"db\": 1, \"client_name\": \"test-client\"}\n        pool = self.get_pool(\n            connection_kwargs=connection_kwargs,\n            connection_class=redis.UnixDomainSocketConnection,\n        )\n        expected = \"path=/abc,db=1,client_name=test-client\"\n        assert expected in repr(pool)\n\n    def test_pool_disconnect(self, master_host):\n        connection_kwargs = {\n            \"host\": master_host[0],\n            \"port\": master_host[1],\n        }\n        pool = self.get_pool(connection_kwargs=connection_kwargs)\n\n        conn = pool.get_connection()\n        pool.disconnect()\n        assert not conn._sock\n\n        conn.connect()\n        pool.disconnect(inuse_connections=False)\n        assert conn._sock\n\n\nclass TestBlockingConnectionPool:\n    def get_pool(self, connection_kwargs=None, max_connections=10, timeout=20):\n        connection_kwargs = connection_kwargs or {}\n        pool = redis.BlockingConnectionPool(\n            connection_class=DummyConnection,\n            max_connections=max_connections,\n            timeout=timeout,\n            **connection_kwargs,\n        )\n        return pool\n\n    def test_connection_creation(self, master_host):\n        connection_kwargs = {\n            \"foo\": \"bar\",\n            \"biz\": \"baz\",\n            \"host\": master_host[0],\n            \"port\": master_host[1],\n        }\n\n        pool = self.get_pool(connection_kwargs=connection_kwargs)\n        connection = pool.get_connection()\n        assert isinstance(connection, DummyConnection)\n        assert connection.kwargs == connection_kwargs\n\n    def test_multiple_connections(self, master_host):\n        connection_kwargs = {\"host\": master_host[0], \"port\": master_host[1]}\n        pool = self.get_pool(connection_kwargs=connection_kwargs)\n        c1 = pool.get_connection()\n        c2 = pool.get_connection()\n        assert c1 != c2\n\n    def test_connection_pool_blocks_until_timeout(self, master_host):\n        \"When out of connections, block for timeout seconds, then raise\"\n        connection_kwargs = {\"host\": master_host[0], \"port\": master_host[1]}\n        pool = self.get_pool(\n            max_connections=1, timeout=0.1, connection_kwargs=connection_kwargs\n        )\n        pool.get_connection()\n\n        start = time.monotonic()\n        with pytest.raises(redis.ConnectionError):\n            pool.get_connection()\n        # we should have waited at least 0.1 seconds\n        assert time.monotonic() - start >= 0.1\n\n    def test_connection_pool_blocks_until_conn_available(self, master_host):\n        \"\"\"\n        When out of connections, block until another connection is released\n        to the pool\n        \"\"\"\n        connection_kwargs = {\"host\": master_host[0], \"port\": master_host[1]}\n        pool = self.get_pool(\n            max_connections=1, timeout=2, connection_kwargs=connection_kwargs\n        )\n        c1 = pool.get_connection()\n\n        def target():\n            time.sleep(0.1)\n            pool.release(c1)\n\n        start = time.monotonic()\n        Thread(target=target).start()\n        pool.get_connection()\n        assert time.monotonic() - start >= 0.1\n\n    def test_reuse_previously_released_connection(self, master_host):\n        connection_kwargs = {\"host\": master_host[0], \"port\": master_host[1]}\n        pool = self.get_pool(connection_kwargs=connection_kwargs)\n        c1 = pool.get_connection()\n        pool.release(c1)\n        c2 = pool.get_connection()\n        assert c1 == c2\n\n    def test_repr_contains_db_info_tcp(self):\n        pool = redis.ConnectionPool(\n            host=\"localhost\", port=6379, client_name=\"test-client\"\n        )\n        expected = \"host=localhost,port=6379,client_name=test-client\"\n        assert expected in repr(pool)\n\n    def test_repr_contains_db_info_unix(self):\n        pool = redis.ConnectionPool(\n            connection_class=redis.UnixDomainSocketConnection,\n            path=\"abc\",\n            db=0,\n            client_name=\"test-client\",\n        )\n        expected = \"path=abc,db=0,client_name=test-client\"\n        assert expected in repr(pool)\n\n    def test_repr_redacts_sensitive_information(self):\n        \"\"\"Test that __repr__ redacts sensitive values like password and username.\"\"\"\n        pool = redis.ConnectionPool(\n            host=\"localhost\",\n            port=6379,\n            password=\"secret_password_123\",\n            username=\"myuser\",\n            ssl_password=\"ssl_secret_456\",\n            db=0,\n        )\n        repr_output = repr(pool)\n\n        # Verify sensitive values are redacted\n        assert \"secret_password_123\" not in repr_output\n        assert \"myuser\" not in repr_output\n        assert \"ssl_secret_456\" not in repr_output\n\n        # Verify the REDACTED placeholder is present\n        assert \"<REDACTED>\" in repr_output\n\n        # Verify non-sensitive values are still visible\n        assert \"host=localhost\" in repr_output\n        assert \"port=6379\" in repr_output\n        assert \"db=0\" in repr_output\n\n    @pytest.mark.onlynoncluster\n    @skip_if_resp_version(2)\n    @skip_if_server_version_lt(\"7.4.0\")\n    def test_initialise_pool_with_cache(self, master_host):\n        pool = redis.BlockingConnectionPool(\n            connection_class=Connection,\n            host=master_host[0],\n            port=master_host[1],\n            protocol=3,\n            cache_config=CacheConfig(),\n        )\n        assert isinstance(pool.get_connection(), CacheProxyConnection)\n\n    def test_pool_disconnect(self, master_host):\n        connection_kwargs = {\n            \"foo\": \"bar\",\n            \"biz\": \"baz\",\n            \"host\": master_host[0],\n            \"port\": master_host[1],\n        }\n        pool = self.get_pool(connection_kwargs=connection_kwargs)\n\n        conn = pool.get_connection()\n        pool.disconnect()\n        assert not conn._sock\n\n        conn.connect()\n        pool.disconnect(inuse_connections=False)\n        assert conn._sock\n\n\nclass TestConnectionPoolURLParsing:\n    def test_hostname(self):\n        pool = redis.ConnectionPool.from_url(\"redis://my.host\")\n        assert pool.connection_class == redis.Connection\n        assert pool.connection_kwargs == {\"host\": \"my.host\"}\n\n    def test_quoted_hostname(self):\n        pool = redis.ConnectionPool.from_url(\"redis://my %2F host %2B%3D+\")\n        assert pool.connection_class == redis.Connection\n        assert pool.connection_kwargs == {\"host\": \"my / host +=+\"}\n\n    def test_port(self):\n        pool = redis.ConnectionPool.from_url(\"redis://localhost:6380\")\n        assert pool.connection_class == redis.Connection\n        assert pool.connection_kwargs == {\"host\": \"localhost\", \"port\": 6380}\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    def test_username(self):\n        pool = redis.ConnectionPool.from_url(\"redis://myuser:@localhost\")\n        assert pool.connection_class == redis.Connection\n        assert pool.connection_kwargs == {\"host\": \"localhost\", \"username\": \"myuser\"}\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    def test_quoted_username(self):\n        pool = redis.ConnectionPool.from_url(\n            \"redis://%2Fmyuser%2F%2B name%3D%24+:@localhost\"\n        )\n        assert pool.connection_class == redis.Connection\n        assert pool.connection_kwargs == {\n            \"host\": \"localhost\",\n            \"username\": \"/myuser/+ name=$+\",\n        }\n\n    def test_password(self):\n        pool = redis.ConnectionPool.from_url(\"redis://:mypassword@localhost\")\n        assert pool.connection_class == redis.Connection\n        assert pool.connection_kwargs == {\"host\": \"localhost\", \"password\": \"mypassword\"}\n\n    def test_quoted_password(self):\n        pool = redis.ConnectionPool.from_url(\n            \"redis://:%2Fmypass%2F%2B word%3D%24+@localhost\"\n        )\n        assert pool.connection_class == redis.Connection\n        assert pool.connection_kwargs == {\n            \"host\": \"localhost\",\n            \"password\": \"/mypass/+ word=$+\",\n        }\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    def test_username_and_password(self):\n        pool = redis.ConnectionPool.from_url(\"redis://myuser:mypass@localhost\")\n        assert pool.connection_class == redis.Connection\n        assert pool.connection_kwargs == {\n            \"host\": \"localhost\",\n            \"username\": \"myuser\",\n            \"password\": \"mypass\",\n        }\n\n    def test_db_as_argument(self):\n        pool = redis.ConnectionPool.from_url(\"redis://localhost\", db=1)\n        assert pool.connection_class == redis.Connection\n        assert pool.connection_kwargs == {\"host\": \"localhost\", \"db\": 1}\n\n    def test_db_in_path(self):\n        pool = redis.ConnectionPool.from_url(\"redis://localhost/2\", db=1)\n        assert pool.connection_class == redis.Connection\n        assert pool.connection_kwargs == {\"host\": \"localhost\", \"db\": 2}\n\n    def test_db_in_querystring(self):\n        pool = redis.ConnectionPool.from_url(\"redis://localhost/2?db=3\", db=1)\n        assert pool.connection_class == redis.Connection\n        assert pool.connection_kwargs == {\"host\": \"localhost\", \"db\": 3}\n\n    def test_extra_typed_querystring_options(self):\n        pool = redis.ConnectionPool.from_url(\n            \"redis://localhost/2?socket_timeout=20&socket_connect_timeout=10\"\n            \"&socket_keepalive=&retry_on_timeout=Yes&max_connections=10\"\n        )\n\n        assert pool.connection_class == redis.Connection\n        assert pool.connection_kwargs == {\n            \"host\": \"localhost\",\n            \"db\": 2,\n            \"socket_timeout\": 20.0,\n            \"socket_connect_timeout\": 10.0,\n            \"retry_on_timeout\": True,\n        }\n        assert pool.max_connections == 10\n\n    def test_boolean_parsing(self):\n        for expected, value in (\n            (None, None),\n            (None, \"\"),\n            (False, 0),\n            (False, \"0\"),\n            (False, \"f\"),\n            (False, \"F\"),\n            (False, \"False\"),\n            (False, \"n\"),\n            (False, \"N\"),\n            (False, \"No\"),\n            (True, 1),\n            (True, \"1\"),\n            (True, \"y\"),\n            (True, \"Y\"),\n            (True, \"Yes\"),\n        ):\n            assert expected is to_bool(value)\n\n    def test_client_name_in_querystring(self):\n        pool = redis.ConnectionPool.from_url(\"redis://location?client_name=test-client\")\n        assert pool.connection_kwargs[\"client_name\"] == \"test-client\"\n\n    def test_invalid_extra_typed_querystring_options(self):\n        with pytest.raises(ValueError):\n            redis.ConnectionPool.from_url(\n                \"redis://localhost/2?socket_timeout=_&socket_connect_timeout=abc\"\n            )\n\n    def test_extra_querystring_options(self):\n        pool = redis.ConnectionPool.from_url(\"redis://localhost?a=1&b=2\")\n        assert pool.connection_class == redis.Connection\n        assert pool.connection_kwargs == {\"host\": \"localhost\", \"a\": \"1\", \"b\": \"2\"}\n\n    def test_calling_from_subclass_returns_correct_instance(self):\n        pool = redis.BlockingConnectionPool.from_url(\"redis://localhost\")\n        assert isinstance(pool, redis.BlockingConnectionPool)\n\n    def test_client_creates_connection_pool(self):\n        r = redis.Redis.from_url(\"redis://myhost\")\n        assert r.connection_pool.connection_class == redis.Connection\n        assert r.connection_pool.connection_kwargs == {\"host\": \"myhost\"}\n\n    def test_invalid_scheme_raises_error(self):\n        with pytest.raises(ValueError) as cm:\n            redis.ConnectionPool.from_url(\"localhost\")\n        assert str(cm.value) == (\n            \"Redis URL must specify one of the following schemes \"\n            \"(redis://, rediss://, unix://)\"\n        )\n\n    def test_invalid_scheme_raises_error_when_double_slash_missing(self):\n        with pytest.raises(ValueError) as cm:\n            redis.ConnectionPool.from_url(\"redis:foo.bar.com:12345\")\n        assert str(cm.value) == (\n            \"Redis URL must specify one of the following schemes \"\n            \"(redis://, rediss://, unix://)\"\n        )\n\n\nclass TestBlockingConnectionPoolURLParsing:\n    def test_extra_typed_querystring_options(self):\n        pool = redis.BlockingConnectionPool.from_url(\n            \"redis://localhost/2?socket_timeout=20&socket_connect_timeout=10\"\n            \"&socket_keepalive=&retry_on_timeout=Yes&max_connections=10&timeout=42\"\n        )\n\n        assert pool.connection_class == redis.Connection\n        assert pool.connection_kwargs == {\n            \"host\": \"localhost\",\n            \"db\": 2,\n            \"socket_timeout\": 20.0,\n            \"socket_connect_timeout\": 10.0,\n            \"retry_on_timeout\": True,\n        }\n        assert pool.max_connections == 10\n        assert pool.timeout == 42.0\n\n    def test_invalid_extra_typed_querystring_options(self):\n        with pytest.raises(ValueError):\n            redis.BlockingConnectionPool.from_url(\n                \"redis://localhost/2?timeout=_not_a_float_\"\n            )\n\n\nclass TestConnectionPoolUnixSocketURLParsing:\n    def test_defaults(self):\n        pool = redis.ConnectionPool.from_url(\"unix:///socket\")\n        assert pool.connection_class == redis.UnixDomainSocketConnection\n        assert pool.connection_kwargs == {\"path\": \"/socket\"}\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    def test_username(self):\n        pool = redis.ConnectionPool.from_url(\"unix://myuser:@/socket\")\n        assert pool.connection_class == redis.UnixDomainSocketConnection\n        assert pool.connection_kwargs == {\"path\": \"/socket\", \"username\": \"myuser\"}\n\n    @skip_if_server_version_lt(\"6.0.0\")\n    def test_quoted_username(self):\n        pool = redis.ConnectionPool.from_url(\n            \"unix://%2Fmyuser%2F%2B name%3D%24+:@/socket\"\n        )\n        assert pool.connection_class == redis.UnixDomainSocketConnection\n        assert pool.connection_kwargs == {\n            \"path\": \"/socket\",\n            \"username\": \"/myuser/+ name=$+\",\n        }\n\n    def test_password(self):\n        pool = redis.ConnectionPool.from_url(\"unix://:mypassword@/socket\")\n        assert pool.connection_class == redis.UnixDomainSocketConnection\n        assert pool.connection_kwargs == {\"path\": \"/socket\", \"password\": \"mypassword\"}\n\n    def test_quoted_password(self):\n        pool = redis.ConnectionPool.from_url(\n            \"unix://:%2Fmypass%2F%2B word%3D%24+@/socket\"\n        )\n        assert pool.connection_class == redis.UnixDomainSocketConnection\n        assert pool.connection_kwargs == {\n            \"path\": \"/socket\",\n            \"password\": \"/mypass/+ word=$+\",\n        }\n\n    def test_quoted_path(self):\n        pool = redis.ConnectionPool.from_url(\n            \"unix://:mypassword@/my%2Fpath%2Fto%2F..%2F+_%2B%3D%24ocket\"\n        )\n        assert pool.connection_class == redis.UnixDomainSocketConnection\n        assert pool.connection_kwargs == {\n            \"path\": \"/my/path/to/../+_+=$ocket\",\n            \"password\": \"mypassword\",\n        }\n\n    def test_db_as_argument(self):\n        pool = redis.ConnectionPool.from_url(\"unix:///socket\", db=1)\n        assert pool.connection_class == redis.UnixDomainSocketConnection\n        assert pool.connection_kwargs == {\"path\": \"/socket\", \"db\": 1}\n\n    def test_db_in_querystring(self):\n        pool = redis.ConnectionPool.from_url(\"unix:///socket?db=2\", db=1)\n        assert pool.connection_class == redis.UnixDomainSocketConnection\n        assert pool.connection_kwargs == {\"path\": \"/socket\", \"db\": 2}\n\n    def test_client_name_in_querystring(self):\n        pool = redis.ConnectionPool.from_url(\"redis://location?client_name=test-client\")\n        assert pool.connection_kwargs[\"client_name\"] == \"test-client\"\n\n    def test_extra_querystring_options(self):\n        pool = redis.ConnectionPool.from_url(\"unix:///socket?a=1&b=2\")\n        assert pool.connection_class == redis.UnixDomainSocketConnection\n        assert pool.connection_kwargs == {\"path\": \"/socket\", \"a\": \"1\", \"b\": \"2\"}\n\n    def test_connection_class_override(self):\n        class MyConnection(redis.UnixDomainSocketConnection):\n            pass\n\n        pool = redis.ConnectionPool.from_url(\n            \"unix:///socket\", connection_class=MyConnection\n        )\n        assert pool.connection_class == MyConnection\n\n\n@pytest.mark.skipif(not SSL_AVAILABLE, reason=\"SSL not installed\")\nclass TestSSLConnectionURLParsing:\n    def test_host(self):\n        pool = redis.ConnectionPool.from_url(\"rediss://my.host\")\n        assert pool.connection_class == redis.SSLConnection\n        assert pool.connection_kwargs == {\"host\": \"my.host\"}\n\n    def test_connection_class_override(self):\n        class MyConnection(redis.SSLConnection):\n            pass\n\n        pool = redis.ConnectionPool.from_url(\n            \"rediss://my.host\", connection_class=MyConnection\n        )\n        assert pool.connection_class == MyConnection\n\n    def test_cert_reqs_options(self):\n        class DummyConnectionPool(redis.ConnectionPool):\n            def get_connection(self):\n                return self.make_connection()\n\n        pool = DummyConnectionPool.from_url(\"rediss://?ssl_cert_reqs=none\")\n        assert pool.get_connection().cert_reqs == ssl.CERT_NONE\n\n        pool = DummyConnectionPool.from_url(\"rediss://?ssl_cert_reqs=optional\")\n        assert pool.get_connection().cert_reqs == ssl.CERT_OPTIONAL\n\n        pool = DummyConnectionPool.from_url(\"rediss://?ssl_cert_reqs=required\")\n        assert pool.get_connection().cert_reqs == ssl.CERT_REQUIRED\n\n        pool = DummyConnectionPool.from_url(\"rediss://?ssl_check_hostname=False\")\n        assert pool.get_connection().check_hostname is False\n\n        pool = DummyConnectionPool.from_url(\"rediss://?ssl_check_hostname=True\")\n        assert pool.get_connection().check_hostname is True\n\n    def test_ssl_flags_config_parsing(self):\n        class DummyConnectionPool(redis.ConnectionPool):\n            def get_connection(self):\n                return self.make_connection()\n\n        pool = DummyConnectionPool.from_url(\n            \"rediss://?ssl_include_verify_flags=VERIFY_X509_STRICT,VERIFY_CRL_CHECK_CHAIN\"\n        )\n\n        assert pool.get_connection().ssl_include_verify_flags == [\n            ssl.VerifyFlags.VERIFY_X509_STRICT,\n            ssl.VerifyFlags.VERIFY_CRL_CHECK_CHAIN,\n        ]\n\n        pool = DummyConnectionPool.from_url(\n            \"rediss://?ssl_include_verify_flags=[VERIFY_X509_STRICT, VERIFY_CRL_CHECK_CHAIN]\"\n        )\n\n        assert pool.get_connection().ssl_include_verify_flags == [\n            ssl.VerifyFlags.VERIFY_X509_STRICT,\n            ssl.VerifyFlags.VERIFY_CRL_CHECK_CHAIN,\n        ]\n\n        pool = DummyConnectionPool.from_url(\n            \"rediss://?ssl_exclude_verify_flags=VERIFY_X509_STRICT, VERIFY_CRL_CHECK_CHAIN\"\n        )\n\n        assert pool.get_connection().ssl_exclude_verify_flags == [\n            ssl.VerifyFlags.VERIFY_X509_STRICT,\n            ssl.VerifyFlags.VERIFY_CRL_CHECK_CHAIN,\n        ]\n\n        pool = DummyConnectionPool.from_url(\n            \"rediss://?ssl_include_verify_flags=VERIFY_X509_STRICT, VERIFY_CRL_CHECK_CHAIN&ssl_exclude_verify_flags=VERIFY_CRL_CHECK_LEAF\"\n        )\n\n        assert pool.get_connection().ssl_include_verify_flags == [\n            ssl.VerifyFlags.VERIFY_X509_STRICT,\n            ssl.VerifyFlags.VERIFY_CRL_CHECK_CHAIN,\n        ]\n        assert pool.get_connection().ssl_exclude_verify_flags == [\n            ssl.VerifyFlags.VERIFY_CRL_CHECK_LEAF,\n        ]\n\n    def test_ssl_flags_config_invalid_flag(self):\n        class DummyConnectionPool(redis.ConnectionPool):\n            def get_connection(self):\n                return self.make_connection()\n\n        with pytest.raises(ValueError):\n            DummyConnectionPool.from_url(\n                \"rediss://?ssl_include_verify_flags=[VERIFY_X509,VERIFY_CRL_CHECK_CHAIN]\"\n            )\n\n        with pytest.raises(ValueError):\n            DummyConnectionPool.from_url(\n                \"rediss://?ssl_exclude_verify_flags=[VERIFY_X509_STRICT1, VERIFY_CRL_CHECK_CHAIN]\"\n            )\n\n\nclass TestConnection:\n    def test_on_connect_error(self):\n        \"\"\"\n        An error in Connection.on_connect should disconnect from the server\n        see for details: https://github.com/andymccurdy/redis-py/issues/368\n        \"\"\"\n        # this assumes the Redis server being tested against doesn't have\n        # 9999 databases ;)\n        bad_connection = redis.Redis(db=9999)\n        # an error should be raised on connect\n        with pytest.raises(redis.RedisError):\n            bad_connection.info()\n        pool = bad_connection.connection_pool\n        assert len(pool._available_connections) == 1\n        assert not pool._available_connections[0]._sock\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"2.8.8\")\n    @skip_if_redis_enterprise()\n    def test_busy_loading_disconnects_socket(self, r):\n        \"\"\"\n        If Redis raises a LOADING error, the connection should be\n        disconnected and a BusyLoadingError raised\n        \"\"\"\n        with pytest.raises(redis.BusyLoadingError):\n            r.execute_command(\"DEBUG\", \"ERROR\", \"LOADING fake message\")\n        assert not r.connection._sock\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"2.8.8\")\n    @skip_if_redis_enterprise()\n    def test_busy_loading_from_pipeline_immediate_command(self, r):\n        \"\"\"\n        BusyLoadingErrors should raise from Pipelines that execute a\n        command immediately, like WATCH does.\n        \"\"\"\n        pipe = r.pipeline()\n        with pytest.raises(redis.BusyLoadingError):\n            pipe.immediate_execute_command(\"DEBUG\", \"ERROR\", \"LOADING fake message\")\n        pool = r.connection_pool\n        assert pipe.connection\n        assert pipe.connection in pool._in_use_connections\n        assert not pipe.connection._sock\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"2.8.8\")\n    @skip_if_redis_enterprise()\n    def test_busy_loading_from_pipeline(self, r):\n        \"\"\"\n        BusyLoadingErrors should be raised from a pipeline execution\n        regardless of the raise_on_error flag.\n        \"\"\"\n        pipe = r.pipeline()\n        pipe.execute_command(\"DEBUG\", \"ERROR\", \"LOADING fake message\")\n        with pytest.raises(redis.BusyLoadingError):\n            pipe.execute()\n        pool = r.connection_pool\n        assert not pipe.connection\n        assert len(pool._available_connections) == 1\n        assert not pool._available_connections[0]._sock\n\n    @skip_if_server_version_lt(\"2.8.8\")\n    @skip_if_redis_enterprise()\n    def test_read_only_error(self, r):\n        \"READONLY errors get turned into ReadOnlyError exceptions\"\n        with pytest.raises(redis.ReadOnlyError):\n            r.execute_command(\"DEBUG\", \"ERROR\", \"READONLY blah blah\")\n\n    def test_oom_error(self, r):\n        \"OOM errors get turned into OutOfMemoryError exceptions\"\n        with pytest.raises(redis.OutOfMemoryError):\n            # note: don't use the DEBUG OOM command since it's not the same\n            # as the db being full\n            r.execute_command(\"DEBUG\", \"ERROR\", \"OOM blah blah\")\n\n    def test_connect_from_url_tcp(self):\n        connection = redis.Redis.from_url(\"redis://localhost:6379?db=0\")\n        pool = connection.connection_pool\n\n        assert re.match(\n            r\"< .*?([^\\.]+) \\( < .*?([^\\.]+) \\( (.+) \\) > \\) >\", repr(pool), re.VERBOSE\n        ).groups() == (\n            \"ConnectionPool\",\n            \"Connection\",\n            \"db=0,host=localhost,port=6379\",\n        )\n\n    def test_connect_from_url_unix(self):\n        connection = redis.Redis.from_url(\"unix:///path/to/socket\")\n        pool = connection.connection_pool\n\n        assert re.match(\n            r\"< .*?([^\\.]+) \\( < .*?([^\\.]+) \\( (.+) \\) > \\) >\", repr(pool), re.VERBOSE\n        ).groups() == (\n            \"ConnectionPool\",\n            \"UnixDomainSocketConnection\",\n            \"path=/path/to/socket\",\n        )\n\n    @skip_if_redis_enterprise()\n    def test_connect_no_auth_configured(self, r):\n        \"\"\"\n        AuthenticationError should be raised when the server is not configured with auth\n        but credentials are supplied by the user.\n        \"\"\"\n        # Redis < 6\n        with pytest.raises(redis.AuthenticationError):\n            r.execute_command(\n                \"DEBUG\", \"ERROR\", \"ERR Client sent AUTH, but no password is set\"\n            )\n\n        # Redis >= 6\n        with pytest.raises(redis.AuthenticationError):\n            r.execute_command(\n                \"DEBUG\",\n                \"ERROR\",\n                \"ERR AUTH <password> called without any password \"\n                \"configured for the default user. Are you sure \"\n                \"your configuration is correct?\",\n            )\n\n    @skip_if_redis_enterprise()\n    def test_connect_invalid_auth_credentials_supplied(self, r):\n        \"\"\"\n        AuthenticationError should be raised when sending invalid username/password\n        \"\"\"\n        # Redis < 6\n        with pytest.raises(redis.AuthenticationError):\n            r.execute_command(\"DEBUG\", \"ERROR\", \"ERR invalid password\")\n\n        # Redis >= 6\n        with pytest.raises(redis.AuthenticationError):\n            r.execute_command(\"DEBUG\", \"ERROR\", \"WRONGPASS\")\n\n\n@pytest.mark.onlynoncluster\nclass TestMultiConnectionClient:\n    @pytest.fixture()\n    def r(self, request):\n        return _get_client(redis.Redis, request, single_connection_client=False)\n\n    def test_multi_connection_command(self, r):\n        assert not r.connection\n        assert r.set(\"a\", \"123\")\n        assert r.get(\"a\") == b\"123\"\n\n\n@pytest.mark.onlynoncluster\nclass TestHealthCheck:\n    interval = 60\n\n    @pytest.fixture()\n    def r(self, request):\n        return _get_client(redis.Redis, request, health_check_interval=self.interval)\n\n    def assert_interval_advanced(self, connection):\n        diff = connection.next_health_check - time.monotonic()\n        assert self.interval > diff > (self.interval - 1)\n\n    def test_health_check_runs(self, r):\n        r.connection.next_health_check = time.monotonic() - 1\n        r.connection.check_health()\n        self.assert_interval_advanced(r.connection)\n\n    def test_arbitrary_command_invokes_health_check(self, r):\n        # invoke a command to make sure the connection is entirely setup\n        r.get(\"foo\")\n        r.connection.next_health_check = time.monotonic()\n        with mock.patch.object(\n            r.connection, \"send_command\", wraps=r.connection.send_command\n        ) as m:\n            r.get(\"foo\")\n            m.assert_called_with(\"PING\", check_health=False)\n\n        self.assert_interval_advanced(r.connection)\n\n    def test_arbitrary_command_advances_next_health_check(self, r):\n        r.get(\"foo\")\n        next_health_check = r.connection.next_health_check\n        r.get(\"foo\")\n        assert next_health_check < r.connection.next_health_check\n\n    def test_health_check_not_invoked_within_interval(self, r):\n        r.get(\"foo\")\n        with mock.patch.object(\n            r.connection, \"send_command\", wraps=r.connection.send_command\n        ) as m:\n            r.get(\"foo\")\n            ping_call_spec = ((\"PING\",), {\"check_health\": False})\n            assert ping_call_spec not in m.call_args_list\n\n    def test_health_check_in_pipeline(self, r):\n        with r.pipeline(transaction=False) as pipe:\n            pipe.connection = pipe.connection_pool.get_connection()\n            pipe.connection.next_health_check = 0\n            with mock.patch.object(\n                pipe.connection, \"send_command\", wraps=pipe.connection.send_command\n            ) as m:\n                responses = pipe.set(\"foo\", \"bar\").get(\"foo\").execute()\n                m.assert_any_call(\"PING\", check_health=False)\n                assert responses == [True, b\"bar\"]\n\n    def test_health_check_in_transaction(self, r):\n        with r.pipeline(transaction=True) as pipe:\n            pipe.connection = pipe.connection_pool.get_connection()\n            pipe.connection.next_health_check = 0\n            with mock.patch.object(\n                pipe.connection, \"send_command\", wraps=pipe.connection.send_command\n            ) as m:\n                responses = pipe.set(\"foo\", \"bar\").get(\"foo\").execute()\n                m.assert_any_call(\"PING\", check_health=False)\n                assert responses == [True, b\"bar\"]\n\n    def test_health_check_in_watched_pipeline(self, r):\n        r.set(\"foo\", \"bar\")\n        with r.pipeline(transaction=False) as pipe:\n            pipe.connection = pipe.connection_pool.get_connection()\n            pipe.connection.next_health_check = 0\n            with mock.patch.object(\n                pipe.connection, \"send_command\", wraps=pipe.connection.send_command\n            ) as m:\n                pipe.watch(\"foo\")\n                # the health check should be called when watching\n                m.assert_called_with(\"PING\", check_health=False)\n                self.assert_interval_advanced(pipe.connection)\n                assert pipe.get(\"foo\") == b\"bar\"\n\n                # reset the mock to clear the call list and schedule another\n                # health check\n                m.reset_mock()\n                pipe.connection.next_health_check = 0\n\n                pipe.multi()\n                responses = pipe.set(\"foo\", \"not-bar\").get(\"foo\").execute()\n                assert responses == [True, b\"not-bar\"]\n                m.assert_any_call(\"PING\", check_health=False)\n\n    def test_health_check_in_pubsub_before_subscribe(self, r):\n        \"A health check happens before the first [p]subscribe\"\n        p = r.pubsub()\n        p.connection = p.connection_pool.get_connection()\n        p.connection.next_health_check = 0\n        with mock.patch.object(\n            p.connection, \"send_command\", wraps=p.connection.send_command\n        ) as m:\n            assert not p.subscribed\n            p.subscribe(\"foo\")\n            # the connection is not yet in pubsub mode, so the normal\n            # ping/pong within connection.send_command should check\n            # the health of the connection\n            m.assert_any_call(\"PING\", check_health=False)\n            self.assert_interval_advanced(p.connection)\n\n            subscribe_message = wait_for_message(p)\n            assert subscribe_message[\"type\"] == \"subscribe\"\n\n    def test_health_check_in_pubsub_after_subscribed(self, r):\n        \"\"\"\n        Pubsub can handle a new subscribe when it's time to check the\n        connection health\n        \"\"\"\n        p = r.pubsub()\n        p.connection = p.connection_pool.get_connection()\n        p.connection.next_health_check = 0\n        with mock.patch.object(\n            p.connection, \"send_command\", wraps=p.connection.send_command\n        ) as m:\n            p.subscribe(\"foo\")\n            subscribe_message = wait_for_message(p)\n            assert subscribe_message[\"type\"] == \"subscribe\"\n            self.assert_interval_advanced(p.connection)\n            # because we weren't subscribed when sending the subscribe\n            # message to 'foo', the connection's standard check_health ran\n            # prior to subscribing.\n            m.assert_any_call(\"PING\", check_health=False)\n\n            p.connection.next_health_check = 0\n            m.reset_mock()\n\n            p.subscribe(\"bar\")\n            # the second subscribe issues exactly only command (the subscribe)\n            # and the health check is not invoked\n            m.assert_called_once_with(\"SUBSCRIBE\", \"bar\", check_health=False)\n\n            # since no message has been read since the health check was\n            # reset, it should still be 0\n            assert p.connection.next_health_check == 0\n\n            subscribe_message = wait_for_message(p)\n            assert subscribe_message[\"type\"] == \"subscribe\"\n            assert wait_for_message(p) is None\n            # now that the connection is subscribed, the pubsub health\n            # check should have taken over and include the HEALTH_CHECK_MESSAGE\n            m.assert_any_call(\"PING\", p.HEALTH_CHECK_MESSAGE, check_health=False)\n            self.assert_interval_advanced(p.connection)\n\n    def test_health_check_in_pubsub_poll(self, r):\n        \"\"\"\n        Polling a pubsub connection that's subscribed will regularly\n        check the connection's health.\n        \"\"\"\n        p = r.pubsub()\n        p.connection = p.connection_pool.get_connection()\n        with mock.patch.object(\n            p.connection, \"send_command\", wraps=p.connection.send_command\n        ) as m:\n            p.subscribe(\"foo\")\n            subscribe_message = wait_for_message(p)\n            assert subscribe_message[\"type\"] == \"subscribe\"\n            self.assert_interval_advanced(p.connection)\n\n            # polling the connection before the health check interval\n            # doesn't result in another health check\n            m.reset_mock()\n            next_health_check = p.connection.next_health_check\n            assert wait_for_message(p) is None\n            assert p.connection.next_health_check == next_health_check\n            m.assert_not_called()\n\n            # reset the health check and poll again\n            # we should not receive a pong message, but the next_health_check\n            # should be advanced\n            p.connection.next_health_check = 0\n            assert wait_for_message(p) is None\n            m.assert_called_with(\"PING\", p.HEALTH_CHECK_MESSAGE, check_health=False)\n            self.assert_interval_advanced(p.connection)\n\n\nclass TestConnectionPoolReleasedEventEmission:\n    \"\"\"Tests for AfterConnectionReleasedEvent emission from ConnectionPool.\"\"\"\n\n    def test_connection_released_event_emitted_on_release(self):\n        \"\"\"Test that AfterConnectionReleasedEvent is emitted when releasing a connection.\"\"\"\n        event_dispatcher = EventDispatcher()\n        listener = MagicMock(spec=EventListenerInterface)\n        event_dispatcher.register_listeners(\n            {\n                AfterConnectionReleasedEvent: [listener],\n            }\n        )\n\n        pool = redis.ConnectionPool(\n            connection_class=DummyConnection,\n            event_dispatcher=event_dispatcher,\n        )\n\n        conn = pool.get_connection()\n        pool.release(conn)\n\n        listener.listen.assert_called_once()\n        event = listener.listen.call_args[0][0]\n        assert isinstance(event, AfterConnectionReleasedEvent)\n\n    def test_connection_released_event_not_emitted_for_foreign_connection(self):\n        \"\"\"Test that AfterConnectionReleasedEvent is NOT emitted for connections not owned by pool.\"\"\"\n        event_dispatcher = EventDispatcher()\n        listener = MagicMock(spec=EventListenerInterface)\n        event_dispatcher.register_listeners(\n            {\n                AfterConnectionReleasedEvent: [listener],\n            }\n        )\n\n        pool = redis.ConnectionPool(\n            connection_class=DummyConnection,\n            event_dispatcher=event_dispatcher,\n        )\n\n        # Create a connection that doesn't belong to this pool\n        foreign_conn = DummyConnection()\n\n        pool.release(foreign_conn)\n\n        # Event should NOT be emitted for foreign connection\n        listener.listen.assert_not_called()\n"
  },
  {
    "path": "tests/test_credentials.py",
    "content": "import functools\nimport random\nimport string\nimport threading\nfrom time import sleep\nfrom typing import Optional, Tuple, Union\nfrom unittest.mock import Mock, call\n\nimport pytest\nimport redis\nfrom redis import AuthenticationError, DataError, Redis, ResponseError\nfrom redis.auth.err import RequestTokenErr\nfrom redis.backoff import NoBackoff\nfrom redis.connection import (\n    ConnectionInterface,\n    ConnectionPool,\n)\nfrom redis.credentials import CredentialProvider, UsernamePasswordCredentialProvider\nfrom redis.exceptions import ConnectionError, RedisError\nfrom redis.retry import Retry\nfrom redis.utils import str_if_bytes\nfrom tests.conftest import (\n    _get_client,\n    get_credential_provider,\n    get_endpoint,\n    skip_if_redis_enterprise,\n)\nfrom tests.entraid_utils import AuthType\n\ntry:\n    from redis_entraid.cred_provider import EntraIdCredentialsProvider\nexcept ImportError:\n    EntraIdCredentialsProvider = None\n\n\n@pytest.fixture()\ndef endpoint(request):\n    endpoint_name = request.config.getoption(\"--endpoint-name\")\n\n    try:\n        return get_endpoint(endpoint_name)\n    except FileNotFoundError as e:\n        pytest.skip(\n            f\"Skipping scenario test because endpoints file is missing: {str(e)}\"\n        )\n\n\n@pytest.fixture()\ndef r_entra(request, endpoint):\n    credential_provider = request.param.get(\"cred_provider_class\", None)\n    single_connection = request.param.get(\"single_connection_client\", False)\n\n    if credential_provider is not None:\n        credential_provider = get_credential_provider(request)\n\n    with _get_client(\n        redis.Redis,\n        request,\n        credential_provider=credential_provider,\n        single_connection_client=single_connection,\n        from_url=endpoint,\n    ) as client:\n        yield client\n\n\nclass NoPassCredProvider(CredentialProvider):\n    def get_credentials(self) -> Union[Tuple[str], Tuple[str, str]]:\n        return \"username\", \"\"\n\n\nclass RandomAuthCredProvider(CredentialProvider):\n    def __init__(self, user: Optional[str], endpoint: str):\n        self.user = user\n        self.endpoint = endpoint\n\n    @functools.lru_cache(maxsize=10)\n    def get_credentials(self) -> Union[Tuple[str, str], Tuple[str]]:\n        def get_random_string(length):\n            letters = string.ascii_lowercase\n            result_str = \"\".join(random.choice(letters) for i in range(length))\n            return result_str\n\n        if self.user:\n            auth_token: str = get_random_string(5) + self.user + \"_\" + self.endpoint\n            return self.user, auth_token\n        else:\n            auth_token: str = get_random_string(5) + self.endpoint\n            return (auth_token,)\n\n\ndef init_acl_user(r, request, username, password):\n    # reset the user\n    r.acl_deluser(username)\n    if password:\n        assert (\n            r.acl_setuser(\n                username,\n                enabled=True,\n                passwords=[\"+\" + password],\n                keys=\"~*\",\n                commands=[\n                    \"+ping\",\n                    \"+command\",\n                    \"+info\",\n                    \"+select\",\n                    \"+flushdb\",\n                    \"+cluster\",\n                ],\n            )\n            is True\n        )\n    else:\n        assert (\n            r.acl_setuser(\n                username,\n                enabled=True,\n                keys=\"~*\",\n                commands=[\n                    \"+ping\",\n                    \"+command\",\n                    \"+info\",\n                    \"+select\",\n                    \"+flushdb\",\n                    \"+cluster\",\n                ],\n                nopass=True,\n            )\n            is True\n        )\n\n    if request is not None:\n\n        def teardown():\n            r.acl_deluser(username)\n\n        request.addfinalizer(teardown)\n\n\ndef init_required_pass(r, request, password):\n    r.config_set(\"requirepass\", password)\n\n    def teardown():\n        try:\n            r.auth(password)\n        except (ResponseError, AuthenticationError):\n            r.auth(\"default\", \"\")\n        r.config_set(\"requirepass\", \"\")\n\n    request.addfinalizer(teardown)\n\n\nclass TestCredentialsProvider:\n    @skip_if_redis_enterprise()\n    def test_only_pass_without_creds_provider(self, r, request):\n        # test for default user (`username` is supposed to be optional)\n        password = \"password\"\n        init_required_pass(r, request, password)\n        assert r.auth(password) is True\n\n        r2 = _get_client(redis.Redis, request, flushdb=False, password=password)\n\n        assert r2.ping() is True\n\n    @skip_if_redis_enterprise()\n    def test_user_and_pass_without_creds_provider(self, r, request):\n        \"\"\"\n        Test backward compatibility with username and password\n        \"\"\"\n        # test for other users\n        username = \"username\"\n        password = \"password\"\n\n        init_acl_user(r, request, username, password)\n        r2 = _get_client(\n            redis.Redis, request, flushdb=False, username=username, password=password\n        )\n\n        assert r2.ping() is True\n\n    @pytest.mark.parametrize(\"username\", [\"username\", None])\n    @skip_if_redis_enterprise()\n    @pytest.mark.onlynoncluster\n    def test_credential_provider_with_supplier(self, r, request, username):\n        creds_provider = RandomAuthCredProvider(\n            user=username,\n            endpoint=\"localhost\",\n        )\n\n        password = creds_provider.get_credentials()[-1]\n\n        if username:\n            init_acl_user(r, request, username, password)\n        else:\n            init_required_pass(r, request, password)\n\n        r2 = _get_client(\n            redis.Redis, request, flushdb=False, credential_provider=creds_provider\n        )\n\n        assert r2.ping() is True\n\n    def test_credential_provider_no_password_success(self, r, request):\n        init_acl_user(r, request, \"username\", \"\")\n        r2 = _get_client(\n            redis.Redis,\n            request,\n            flushdb=False,\n            credential_provider=NoPassCredProvider(),\n        )\n        assert r2.ping() is True\n\n    @pytest.mark.onlynoncluster\n    def test_credential_provider_no_password_error(self, r, request):\n        init_acl_user(r, request, \"username\", \"password\")\n        with pytest.raises(AuthenticationError) as e:\n            _get_client(\n                redis.Redis,\n                request,\n                flushdb=False,\n                credential_provider=NoPassCredProvider(),\n            )\n        assert e.match(\"invalid username-password\")\n\n    @pytest.mark.onlynoncluster\n    def test_password_and_username_together_with_cred_provider_raise_error(\n        self, r, request\n    ):\n        init_acl_user(r, request, \"username\", \"password\")\n        cred_provider = UsernamePasswordCredentialProvider(\n            username=\"username\", password=\"password\"\n        )\n        with pytest.raises(DataError) as e:\n            _get_client(\n                redis.Redis,\n                request,\n                flushdb=False,\n                username=\"username\",\n                password=\"password\",\n                credential_provider=cred_provider,\n            )\n        assert e.match(\n            \"'username' and 'password' cannot be passed along with \"\n            \"'credential_provider'.\"\n        )\n\n    @pytest.mark.onlynoncluster\n    def test_change_username_password_on_existing_connection(self, r, request):\n        username = \"origin_username\"\n        password = \"origin_password\"\n        new_username = \"new_username\"\n        new_password = \"new_password\"\n\n        def teardown():\n            r.acl_deluser(new_username)\n\n        request.addfinalizer(teardown)\n\n        init_acl_user(r, request, username, password)\n        r2 = _get_client(\n            redis.Redis, request, flushdb=False, username=username, password=password\n        )\n        assert r2.ping() is True\n        conn = r2.connection_pool.get_connection()\n        conn.send_command(\"PING\")\n        assert str_if_bytes(conn.read_response()) == \"PONG\"\n        assert conn.username == username\n        assert conn.password == password\n        init_acl_user(r, request, new_username, new_password)\n        conn.password = new_password\n        conn.username = new_username\n        conn.send_command(\"PING\")\n        assert str_if_bytes(conn.read_response()) == \"PONG\"\n\n\nclass TestUsernamePasswordCredentialProvider:\n    def test_user_pass_credential_provider_acl_user_and_pass(self, r, request):\n        username = \"username\"\n        password = \"password\"\n        provider = UsernamePasswordCredentialProvider(username, password)\n        assert provider.username == username\n        assert provider.password == password\n        assert provider.get_credentials() == (username, password)\n        init_acl_user(r, request, provider.username, provider.password)\n        r2 = _get_client(\n            redis.Redis, request, flushdb=False, credential_provider=provider\n        )\n        assert r2.ping() is True\n\n    def test_user_pass_provider_only_password(self, r, request):\n        password = \"password\"\n        provider = UsernamePasswordCredentialProvider(password=password)\n        assert provider.username == \"\"\n        assert provider.password == password\n        assert provider.get_credentials() == (password,)\n\n        init_required_pass(r, request, password)\n\n        r2 = _get_client(\n            redis.Redis, request, flushdb=False, credential_provider=provider\n        )\n        assert r2.auth(provider.password) is True\n        assert r2.ping() is True\n\n\n@pytest.mark.onlynoncluster\n@pytest.mark.skipif(not EntraIdCredentialsProvider, reason=\"requires redis-entraid\")\nclass TestStreamingCredentialProvider:\n    @pytest.mark.parametrize(\n        \"credential_provider\",\n        [\n            {\n                \"cred_provider_class\": EntraIdCredentialsProvider,\n                \"cred_provider_kwargs\": {\"expiration_refresh_ratio\": 0.00005},\n                \"mock_idp\": True,\n            }\n        ],\n        indirect=True,\n    )\n    def test_re_auth_all_connections(self, credential_provider):\n        mock_connection = Mock(spec=ConnectionInterface)\n        mock_connection.retry = Retry(NoBackoff(), 0)\n        mock_connection.host = \"localhost\"\n        mock_connection.port = 6379\n        mock_connection.db = 0\n        mock_another_connection = Mock(spec=ConnectionInterface)\n        mock_another_connection.host = \"localhost\"\n        mock_another_connection.port = 6379\n        mock_another_connection.db = 0\n        mock_pool = Mock(spec=ConnectionPool)\n        mock_pool.connection_kwargs = {\n            \"credential_provider\": credential_provider,\n        }\n        mock_pool.get_connection.return_value = mock_connection\n        mock_pool._available_connections = [mock_connection, mock_another_connection]\n        mock_pool._lock = threading.RLock()\n        auth_token = None\n\n        def re_auth_callback(token):\n            nonlocal auth_token\n            auth_token = token\n            with mock_pool._lock:\n                for conn in mock_pool._available_connections:\n                    conn.send_command(\"AUTH\", token.try_get(\"oid\"), token.get_value())\n                    conn.read_response()\n\n        mock_pool.re_auth_callback = re_auth_callback\n\n        Redis(\n            connection_pool=mock_pool,\n            credential_provider=credential_provider,\n        )\n\n        credential_provider.get_credentials()\n        sleep(0.5)\n\n        mock_connection.send_command.assert_has_calls(\n            [call(\"AUTH\", auth_token.try_get(\"oid\"), auth_token.get_value())]\n        )\n        mock_another_connection.send_command.assert_has_calls(\n            [call(\"AUTH\", auth_token.try_get(\"oid\"), auth_token.get_value())]\n        )\n\n    @pytest.mark.parametrize(\n        \"credential_provider\",\n        [\n            {\n                \"cred_provider_class\": EntraIdCredentialsProvider,\n                \"cred_provider_kwargs\": {\"expiration_refresh_ratio\": 0.00005},\n                \"mock_idp\": True,\n            }\n        ],\n        indirect=True,\n    )\n    def test_re_auth_partial_connections(self, credential_provider):\n        mock_connection = Mock(spec=ConnectionInterface)\n        mock_connection.retry = Retry(NoBackoff(), 3)\n        mock_connection.host = \"localhost\"\n        mock_connection.port = 6379\n        mock_connection.db = 0\n        mock_another_connection = Mock(spec=ConnectionInterface)\n        mock_another_connection.retry = Retry(NoBackoff(), 3)\n        mock_another_connection.host = \"localhost\"\n        mock_another_connection.port = 6379\n        mock_another_connection.db = 0\n        mock_failed_connection = Mock(spec=ConnectionInterface)\n        mock_failed_connection.read_response.side_effect = ConnectionError(\n            \"Failed auth\"\n        )\n        mock_failed_connection.retry = Retry(NoBackoff(), 3)\n        mock_failed_connection.host = \"localhost\"\n        mock_failed_connection.port = 6379\n        mock_failed_connection.db = 0\n        mock_pool = Mock(spec=ConnectionPool)\n        mock_pool.connection_kwargs = {\n            \"credential_provider\": credential_provider,\n        }\n        mock_pool.get_connection.return_value = mock_connection\n        mock_pool._available_connections = [\n            mock_connection,\n            mock_another_connection,\n            mock_failed_connection,\n        ]\n        mock_pool._lock = threading.RLock()\n\n        def _raise(error: RedisError):\n            pass\n\n        def re_auth_callback(token):\n            with mock_pool._lock:\n                for conn in mock_pool._available_connections:\n                    conn.retry.call_with_retry(\n                        lambda: conn.send_command(\n                            \"AUTH\", token.try_get(\"oid\"), token.get_value()\n                        ),\n                        lambda error: _raise(error),\n                    )\n                    conn.retry.call_with_retry(\n                        lambda: conn.read_response(), lambda error: _raise(error)\n                    )\n\n        mock_pool.re_auth_callback = re_auth_callback\n\n        Redis(\n            connection_pool=mock_pool,\n            credential_provider=credential_provider,\n        )\n\n        credential_provider.get_credentials()\n        sleep(0.5)\n\n        mock_connection.read_response.assert_has_calls([call()])\n        mock_another_connection.read_response.assert_has_calls([call()])\n        mock_failed_connection.read_response.assert_has_calls([call(), call(), call()])\n\n    @pytest.mark.parametrize(\n        \"credential_provider\",\n        [\n            {\n                \"cred_provider_class\": EntraIdCredentialsProvider,\n                \"cred_provider_kwargs\": {\"expiration_refresh_ratio\": 0.00005},\n                \"mock_idp\": True,\n            }\n        ],\n        indirect=True,\n    )\n    def test_re_auth_pub_sub_in_resp3(self, credential_provider):\n        mock_pubsub_connection = Mock(spec=ConnectionInterface)\n        mock_pubsub_connection.get_protocol.return_value = 3\n        mock_pubsub_connection.should_reconnect = Mock(return_value=False)\n        mock_pubsub_connection.credential_provider = credential_provider\n        mock_pubsub_connection.retry = Retry(NoBackoff(), 3)\n        mock_pubsub_connection.host = \"localhost\"\n        mock_pubsub_connection.port = 6379\n        mock_pubsub_connection.db = 0\n        mock_another_connection = Mock(spec=ConnectionInterface)\n        mock_another_connection.retry = Retry(NoBackoff(), 3)\n        mock_another_connection.host = \"localhost\"\n        mock_another_connection.port = 6379\n        mock_another_connection.db = 0\n\n        mock_pool = Mock(spec=ConnectionPool)\n        mock_pool.connection_kwargs = {\n            \"credential_provider\": credential_provider,\n        }\n        mock_pool.get_connection.side_effect = [\n            mock_pubsub_connection,\n            mock_another_connection,\n        ]\n        mock_pool._available_connections = [mock_another_connection]\n        mock_pool._lock = threading.RLock()\n        auth_token = None\n\n        def re_auth_callback(token):\n            nonlocal auth_token\n            auth_token = token\n            with mock_pool._lock:\n                for conn in mock_pool._available_connections:\n                    conn.send_command(\"AUTH\", token.try_get(\"oid\"), token.get_value())\n                    conn.read_response()\n\n        mock_pool.re_auth_callback = re_auth_callback\n\n        r = Redis(\n            connection_pool=mock_pool,\n            credential_provider=credential_provider,\n        )\n        p = r.pubsub()\n        p.subscribe(\"test\")\n        credential_provider.get_credentials()\n        sleep(0.5)\n\n        mock_pubsub_connection.send_command.assert_has_calls(\n            [\n                call(\"SUBSCRIBE\", \"test\", check_health=True),\n                call(\"AUTH\", auth_token.try_get(\"oid\"), auth_token.get_value()),\n            ]\n        )\n        mock_another_connection.send_command.assert_has_calls(\n            [call(\"AUTH\", auth_token.try_get(\"oid\"), auth_token.get_value())]\n        )\n\n    @pytest.mark.parametrize(\n        \"credential_provider\",\n        [\n            {\n                \"cred_provider_class\": EntraIdCredentialsProvider,\n                \"cred_provider_kwargs\": {\"expiration_refresh_ratio\": 0.00005},\n                \"mock_idp\": True,\n            }\n        ],\n        indirect=True,\n    )\n    def test_do_not_re_auth_pub_sub_in_resp2(self, credential_provider):\n        mock_pubsub_connection = Mock(spec=ConnectionInterface)\n        mock_pubsub_connection.get_protocol.return_value = 2\n        mock_pubsub_connection.should_reconnect = Mock(return_value=False)\n        mock_pubsub_connection.credential_provider = credential_provider\n        mock_pubsub_connection.retry = Retry(NoBackoff(), 3)\n        mock_pubsub_connection.host = \"localhost\"\n        mock_pubsub_connection.port = 6379\n        mock_pubsub_connection.db = 0\n        mock_another_connection = Mock(spec=ConnectionInterface)\n        mock_another_connection.retry = Retry(NoBackoff(), 3)\n        mock_another_connection.host = \"localhost\"\n        mock_another_connection.port = 6379\n        mock_another_connection.db = 0\n\n        mock_pool = Mock(spec=ConnectionPool)\n        mock_pool.connection_kwargs = {\n            \"credential_provider\": credential_provider,\n        }\n        mock_pool.get_connection.side_effect = [\n            mock_pubsub_connection,\n            mock_another_connection,\n        ]\n        mock_pool._available_connections = [mock_another_connection]\n        mock_pool._lock = threading.RLock()\n        auth_token = None\n\n        def re_auth_callback(token):\n            nonlocal auth_token\n            auth_token = token\n            with mock_pool._lock:\n                for conn in mock_pool._available_connections:\n                    conn.send_command(\"AUTH\", token.try_get(\"oid\"), token.get_value())\n                    conn.read_response()\n\n        mock_pool.re_auth_callback = re_auth_callback\n\n        r = Redis(\n            connection_pool=mock_pool,\n            credential_provider=credential_provider,\n        )\n        p = r.pubsub()\n        p.subscribe(\"test\")\n        credential_provider.get_credentials()\n        sleep(0.5)\n\n        mock_pubsub_connection.send_command.assert_has_calls(\n            [\n                call(\"SUBSCRIBE\", \"test\", check_health=True),\n            ]\n        )\n        mock_another_connection.send_command.assert_has_calls(\n            [call(\"AUTH\", auth_token.try_get(\"oid\"), auth_token.get_value())]\n        )\n\n    @pytest.mark.parametrize(\n        \"credential_provider\",\n        [\n            {\n                \"cred_provider_class\": EntraIdCredentialsProvider,\n                \"cred_provider_kwargs\": {\"expiration_refresh_ratio\": 0.00005},\n                \"mock_idp\": True,\n            }\n        ],\n        indirect=True,\n    )\n    def test_fails_on_token_renewal(self, credential_provider):\n        credential_provider._token_mgr._idp.request_token.side_effect = [\n            RequestTokenErr,\n            RequestTokenErr,\n            RequestTokenErr,\n            RequestTokenErr,\n        ]\n        mock_connection = Mock(spec=ConnectionInterface)\n        mock_connection.retry = Retry(NoBackoff(), 0)\n        mock_connection.host = \"localhost\"\n        mock_connection.port = 6379\n        mock_connection.db = 0\n        mock_another_connection = Mock(spec=ConnectionInterface)\n        mock_another_connection.host = \"localhost\"\n        mock_another_connection.port = 6379\n        mock_another_connection.db = 0\n        mock_pool = Mock(spec=ConnectionPool)\n        mock_pool.connection_kwargs = {\n            \"credential_provider\": credential_provider,\n        }\n        mock_pool.get_connection.return_value = mock_connection\n        mock_pool._available_connections = [mock_connection, mock_another_connection]\n        mock_pool._lock = threading.RLock()\n\n        Redis(\n            connection_pool=mock_pool,\n            credential_provider=credential_provider,\n        )\n\n        with pytest.raises(RequestTokenErr):\n            credential_provider.get_credentials()\n\n\n@pytest.mark.onlynoncluster\n@pytest.mark.cp_integration\n@pytest.mark.skipif(not EntraIdCredentialsProvider, reason=\"requires redis-entraid\")\nclass TestEntraIdCredentialsProvider:\n    @pytest.mark.parametrize(\n        \"r_entra\",\n        [\n            {\n                \"cred_provider_class\": EntraIdCredentialsProvider,\n                \"single_connection_client\": False,\n            },\n            {\n                \"cred_provider_class\": EntraIdCredentialsProvider,\n                \"single_connection_client\": True,\n            },\n            {\n                \"cred_provider_class\": EntraIdCredentialsProvider,\n                \"idp_kwargs\": {\"auth_type\": AuthType.DEFAULT_AZURE_CREDENTIAL},\n            },\n        ],\n        ids=[\"pool\", \"single\", \"DefaultAzureCredential\"],\n        indirect=True,\n    )\n    @pytest.mark.onlynoncluster\n    @pytest.mark.cp_integration\n    def test_auth_pool_with_credential_provider(self, r_entra: redis.Redis):\n        assert r_entra.ping() is True\n\n    @pytest.mark.parametrize(\n        \"r_entra\",\n        [\n            {\n                \"cred_provider_class\": EntraIdCredentialsProvider,\n                \"single_connection_client\": False,\n            },\n            {\n                \"cred_provider_class\": EntraIdCredentialsProvider,\n                \"single_connection_client\": True,\n            },\n        ],\n        ids=[\"pool\", \"single\"],\n        indirect=True,\n    )\n    @pytest.mark.onlynoncluster\n    @pytest.mark.cp_integration\n    def test_auth_pipeline_with_credential_provider(self, r_entra: redis.Redis):\n        pipe = r_entra.pipeline()\n\n        pipe.set(\"key\", \"value\")\n        pipe.get(\"key\")\n\n        assert pipe.execute() == [True, b\"value\"]\n\n    @pytest.mark.parametrize(\n        \"r_entra\",\n        [\n            {\n                \"cred_provider_class\": EntraIdCredentialsProvider,\n            },\n        ],\n        indirect=True,\n    )\n    @pytest.mark.onlynoncluster\n    @pytest.mark.cp_integration\n    def test_auth_pubsub_with_credential_provider(self, r_entra: redis.Redis):\n        p = r_entra.pubsub()\n        p.subscribe(\"entraid\")\n\n        r_entra.publish(\"entraid\", \"test\")\n        r_entra.publish(\"entraid\", \"test\")\n\n        assert p.get_message()[\"type\"] == \"subscribe\"\n        assert p.get_message()[\"type\"] == \"message\"\n\n\n@pytest.mark.onlycluster\n@pytest.mark.cp_integration\n@pytest.mark.skipif(not EntraIdCredentialsProvider, reason=\"requires redis-entraid\")\nclass TestClusterEntraIdCredentialsProvider:\n    @pytest.mark.parametrize(\n        \"r_entra\",\n        [\n            {\n                \"cred_provider_class\": EntraIdCredentialsProvider,\n                \"single_connection_client\": False,\n            },\n            {\n                \"cred_provider_class\": EntraIdCredentialsProvider,\n                \"single_connection_client\": True,\n            },\n            {\n                \"cred_provider_class\": EntraIdCredentialsProvider,\n                \"idp_kwargs\": {\"auth_type\": AuthType.DEFAULT_AZURE_CREDENTIAL},\n            },\n        ],\n        ids=[\"pool\", \"single\", \"DefaultAzureCredential\"],\n        indirect=True,\n    )\n    @pytest.mark.onlycluster\n    @pytest.mark.cp_integration\n    def test_auth_pool_with_credential_provider(self, r_entra: redis.Redis):\n        assert r_entra.ping() is True\n"
  },
  {
    "path": "tests/test_data_structure.py",
    "content": "import concurrent\nimport random\nfrom concurrent.futures import ThreadPoolExecutor\nfrom time import sleep\n\nfrom redis.data_structure import WeightedList\n\n\nclass TestWeightedList:\n    def test_add_items(self):\n        wlist = WeightedList()\n\n        wlist.add(\"item1\", 3.0)\n        wlist.add(\"item2\", 2.0)\n        wlist.add(\"item3\", 4.0)\n        wlist.add(\"item4\", 4.0)\n\n        assert wlist.get_top_n(4) == [\n            (\"item3\", 4.0),\n            (\"item4\", 4.0),\n            (\"item1\", 3.0),\n            (\"item2\", 2.0),\n        ]\n\n    def test_remove_items(self):\n        wlist = WeightedList()\n        wlist.add(\"item1\", 3.0)\n        wlist.add(\"item2\", 2.0)\n        wlist.add(\"item3\", 4.0)\n        wlist.add(\"item4\", 4.0)\n\n        assert wlist.remove(\"item2\") == 2.0\n        assert wlist.remove(\"item4\") == 4.0\n\n        assert wlist.get_top_n(4) == [(\"item3\", 4.0), (\"item1\", 3.0)]\n\n    def test_get_by_weight_range(self):\n        wlist = WeightedList()\n        wlist.add(\"item1\", 3.0)\n        wlist.add(\"item2\", 2.0)\n        wlist.add(\"item3\", 4.0)\n        wlist.add(\"item4\", 4.0)\n\n        assert wlist.get_by_weight_range(2.0, 3.0) == [(\"item1\", 3.0), (\"item2\", 2.0)]\n\n    def test_update_weights(self):\n        wlist = WeightedList()\n        wlist.add(\"item1\", 3.0)\n        wlist.add(\"item2\", 2.0)\n        wlist.add(\"item3\", 4.0)\n        wlist.add(\"item4\", 4.0)\n\n        assert wlist.get_top_n(4) == [\n            (\"item3\", 4.0),\n            (\"item4\", 4.0),\n            (\"item1\", 3.0),\n            (\"item2\", 2.0),\n        ]\n\n        wlist.update_weight(\"item2\", 5.0)\n\n        assert wlist.get_top_n(4) == [\n            (\"item2\", 5.0),\n            (\"item3\", 4.0),\n            (\"item4\", 4.0),\n            (\"item1\", 3.0),\n        ]\n\n    def test_thread_safety(self) -> None:\n        \"\"\"Test thread safety with concurrent operations\"\"\"\n        wl = WeightedList()\n\n        def worker(worker_id):\n            for i in range(100):\n                # Add items\n                wl.add(f\"item_{worker_id}_{i}\", random.uniform(0, 100))\n\n                # Read operations\n                try:\n                    length = len(wl)\n                    if length > 0:\n                        wl.get_top_n(min(5, length))\n                        wl.get_by_weight_range(20, 80)\n                except Exception as e:\n                    print(f\"Error in worker {worker_id}: {e}\")\n\n                sleep(0.001)  # Small delay\n\n        # Run multiple workers concurrently\n        with ThreadPoolExecutor(max_workers=5) as executor:\n            futures = [executor.submit(worker, i) for i in range(5)]\n            concurrent.futures.wait(futures)\n\n        assert len(wl) == 500\n"
  },
  {
    "path": "tests/test_driver_info.py",
    "content": "import pytest\n\nfrom redis.driver_info import DriverInfo\nfrom redis.utils import get_lib_version\n\n\ndef test_driver_info_default_name_no_upstream():\n    info = DriverInfo()\n    assert info.formatted_name == \"redis-py\"\n    assert info.upstream_drivers == []\n    assert info.lib_version == get_lib_version()\n\n\ndef test_driver_info_custom_lib_version():\n    info = DriverInfo(lib_version=\"5.0.0\")\n    assert info.lib_version == \"5.0.0\"\n    assert info.formatted_name == \"redis-py\"\n\n\ndef test_driver_info_single_upstream():\n    info = DriverInfo().add_upstream_driver(\"django-redis\", \"5.4.0\")\n    assert info.formatted_name == \"redis-py(django-redis_v5.4.0)\"\n\n\ndef test_driver_info_multiple_upstreams_latest_first():\n    info = DriverInfo()\n    info.add_upstream_driver(\"django-redis\", \"5.4.0\")\n    info.add_upstream_driver(\"celery\", \"5.4.1\")\n    assert info.formatted_name == \"redis-py(celery_v5.4.1;django-redis_v5.4.0)\"\n\n\n@pytest.mark.parametrize(\n    \"name\",\n    [\n        \"DjangoRedis\",  # must start with lowercase\n        \"django redis\",  # spaces not allowed\n        \"django{redis}\",  # braces not allowed\n        \"django:redis\",  # ':' not allowed by validation regex\n    ],\n)\ndef test_driver_info_invalid_name(name):\n    info = DriverInfo()\n    with pytest.raises(ValueError):\n        info.add_upstream_driver(name, \"3.2.0\")\n\n\n@pytest.mark.parametrize(\n    \"version\",\n    [\n        \"3.2.0 beta\",  # space not allowed\n        \"3.2.0)\",  # brace not allowed\n        \"3.2.0\\n\",  # newline not allowed\n    ],\n)\ndef test_driver_info_invalid_version(version):\n    info = DriverInfo()\n    with pytest.raises(ValueError):\n        info.add_upstream_driver(\"django-redis\", version)\n"
  },
  {
    "path": "tests/test_encoding.py",
    "content": "import pytest\nimport redis\n\nfrom .conftest import _get_client\n\n\nclass TestEncoding:\n    @pytest.fixture()\n    def r(self, request):\n        return _get_client(redis.Redis, request=request, decode_responses=True)\n\n    @pytest.fixture()\n    def r_no_decode(self, request):\n        return _get_client(redis.Redis, request=request, decode_responses=False)\n\n    def test_simple_encoding(self, r_no_decode):\n        unicode_string = chr(3456) + \"abcd\" + chr(3421)\n        r_no_decode[\"unicode-string\"] = unicode_string.encode(\"utf-8\")\n        cached_val = r_no_decode[\"unicode-string\"]\n        assert isinstance(cached_val, bytes)\n        assert unicode_string == cached_val.decode(\"utf-8\")\n\n    def test_simple_encoding_and_decoding(self, r):\n        unicode_string = chr(3456) + \"abcd\" + chr(3421)\n        r[\"unicode-string\"] = unicode_string\n        cached_val = r[\"unicode-string\"]\n        assert isinstance(cached_val, str)\n        assert unicode_string == cached_val\n\n    def test_memoryview_encoding(self, r_no_decode):\n        unicode_string = chr(3456) + \"abcd\" + chr(3421)\n        unicode_string_view = memoryview(unicode_string.encode(\"utf-8\"))\n        r_no_decode[\"unicode-string-memoryview\"] = unicode_string_view\n        cached_val = r_no_decode[\"unicode-string-memoryview\"]\n        # The cached value won't be a memoryview because it's a copy from Redis\n        assert isinstance(cached_val, bytes)\n        assert unicode_string == cached_val.decode(\"utf-8\")\n\n    def test_memoryview_encoding_and_decoding(self, r):\n        unicode_string = chr(3456) + \"abcd\" + chr(3421)\n        unicode_string_view = memoryview(unicode_string.encode(\"utf-8\"))\n        r[\"unicode-string-memoryview\"] = unicode_string_view\n        cached_val = r[\"unicode-string-memoryview\"]\n        assert isinstance(cached_val, str)\n        assert unicode_string == cached_val\n\n    def test_list_encoding(self, r):\n        unicode_string = chr(3456) + \"abcd\" + chr(3421)\n        result = [unicode_string, unicode_string, unicode_string]\n        r.rpush(\"a\", *result)\n        assert r.lrange(\"a\", 0, -1) == result\n\n\nclass TestEncodingErrors:\n    def test_ignore(self, request):\n        r = _get_client(\n            redis.Redis,\n            request=request,\n            decode_responses=True,\n            encoding_errors=\"ignore\",\n        )\n        r.set(\"a\", b\"foo\\xff\")\n        assert r.get(\"a\") == \"foo\"\n\n    def test_replace(self, request):\n        r = _get_client(\n            redis.Redis,\n            request=request,\n            decode_responses=True,\n            encoding_errors=\"replace\",\n        )\n        r.set(\"a\", b\"foo\\xff\")\n        assert r.get(\"a\") == \"foo\\ufffd\"\n\n\nclass TestCommandsAreNotEncoded:\n    @pytest.fixture()\n    def r(self, request):\n        return _get_client(redis.Redis, request=request, encoding=\"utf-8\")\n\n    def test_basic_command(self, r):\n        r.set(\"hello\", \"world\")\n\n\nclass TestInvalidUserInput:\n    def test_boolean_fails(self, r):\n        with pytest.raises(redis.DataError):\n            r.set(\"a\", True)\n\n    def test_none_fails(self, r):\n        with pytest.raises(redis.DataError):\n            r.set(\"a\", None)\n\n    def test_user_type_fails(self, r):\n        class Foo:\n            def __str__(self):\n                return \"Foo\"\n\n        with pytest.raises(redis.DataError):\n            r.set(\"a\", Foo())\n"
  },
  {
    "path": "tests/test_event.py",
    "content": "from unittest.mock import Mock, AsyncMock\n\nfrom redis.event import (\n    EventListenerInterface,\n    EventDispatcher,\n    AsyncEventListenerInterface,\n)\n\n\nclass TestEventDispatcher:\n    def test_register_listeners(self):\n        mock_event = Mock(spec=object)\n        mock_event_listener = Mock(spec=EventListenerInterface)\n        listener_called = 0\n\n        def callback(event):\n            nonlocal listener_called\n            listener_called += 1\n\n        mock_event_listener.listen = callback\n\n        # Register via constructor\n        dispatcher = EventDispatcher(\n            event_listeners={type(mock_event): [mock_event_listener]}\n        )\n        dispatcher.dispatch(mock_event)\n\n        assert listener_called == 1\n\n        # Register additional listener for the same event\n        mock_another_event_listener = Mock(spec=EventListenerInterface)\n        mock_another_event_listener.listen = callback\n        dispatcher.register_listeners(\n            mappings={type(mock_event): [mock_another_event_listener]}\n        )\n        dispatcher.dispatch(mock_event)\n\n        assert listener_called == 3\n\n    async def test_register_listeners_async(self):\n        mock_event = Mock(spec=object)\n        mock_event_listener = AsyncMock(spec=AsyncEventListenerInterface)\n        listener_called = 0\n\n        async def callback(event):\n            nonlocal listener_called\n            listener_called += 1\n\n        mock_event_listener.listen = callback\n\n        # Register via constructor\n        dispatcher = EventDispatcher(\n            event_listeners={type(mock_event): [mock_event_listener]}\n        )\n        await dispatcher.dispatch_async(mock_event)\n\n        assert listener_called == 1\n\n        # Register additional listener for the same event\n        mock_another_event_listener = Mock(spec=AsyncEventListenerInterface)\n        mock_another_event_listener.listen = callback\n        dispatcher.register_listeners(\n            mappings={type(mock_event): [mock_another_event_listener]}\n        )\n        await dispatcher.dispatch_async(mock_event)\n\n        assert listener_called == 3\n"
  },
  {
    "path": "tests/test_function.py",
    "content": "import pytest\nfrom redis.exceptions import ResponseError\n\nfrom .conftest import assert_resp_response, skip_if_server_version_lt\n\nengine = \"lua\"\nlib = \"mylib\"\nlib2 = \"mylib2\"\nfunction = \"redis.register_function{function_name='myfunc', callback=function(keys, \\\n            args) return args[1] end, flags={ 'no-writes' }}\"\nfunction2 = \"redis.register_function('hello', function() return 'Hello World' end)\"\nset_function = \"redis.register_function('set', function(keys, args) return \\\n                redis.call('SET', keys[1], args[1]) end)\"\nget_function = \"redis.register_function('get', function(keys, args) return \\\n                redis.call('GET', keys[1]) end)\"\n\n\n@skip_if_server_version_lt(\"7.0.0\")\nclass TestFunction:\n    @pytest.fixture(autouse=True)\n    def reset_functions(self, r):\n        r.function_flush()\n\n    @pytest.mark.onlynoncluster\n    def test_function_load(self, r):\n        assert b\"mylib\" == r.function_load(f\"#!{engine} name={lib} \\n {function}\")\n        assert b\"mylib\" == r.function_load(\n            f\"#!{engine} name={lib} \\n {function}\", replace=True\n        )\n        with pytest.raises(ResponseError):\n            r.function_load(f\"#!{engine} name={lib} \\n {function}\")\n        with pytest.raises(ResponseError):\n            r.function_load(f\"#!{engine} name={lib2} \\n {function}\")\n\n    def test_function_delete(self, r):\n        r.function_load(f\"#!{engine} name={lib} \\n {set_function}\")\n        with pytest.raises(ResponseError):\n            r.function_load(f\"#!{engine} name={lib} \\n {set_function}\")\n        assert r.fcall(\"set\", 1, \"foo\", \"bar\") == b\"OK\"\n        assert r.function_delete(\"mylib\")\n        with pytest.raises(ResponseError):\n            r.fcall(\"set\", 1, \"foo\", \"bar\")\n\n    def test_function_flush(self, r):\n        r.function_load(f\"#!{engine} name={lib} \\n {function}\")\n        assert r.fcall(\"myfunc\", 0, \"hello\") == b\"hello\"\n        assert r.function_flush()\n        with pytest.raises(ResponseError):\n            r.fcall(\"myfunc\", 0, \"hello\")\n        with pytest.raises(ResponseError):\n            r.function_flush(\"ABC\")\n\n    @pytest.mark.onlynoncluster\n    def test_function_list(self, r):\n        r.function_load(f\"#!{engine} name={lib} \\n {function}\")\n        res = [\n            [\n                b\"library_name\",\n                b\"mylib\",\n                b\"engine\",\n                b\"LUA\",\n                b\"functions\",\n                [[b\"name\", b\"myfunc\", b\"description\", None, b\"flags\", [b\"no-writes\"]]],\n            ]\n        ]\n        resp3_res = [\n            {\n                b\"library_name\": b\"mylib\",\n                b\"engine\": b\"LUA\",\n                b\"functions\": [\n                    {b\"name\": b\"myfunc\", b\"description\": None, b\"flags\": [b\"no-writes\"]}\n                ],\n            }\n        ]\n        assert_resp_response(r, r.function_list(), res, resp3_res)\n        assert_resp_response(r, r.function_list(library=\"*lib\"), res, resp3_res)\n        res[0].extend(\n            [b\"library_code\", f\"#!{engine} name={lib} \\n {function}\".encode()]\n        )\n        resp3_res[0][b\"library_code\"] = f\"#!{engine} name={lib} \\n {function}\".encode()\n        assert_resp_response(r, r.function_list(withcode=True), res, resp3_res)\n\n    @pytest.mark.onlycluster\n    def test_function_list_on_cluster(self, r):\n        r.function_load(f\"#!{engine} name={lib} \\n {function}\")\n        function_list = [\n            [\n                b\"library_name\",\n                b\"mylib\",\n                b\"engine\",\n                b\"LUA\",\n                b\"functions\",\n                [[b\"name\", b\"myfunc\", b\"description\", None, b\"flags\", [b\"no-writes\"]]],\n            ]\n        ]\n        resp3_function_list = [\n            {\n                b\"library_name\": b\"mylib\",\n                b\"engine\": b\"LUA\",\n                b\"functions\": [\n                    {b\"name\": b\"myfunc\", b\"description\": None, b\"flags\": [b\"no-writes\"]}\n                ],\n            }\n        ]\n        primaries = r.get_primaries()\n        res = {}\n        resp3_res = {}\n        for node in primaries:\n            res[node.name] = function_list\n            resp3_res[node.name] = resp3_function_list\n        assert_resp_response(r, r.function_list(), res, resp3_res)\n        assert_resp_response(r, r.function_list(library=\"*lib\"), res, resp3_res)\n        node = primaries[0].name\n        code = f\"#!{engine} name={lib} \\n {function}\".encode()\n        res[node][0].extend([b\"library_code\", code])\n        resp3_res[node][0][b\"library_code\"] = code\n        assert_resp_response(r, r.function_list(withcode=True), res, resp3_res)\n\n    def test_fcall(self, r):\n        r.function_load(f\"#!{engine} name={lib} \\n {set_function}\")\n        r.function_load(f\"#!{engine} name={lib2} \\n {get_function}\")\n        assert r.fcall(\"set\", 1, \"foo\", \"bar\") == b\"OK\"\n        assert r.fcall(\"get\", 1, \"foo\") == b\"bar\"\n        with pytest.raises(ResponseError):\n            r.fcall(\"myfunc\", 0, \"hello\")\n\n    def test_fcall_ro(self, r):\n        r.function_load(f\"#!{engine} name={lib} \\n {function}\")\n        assert r.fcall_ro(\"myfunc\", 0, \"hello\") == b\"hello\"\n        r.function_load(f\"#!{engine} name={lib2} \\n {set_function}\")\n        with pytest.raises(ResponseError):\n            r.fcall_ro(\"set\", 1, \"foo\", \"bar\")\n\n    def test_function_dump_restore(self, r):\n        r.function_load(f\"#!{engine} name={lib} \\n {set_function}\")\n        payload = r.function_dump()\n        assert r.fcall(\"set\", 1, \"foo\", \"bar\") == b\"OK\"\n        r.function_delete(\"mylib\")\n        with pytest.raises(ResponseError):\n            r.fcall(\"set\", 1, \"foo\", \"bar\")\n        assert r.function_restore(payload)\n        assert r.fcall(\"set\", 1, \"foo\", \"bar\") == b\"OK\"\n        r.function_load(f\"#!{engine} name={lib2} \\n {get_function}\")\n        assert r.fcall(\"get\", 1, \"foo\") == b\"bar\"\n        r.function_delete(\"mylib\")\n        assert r.function_restore(payload, \"FLUSH\")\n        with pytest.raises(ResponseError):\n            r.fcall(\"get\", 1, \"foo\")\n"
  },
  {
    "path": "tests/test_hash.py",
    "content": "import math\nimport time\nfrom datetime import datetime, timedelta\n\nimport pytest\nfrom redis import exceptions\nfrom redis.commands.core import HashDataPersistOptions\nfrom tests.conftest import skip_if_server_version_lt\nfrom tests.test_utils import redis_server_time\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_hexpire_basic(r):\n    r.delete(\"test:hash\")\n    r.hset(\"test:hash\", mapping={\"field1\": \"value1\", \"field2\": \"value2\"})\n    assert r.hexpire(\"test:hash\", 1, \"field1\") == [1]\n    time.sleep(1.1)\n    assert r.hexists(\"test:hash\", \"field1\") is False\n    assert r.hexists(\"test:hash\", \"field2\") is True\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_hexpire_with_timedelta(r):\n    r.delete(\"test:hash\")\n    r.hset(\"test:hash\", mapping={\"field1\": \"value1\", \"field2\": \"value2\"})\n    assert r.hexpire(\"test:hash\", timedelta(seconds=1), \"field1\") == [1]\n    time.sleep(1.1)\n    assert r.hexists(\"test:hash\", \"field1\") is False\n    assert r.hexists(\"test:hash\", \"field2\") is True\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_hexpire_conditions(r):\n    r.delete(\"test:hash\")\n    r.hset(\"test:hash\", mapping={\"field1\": \"value1\"})\n    assert r.hexpire(\"test:hash\", 2, \"field1\", xx=True) == [0]\n    assert r.hexpire(\"test:hash\", 2, \"field1\", nx=True) == [1]\n    assert r.hexpire(\"test:hash\", 1, \"field1\", xx=True) == [1]\n    assert r.hexpire(\"test:hash\", 2, \"field1\", nx=True) == [0]\n    time.sleep(1.1)\n    assert r.hexists(\"test:hash\", \"field1\") is False\n    r.hset(\"test:hash\", \"field1\", \"value1\")\n    r.hexpire(\"test:hash\", 2, \"field1\")\n    assert r.hexpire(\"test:hash\", 1, \"field1\", gt=True) == [0]\n    assert r.hexpire(\"test:hash\", 1, \"field1\", lt=True) == [1]\n    time.sleep(1.1)\n    assert r.hexists(\"test:hash\", \"field1\") is False\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_hexpire_nonexistent_key_or_field(r):\n    r.delete(\"test:hash\")\n    assert r.hexpire(\"test:hash\", 1, \"field1\") == [-2]\n    r.hset(\"test:hash\", \"field1\", \"value1\")\n    assert r.hexpire(\"test:hash\", 1, \"nonexistent_field\") == [-2]\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_hexpire_multiple_fields(r):\n    r.delete(\"test:hash\")\n    r.hset(\n        \"test:hash\",\n        mapping={\"field1\": \"value1\", \"field2\": \"value2\", \"field3\": \"value3\"},\n    )\n    assert r.hexpire(\"test:hash\", 1, \"field1\", \"field2\") == [1, 1]\n    time.sleep(1.1)\n    assert r.hexists(\"test:hash\", \"field1\") is False\n    assert r.hexists(\"test:hash\", \"field2\") is False\n    assert r.hexists(\"test:hash\", \"field3\") is True\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_hexpire_multiple_condition_flags_error(r):\n    r.delete(\"test:hash\")\n    r.hset(\"test:hash\", mapping={\"field1\": \"value1\"})\n    with pytest.raises(ValueError) as e:\n        r.hexpire(\"test:hash\", 1, \"field1\", nx=True, xx=True)\n    assert \"Only one of\" in str(e)\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_hpexpire_basic(r):\n    r.delete(\"test:hash\")\n    r.hset(\"test:hash\", mapping={\"field1\": \"value1\", \"field2\": \"value2\"})\n    assert r.hpexpire(\"test:hash\", 500, \"field1\") == [1]\n    time.sleep(0.6)\n    assert r.hexists(\"test:hash\", \"field1\") is False\n    assert r.hexists(\"test:hash\", \"field2\") is True\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_hpexpire_with_timedelta(r):\n    r.delete(\"test:hash\")\n    r.hset(\"test:hash\", mapping={\"field1\": \"value1\", \"field2\": \"value2\"})\n    assert r.hpexpire(\"test:hash\", timedelta(milliseconds=500), \"field1\") == [1]\n    time.sleep(0.6)\n    assert r.hexists(\"test:hash\", \"field1\") is False\n    assert r.hexists(\"test:hash\", \"field2\") is True\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_hpexpire_conditions(r):\n    r.delete(\"test:hash\")\n    r.hset(\"test:hash\", mapping={\"field1\": \"value1\"})\n    assert r.hpexpire(\"test:hash\", 1500, \"field1\", xx=True) == [0]\n    assert r.hpexpire(\"test:hash\", 1500, \"field1\", nx=True) == [1]\n    assert r.hpexpire(\"test:hash\", 500, \"field1\", xx=True) == [1]\n    assert r.hpexpire(\"test:hash\", 1500, \"field1\", nx=True) == [0]\n    time.sleep(0.6)\n    assert r.hexists(\"test:hash\", \"field1\") is False\n    r.hset(\"test:hash\", \"field1\", \"value1\")\n    r.hpexpire(\"test:hash\", 1000, \"field1\")\n    assert r.hpexpire(\"test:hash\", 500, \"field1\", gt=True) == [0]\n    assert r.hpexpire(\"test:hash\", 500, \"field1\", lt=True) == [1]\n    time.sleep(0.6)\n    assert r.hexists(\"test:hash\", \"field1\") is False\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_hpexpire_nonexistent_key_or_field(r):\n    r.delete(\"test:hash\")\n    assert r.hpexpire(\"test:hash\", 500, \"field1\") == [-2]\n    r.hset(\"test:hash\", \"field1\", \"value1\")\n    assert r.hpexpire(\"test:hash\", 500, \"nonexistent_field\") == [-2]\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_hpexpire_multiple_fields(r):\n    r.delete(\"test:hash\")\n    r.hset(\n        \"test:hash\",\n        mapping={\"field1\": \"value1\", \"field2\": \"value2\", \"field3\": \"value3\"},\n    )\n    assert r.hpexpire(\"test:hash\", 500, \"field1\", \"field2\") == [1, 1]\n    time.sleep(0.6)\n    assert r.hexists(\"test:hash\", \"field1\") is False\n    assert r.hexists(\"test:hash\", \"field2\") is False\n    assert r.hexists(\"test:hash\", \"field3\") is True\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_hpexpire_multiple_condition_flags_error(r):\n    r.delete(\"test:hash\")\n    r.hset(\"test:hash\", mapping={\"field1\": \"value1\"})\n    with pytest.raises(ValueError) as e:\n        r.hpexpire(\"test:hash\", 500, \"field1\", nx=True, xx=True)\n    assert \"Only one of\" in str(e)\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_hexpireat_basic(r):\n    r.delete(\"test:hash\")\n    r.hset(\"test:hash\", mapping={\"field1\": \"value1\", \"field2\": \"value2\"})\n    exp_time = math.ceil((datetime.now() + timedelta(seconds=1)).timestamp())\n    assert r.hexpireat(\"test:hash\", exp_time, \"field1\") == [1]\n    time.sleep(2.1)\n    assert r.hexists(\"test:hash\", \"field1\") is False\n    assert r.hexists(\"test:hash\", \"field2\") is True\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_hexpireat_with_datetime(r):\n    r.delete(\"test:hash\")\n    r.hset(\"test:hash\", mapping={\"field1\": \"value1\", \"field2\": \"value2\"})\n    exp_time = (datetime.now() + timedelta(seconds=2)).replace(microsecond=0)\n    assert r.hexpireat(\"test:hash\", exp_time, \"field1\") == [1]\n    time.sleep(2.1)\n    assert r.hexists(\"test:hash\", \"field1\") is False\n    assert r.hexists(\"test:hash\", \"field2\") is True\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_hexpireat_conditions(r):\n    r.delete(\"test:hash\")\n    r.hset(\"test:hash\", mapping={\"field1\": \"value1\"})\n    future_exp_time = int((datetime.now() + timedelta(seconds=2)).timestamp())\n    past_exp_time = int((datetime.now() - timedelta(seconds=1)).timestamp())\n    assert r.hexpireat(\"test:hash\", future_exp_time, \"field1\", xx=True) == [0]\n    assert r.hexpireat(\"test:hash\", future_exp_time, \"field1\", nx=True) == [1]\n    assert r.hexpireat(\"test:hash\", past_exp_time, \"field1\", gt=True) == [0]\n    assert r.hexpireat(\"test:hash\", past_exp_time, \"field1\", lt=True) == [2]\n    assert r.hexists(\"test:hash\", \"field1\") is False\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_hexpireat_nonexistent_key_or_field(r):\n    r.delete(\"test:hash\")\n    future_exp_time = int((datetime.now() + timedelta(seconds=1)).timestamp())\n    assert r.hexpireat(\"test:hash\", future_exp_time, \"field1\") == [-2]\n    r.hset(\"test:hash\", \"field1\", \"value1\")\n    assert r.hexpireat(\"test:hash\", future_exp_time, \"nonexistent_field\") == [-2]\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_hexpireat_multiple_fields(r):\n    r.delete(\"test:hash\")\n    r.hset(\n        \"test:hash\",\n        mapping={\"field1\": \"value1\", \"field2\": \"value2\", \"field3\": \"value3\"},\n    )\n    exp_time = math.ceil((datetime.now() + timedelta(seconds=1)).timestamp())\n    assert r.hexpireat(\"test:hash\", exp_time, \"field1\", \"field2\") == [1, 1]\n    time.sleep(2.1)\n    assert r.hexists(\"test:hash\", \"field1\") is False\n    assert r.hexists(\"test:hash\", \"field2\") is False\n    assert r.hexists(\"test:hash\", \"field3\") is True\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_hexpireat_multiple_condition_flags_error(r):\n    r.delete(\"test:hash\")\n    r.hset(\"test:hash\", mapping={\"field1\": \"value1\"})\n    exp_time = int((datetime.now() + timedelta(seconds=1)).timestamp())\n    with pytest.raises(ValueError) as e:\n        r.hexpireat(\"test:hash\", exp_time, \"field1\", nx=True, xx=True)\n    assert \"Only one of\" in str(e)\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_hpexpireat_basic(r):\n    r.delete(\"test:hash\")\n    r.hset(\"test:hash\", mapping={\"field1\": \"value1\", \"field2\": \"value2\"})\n    exp_time = int((datetime.now() + timedelta(milliseconds=400)).timestamp() * 1000)\n    assert r.hpexpireat(\"test:hash\", exp_time, \"field1\") == [1]\n    time.sleep(0.5)\n    assert r.hexists(\"test:hash\", \"field1\") is False\n    assert r.hexists(\"test:hash\", \"field2\") is True\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_hpexpireat_with_datetime(r):\n    r.delete(\"test:hash\")\n    r.hset(\"test:hash\", mapping={\"field1\": \"value1\", \"field2\": \"value2\"})\n    exp_time = datetime.now() + timedelta(milliseconds=400)\n    assert r.hpexpireat(\"test:hash\", exp_time, \"field1\") == [1]\n    time.sleep(0.5)\n    assert r.hexists(\"test:hash\", \"field1\") is False\n    assert r.hexists(\"test:hash\", \"field2\") is True\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_hpexpireat_conditions(r):\n    r.delete(\"test:hash\")\n    r.hset(\"test:hash\", mapping={\"field1\": \"value1\"})\n    future_exp_time = int(\n        (datetime.now() + timedelta(milliseconds=500)).timestamp() * 1000\n    )\n    past_exp_time = int(\n        (datetime.now() - timedelta(milliseconds=500)).timestamp() * 1000\n    )\n    assert r.hpexpireat(\"test:hash\", future_exp_time, \"field1\", xx=True) == [0]\n    assert r.hpexpireat(\"test:hash\", future_exp_time, \"field1\", nx=True) == [1]\n    assert r.hpexpireat(\"test:hash\", past_exp_time, \"field1\", gt=True) == [0]\n    assert r.hpexpireat(\"test:hash\", past_exp_time, \"field1\", lt=True) == [2]\n    assert r.hexists(\"test:hash\", \"field1\") is False\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_hpexpireat_nonexistent_key_or_field(r):\n    r.delete(\"test:hash\")\n    future_exp_time = int(\n        (datetime.now() + timedelta(milliseconds=500)).timestamp() * 1000\n    )\n    assert r.hpexpireat(\"test:hash\", future_exp_time, \"field1\") == [-2]\n    r.hset(\"test:hash\", \"field1\", \"value1\")\n    assert r.hpexpireat(\"test:hash\", future_exp_time, \"nonexistent_field\") == [-2]\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_hpexpireat_multiple_fields(r):\n    r.delete(\"test:hash\")\n    r.hset(\n        \"test:hash\",\n        mapping={\"field1\": \"value1\", \"field2\": \"value2\", \"field3\": \"value3\"},\n    )\n    exp_time = int((datetime.now() + timedelta(milliseconds=400)).timestamp() * 1000)\n    assert r.hpexpireat(\"test:hash\", exp_time, \"field1\", \"field2\") == [1, 1]\n    time.sleep(0.5)\n    assert r.hexists(\"test:hash\", \"field1\") is False\n    assert r.hexists(\"test:hash\", \"field2\") is False\n    assert r.hexists(\"test:hash\", \"field3\") is True\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_hpexpireat_multiple_condition_flags_error(r):\n    r.delete(\"test:hash\")\n    r.hset(\"test:hash\", mapping={\"field1\": \"value1\"})\n    exp_time = int((datetime.now() + timedelta(milliseconds=500)).timestamp())\n    with pytest.raises(ValueError) as e:\n        r.hpexpireat(\"test:hash\", exp_time, \"field1\", nx=True, xx=True)\n    assert \"Only one of\" in str(e)\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_hpersist_multiple_fields(r):\n    r.delete(\"test:hash\")\n    r.hset(\"test:hash\", mapping={\"field1\": \"value1\", \"field2\": \"value2\"})\n    r.hexpire(\"test:hash\", 5000, \"field1\")\n    assert r.hpersist(\"test:hash\", \"field1\", \"field2\", \"field3\") == [1, -1, -2]\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_hpersist_nonexistent_key(r):\n    r.delete(\"test:hash\")\n    assert r.hpersist(\"test:hash\", \"field1\", \"field2\", \"field3\") == [-2, -2, -2]\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_hexpiretime_multiple_fields_mixed_conditions(r):\n    r.delete(\"test:hash\")\n    r.hset(\"test:hash\", mapping={\"field1\": \"value1\", \"field2\": \"value2\"})\n    future_time = int((datetime.now() + timedelta(minutes=30)).timestamp())\n    r.hexpireat(\"test:hash\", future_time, \"field1\")\n    result = r.hexpiretime(\"test:hash\", \"field1\", \"field2\", \"field3\")\n    assert future_time - 10 < result[0] <= future_time\n    assert result[1:] == [-1, -2]\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_hexpiretime_nonexistent_key(r):\n    r.delete(\"test:hash\")\n    assert r.hexpiretime(\"test:hash\", \"field1\", \"field2\", \"field3\") == [-2, -2, -2]\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_hpexpiretime_multiple_fields_mixed_conditions(r):\n    r.delete(\"test:hash\")\n    r.hset(\"test:hash\", mapping={\"field1\": \"value1\", \"field2\": \"value2\"})\n    future_time = int((datetime.now() + timedelta(minutes=30)).timestamp())\n    r.hexpireat(\"test:hash\", future_time, \"field1\")\n    result = r.hpexpiretime(\"test:hash\", \"field1\", \"field2\", \"field3\")\n    assert future_time * 1000 - 10000 < result[0] <= future_time * 1000\n    assert result[1:] == [-1, -2]\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_hpexpiretime_nonexistent_key(r):\n    r.delete(\"test:hash\")\n    assert r.hpexpiretime(\"test:hash\", \"field1\", \"field2\", \"field3\") == [-2, -2, -2]\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_httl_multiple_fields_mixed_conditions(r):\n    r.delete(\"test:hash\")\n    r.hset(\"test:hash\", mapping={\"field1\": \"value1\", \"field2\": \"value2\"})\n    future_time = int((datetime.now() + timedelta(minutes=30)).timestamp())\n    r.hexpireat(\"test:hash\", future_time, \"field1\")\n    result = r.httl(\"test:hash\", \"field1\", \"field2\", \"field3\")\n    assert 30 * 60 - 10 < result[0] <= 30 * 60\n    assert result[1:] == [-1, -2]\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_httl_nonexistent_key(r):\n    r.delete(\"test:hash\")\n    assert r.httl(\"test:hash\", \"field1\", \"field2\", \"field3\") == [-2, -2, -2]\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_hpttl_multiple_fields_mixed_conditions(r):\n    r.delete(\"test:hash\")\n    r.hset(\"test:hash\", mapping={\"field1\": \"value1\", \"field2\": \"value2\"})\n    future_time = int((datetime.now() + timedelta(minutes=30)).timestamp())\n    r.hexpireat(\"test:hash\", future_time, \"field1\")\n    result = r.hpttl(\"test:hash\", \"field1\", \"field2\", \"field3\")\n    assert 30 * 60000 - 10000 < result[0] <= 30 * 60000\n    assert result[1:] == [-1, -2]\n\n\n@skip_if_server_version_lt(\"7.3.240\")\ndef test_hpttl_nonexistent_key(r):\n    r.delete(\"test:hash\")\n    assert r.hpttl(\"test:hash\", \"field1\", \"field2\", \"field3\") == [-2, -2, -2]\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_hgetdel(r):\n    r.delete(\"test:hash\")\n    r.hset(\"test:hash\", \"foo\", \"bar\", mapping={\"1\": 1, \"2\": 2})\n    assert r.hgetdel(\"test:hash\", \"foo\", \"1\") == [b\"bar\", b\"1\"]\n    assert r.hget(\"test:hash\", \"foo\") is None\n    assert r.hget(\"test:hash\", \"1\") is None\n    assert r.hget(\"test:hash\", \"2\") == b\"2\"\n    assert r.hgetdel(\"test:hash\", \"foo\", \"1\") == [None, None]\n    assert r.hget(\"test:hash\", \"2\") == b\"2\"\n\n    with pytest.raises(exceptions.DataError):\n        r.hgetdel(\"test:hash\")\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_hgetex_no_expiration(r):\n    r.delete(\"test:hash\")\n    r.hset(\"b\", \"foo\", \"bar\", mapping={\"1\": 1, \"2\": 2, \"3\": \"three\", \"4\": b\"four\"})\n\n    assert r.hgetex(\"b\", \"foo\", \"1\", \"4\") == [b\"bar\", b\"1\", b\"four\"]\n    assert r.httl(\"b\", \"foo\", \"1\", \"4\") == [-1, -1, -1]\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_hgetex_expiration_configs(r):\n    r.delete(\"test:hash\")\n    r.hset(\"test:hash\", \"foo\", \"bar\", mapping={\"1\": 1, \"3\": \"three\", \"4\": b\"four\"})\n    test_keys = [\"foo\", \"1\", \"4\"]\n\n    # test get with multiple fields with expiration set through 'ex'\n    assert r.hgetex(\"test:hash\", *test_keys, ex=10) == [b\"bar\", b\"1\", b\"four\"]\n    ttls = r.httl(\"test:hash\", *test_keys)\n    for ttl in ttls:\n        assert pytest.approx(ttl) == 10\n\n    # test get with multiple fields removing expiration settings with 'persist'\n    assert r.hgetex(\"test:hash\", *test_keys, persist=True) == [\n        b\"bar\",\n        b\"1\",\n        b\"four\",\n    ]\n    assert r.httl(\"test:hash\", *test_keys) == [-1, -1, -1]\n\n    # test get with multiple fields with expiration set through 'px'\n    assert r.hgetex(\"test:hash\", *test_keys, px=6000) == [b\"bar\", b\"1\", b\"four\"]\n    ttls = r.httl(\"test:hash\", *test_keys)\n    for ttl in ttls:\n        assert pytest.approx(ttl) == 6\n\n    # test get single field with expiration set through 'pxat'\n    expire_at = redis_server_time(r) + timedelta(minutes=1)\n    assert r.hgetex(\"test:hash\", \"foo\", pxat=expire_at) == [b\"bar\"]\n    assert r.httl(\"test:hash\", \"foo\")[0] <= 61\n\n    # test get single field with expiration set through 'exat'\n    expire_at = redis_server_time(r) + timedelta(seconds=10)\n    assert r.hgetex(\"test:hash\", \"foo\", exat=expire_at) == [b\"bar\"]\n    assert r.httl(\"test:hash\", \"foo\")[0] <= 10\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_hgetex_validate_expired_fields_removed(r):\n    r.delete(\"test:hash\")\n    r.hset(\"test:hash\", \"foo\", \"bar\", mapping={\"1\": 1, \"3\": \"three\", \"4\": b\"four\"})\n\n    test_keys = [\"foo\", \"1\", \"3\"]\n    # test get multiple fields with expiration set\n    # validate that expired fields are removed\n    assert r.hgetex(\"test:hash\", *test_keys, ex=1) == [b\"bar\", b\"1\", b\"three\"]\n    time.sleep(1.1)\n    assert r.hgetex(\"test:hash\", *test_keys) == [None, None, None]\n    assert r.httl(\"test:hash\", *test_keys) == [-2, -2, -2]\n    assert r.hgetex(\"test:hash\", \"4\") == [b\"four\"]\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_hgetex_invalid_inputs(r):\n    with pytest.raises(exceptions.DataError):\n        r.hgetex(\"b\", \"foo\", \"1\", \"3\", ex=10, persist=True)\n\n    with pytest.raises(exceptions.DataError):\n        r.hgetex(\"b\", \"foo\", ex=10.0, persist=True)\n\n    with pytest.raises(exceptions.DataError):\n        r.hgetex(\"b\", \"foo\", ex=10, px=6000)\n\n    with pytest.raises(exceptions.DataError):\n        r.hgetex(\"b\", ex=10)\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_hsetex_no_expiration(r):\n    r.delete(\"test:hash\")\n\n    # # set items from mapping without expiration\n    assert r.hsetex(\"test:hash\", None, None, mapping={\"1\": 1, \"4\": b\"four\"}) == 1\n    assert r.httl(\"test:hash\", \"foo\", \"1\", \"4\") == [-2, -1, -1]\n    assert r.hgetex(\"test:hash\", \"foo\", \"1\") == [None, b\"1\"]\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_hsetex_expiration_ex_and_keepttl(r):\n    r.delete(\"test:hash\")\n\n    # set items from key/value provided\n    # combined with mapping and items with expiration - testing ex field\n    assert (\n        r.hsetex(\n            \"test:hash\",\n            \"foo\",\n            \"bar\",\n            mapping={\"1\": 1, \"2\": \"2\"},\n            items=[\"i1\", 11, \"i2\", 22],\n            ex=10,\n        )\n        == 1\n    )\n    ttls = r.httl(\"test:hash\", \"foo\", \"1\", \"2\", \"i1\", \"i2\")\n    for ttl in ttls:\n        assert pytest.approx(ttl) == 10\n\n    assert r.hgetex(\"test:hash\", \"foo\", \"1\", \"2\", \"i1\", \"i2\") == [\n        b\"bar\",\n        b\"1\",\n        b\"2\",\n        b\"11\",\n        b\"22\",\n    ]\n    time.sleep(1.1)\n    # validate keepttl\n    assert r.hsetex(\"test:hash\", \"foo\", \"bar1\", keepttl=True) == 1\n    assert r.httl(\"test:hash\", \"foo\")[0] < 10\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_hsetex_expiration_px(r):\n    r.delete(\"test:hash\")\n    # set items from key/value provided and mapping\n    # with expiration - testing px field\n    assert (\n        r.hsetex(\"test:hash\", \"foo\", \"bar\", mapping={\"1\": 1, \"2\": \"2\"}, px=60000) == 1\n    )\n    test_keys = [\"foo\", \"1\", \"2\"]\n    ttls = r.httl(\"test:hash\", *test_keys)\n    for ttl in ttls:\n        assert pytest.approx(ttl) == 60\n    assert r.hgetex(\"test:hash\", *test_keys) == [b\"bar\", b\"1\", b\"2\"]\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_hsetex_expiration_pxat_and_fnx(r):\n    r.delete(\"test:hash\")\n    assert r.hsetex(\"test:hash\", \"foo\", \"bar\", mapping={\"1\": 1, \"2\": \"2\"}, ex=30) == 1\n\n    expire_at = redis_server_time(r) + timedelta(minutes=1)\n    assert (\n        r.hsetex(\n            \"test:hash\",\n            \"foo\",\n            \"bar1\",\n            mapping={\"new\": \"ok\"},\n            pxat=expire_at,\n            data_persist_option=HashDataPersistOptions.FNX,\n        )\n        == 0\n    )\n    ttls = r.httl(\"test:hash\", \"foo\", \"new\")\n    assert ttls[0] <= 30\n    assert ttls[1] == -2\n\n    assert r.hgetex(\"test:hash\", \"foo\", \"1\", \"new\") == [b\"bar\", b\"1\", None]\n    assert (\n        r.hsetex(\n            \"test:hash\",\n            \"foo_new\",\n            \"bar1\",\n            mapping={\"new\": \"ok\"},\n            pxat=expire_at,\n            data_persist_option=HashDataPersistOptions.FNX,\n        )\n        == 1\n    )\n    ttls = r.httl(\"test:hash\", \"foo\", \"new\")\n    for ttl in ttls:\n        assert ttl <= 61\n    assert r.hgetex(\"test:hash\", \"foo\", \"foo_new\", \"new\") == [\n        b\"bar\",\n        b\"bar1\",\n        b\"ok\",\n    ]\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_hsetex_expiration_exat_and_fxx(r):\n    r.delete(\"test:hash\")\n    assert r.hsetex(\"test:hash\", \"foo\", \"bar\", mapping={\"1\": 1, \"2\": \"2\"}, ex=30) == 1\n\n    expire_at = redis_server_time(r) + timedelta(seconds=10)\n    assert (\n        r.hsetex(\n            \"test:hash\",\n            \"foo\",\n            \"bar1\",\n            mapping={\"new\": \"ok\"},\n            exat=expire_at,\n            data_persist_option=HashDataPersistOptions.FXX,\n        )\n        == 0\n    )\n    ttls = r.httl(\"test:hash\", \"foo\", \"new\")\n    assert 10 < ttls[0] <= 30\n    assert ttls[1] == -2\n\n    assert r.hgetex(\"test:hash\", \"foo\", \"1\", \"new\") == [b\"bar\", b\"1\", None]\n    assert (\n        r.hsetex(\n            \"test:hash\",\n            \"foo\",\n            \"bar1\",\n            mapping={\"1\": \"new_value\"},\n            exat=expire_at,\n            data_persist_option=HashDataPersistOptions.FXX,\n        )\n        == 1\n    )\n    assert r.hgetex(\"test:hash\", \"foo\", \"1\") == [b\"bar1\", b\"new_value\"]\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_hsetex_invalid_inputs(r):\n    with pytest.raises(exceptions.DataError):\n        r.hsetex(\"b\", \"foo\", \"bar\", ex=10.0)\n\n    with pytest.raises(exceptions.DataError):\n        r.hsetex(\"b\", None, None)\n\n    with pytest.raises(exceptions.DataError):\n        r.hsetex(\"b\", \"foo\", \"bar\", items=[\"i1\", 11, \"i2\"], px=6000)\n\n    with pytest.raises(exceptions.DataError):\n        r.hsetex(\"b\", \"foo\", \"bar\", ex=10, keepttl=True)\n"
  },
  {
    "path": "tests/test_helpers.py",
    "content": "import string\n\nfrom redis.commands.helpers import (\n    delist,\n    list_or_args,\n    nativestr,\n    parse_to_list,\n    random_string,\n)\n\n\ndef test_list_or_args():\n    k = [\"hello, world\"]\n    a = [\"some\", \"argument\", \"list\"]\n    assert list_or_args(k, a) == k + a\n\n    for i in [\"banana\", b\"banana\"]:\n        assert list_or_args(i, a) == [i] + a\n\n\ndef test_parse_to_list():\n    assert parse_to_list(None) == []\n    r = [\"hello\", b\"my name\", \"45\", \"555.55\", \"is simon!\", None]\n    assert parse_to_list(r) == [\"hello\", \"my name\", 45, 555.55, \"is simon!\", None]\n\n\ndef test_nativestr():\n    assert nativestr(\"teststr\") == \"teststr\"\n    assert nativestr(b\"teststr\") == \"teststr\"\n    assert nativestr(\"null\") is None\n\n\ndef test_delist():\n    assert delist(None) is None\n    assert delist([b\"hello\", \"world\", b\"banana\"]) == [\"hello\", \"world\", \"banana\"]\n\n\ndef test_random_string():\n    assert len(random_string()) == 10\n    assert len(random_string(15)) == 15\n    for a in random_string():\n        assert a in string.ascii_lowercase\n"
  },
  {
    "path": "tests/test_http/__init__.py",
    "content": ""
  },
  {
    "path": "tests/test_http/test_http_client.py",
    "content": "import json\nimport gzip\nfrom io import BytesIO\nfrom typing import Any, Dict\nfrom urllib.error import HTTPError\nfrom urllib.parse import urlparse, parse_qs\n\nimport pytest\n\nfrom redis.backoff import ExponentialWithJitterBackoff\nfrom redis.http.http_client import HttpClient, HttpError\nfrom redis.retry import Retry\n\n\nclass FakeResponse:\n    def __init__(\n        self, *, status: int, headers: Dict[str, str], url: str, content: bytes\n    ):\n        self.status = status\n        self.headers = headers\n        self._url = url\n        self._content = content\n\n    def read(self) -> bytes:\n        return self._content\n\n    def geturl(self) -> str:\n        return self._url\n\n    # Support context manager used by urlopen\n    def __enter__(self) -> \"FakeResponse\":\n        return self\n\n    def __exit__(self, exc_type, exc, tb) -> None:\n        return None\n\n\nclass TestHttpClient:\n    def test_get_returns_parsed_json_and_uses_timeout(\n        self, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        # Arrange\n        base_url = \"https://api.example.com/\"\n        path = \"v1/items\"\n        params = {\"limit\": 5, \"q\": \"hello world\"}\n        expected_url = f\"{base_url}{path}?limit=5&q=hello+world\"\n        payload: Dict[str, Any] = {\"items\": [1, 2, 3], \"ok\": True}\n        content = json.dumps(payload).encode(\"utf-8\")\n\n        captured_kwargs = {}\n\n        def fake_urlopen(request, *, timeout=None, context=None):\n            # Capture call details for assertions\n            captured_kwargs[\"timeout\"] = timeout\n            captured_kwargs[\"context\"] = context\n            # Assert the request was constructed correctly\n            assert getattr(request, \"method\", \"\").upper() == \"GET\"\n            assert request.full_url == expected_url\n            # Return a successful response\n            return FakeResponse(\n                status=200,\n                headers={\"Content-Type\": \"application/json; charset=utf-8\"},\n                url=expected_url,\n                content=content,\n            )\n\n        # Patch the urlopen used inside HttpClient\n        monkeypatch.setattr(\"redis.http.http_client.urlopen\", fake_urlopen)\n\n        client = HttpClient(base_url=base_url)\n\n        # Act\n        result = client.get(\n            path, params=params, timeout=12.34\n        )  # default expect_json=True\n\n        # Assert\n        assert result == payload\n        assert pytest.approx(captured_kwargs[\"timeout\"], rel=1e-6) == 12.34\n        # HTTPS -> a context should be provided (created by ssl.create_default_context)\n        assert captured_kwargs[\"context\"] is not None\n\n    def test_get_handles_gzip_response(self, monkeypatch: pytest.MonkeyPatch) -> None:\n        # Arrange\n        base_url = \"https://api.example.com/\"\n        path = \"gzip-endpoint\"\n        expected_url = f\"{base_url}{path}\"\n        payload = {\"message\": \"compressed ok\"}\n        raw = json.dumps(payload).encode(\"utf-8\")\n        gzipped = gzip.compress(raw)\n\n        def fake_urlopen(request, *, timeout=None, context=None):\n            # Return gzipped content with appropriate header\n            return FakeResponse(\n                status=200,\n                headers={\n                    \"Content-Type\": \"application/json; charset=utf-8\",\n                    \"Content-Encoding\": \"gzip\",\n                },\n                url=expected_url,\n                content=gzipped,\n            )\n\n        monkeypatch.setattr(\"redis.http.http_client.urlopen\", fake_urlopen)\n\n        client = HttpClient(base_url=base_url)\n\n        # Act\n        result = client.get(path)  # expect_json=True by default\n\n        # Assert\n        assert result == payload\n\n    def test_get_retries_on_retryable_http_errors_and_succeeds(\n        self, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        # Arrange: configure limited retries so we can assert attempts\n        retry_policy = Retry(\n            backoff=ExponentialWithJitterBackoff(base=0, cap=0), retries=2\n        )  # 2 retries -> up to 3 attempts\n        base_url = \"https://api.example.com/\"\n        path = \"sometimes-busy\"\n        expected_url = f\"{base_url}{path}\"\n        payload = {\"ok\": True}\n        success_content = json.dumps(payload).encode(\"utf-8\")\n\n        call_count = {\"n\": 0}\n\n        def make_http_error(url: str, code: int, body: bytes = b\"busy\"):\n            # Provide a file-like object for .read() when HttpClient tries to read error content\n            fp = BytesIO(body)\n            return HTTPError(\n                url=url,\n                code=code,\n                msg=\"Service Unavailable\",\n                hdrs={\"Content-Type\": \"text/plain\"},\n                fp=fp,\n            )\n\n        def flaky_urlopen(request, *, timeout=None, context=None):\n            call_count[\"n\"] += 1\n            # Fail with a retryable status (503) for the first two calls, then succeed\n            if call_count[\"n\"] <= 2:\n                raise make_http_error(expected_url, 503)\n            return FakeResponse(\n                status=200,\n                headers={\"Content-Type\": \"application/json; charset=utf-8\"},\n                url=expected_url,\n                content=success_content,\n            )\n\n        monkeypatch.setattr(\"redis.http.http_client.urlopen\", flaky_urlopen)\n\n        client = HttpClient(base_url=base_url, retry=retry_policy)\n\n        # Act\n        result = client.get(path)\n\n        # Assert: should have retried twice (total 3 attempts) and finally returned parsed JSON\n        assert result == payload\n        assert call_count[\"n\"] == retry_policy.get_retries() + 1\n\n    def test_post_sends_json_body_and_parses_response(\n        self, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        # Arrange\n        base_url = \"https://api.example.com/\"\n        path = \"v1/create\"\n        expected_url = f\"{base_url}{path}\"\n        send_payload = {\"a\": 1, \"b\": \"x\"}\n        recv_payload = {\"id\": 10, \"ok\": True}\n        recv_content = json.dumps(recv_payload, separators=(\",\", \":\")).encode(\"utf-8\")\n\n        def fake_urlopen(request, *, timeout=None, context=None):\n            # Verify method, URL and headers\n            assert getattr(request, \"method\", \"\").upper() == \"POST\"\n            assert request.full_url == expected_url\n            # Content-Type should be auto-set for string JSON body\n            assert (\n                request.headers.get(\"Content-type\") == \"application/json; charset=utf-8\"\n            )\n            # Body should be already UTF-8 encoded JSON with no spaces\n            assert request.data == json.dumps(\n                send_payload, ensure_ascii=False, separators=(\",\", \":\")\n            ).encode(\"utf-8\")\n            return FakeResponse(\n                status=200,\n                headers={\"Content-Type\": \"application/json; charset=utf-8\"},\n                url=expected_url,\n                content=recv_content,\n            )\n\n        monkeypatch.setattr(\"redis.http.http_client.urlopen\", fake_urlopen)\n\n        client = HttpClient(base_url=base_url)\n\n        # Act\n        result = client.post(path, json_body=send_payload)\n\n        # Assert\n        assert result == recv_payload\n\n    def test_post_with_raw_data_and_custom_headers(\n        self, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        # Arrange\n        base_url = \"https://api.example.com/\"\n        path = \"upload\"\n        expected_url = f\"{base_url}{path}\"\n        raw_data = b\"\\x00\\x01BINARY\"\n        custom_headers = {\"Content-type\": \"application/octet-stream\", \"X-extra\": \"1\"}\n        recv_payload = {\"status\": \"ok\"}\n\n        def fake_urlopen(request, *, timeout=None, context=None):\n            assert getattr(request, \"method\", \"\").upper() == \"POST\"\n            assert request.full_url == expected_url\n            # Ensure our provided headers are present\n            assert request.headers.get(\"Content-type\") == \"application/octet-stream\"\n            assert request.headers.get(\"X-extra\") == \"1\"\n            assert request.data == raw_data\n            return FakeResponse(\n                status=200,\n                headers={\"Content-Type\": \"application/json\"},\n                url=expected_url,\n                content=json.dumps(recv_payload).encode(\"utf-8\"),\n            )\n\n        monkeypatch.setattr(\"redis.http.http_client.urlopen\", fake_urlopen)\n\n        client = HttpClient(base_url=base_url)\n        # Act\n        result = client.post(path, data=raw_data, headers=custom_headers)\n\n        # Assert\n        assert result == recv_payload\n\n    def test_delete_returns_http_response_when_expect_json_false(\n        self, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        # Arrange\n        base_url = \"https://api.example.com/\"\n        path = \"v1/resource/42\"\n        expected_url = f\"{base_url}{path}\"\n        body = b\"deleted\"\n\n        def fake_urlopen(request, *, timeout=None, context=None):\n            assert getattr(request, \"method\", \"\").upper() == \"DELETE\"\n            assert request.full_url == expected_url\n            return FakeResponse(\n                status=204,\n                headers={\"Content-Type\": \"text/plain\"},\n                url=expected_url,\n                content=body,\n            )\n\n        monkeypatch.setattr(\"redis.http.http_client.urlopen\", fake_urlopen)\n        client = HttpClient(base_url=base_url)\n\n        # Act\n        resp = client.delete(path, expect_json=False)\n\n        # Assert\n        assert resp.status == 204\n        assert resp.url == expected_url\n        assert resp.content == body\n\n    def test_put_raises_http_error_on_non_success(\n        self, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        # Arrange\n        base_url = \"https://api.example.com/\"\n        path = \"v1/update/1\"\n        expected_url = f\"{base_url}{path}\"\n\n        def make_http_error(url: str, code: int, body: bytes = b\"not found\"):\n            fp = BytesIO(body)\n            return HTTPError(\n                url=url,\n                code=code,\n                msg=\"Not Found\",\n                hdrs={\"Content-Type\": \"text/plain\"},\n                fp=fp,\n            )\n\n        def fake_urlopen(request, *, timeout=None, context=None):\n            raise make_http_error(expected_url, 404)\n\n        monkeypatch.setattr(\"redis.http.http_client.urlopen\", fake_urlopen)\n        client = HttpClient(base_url=base_url)\n\n        # Act / Assert\n        with pytest.raises(HttpError) as exc:\n            client.put(path, json_body={\"x\": 1})\n        assert exc.value.status == 404\n        assert exc.value.url == expected_url\n\n    def test_patch_with_params_encodes_query(\n        self, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        # Arrange\n        base_url = \"https://api.example.com/\"\n        path = \"v1/edit\"\n        params = {\"tag\": [\"a\", \"b\"], \"q\": \"hello world\"}\n\n        captured_url = {\"u\": None}\n\n        def fake_urlopen(request, *, timeout=None, context=None):\n            captured_url[\"u\"] = request.full_url\n            return FakeResponse(\n                status=200,\n                headers={\"Content-Type\": \"application/json\"},\n                url=request.full_url,\n                content=json.dumps({\"ok\": True}).encode(\"utf-8\"),\n            )\n\n        monkeypatch.setattr(\"redis.http.http_client.urlopen\", fake_urlopen)\n\n        client = HttpClient(base_url=base_url)\n        client.patch(path, params=params)  # We don't care about response here\n\n        # Assert query parameters regardless of ordering\n        parsed = urlparse(captured_url[\"u\"])\n        qs = parse_qs(parsed.query)\n        assert qs[\"q\"] == [\"hello world\"]\n        assert qs[\"tag\"] == [\"a\", \"b\"]\n\n    def test_request_low_level_headers_auth_and_timeout_default(\n        self, monkeypatch: pytest.MonkeyPatch\n    ) -> None:\n        # Arrange: use plain HTTP to verify no TLS context, and check default timeout used\n        base_url = \"http://example.com/\"\n        path = \"ping\"\n        captured = {\n            \"timeout\": None,\n            \"context\": \"unset\",\n            \"headers\": None,\n            \"method\": None,\n        }\n\n        def fake_urlopen(request, *, timeout=None, context=None):\n            captured[\"timeout\"] = timeout\n            captured[\"context\"] = context\n            captured[\"headers\"] = dict(request.headers)\n            captured[\"method\"] = getattr(request, \"method\", \"\").upper()\n            return FakeResponse(\n                status=200,\n                headers={\"Content-Type\": \"application/json\"},\n                url=request.full_url,\n                content=json.dumps({\"pong\": True}).encode(\"utf-8\"),\n            )\n\n        monkeypatch.setattr(\"redis.http.http_client.urlopen\", fake_urlopen)\n\n        client = HttpClient(base_url=base_url, auth_basic=(\"user\", \"pass\"))\n        resp = client.request(\"GET\", path)\n\n        # Assert\n        assert resp.status == 200\n        assert captured[\"method\"] == \"GET\"\n        assert captured[\"context\"] is None  # no TLS for http\n        assert (\n            pytest.approx(captured[\"timeout\"], rel=1e-6) == client.timeout\n        )  # default used\n        # Check some default headers and Authorization presence\n        headers = {k.lower(): v for k, v in captured[\"headers\"].items()}\n        assert \"authorization\" in headers and headers[\"authorization\"].startswith(\n            \"Basic \"\n        )\n        assert headers.get(\"accept\") == \"application/json\"\n        assert \"gzip\" in headers.get(\"accept-encoding\", \"\").lower()\n        assert \"user-agent\" in headers\n"
  },
  {
    "path": "tests/test_json.py",
    "content": "import pytest\nimport redis\nfrom redis import Redis, exceptions\nfrom redis.commands.json.decoders import decode_list, unstring\nfrom redis.commands.json.path import Path\n\nfrom .conftest import _get_client, assert_resp_response, skip_ifmodversion_lt\n\n\n@pytest.fixture\ndef client(request, stack_url):\n    r = _get_client(Redis, request, decode_responses=True, from_url=stack_url)\n    r.flushdb()\n    return r\n\n\n@pytest.mark.redismod\ndef test_json_setbinarykey(client):\n    d = {\"hello\": \"world\", b\"some\": \"value\"}\n    with pytest.raises(TypeError):\n        client.json().set(\"somekey\", Path.root_path(), d)\n    assert client.json().set(\"somekey\", Path.root_path(), d, decode_keys=True)\n\n\n@pytest.mark.redismod\ndef test_json_setgetdeleteforget(client):\n    assert client.json().set(\"foo\", Path.root_path(), \"bar\")\n    assert client.json().get(\"foo\") == \"bar\"\n    assert client.json().get(\"baz\") is None\n    assert client.json().delete(\"foo\") == 1\n    assert client.json().forget(\"foo\") == 0  # second delete\n    assert client.exists(\"foo\") == 0\n\n\n@pytest.mark.redismod\ndef test_jsonget(client):\n    client.json().set(\"foo\", Path.root_path(), \"bar\")\n    assert client.json().get(\"foo\") == \"bar\"\n\n\n@pytest.mark.redismod\ndef test_json_get_jset(client):\n    assert client.json().set(\"foo\", Path.root_path(), \"bar\")\n    assert client.json().get(\"foo\") == \"bar\"\n    assert client.json().get(\"baz\") is None\n    assert 1 == client.json().delete(\"foo\")\n    assert client.exists(\"foo\") == 0\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"2.06.00\", \"ReJSON\")  # todo: update after the release\ndef test_json_merge(client):\n    # Test with root path $\n    assert client.json().set(\n        \"person_data\",\n        \"$\",\n        {\"person1\": {\"personal_data\": {\"name\": \"John\"}}},\n    )\n    assert client.json().merge(\n        \"person_data\", \"$\", {\"person1\": {\"personal_data\": {\"hobbies\": \"reading\"}}}\n    )\n    assert client.json().get(\"person_data\") == {\n        \"person1\": {\"personal_data\": {\"name\": \"John\", \"hobbies\": \"reading\"}}\n    }\n\n    # Test with root path path $.person1.personal_data\n    assert client.json().merge(\n        \"person_data\", \"$.person1.personal_data\", {\"country\": \"Israel\"}\n    )\n    assert client.json().get(\"person_data\") == {\n        \"person1\": {\n            \"personal_data\": {\"name\": \"John\", \"hobbies\": \"reading\", \"country\": \"Israel\"}\n        }\n    }\n\n    # Test with null value to delete a value\n    assert client.json().merge(\"person_data\", \"$.person1.personal_data\", {\"name\": None})\n    assert client.json().get(\"person_data\") == {\n        \"person1\": {\"personal_data\": {\"country\": \"Israel\", \"hobbies\": \"reading\"}}\n    }\n\n\n@pytest.mark.redismod\ndef test_nonascii_setgetdelete(client):\n    assert client.json().set(\"notascii\", Path.root_path(), \"hyvää-élève\")\n    assert client.json().get(\"notascii\", no_escape=True) == \"hyvää-élève\"\n    assert 1 == client.json().delete(\"notascii\")\n    assert client.exists(\"notascii\") == 0\n\n\n@pytest.mark.redismod\ndef test_jsonsetexistentialmodifiersshouldsucceed(client):\n    obj = {\"foo\": \"bar\"}\n    assert client.json().set(\"obj\", Path.root_path(), obj)\n\n    # Test that flags prevent updates when conditions are unmet\n    assert client.json().set(\"obj\", Path(\"foo\"), \"baz\", nx=True) is None\n    assert client.json().set(\"obj\", Path(\"qaz\"), \"baz\", xx=True) is None\n\n    # Test that flags allow updates when conditions are met\n    assert client.json().set(\"obj\", Path(\"foo\"), \"baz\", xx=True)\n    assert client.json().set(\"obj\", Path(\"qaz\"), \"baz\", nx=True)\n\n    # Test that flags are mutually exclusive\n    with pytest.raises(Exception):\n        client.json().set(\"obj\", Path(\"foo\"), \"baz\", nx=True, xx=True)\n\n\n@pytest.mark.redismod\ndef test_mgetshouldsucceed(client):\n    client.json().set(\"1\", Path.root_path(), 1)\n    client.json().set(\"2\", Path.root_path(), 2)\n    assert client.json().mget([\"1\"], Path.root_path()) == [1]\n\n    assert client.json().mget([1, 2], Path.root_path()) == [1, 2]\n\n\n@pytest.mark.onlynoncluster\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"2.06.00\", \"ReJSON\")\ndef test_mset(client):\n    client.json().mset([(\"1\", Path.root_path(), 1), (\"2\", Path.root_path(), 2)])\n\n    assert client.json().mget([\"1\"], Path.root_path()) == [1]\n    assert client.json().mget([\"1\", \"2\"], Path.root_path()) == [1, 2]\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"99.99.99\", \"ReJSON\")  # todo: update after the release\ndef test_clear(client):\n    client.json().set(\"arr\", Path.root_path(), [0, 1, 2, 3, 4])\n    assert 1 == client.json().clear(\"arr\", Path.root_path())\n    assert_resp_response(client, client.json().get(\"arr\"), [], [])\n\n\n@pytest.mark.redismod\ndef test_type(client):\n    client.json().set(\"1\", Path.root_path(), 1)\n    assert_resp_response(\n        client, client.json().type(\"1\", Path.root_path()), \"integer\", [\"integer\"]\n    )\n    assert_resp_response(client, client.json().type(\"1\"), \"integer\", [\"integer\"])\n\n\n@pytest.mark.redismod\ndef test_numincrby(client):\n    client.json().set(\"num\", Path.root_path(), 1)\n    assert_resp_response(\n        client, client.json().numincrby(\"num\", Path.root_path(), 1), 2, [2]\n    )\n    assert_resp_response(\n        client, client.json().numincrby(\"num\", Path.root_path(), 0.5), 2.5, [2.5]\n    )\n    assert_resp_response(\n        client, client.json().numincrby(\"num\", Path.root_path(), -1.25), 1.25, [1.25]\n    )\n\n\n@pytest.mark.redismod\ndef test_nummultby(client):\n    client.json().set(\"num\", Path.root_path(), 1)\n\n    with pytest.deprecated_call():\n        assert_resp_response(\n            client, client.json().nummultby(\"num\", Path.root_path(), 2), 2, [2]\n        )\n        assert_resp_response(\n            client, client.json().nummultby(\"num\", Path.root_path(), 2.5), 5, [5]\n        )\n        assert_resp_response(\n            client, client.json().nummultby(\"num\", Path.root_path(), 0.5), 2.5, [2.5]\n        )\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"99.99.99\", \"ReJSON\")  # todo: update after the release\ndef test_toggle(client):\n    client.json().set(\"bool\", Path.root_path(), False)\n    assert client.json().toggle(\"bool\", Path.root_path())\n    assert client.json().toggle(\"bool\", Path.root_path()) is False\n    # check non-boolean value\n    client.json().set(\"num\", Path.root_path(), 1)\n    with pytest.raises(redis.exceptions.ResponseError):\n        client.json().toggle(\"num\", Path.root_path())\n\n\n@pytest.mark.redismod\ndef test_strappend(client):\n    client.json().set(\"jsonkey\", Path.root_path(), \"foo\")\n    assert 6 == client.json().strappend(\"jsonkey\", \"bar\")\n    assert \"foobar\" == client.json().get(\"jsonkey\", Path.root_path())\n\n\n@pytest.mark.redismod\ndef test_strlen(client):\n    client.json().set(\"str\", Path.root_path(), \"foo\")\n    assert 3 == client.json().strlen(\"str\", Path.root_path())\n    client.json().strappend(\"str\", \"bar\", Path.root_path())\n    assert 6 == client.json().strlen(\"str\", Path.root_path())\n    assert 6 == client.json().strlen(\"str\")\n\n\n@pytest.mark.redismod\ndef test_arrappend(client):\n    client.json().set(\"arr\", Path.root_path(), [1])\n    assert 2 == client.json().arrappend(\"arr\", Path.root_path(), 2)\n    assert 4 == client.json().arrappend(\"arr\", Path.root_path(), 3, 4)\n    assert 7 == client.json().arrappend(\"arr\", Path.root_path(), *[5, 6, 7])\n\n\n@pytest.mark.redismod\ndef test_arrindex(client):\n    client.json().set(\"arr\", Path.root_path(), [0, 1, 2, 3, 4])\n    assert 1 == client.json().arrindex(\"arr\", Path.root_path(), 1)\n    assert -1 == client.json().arrindex(\"arr\", Path.root_path(), 1, 2)\n    assert 4 == client.json().arrindex(\"arr\", Path.root_path(), 4)\n    assert 4 == client.json().arrindex(\"arr\", Path.root_path(), 4, start=0)\n    assert 4 == client.json().arrindex(\"arr\", Path.root_path(), 4, start=0, stop=5000)\n    assert -1 == client.json().arrindex(\"arr\", Path.root_path(), 4, start=0, stop=-1)\n    assert -1 == client.json().arrindex(\"arr\", Path.root_path(), 4, start=1, stop=3)\n\n\n@pytest.mark.redismod\ndef test_arrinsert(client):\n    client.json().set(\"arr\", Path.root_path(), [0, 4])\n    assert 5 - -client.json().arrinsert(\"arr\", Path.root_path(), 1, *[1, 2, 3])\n    assert client.json().get(\"arr\") == [0, 1, 2, 3, 4]\n\n    # test prepends\n    client.json().set(\"val2\", Path.root_path(), [5, 6, 7, 8, 9])\n    client.json().arrinsert(\"val2\", Path.root_path(), 0, [\"some\", \"thing\"])\n    assert client.json().get(\"val2\") == [[\"some\", \"thing\"], 5, 6, 7, 8, 9]\n\n\n@pytest.mark.redismod\ndef test_arrlen(client):\n    client.json().set(\"arr\", Path.root_path(), [0, 1, 2, 3, 4])\n    assert 5 == client.json().arrlen(\"arr\", Path.root_path())\n    assert 5 == client.json().arrlen(\"arr\")\n    assert client.json().arrlen(\"fakekey\") is None\n\n\n@pytest.mark.redismod\ndef test_arrpop(client):\n    client.json().set(\"arr\", Path.root_path(), [0, 1, 2, 3, 4])\n    assert 4 == client.json().arrpop(\"arr\", Path.root_path(), 4)\n    assert 3 == client.json().arrpop(\"arr\", Path.root_path(), -1)\n    assert 2 == client.json().arrpop(\"arr\", Path.root_path())\n    assert 0 == client.json().arrpop(\"arr\", Path.root_path(), 0)\n    assert [1] == client.json().get(\"arr\")\n\n    # test out of bounds\n    client.json().set(\"arr\", Path.root_path(), [0, 1, 2, 3, 4])\n    assert 4 == client.json().arrpop(\"arr\", Path.root_path(), 99)\n\n    # none test\n    client.json().set(\"arr\", Path.root_path(), [])\n    assert client.json().arrpop(\"arr\") is None\n\n\n@pytest.mark.redismod\ndef test_arrtrim(client):\n    client.json().set(\"arr\", Path.root_path(), [0, 1, 2, 3, 4])\n    assert 3 == client.json().arrtrim(\"arr\", Path.root_path(), 1, 3)\n    assert [1, 2, 3] == client.json().get(\"arr\")\n\n    # <0 test, should be 0 equivalent\n    client.json().set(\"arr\", Path.root_path(), [0, 1, 2, 3, 4])\n    assert 0 == client.json().arrtrim(\"arr\", Path.root_path(), -1, 3)\n\n    # testing stop > end\n    client.json().set(\"arr\", Path.root_path(), [0, 1, 2, 3, 4])\n    assert 2 == client.json().arrtrim(\"arr\", Path.root_path(), 3, 99)\n\n    # start > array size and stop\n    client.json().set(\"arr\", Path.root_path(), [0, 1, 2, 3, 4])\n    assert 0 == client.json().arrtrim(\"arr\", Path.root_path(), 9, 1)\n\n    # all larger\n    client.json().set(\"arr\", Path.root_path(), [0, 1, 2, 3, 4])\n    assert 0 == client.json().arrtrim(\"arr\", Path.root_path(), 9, 11)\n\n\n@pytest.mark.redismod\ndef test_resp(client):\n    obj = {\"foo\": \"bar\", \"baz\": 1, \"qaz\": True}\n    client.json().set(\"obj\", Path.root_path(), obj)\n    assert \"bar\" == client.json().resp(\"obj\", Path(\"foo\"))\n    assert 1 == client.json().resp(\"obj\", Path(\"baz\"))\n    assert client.json().resp(\"obj\", Path(\"qaz\"))\n    assert isinstance(client.json().resp(\"obj\"), list)\n\n\n@pytest.mark.redismod\ndef test_objkeys(client):\n    obj = {\"foo\": \"bar\", \"baz\": \"qaz\"}\n    client.json().set(\"obj\", Path.root_path(), obj)\n    keys = client.json().objkeys(\"obj\", Path.root_path())\n    keys.sort()\n    exp = list(obj.keys())\n    exp.sort()\n    assert exp == keys\n\n    client.json().set(\"obj\", Path.root_path(), obj)\n    keys = client.json().objkeys(\"obj\")\n    assert keys == list(obj.keys())\n\n    assert client.json().objkeys(\"fakekey\") is None\n\n\n@pytest.mark.redismod\ndef test_objlen(client):\n    obj = {\"foo\": \"bar\", \"baz\": \"qaz\"}\n    client.json().set(\"obj\", Path.root_path(), obj)\n    assert len(obj) == client.json().objlen(\"obj\", Path.root_path())\n\n    client.json().set(\"obj\", Path.root_path(), obj)\n    assert len(obj) == client.json().objlen(\"obj\")\n\n\n@pytest.mark.redismod\ndef test_json_commands_in_pipeline(client):\n    p = client.json().pipeline()\n    p.set(\"foo\", Path.root_path(), \"bar\")\n    p.get(\"foo\")\n    p.delete(\"foo\")\n    assert p.execute() == [True, \"bar\", 1]\n    assert client.keys() == []\n    assert client.get(\"foo\") is None\n\n    # now with a true, json object\n    client.flushdb()\n    p = client.json().pipeline()\n    d = {\"hello\": \"world\", \"oh\": \"snap\"}\n    with pytest.deprecated_call():\n        p.jsonset(\"foo\", Path.root_path(), d)\n        p.jsonget(\"foo\")\n    p.exists(\"notarealkey\")\n    p.delete(\"foo\")\n    assert p.execute() == [True, d, 0, 1]\n    assert client.keys() == []\n    assert client.get(\"foo\") is None\n\n\n@pytest.mark.redismod\ndef test_json_delete_with_dollar(client):\n    doc1 = {\"a\": 1, \"nested\": {\"a\": 2, \"b\": 3}}\n    assert client.json().set(\"doc1\", \"$\", doc1)\n    assert client.json().delete(\"doc1\", \"$..a\") == 2\n    res = [{\"nested\": {\"b\": 3}}]\n    assert client.json().get(\"doc1\", \"$\") == res\n\n    doc2 = {\"a\": {\"a\": 2, \"b\": 3}, \"b\": [\"a\", \"b\"], \"nested\": {\"b\": [True, \"a\", \"b\"]}}\n    assert client.json().set(\"doc2\", \"$\", doc2)\n    assert client.json().delete(\"doc2\", \"$..a\") == 1\n    res = [{\"nested\": {\"b\": [True, \"a\", \"b\"]}, \"b\": [\"a\", \"b\"]}]\n    assert client.json().get(\"doc2\", \"$\") == res\n\n    doc3 = [\n        {\n            \"ciao\": [\"non ancora\"],\n            \"nested\": [\n                {\"ciao\": [1, \"a\"]},\n                {\"ciao\": [2, \"a\"]},\n                {\"ciaoc\": [3, \"non\", \"ciao\"]},\n                {\"ciao\": [4, \"a\"]},\n                {\"e\": [5, \"non\", \"ciao\"]},\n            ],\n        }\n    ]\n    assert client.json().set(\"doc3\", \"$\", doc3)\n    assert client.json().delete(\"doc3\", '$.[0][\"nested\"]..ciao') == 3\n\n    doc3val = [\n        [\n            {\n                \"ciao\": [\"non ancora\"],\n                \"nested\": [\n                    {},\n                    {},\n                    {\"ciaoc\": [3, \"non\", \"ciao\"]},\n                    {},\n                    {\"e\": [5, \"non\", \"ciao\"]},\n                ],\n            }\n        ]\n    ]\n    assert client.json().get(\"doc3\", \"$\") == doc3val\n\n    # Test default path\n    assert client.json().delete(\"doc3\") == 1\n    assert client.json().get(\"doc3\", \"$\") is None\n\n    client.json().delete(\"not_a_document\", \"..a\")\n\n\n@pytest.mark.redismod\ndef test_json_forget_with_dollar(client):\n    doc1 = {\"a\": 1, \"nested\": {\"a\": 2, \"b\": 3}}\n    assert client.json().set(\"doc1\", \"$\", doc1)\n    assert client.json().forget(\"doc1\", \"$..a\") == 2\n    res = [{\"nested\": {\"b\": 3}}]\n    assert client.json().get(\"doc1\", \"$\") == res\n\n    doc2 = {\"a\": {\"a\": 2, \"b\": 3}, \"b\": [\"a\", \"b\"], \"nested\": {\"b\": [True, \"a\", \"b\"]}}\n    assert client.json().set(\"doc2\", \"$\", doc2)\n    assert client.json().forget(\"doc2\", \"$..a\") == 1\n    res = [{\"nested\": {\"b\": [True, \"a\", \"b\"]}, \"b\": [\"a\", \"b\"]}]\n    assert client.json().get(\"doc2\", \"$\") == res\n\n    doc3 = [\n        {\n            \"ciao\": [\"non ancora\"],\n            \"nested\": [\n                {\"ciao\": [1, \"a\"]},\n                {\"ciao\": [2, \"a\"]},\n                {\"ciaoc\": [3, \"non\", \"ciao\"]},\n                {\"ciao\": [4, \"a\"]},\n                {\"e\": [5, \"non\", \"ciao\"]},\n            ],\n        }\n    ]\n    assert client.json().set(\"doc3\", \"$\", doc3)\n    assert client.json().forget(\"doc3\", '$.[0][\"nested\"]..ciao') == 3\n\n    doc3val = [\n        [\n            {\n                \"ciao\": [\"non ancora\"],\n                \"nested\": [\n                    {},\n                    {},\n                    {\"ciaoc\": [3, \"non\", \"ciao\"]},\n                    {},\n                    {\"e\": [5, \"non\", \"ciao\"]},\n                ],\n            }\n        ]\n    ]\n    assert client.json().get(\"doc3\", \"$\") == doc3val\n\n    # Test default path\n    assert client.json().forget(\"doc3\") == 1\n    assert client.json().get(\"doc3\", \"$\") is None\n\n    client.json().forget(\"not_a_document\", \"..a\")\n\n\n@pytest.mark.redismod\ndef test_json_mget_dollar(client):\n    # Test mget with multi paths\n    client.json().set(\n        \"doc1\",\n        \"$\",\n        {\"a\": 1, \"b\": 2, \"nested\": {\"a\": 3}, \"c\": None, \"nested2\": {\"a\": None}},\n    )\n    client.json().set(\n        \"doc2\",\n        \"$\",\n        {\"a\": 4, \"b\": 5, \"nested\": {\"a\": 6}, \"c\": None, \"nested2\": {\"a\": [None]}},\n    )\n    # Compare also to single JSON.GET\n    res = [1, 3, None]\n    assert client.json().get(\"doc1\", \"$..a\") == res\n    res = [4, 6, [None]]\n    assert client.json().get(\"doc2\", \"$..a\") == res\n\n    # Test mget with single path\n    assert client.json().mget([\"doc1\"], \"$..a\") == [[1, 3, None]]\n    # Test mget with multi path\n    res = [[1, 3, None], [4, 6, [None]]]\n    assert client.json().mget([\"doc1\", \"doc2\"], \"$..a\") == res\n\n    # Test missing key\n    assert client.json().mget([\"doc1\", \"missing_doc\"], \"$..a\") == [[1, 3, None], None]\n    assert client.json().mget([\"missing_doc1\", \"missing_doc2\"], \"$..a\") == [None, None]\n\n\n@pytest.mark.redismod\ndef test_numby_commands_dollar(client):\n    # Test NUMINCRBY\n    client.json().set(\"doc1\", \"$\", {\"a\": \"b\", \"b\": [{\"a\": 2}, {\"a\": 5.0}, {\"a\": \"c\"}]})\n    # Test multi\n    assert client.json().numincrby(\"doc1\", \"$..a\", 2) == [None, 4, 7.0, None]\n\n    assert client.json().numincrby(\"doc1\", \"$..a\", 2.5) == [None, 6.5, 9.5, None]\n    # Test single\n    assert client.json().numincrby(\"doc1\", \"$.b[1].a\", 2) == [11.5]\n\n    assert client.json().numincrby(\"doc1\", \"$.b[2].a\", 2) == [None]\n    assert client.json().numincrby(\"doc1\", \"$.b[1].a\", 3.5) == [15.0]\n\n    # Test NUMMULTBY\n    client.json().set(\"doc1\", \"$\", {\"a\": \"b\", \"b\": [{\"a\": 2}, {\"a\": 5.0}, {\"a\": \"c\"}]})\n\n    # test list\n    with pytest.deprecated_call():\n        assert client.json().nummultby(\"doc1\", \"$..a\", 2) == [None, 4, 10, None]\n        assert client.json().nummultby(\"doc1\", \"$..a\", 2.5) == [None, 10.0, 25.0, None]\n\n    # Test single\n    with pytest.deprecated_call():\n        assert client.json().nummultby(\"doc1\", \"$.b[1].a\", 2) == [50.0]\n        assert client.json().nummultby(\"doc1\", \"$.b[2].a\", 2) == [None]\n        assert client.json().nummultby(\"doc1\", \"$.b[1].a\", 3) == [150.0]\n\n    # test missing keys\n    with pytest.raises(exceptions.ResponseError):\n        client.json().numincrby(\"non_existing_doc\", \"$..a\", 2)\n        client.json().nummultby(\"non_existing_doc\", \"$..a\", 2)\n\n    # Test legacy NUMINCRBY\n    client.json().set(\"doc1\", \"$\", {\"a\": \"b\", \"b\": [{\"a\": 2}, {\"a\": 5.0}, {\"a\": \"c\"}]})\n    assert_resp_response(client, client.json().numincrby(\"doc1\", \".b[0].a\", 3), 5, [5])\n\n    # Test legacy NUMMULTBY\n    client.json().set(\"doc1\", \"$\", {\"a\": \"b\", \"b\": [{\"a\": 2}, {\"a\": 5.0}, {\"a\": \"c\"}]})\n\n    with pytest.deprecated_call():\n        assert_resp_response(\n            client, client.json().nummultby(\"doc1\", \".b[0].a\", 3), 6, [6]\n        )\n\n\n@pytest.mark.redismod\ndef test_strappend_dollar(client):\n    client.json().set(\n        \"doc1\", \"$\", {\"a\": \"foo\", \"nested1\": {\"a\": \"hello\"}, \"nested2\": {\"a\": 31}}\n    )\n    # Test multi\n    assert client.json().strappend(\"doc1\", \"bar\", \"$..a\") == [6, 8, None]\n\n    res = [{\"a\": \"foobar\", \"nested1\": {\"a\": \"hellobar\"}, \"nested2\": {\"a\": 31}}]\n    assert_resp_response(client, client.json().get(\"doc1\", \"$\"), res, res)\n\n    # Test single\n    assert client.json().strappend(\"doc1\", \"baz\", \"$.nested1.a\") == [11]\n\n    res = [{\"a\": \"foobar\", \"nested1\": {\"a\": \"hellobarbaz\"}, \"nested2\": {\"a\": 31}}]\n    assert_resp_response(client, client.json().get(\"doc1\", \"$\"), res, res)\n\n    # Test missing key\n    with pytest.raises(exceptions.ResponseError):\n        client.json().strappend(\"non_existing_doc\", \"$..a\", \"err\")\n\n    # Test multi\n    assert client.json().strappend(\"doc1\", \"bar\", \".*.a\") == 14\n    res = [{\"a\": \"foobar\", \"nested1\": {\"a\": \"hellobarbazbar\"}, \"nested2\": {\"a\": 31}}]\n    assert_resp_response(client, client.json().get(\"doc1\", \"$\"), res, res)\n\n    # Test missing path\n    with pytest.raises(exceptions.ResponseError):\n        client.json().strappend(\"doc1\", \"piu\")\n\n\n@pytest.mark.redismod\ndef test_strlen_dollar(client):\n    # Test multi\n    client.json().set(\n        \"doc1\", \"$\", {\"a\": \"foo\", \"nested1\": {\"a\": \"hello\"}, \"nested2\": {\"a\": 31}}\n    )\n    assert client.json().strlen(\"doc1\", \"$..a\") == [3, 5, None]\n\n    res2 = client.json().strappend(\"doc1\", \"bar\", \"$..a\")\n    res1 = client.json().strlen(\"doc1\", \"$..a\")\n    assert res1 == res2\n\n    # Test single\n    assert client.json().strlen(\"doc1\", \"$.nested1.a\") == [8]\n    assert client.json().strlen(\"doc1\", \"$.nested2.a\") == [None]\n\n    # Test missing key\n    with pytest.raises(exceptions.ResponseError):\n        client.json().strlen(\"non_existing_doc\", \"$..a\")\n\n\n@pytest.mark.redismod\ndef test_arrappend_dollar(client):\n    client.json().set(\n        \"doc1\",\n        \"$\",\n        {\n            \"a\": [\"foo\"],\n            \"nested1\": {\"a\": [\"hello\", None, \"world\"]},\n            \"nested2\": {\"a\": 31},\n        },\n    )\n    # Test multi\n    assert client.json().arrappend(\"doc1\", \"$..a\", \"bar\", \"racuda\") == [3, 5, None]\n    res = [\n        {\n            \"a\": [\"foo\", \"bar\", \"racuda\"],\n            \"nested1\": {\"a\": [\"hello\", None, \"world\", \"bar\", \"racuda\"]},\n            \"nested2\": {\"a\": 31},\n        }\n    ]\n    assert client.json().get(\"doc1\", \"$\") == res\n\n    # Test single\n    assert client.json().arrappend(\"doc1\", \"$.nested1.a\", \"baz\") == [6]\n    res = [\n        {\n            \"a\": [\"foo\", \"bar\", \"racuda\"],\n            \"nested1\": {\"a\": [\"hello\", None, \"world\", \"bar\", \"racuda\", \"baz\"]},\n            \"nested2\": {\"a\": 31},\n        }\n    ]\n    assert client.json().get(\"doc1\", \"$\") == res\n\n    # Test missing key\n    with pytest.raises(exceptions.ResponseError):\n        client.json().arrappend(\"non_existing_doc\", \"$..a\")\n\n    # Test legacy\n    client.json().set(\n        \"doc1\",\n        \"$\",\n        {\n            \"a\": [\"foo\"],\n            \"nested1\": {\"a\": [\"hello\", None, \"world\"]},\n            \"nested2\": {\"a\": 31},\n        },\n    )\n    # Test multi (all paths are updated, but return result of last path)\n    assert client.json().arrappend(\"doc1\", \"..a\", \"bar\", \"racuda\") == 5\n\n    res = [\n        {\n            \"a\": [\"foo\", \"bar\", \"racuda\"],\n            \"nested1\": {\"a\": [\"hello\", None, \"world\", \"bar\", \"racuda\"]},\n            \"nested2\": {\"a\": 31},\n        }\n    ]\n    assert client.json().get(\"doc1\", \"$\") == res\n\n    # Test single\n    assert client.json().arrappend(\"doc1\", \".nested1.a\", \"baz\") == 6\n    res = [\n        {\n            \"a\": [\"foo\", \"bar\", \"racuda\"],\n            \"nested1\": {\"a\": [\"hello\", None, \"world\", \"bar\", \"racuda\", \"baz\"]},\n            \"nested2\": {\"a\": 31},\n        }\n    ]\n    assert client.json().get(\"doc1\", \"$\") == res\n\n    # Test missing key\n    with pytest.raises(exceptions.ResponseError):\n        client.json().arrappend(\"non_existing_doc\", \"$..a\")\n\n\n@pytest.mark.redismod\ndef test_arrinsert_dollar(client):\n    client.json().set(\n        \"doc1\",\n        \"$\",\n        {\n            \"a\": [\"foo\"],\n            \"nested1\": {\"a\": [\"hello\", None, \"world\"]},\n            \"nested2\": {\"a\": 31},\n        },\n    )\n    # Test multi\n    assert client.json().arrinsert(\"doc1\", \"$..a\", \"1\", \"bar\", \"racuda\") == [3, 5, None]\n\n    res = [\n        {\n            \"a\": [\"foo\", \"bar\", \"racuda\"],\n            \"nested1\": {\"a\": [\"hello\", \"bar\", \"racuda\", None, \"world\"]},\n            \"nested2\": {\"a\": 31},\n        }\n    ]\n    assert client.json().get(\"doc1\", \"$\") == res\n\n    # Test single\n    assert client.json().arrinsert(\"doc1\", \"$.nested1.a\", -2, \"baz\") == [6]\n    res = [\n        {\n            \"a\": [\"foo\", \"bar\", \"racuda\"],\n            \"nested1\": {\"a\": [\"hello\", \"bar\", \"racuda\", \"baz\", None, \"world\"]},\n            \"nested2\": {\"a\": 31},\n        }\n    ]\n    assert client.json().get(\"doc1\", \"$\") == res\n\n    # Test missing key\n    with pytest.raises(exceptions.ResponseError):\n        client.json().arrappend(\"non_existing_doc\", \"$..a\")\n\n\n@pytest.mark.redismod\ndef test_arrlen_dollar(client):\n    client.json().set(\n        \"doc1\",\n        \"$\",\n        {\n            \"a\": [\"foo\"],\n            \"nested1\": {\"a\": [\"hello\", None, \"world\"]},\n            \"nested2\": {\"a\": 31},\n        },\n    )\n\n    # Test multi\n    assert client.json().arrlen(\"doc1\", \"$..a\") == [1, 3, None]\n    assert client.json().arrappend(\"doc1\", \"$..a\", \"non\", \"abba\", \"stanza\") == [\n        4,\n        6,\n        None,\n    ]\n\n    client.json().clear(\"doc1\", \"$.a\")\n    assert client.json().arrlen(\"doc1\", \"$..a\") == [0, 6, None]\n    # Test single\n    assert client.json().arrlen(\"doc1\", \"$.nested1.a\") == [6]\n\n    # Test missing key\n    with pytest.raises(exceptions.ResponseError):\n        client.json().arrappend(\"non_existing_doc\", \"$..a\")\n\n    client.json().set(\n        \"doc1\",\n        \"$\",\n        {\n            \"a\": [\"foo\"],\n            \"nested1\": {\"a\": [\"hello\", None, \"world\"]},\n            \"nested2\": {\"a\": 31},\n        },\n    )\n    # Test multi (return result of last path)\n    assert client.json().arrlen(\"doc1\", \"$..a\") == [1, 3, None]\n    assert client.json().arrappend(\"doc1\", \"..a\", \"non\", \"abba\", \"stanza\") == 6\n\n    # Test single\n    assert client.json().arrlen(\"doc1\", \".nested1.a\") == 6\n\n    # Test missing key\n    assert client.json().arrlen(\"non_existing_doc\", \"..a\") is None\n\n\n@pytest.mark.redismod\ndef test_arrpop_dollar(client):\n    client.json().set(\n        \"doc1\",\n        \"$\",\n        {\n            \"a\": [\"foo\"],\n            \"nested1\": {\"a\": [\"hello\", None, \"world\"]},\n            \"nested2\": {\"a\": 31},\n        },\n    )\n\n    # # # Test multi\n    assert client.json().arrpop(\"doc1\", \"$..a\", 1) == ['\"foo\"', None, None]\n\n    res = [{\"a\": [], \"nested1\": {\"a\": [\"hello\", \"world\"]}, \"nested2\": {\"a\": 31}}]\n    assert client.json().get(\"doc1\", \"$\") == res\n\n    # Test missing key\n    with pytest.raises(exceptions.ResponseError):\n        client.json().arrpop(\"non_existing_doc\", \"..a\")\n\n    # # Test legacy\n    client.json().set(\n        \"doc1\",\n        \"$\",\n        {\n            \"a\": [\"foo\"],\n            \"nested1\": {\"a\": [\"hello\", None, \"world\"]},\n            \"nested2\": {\"a\": 31},\n        },\n    )\n    # Test multi (all paths are updated, but return result of last path)\n    assert client.json().arrpop(\"doc1\", \"..a\", \"1\") == \"null\"\n    res = [{\"a\": [], \"nested1\": {\"a\": [\"hello\", \"world\"]}, \"nested2\": {\"a\": 31}}]\n    assert client.json().get(\"doc1\", \"$\") == res\n\n    # # Test missing key\n    with pytest.raises(exceptions.ResponseError):\n        client.json().arrpop(\"non_existing_doc\", \"..a\")\n\n\n@pytest.mark.redismod\ndef test_arrtrim_dollar(client):\n    client.json().set(\n        \"doc1\",\n        \"$\",\n        {\n            \"a\": [\"foo\"],\n            \"nested1\": {\"a\": [\"hello\", None, \"world\"]},\n            \"nested2\": {\"a\": 31},\n        },\n    )\n    # Test multi\n    assert client.json().arrtrim(\"doc1\", \"$..a\", \"1\", -1) == [0, 2, None]\n    res = [{\"a\": [], \"nested1\": {\"a\": [None, \"world\"]}, \"nested2\": {\"a\": 31}}]\n    assert client.json().get(\"doc1\", \"$\") == res\n\n    assert client.json().arrtrim(\"doc1\", \"$..a\", \"1\", \"1\") == [0, 1, None]\n    res = [{\"a\": [], \"nested1\": {\"a\": [\"world\"]}, \"nested2\": {\"a\": 31}}]\n    assert client.json().get(\"doc1\", \"$\") == res\n\n    # Test single\n    assert client.json().arrtrim(\"doc1\", \"$.nested1.a\", 1, 0) == [0]\n    res = [{\"a\": [], \"nested1\": {\"a\": []}, \"nested2\": {\"a\": 31}}]\n    assert client.json().get(\"doc1\", \"$\") == res\n\n    # Test missing key\n    with pytest.raises(exceptions.ResponseError):\n        client.json().arrtrim(\"non_existing_doc\", \"..a\", \"0\", 1)\n\n    # Test legacy\n    client.json().set(\n        \"doc1\",\n        \"$\",\n        {\n            \"a\": [\"foo\"],\n            \"nested1\": {\"a\": [\"hello\", None, \"world\"]},\n            \"nested2\": {\"a\": 31},\n        },\n    )\n\n    # Test multi (all paths are updated, but return result of last path)\n    assert client.json().arrtrim(\"doc1\", \"..a\", \"1\", \"-1\") == 2\n\n    # Test single\n    assert client.json().arrtrim(\"doc1\", \".nested1.a\", \"1\", \"1\") == 1\n    res = [{\"a\": [], \"nested1\": {\"a\": [\"world\"]}, \"nested2\": {\"a\": 31}}]\n    assert client.json().get(\"doc1\", \"$\") == res\n\n    # Test missing key\n    with pytest.raises(exceptions.ResponseError):\n        client.json().arrtrim(\"non_existing_doc\", \"..a\", 1, 1)\n\n\n@pytest.mark.redismod\ndef test_objkeys_dollar(client):\n    client.json().set(\n        \"doc1\",\n        \"$\",\n        {\n            \"nested1\": {\"a\": {\"foo\": 10, \"bar\": 20}},\n            \"a\": [\"foo\"],\n            \"nested2\": {\"a\": {\"baz\": 50}},\n        },\n    )\n\n    # Test single\n    assert client.json().objkeys(\"doc1\", \"$.nested1.a\") == [[\"foo\", \"bar\"]]\n\n    # Test legacy\n    assert client.json().objkeys(\"doc1\", \".*.a\") == [\"foo\", \"bar\"]\n    # Test single\n    assert client.json().objkeys(\"doc1\", \".nested2.a\") == [\"baz\"]\n\n    # Test missing key\n    assert client.json().objkeys(\"non_existing_doc\", \"..a\") is None\n\n    # Test non existing doc\n    with pytest.raises(exceptions.ResponseError):\n        assert client.json().objkeys(\"non_existing_doc\", \"$..a\") == []\n\n    assert client.json().objkeys(\"doc1\", \"$..nowhere\") == []\n\n\n@pytest.mark.redismod\ndef test_objlen_dollar(client):\n    client.json().set(\n        \"doc1\",\n        \"$\",\n        {\n            \"nested1\": {\"a\": {\"foo\": 10, \"bar\": 20}},\n            \"a\": [\"foo\"],\n            \"nested2\": {\"a\": {\"baz\": 50}},\n        },\n    )\n    # Test multi\n    assert client.json().objlen(\"doc1\", \"$..a\") == [None, 2, 1]\n    # Test single\n    assert client.json().objlen(\"doc1\", \"$.nested1.a\") == [2]\n\n    # Test missing key, and path\n    with pytest.raises(exceptions.ResponseError):\n        client.json().objlen(\"non_existing_doc\", \"$..a\")\n\n    assert client.json().objlen(\"doc1\", \"$.nowhere\") == []\n\n    # Test legacy\n    assert client.json().objlen(\"doc1\", \".*.a\") == 2\n\n    # Test single\n    assert client.json().objlen(\"doc1\", \".nested2.a\") == 1\n\n    # Test missing key\n    assert client.json().objlen(\"non_existing_doc\", \"..a\") is None\n\n    # Test missing path\n    # with pytest.raises(exceptions.ResponseError):\n    client.json().objlen(\"doc1\", \".nowhere\")\n\n\ndef load_types_data(nested_key_name):\n    td = {\n        \"object\": {},\n        \"array\": [],\n        \"string\": \"str\",\n        \"integer\": 42,\n        \"number\": 1.2,\n        \"boolean\": False,\n        \"null\": None,\n    }\n    jdata = {}\n    types = []\n    for i, (k, v) in zip(range(1, len(td) + 1), iter(td.items())):\n        jdata[\"nested\" + str(i)] = {nested_key_name: v}\n        types.append(k)\n\n    return jdata, types\n\n\n@pytest.mark.redismod\ndef test_type_dollar(client):\n    jdata, jtypes = load_types_data(\"a\")\n    client.json().set(\"doc1\", \"$\", jdata)\n    # Test multi\n    assert_resp_response(client, client.json().type(\"doc1\", \"$..a\"), jtypes, [jtypes])\n\n    # Test single\n    assert_resp_response(\n        client, client.json().type(\"doc1\", \"$.nested2.a\"), [jtypes[1]], [[jtypes[1]]]\n    )\n\n    # Test missing key\n    assert_resp_response(\n        client, client.json().type(\"non_existing_doc\", \"..a\"), None, [None]\n    )\n\n\n@pytest.mark.redismod\ndef test_clear_dollar(client):\n    client.json().set(\n        \"doc1\",\n        \"$\",\n        {\n            \"nested1\": {\"a\": {\"foo\": 10, \"bar\": 20}},\n            \"a\": [\"foo\"],\n            \"nested2\": {\"a\": \"claro\"},\n            \"nested3\": {\"a\": {\"baz\": 50}},\n        },\n    )\n    # Test multi\n    assert client.json().clear(\"doc1\", \"$..a\") == 3\n\n    res = [\n        {\"nested1\": {\"a\": {}}, \"a\": [], \"nested2\": {\"a\": \"claro\"}, \"nested3\": {\"a\": {}}}\n    ]\n    assert client.json().get(\"doc1\", \"$\") == res\n\n    # Test single\n    client.json().set(\n        \"doc1\",\n        \"$\",\n        {\n            \"nested1\": {\"a\": {\"foo\": 10, \"bar\": 20}},\n            \"a\": [\"foo\"],\n            \"nested2\": {\"a\": \"claro\"},\n            \"nested3\": {\"a\": {\"baz\": 50}},\n        },\n    )\n    assert client.json().clear(\"doc1\", \"$.nested1.a\") == 1\n    res = [\n        {\n            \"nested1\": {\"a\": {}},\n            \"a\": [\"foo\"],\n            \"nested2\": {\"a\": \"claro\"},\n            \"nested3\": {\"a\": {\"baz\": 50}},\n        }\n    ]\n    assert client.json().get(\"doc1\", \"$\") == res\n\n    # Test missing path (defaults to root)\n    assert client.json().clear(\"doc1\") == 1\n    assert client.json().get(\"doc1\", \"$\") == [{}]\n\n    # Test missing key\n    with pytest.raises(exceptions.ResponseError):\n        client.json().clear(\"non_existing_doc\", \"$..a\")\n\n\n@pytest.mark.redismod\ndef test_toggle_dollar(client):\n    client.json().set(\n        \"doc1\",\n        \"$\",\n        {\n            \"a\": [\"foo\"],\n            \"nested1\": {\"a\": False},\n            \"nested2\": {\"a\": 31},\n            \"nested3\": {\"a\": True},\n        },\n    )\n    # Test multi\n    assert client.json().toggle(\"doc1\", \"$..a\") == [None, 1, None, 0]\n    res = [\n        {\n            \"a\": [\"foo\"],\n            \"nested1\": {\"a\": True},\n            \"nested2\": {\"a\": 31},\n            \"nested3\": {\"a\": False},\n        }\n    ]\n    assert client.json().get(\"doc1\", \"$\") == res\n\n    # Test missing key\n    with pytest.raises(exceptions.ResponseError):\n        client.json().toggle(\"non_existing_doc\", \"$..a\")\n\n\n@pytest.mark.redismod\ndef test_resp_dollar(client):\n    data = {\n        \"L1\": {\n            \"a\": {\n                \"A1_B1\": 10,\n                \"A1_B2\": False,\n                \"A1_B3\": {\n                    \"A1_B3_C1\": None,\n                    \"A1_B3_C2\": [\n                        \"A1_B3_C2_D1_1\",\n                        \"A1_B3_C2_D1_2\",\n                        -19.5,\n                        \"A1_B3_C2_D1_4\",\n                        \"A1_B3_C2_D1_5\",\n                        {\"A1_B3_C2_D1_6_E1\": True},\n                    ],\n                    \"A1_B3_C3\": [1],\n                },\n                \"A1_B4\": {\"A1_B4_C1\": \"foo\"},\n            }\n        },\n        \"L2\": {\n            \"a\": {\n                \"A2_B1\": 20,\n                \"A2_B2\": False,\n                \"A2_B3\": {\n                    \"A2_B3_C1\": None,\n                    \"A2_B3_C2\": [\n                        \"A2_B3_C2_D1_1\",\n                        \"A2_B3_C2_D1_2\",\n                        -37.5,\n                        \"A2_B3_C2_D1_4\",\n                        \"A2_B3_C2_D1_5\",\n                        {\"A2_B3_C2_D1_6_E1\": False},\n                    ],\n                    \"A2_B3_C3\": [2],\n                },\n                \"A2_B4\": {\"A2_B4_C1\": \"bar\"},\n            }\n        },\n    }\n    client.json().set(\"doc1\", \"$\", data)\n    # Test multi\n    res = client.json().resp(\"doc1\", \"$..a\")\n    resp2 = [\n        [\n            \"{\",\n            \"A1_B1\",\n            10,\n            \"A1_B2\",\n            \"false\",\n            \"A1_B3\",\n            [\n                \"{\",\n                \"A1_B3_C1\",\n                None,\n                \"A1_B3_C2\",\n                [\n                    \"[\",\n                    \"A1_B3_C2_D1_1\",\n                    \"A1_B3_C2_D1_2\",\n                    \"-19.5\",\n                    \"A1_B3_C2_D1_4\",\n                    \"A1_B3_C2_D1_5\",\n                    [\"{\", \"A1_B3_C2_D1_6_E1\", \"true\"],\n                ],\n                \"A1_B3_C3\",\n                [\"[\", 1],\n            ],\n            \"A1_B4\",\n            [\"{\", \"A1_B4_C1\", \"foo\"],\n        ],\n        [\n            \"{\",\n            \"A2_B1\",\n            20,\n            \"A2_B2\",\n            \"false\",\n            \"A2_B3\",\n            [\n                \"{\",\n                \"A2_B3_C1\",\n                None,\n                \"A2_B3_C2\",\n                [\n                    \"[\",\n                    \"A2_B3_C2_D1_1\",\n                    \"A2_B3_C2_D1_2\",\n                    \"-37.5\",\n                    \"A2_B3_C2_D1_4\",\n                    \"A2_B3_C2_D1_5\",\n                    [\"{\", \"A2_B3_C2_D1_6_E1\", \"false\"],\n                ],\n                \"A2_B3_C3\",\n                [\"[\", 2],\n            ],\n            \"A2_B4\",\n            [\"{\", \"A2_B4_C1\", \"bar\"],\n        ],\n    ]\n    resp3 = [\n        [\n            \"{\",\n            \"A1_B1\",\n            10,\n            \"A1_B2\",\n            \"false\",\n            \"A1_B3\",\n            [\n                \"{\",\n                \"A1_B3_C1\",\n                None,\n                \"A1_B3_C2\",\n                [\n                    \"[\",\n                    \"A1_B3_C2_D1_1\",\n                    \"A1_B3_C2_D1_2\",\n                    -19.5,\n                    \"A1_B3_C2_D1_4\",\n                    \"A1_B3_C2_D1_5\",\n                    [\"{\", \"A1_B3_C2_D1_6_E1\", \"true\"],\n                ],\n                \"A1_B3_C3\",\n                [\"[\", 1],\n            ],\n            \"A1_B4\",\n            [\"{\", \"A1_B4_C1\", \"foo\"],\n        ],\n        [\n            \"{\",\n            \"A2_B1\",\n            20,\n            \"A2_B2\",\n            \"false\",\n            \"A2_B3\",\n            [\n                \"{\",\n                \"A2_B3_C1\",\n                None,\n                \"A2_B3_C2\",\n                [\n                    \"[\",\n                    \"A2_B3_C2_D1_1\",\n                    \"A2_B3_C2_D1_2\",\n                    -37.5,\n                    \"A2_B3_C2_D1_4\",\n                    \"A2_B3_C2_D1_5\",\n                    [\"{\", \"A2_B3_C2_D1_6_E1\", \"false\"],\n                ],\n                \"A2_B3_C3\",\n                [\"[\", 2],\n            ],\n            \"A2_B4\",\n            [\"{\", \"A2_B4_C1\", \"bar\"],\n        ],\n    ]\n    assert_resp_response(client, res, resp2, resp3)\n\n    # Test single\n    res = client.json().resp(\"doc1\", \"$.L1.a\")\n    resp2 = [\n        [\n            \"{\",\n            \"A1_B1\",\n            10,\n            \"A1_B2\",\n            \"false\",\n            \"A1_B3\",\n            [\n                \"{\",\n                \"A1_B3_C1\",\n                None,\n                \"A1_B3_C2\",\n                [\n                    \"[\",\n                    \"A1_B3_C2_D1_1\",\n                    \"A1_B3_C2_D1_2\",\n                    \"-19.5\",\n                    \"A1_B3_C2_D1_4\",\n                    \"A1_B3_C2_D1_5\",\n                    [\"{\", \"A1_B3_C2_D1_6_E1\", \"true\"],\n                ],\n                \"A1_B3_C3\",\n                [\"[\", 1],\n            ],\n            \"A1_B4\",\n            [\"{\", \"A1_B4_C1\", \"foo\"],\n        ]\n    ]\n    resp3 = [\n        [\n            \"{\",\n            \"A1_B1\",\n            10,\n            \"A1_B2\",\n            \"false\",\n            \"A1_B3\",\n            [\n                \"{\",\n                \"A1_B3_C1\",\n                None,\n                \"A1_B3_C2\",\n                [\n                    \"[\",\n                    \"A1_B3_C2_D1_1\",\n                    \"A1_B3_C2_D1_2\",\n                    -19.5,\n                    \"A1_B3_C2_D1_4\",\n                    \"A1_B3_C2_D1_5\",\n                    [\"{\", \"A1_B3_C2_D1_6_E1\", \"true\"],\n                ],\n                \"A1_B3_C3\",\n                [\"[\", 1],\n            ],\n            \"A1_B4\",\n            [\"{\", \"A1_B4_C1\", \"foo\"],\n        ]\n    ]\n    assert_resp_response(client, res, resp2, resp3)\n\n    # Test missing path\n    client.json().resp(\"doc1\", \"$.nowhere\")\n\n    # Test missing key\n    # with pytest.raises(exceptions.ResponseError):\n    client.json().resp(\"non_existing_doc\", \"$..a\")\n\n\n@pytest.mark.redismod\ndef test_arrindex_dollar(client):\n    client.json().set(\n        \"store\",\n        \"$\",\n        {\n            \"store\": {\n                \"book\": [\n                    {\n                        \"category\": \"reference\",\n                        \"author\": \"Nigel Rees\",\n                        \"title\": \"Sayings of the Century\",\n                        \"price\": 8.95,\n                        \"size\": [10, 20, 30, 40],\n                    },\n                    {\n                        \"category\": \"fiction\",\n                        \"author\": \"Evelyn Waugh\",\n                        \"title\": \"Sword of Honour\",\n                        \"price\": 12.99,\n                        \"size\": [50, 60, 70, 80],\n                    },\n                    {\n                        \"category\": \"fiction\",\n                        \"author\": \"Herman Melville\",\n                        \"title\": \"Moby Dick\",\n                        \"isbn\": \"0-553-21311-3\",\n                        \"price\": 8.99,\n                        \"size\": [5, 10, 20, 30],\n                    },\n                    {\n                        \"category\": \"fiction\",\n                        \"author\": \"J. R. R. Tolkien\",\n                        \"title\": \"The Lord of the Rings\",\n                        \"isbn\": \"0-395-19395-8\",\n                        \"price\": 22.99,\n                        \"size\": [5, 6, 7, 8],\n                    },\n                ],\n                \"bicycle\": {\"color\": \"red\", \"price\": 19.95},\n            }\n        },\n    )\n\n    assert client.json().get(\"store\", \"$.store.book[?(@.price<10)].size\") == [\n        [10, 20, 30, 40],\n        [5, 10, 20, 30],\n    ]\n\n    assert client.json().arrindex(\n        \"store\", \"$.store.book[?(@.price<10)].size\", \"20\"\n    ) == [-1, -1]\n\n    # Test index of int scalar in multi values\n    client.json().set(\n        \"test_num\",\n        \".\",\n        [\n            {\"arr\": [0, 1, 3.0, 3, 2, 1, 0, 3]},\n            {\"nested1_found\": {\"arr\": [5, 4, 3, 2, 1, 0, 1, 2, 3.0, 2, 4, 5]}},\n            {\"nested2_not_found\": {\"arr\": [2, 4, 6]}},\n            {\"nested3_scalar\": {\"arr\": \"3\"}},\n            [\n                {\"nested41_not_arr\": {\"arr_renamed\": [1, 2, 3]}},\n                {\"nested42_empty_arr\": {\"arr\": []}},\n            ],\n        ],\n    )\n\n    res = [\n        [0, 1, 3.0, 3, 2, 1, 0, 3],\n        [5, 4, 3, 2, 1, 0, 1, 2, 3.0, 2, 4, 5],\n        [2, 4, 6],\n        \"3\",\n        [],\n    ]\n    assert client.json().get(\"test_num\", \"$..arr\") == res\n\n    assert client.json().arrindex(\"test_num\", \"$..arr\", 3) == [3, 2, -1, None, -1]\n\n    # Test index of double scalar in multi values\n    assert client.json().arrindex(\"test_num\", \"$..arr\", 3.0) == [2, 8, -1, None, -1]\n\n    # Test index of string scalar in multi values\n    client.json().set(\n        \"test_string\",\n        \".\",\n        [\n            {\"arr\": [\"bazzz\", \"bar\", 2, \"baz\", 2, \"ba\", \"baz\", 3]},\n            {\n                \"nested1_found\": {\n                    \"arr\": [None, \"baz2\", \"buzz\", 2, 1, 0, 1, \"2\", \"baz\", 2, 4, 5]\n                }\n            },\n            {\"nested2_not_found\": {\"arr\": [\"baz2\", 4, 6]}},\n            {\"nested3_scalar\": {\"arr\": \"3\"}},\n            [\n                {\"nested41_arr\": {\"arr_renamed\": [1, \"baz\", 3]}},\n                {\"nested42_empty_arr\": {\"arr\": []}},\n            ],\n        ],\n    )\n    res = [\n        [\"bazzz\", \"bar\", 2, \"baz\", 2, \"ba\", \"baz\", 3],\n        [None, \"baz2\", \"buzz\", 2, 1, 0, 1, \"2\", \"baz\", 2, 4, 5],\n        [\"baz2\", 4, 6],\n        \"3\",\n        [],\n    ]\n    assert client.json().get(\"test_string\", \"$..arr\") == res\n\n    assert client.json().arrindex(\"test_string\", \"$..arr\", \"baz\") == [\n        3,\n        8,\n        -1,\n        None,\n        -1,\n    ]\n\n    assert client.json().arrindex(\"test_string\", \"$..arr\", \"baz\", 2) == [\n        3,\n        8,\n        -1,\n        None,\n        -1,\n    ]\n    assert client.json().arrindex(\"test_string\", \"$..arr\", \"baz\", 4) == [\n        6,\n        8,\n        -1,\n        None,\n        -1,\n    ]\n    assert client.json().arrindex(\"test_string\", \"$..arr\", \"baz\", -5) == [\n        3,\n        8,\n        -1,\n        None,\n        -1,\n    ]\n    assert client.json().arrindex(\"test_string\", \"$..arr\", \"baz\", 4, 7) == [\n        6,\n        -1,\n        -1,\n        None,\n        -1,\n    ]\n    assert client.json().arrindex(\"test_string\", \"$..arr\", \"baz\", 4, -1) == [\n        6,\n        8,\n        -1,\n        None,\n        -1,\n    ]\n    assert client.json().arrindex(\"test_string\", \"$..arr\", \"baz\", 4, 0) == [\n        6,\n        8,\n        -1,\n        None,\n        -1,\n    ]\n    assert client.json().arrindex(\"test_string\", \"$..arr\", \"5\", 7, -1) == [\n        -1,\n        -1,\n        -1,\n        None,\n        -1,\n    ]\n    assert client.json().arrindex(\"test_string\", \"$..arr\", \"5\", 7, 0) == [\n        -1,\n        -1,\n        -1,\n        None,\n        -1,\n    ]\n\n    # Test index of None scalar in multi values\n    client.json().set(\n        \"test_None\",\n        \".\",\n        [\n            {\"arr\": [\"bazzz\", \"None\", 2, None, 2, \"ba\", \"baz\", 3]},\n            {\n                \"nested1_found\": {\n                    \"arr\": [\"zaz\", \"baz2\", \"buzz\", 2, 1, 0, 1, \"2\", None, 2, 4, 5]\n                }\n            },\n            {\"nested2_not_found\": {\"arr\": [\"None\", 4, 6]}},\n            {\"nested3_scalar\": {\"arr\": None}},\n            [\n                {\"nested41_arr\": {\"arr_renamed\": [1, None, 3]}},\n                {\"nested42_empty_arr\": {\"arr\": []}},\n            ],\n        ],\n    )\n    res = [\n        [\"bazzz\", \"None\", 2, None, 2, \"ba\", \"baz\", 3],\n        [\"zaz\", \"baz2\", \"buzz\", 2, 1, 0, 1, \"2\", None, 2, 4, 5],\n        [\"None\", 4, 6],\n        None,\n        [],\n    ]\n    assert client.json().get(\"test_None\", \"$..arr\") == res\n\n    # Test with none-scalar value\n    assert client.json().arrindex(\n        \"test_None\", \"$..nested42_empty_arr.arr\", {\"arr\": []}\n    ) == [-1]\n\n    # Test legacy (path begins with dot)\n    # Test index of int scalar in single value\n    assert client.json().arrindex(\"test_num\", \".[0].arr\", 3) == 3\n    assert client.json().arrindex(\"test_num\", \".[0].arr\", 9) == -1\n\n    with pytest.raises(exceptions.ResponseError):\n        client.json().arrindex(\"test_num\", \".[0].arr_not\", 3)\n    # Test index of string scalar in single value\n    assert client.json().arrindex(\"test_string\", \".[0].arr\", \"baz\") == 3\n    assert client.json().arrindex(\"test_string\", \".[0].arr\", \"faz\") == -1\n    # Test index of None scalar in single value\n    assert client.json().arrindex(\"test_None\", \".[0].arr\", \"None\") == 1\n    assert client.json().arrindex(\"test_None\", \"..nested2_not_found.arr\", \"None\") == 0\n\n\n@pytest.mark.redismod\ndef test_decoders_and_unstring():\n    assert unstring(\"4\") == 4\n    assert unstring(\"45.55\") == 45.55\n    assert unstring(\"hello world\") == \"hello world\"\n\n    assert decode_list(b\"45.55\") == 45.55\n    assert decode_list(\"45.55\") == 45.55\n    assert decode_list([\"hello\", b\"world\"]) == [\"hello\", \"world\"]\n\n\n@pytest.mark.redismod\ndef test_custom_decoder(client):\n    import json\n\n    import ujson\n\n    cj = client.json(encoder=ujson, decoder=ujson)\n    assert cj.set(\"foo\", Path.root_path(), \"bar\")\n    assert cj.get(\"foo\") == \"bar\"\n    assert cj.get(\"baz\") is None\n    assert 1 == cj.delete(\"foo\")\n    assert client.exists(\"foo\") == 0\n    assert not isinstance(cj.__encoder__, json.JSONEncoder)\n    assert not isinstance(cj.__decoder__, json.JSONDecoder)\n\n\n@pytest.mark.redismod\ndef test_set_file(client):\n    import json\n    import tempfile\n\n    obj = {\"hello\": \"world\"}\n    jsonfile = tempfile.NamedTemporaryFile(suffix=\".json\")\n    with open(jsonfile.name, \"w+\") as fp:\n        fp.write(json.dumps(obj))\n\n    nojsonfile = tempfile.NamedTemporaryFile()\n    nojsonfile.write(b\"Hello World\")\n\n    assert client.json().set_file(\"test\", Path.root_path(), jsonfile.name)\n    assert client.json().get(\"test\") == obj\n    with pytest.raises(json.JSONDecodeError):\n        client.json().set_file(\"test2\", Path.root_path(), nojsonfile.name)\n\n\n@pytest.mark.redismod\ndef test_set_path(client):\n    import json\n    import tempfile\n\n    root = tempfile.mkdtemp()\n    sub = tempfile.mkdtemp(dir=root)\n    jsonfile = tempfile.mkstemp(suffix=\".json\", dir=sub)[1]\n    nojsonfile = tempfile.mkstemp(dir=root)[1]\n\n    with open(jsonfile, \"w+\") as fp:\n        fp.write(json.dumps({\"hello\": \"world\"}))\n    with open(nojsonfile, \"a+\") as fp:\n        fp.write(\"hello\")\n\n    result = {jsonfile: True, nojsonfile: False}\n    assert client.json().set_path(Path.root_path(), root) == result\n    res = {\"hello\": \"world\"}\n    assert client.json().get(jsonfile.rsplit(\".\")[0]) == res\n"
  },
  {
    "path": "tests/test_lock.py",
    "content": "import time\n\nimport pytest\nfrom redis.client import Redis\nfrom redis.exceptions import LockError, LockNotOwnedError\nfrom redis.lock import Lock\n\nfrom .conftest import _get_client\n\n\nclass TestLock:\n    @pytest.fixture()\n    def r_decoded(self, request):\n        return _get_client(Redis, request=request, decode_responses=True)\n\n    def get_lock(self, redis, *args, **kwargs):\n        kwargs[\"lock_class\"] = Lock\n        return redis.lock(*args, **kwargs)\n\n    def test_lock(self, r):\n        lock = self.get_lock(r, \"foo\")\n        assert lock.acquire(blocking=False)\n        assert r.get(\"foo\") == lock.local.token\n        assert r.ttl(\"foo\") == -1\n        lock.release()\n        assert r.get(\"foo\") is None\n\n    def test_lock_token(self, r):\n        lock = self.get_lock(r, \"foo\")\n        self._test_lock_token(r, lock)\n\n    def test_lock_token_thread_local_false(self, r):\n        lock = self.get_lock(r, \"foo\", thread_local=False)\n        self._test_lock_token(r, lock)\n\n    def _test_lock_token(self, r, lock):\n        assert lock.acquire(blocking=False, token=\"test\")\n        assert r.get(\"foo\") == b\"test\"\n        assert lock.local.token == b\"test\"\n        assert r.ttl(\"foo\") == -1\n        lock.release()\n        assert r.get(\"foo\") is None\n        assert lock.local.token is None\n\n    def test_locked(self, r):\n        lock = self.get_lock(r, \"foo\")\n        assert lock.locked() is False\n        lock.acquire(blocking=False)\n        assert lock.locked() is True\n        lock.release()\n        assert lock.locked() is False\n\n    def _test_owned(self, client):\n        lock = self.get_lock(client, \"foo\")\n        assert lock.owned() is False\n        lock.acquire(blocking=False)\n        assert lock.owned() is True\n        lock.release()\n        assert lock.owned() is False\n\n        lock2 = self.get_lock(client, \"foo\")\n        assert lock.owned() is False\n        assert lock2.owned() is False\n        lock2.acquire(blocking=False)\n        assert lock.owned() is False\n        assert lock2.owned() is True\n        lock2.release()\n        assert lock.owned() is False\n        assert lock2.owned() is False\n\n    def test_owned(self, r):\n        self._test_owned(r)\n\n    def test_owned_with_decoded_responses(self, r_decoded):\n        self._test_owned(r_decoded)\n\n    def test_competing_locks(self, r):\n        lock1 = self.get_lock(r, \"foo\")\n        lock2 = self.get_lock(r, \"foo\")\n        assert lock1.acquire(blocking=False)\n        assert not lock2.acquire(blocking=False)\n        lock1.release()\n        assert lock2.acquire(blocking=False)\n        assert not lock1.acquire(blocking=False)\n        lock2.release()\n\n    def test_timeout(self, r):\n        lock = self.get_lock(r, \"foo\", timeout=10)\n        assert lock.acquire(blocking=False)\n        assert 8 < r.ttl(\"foo\") <= 10\n        lock.release()\n\n    def test_float_timeout(self, r):\n        lock = self.get_lock(r, \"foo\", timeout=9.5)\n        assert lock.acquire(blocking=False)\n        assert 8 < r.pttl(\"foo\") <= 9500\n        lock.release()\n\n    def test_blocking_timeout(self, r):\n        lock1 = self.get_lock(r, \"foo\")\n        assert lock1.acquire(blocking=False)\n        bt = 0.4\n        sleep = 0.05\n        fudge_factor = 0.05\n        lock2 = self.get_lock(r, \"foo\", sleep=sleep, blocking_timeout=bt)\n        start = time.monotonic()\n        assert not lock2.acquire()\n        # The elapsed duration should be less than the total blocking_timeout\n        assert (bt + fudge_factor) > (time.monotonic() - start) > bt - sleep\n        lock1.release()\n\n    def test_context_manager(self, r):\n        # blocking_timeout prevents a deadlock if the lock can't be acquired\n        # for some reason\n        with self.get_lock(r, \"foo\", blocking_timeout=0.2) as lock:\n            assert r.get(\"foo\") == lock.local.token\n        assert r.get(\"foo\") is None\n\n    def test_context_manager_blocking_timeout(self, r):\n        with self.get_lock(r, \"foo\", blocking=False):\n            bt = 0.4\n            sleep = 0.05\n            fudge_factor = 0.05\n            lock2 = self.get_lock(r, \"foo\", sleep=sleep, blocking_timeout=bt)\n            start = time.monotonic()\n            assert not lock2.acquire()\n            # The elapsed duration should be less than the total blocking_timeout\n            assert (bt + fudge_factor) > (time.monotonic() - start) > bt - sleep\n\n    def test_context_manager_raises_when_locked_not_acquired(self, r):\n        r.set(\"foo\", \"bar\")\n        with pytest.raises(LockError):\n            with self.get_lock(r, \"foo\", blocking_timeout=0.1):\n                pass\n\n    def test_context_manager_not_raise_on_release_lock_not_owned_error(self, r):\n        try:\n            with self.get_lock(r, \"foo\", timeout=0.1, raise_on_release_error=False):\n                time.sleep(0.15)\n        except LockNotOwnedError:\n            pytest.fail(\"LockNotOwnedError should not have been raised\")\n\n        with pytest.raises(LockNotOwnedError):\n            with self.get_lock(r, \"foo\", timeout=0.1, raise_on_release_error=True):\n                time.sleep(0.15)\n\n    def test_context_manager_not_raise_on_release_lock_error(self, r):\n        try:\n            with self.get_lock(\n                r, \"foo\", timeout=0.1, raise_on_release_error=False\n            ) as lock:\n                lock.release()\n        except LockError:\n            pytest.fail(\"LockError should not have been raised\")\n\n        with pytest.raises(LockError):\n            with self.get_lock(\n                r, \"foo\", timeout=0.1, raise_on_release_error=True\n            ) as lock:\n                lock.release()\n\n    def test_high_sleep_small_blocking_timeout(self, r):\n        lock1 = self.get_lock(r, \"foo\")\n        assert lock1.acquire(blocking=False)\n        sleep = 60\n        bt = 1\n        lock2 = self.get_lock(r, \"foo\", sleep=sleep, blocking_timeout=bt)\n        start = time.monotonic()\n        assert not lock2.acquire()\n        # the elapsed timed is less than the blocking_timeout as the lock is\n        # unattainable given the sleep/blocking_timeout configuration\n        assert bt > (time.monotonic() - start)\n        lock1.release()\n\n    def test_releasing_unlocked_lock_raises_error(self, r):\n        lock = self.get_lock(r, \"foo\")\n        with pytest.raises(LockError):\n            lock.release()\n\n    def test_releasing_lock_no_longer_owned_raises_error(self, r):\n        lock = self.get_lock(r, \"foo\")\n        lock.acquire(blocking=False)\n        # manually change the token\n        r.set(\"foo\", \"a\")\n        with pytest.raises(LockNotOwnedError):\n            lock.release()\n        # even though we errored, the token is still cleared\n        assert lock.local.token is None\n\n    def test_extend_lock(self, r):\n        lock = self.get_lock(r, \"foo\", timeout=10)\n        assert lock.acquire(blocking=False)\n        assert 8000 < r.pttl(\"foo\") <= 10000\n        assert lock.extend(10)\n        assert 16000 < r.pttl(\"foo\") <= 20000\n        lock.release()\n\n    def test_extend_lock_replace_ttl(self, r):\n        lock = self.get_lock(r, \"foo\", timeout=10)\n        assert lock.acquire(blocking=False)\n        assert 8000 < r.pttl(\"foo\") <= 10000\n        assert lock.extend(10, replace_ttl=True)\n        assert 8000 < r.pttl(\"foo\") <= 10000\n        lock.release()\n\n    def test_extend_lock_float(self, r):\n        lock = self.get_lock(r, \"foo\", timeout=10.5)\n        assert lock.acquire(blocking=False)\n        assert 10400 < r.pttl(\"foo\") <= 10500\n        old_ttl = r.pttl(\"foo\")\n        assert lock.extend(10.5)\n        assert old_ttl + 10400 < r.pttl(\"foo\") <= old_ttl + 10500\n        lock.release()\n\n    def test_extending_unlocked_lock_raises_error(self, r):\n        lock = self.get_lock(r, \"foo\", timeout=10)\n        with pytest.raises(LockError):\n            lock.extend(10)\n\n    def test_extending_lock_with_no_timeout_raises_error(self, r):\n        lock = self.get_lock(r, \"foo\")\n        assert lock.acquire(blocking=False)\n        with pytest.raises(LockError):\n            lock.extend(10)\n        lock.release()\n\n    def test_extending_lock_no_longer_owned_raises_error(self, r):\n        lock = self.get_lock(r, \"foo\", timeout=10)\n        assert lock.acquire(blocking=False)\n        r.set(\"foo\", \"a\")\n        with pytest.raises(LockNotOwnedError):\n            lock.extend(10)\n\n    def test_reacquire_lock(self, r):\n        lock = self.get_lock(r, \"foo\", timeout=10)\n        assert lock.acquire(blocking=False)\n        assert r.pexpire(\"foo\", 5000)\n        assert r.pttl(\"foo\") <= 5000\n        assert lock.reacquire()\n        assert 8000 < r.pttl(\"foo\") <= 10000\n        lock.release()\n\n    def test_reacquiring_unlocked_lock_raises_error(self, r):\n        lock = self.get_lock(r, \"foo\", timeout=10)\n        with pytest.raises(LockError):\n            lock.reacquire()\n\n    def test_reacquiring_lock_with_no_timeout_raises_error(self, r):\n        lock = self.get_lock(r, \"foo\")\n        assert lock.acquire(blocking=False)\n        with pytest.raises(LockError):\n            lock.reacquire()\n        lock.release()\n\n    def test_reacquiring_lock_no_longer_owned_raises_error(self, r):\n        lock = self.get_lock(r, \"foo\", timeout=10)\n        assert lock.acquire(blocking=False)\n        r.set(\"foo\", \"a\")\n        with pytest.raises(LockNotOwnedError):\n            lock.reacquire()\n\n    def test_context_manager_reacquiring_lock_with_no_timeout_raises_error(self, r):\n        with self.get_lock(r, \"foo\", timeout=None, blocking=False) as lock:\n            with pytest.raises(LockError):\n                lock.reacquire()\n\n    def test_context_manager_reacquiring_lock_no_longer_owned_raises_error(self, r):\n        with pytest.raises(LockNotOwnedError):\n            with self.get_lock(r, \"foo\", timeout=10, blocking=False):\n                r.set(\"foo\", \"a\")\n\n    def test_lock_error_gives_correct_lock_name(self, r):\n        r.set(\"foo\", \"bar\")\n        with pytest.raises(LockError) as excinfo:\n            with self.get_lock(r, \"foo\", blocking_timeout=0.1):\n                pass\n            assert excinfo.value.lock_name == \"foo\"\n\n\nclass TestLockClassSelection:\n    def test_lock_class_argument(self, r):\n        class MyLock:\n            def __init__(self, *args, **kwargs):\n                pass\n\n        lock = r.lock(\"foo\", lock_class=MyLock)\n        assert isinstance(lock, MyLock)\n"
  },
  {
    "path": "tests/test_max_connections_error.py",
    "content": "import pytest\nimport redis\nfrom unittest import mock\nfrom redis.connection import ConnectionInterface\n\n\nclass DummyConnection(ConnectionInterface):\n    \"\"\"A dummy connection class for testing that doesn't actually connect to Redis\"\"\"\n\n    def __init__(self, *args, **kwargs):\n        self.connected = False\n\n    def connect(self):\n        self.connected = True\n\n    def disconnect(self):\n        self.connected = False\n\n    def register_connect_callback(self, callback):\n        pass\n\n    def deregister_connect_callback(self, callback):\n        pass\n\n    def set_parser(self, parser_class):\n        pass\n\n    def get_protocol(self):\n        return 2\n\n    def on_connect(self):\n        pass\n\n    def check_health(self):\n        return True\n\n    def send_packed_command(self, command, check_health=True):\n        pass\n\n    def send_command(self, *args, **kwargs):\n        pass\n\n    def can_read(self, timeout=0):\n        return False\n\n    def read_response(self, disable_decoding=False, **kwargs):\n        return \"PONG\"\n\n\n@pytest.mark.onlynoncluster\ndef test_max_connections_error_inheritance():\n    \"\"\"Test that MaxConnectionsError is a subclass of ConnectionError\"\"\"\n    assert issubclass(redis.MaxConnectionsError, redis.ConnectionError)\n\n\n@pytest.mark.onlynoncluster\ndef test_connection_pool_raises_max_connections_error():\n    \"\"\"Test that ConnectionPool raises MaxConnectionsError and not ConnectionError\"\"\"\n    # Use a dummy connection class that doesn't try to connect to a real Redis server\n    pool = redis.ConnectionPool(max_connections=1, connection_class=DummyConnection)\n    pool.get_connection()\n\n    with pytest.raises(redis.MaxConnectionsError):\n        pool.get_connection()\n\n\n@pytest.mark.skipif(\n    not hasattr(redis, \"RedisCluster\"), reason=\"RedisCluster not available\"\n)\ndef test_cluster_handles_max_connections_error():\n    \"\"\"\n    Test that RedisCluster doesn't reinitialize when MaxConnectionsError is raised\n    \"\"\"\n    # Create a more complete mock cluster\n    cluster = mock.MagicMock(spec=redis.RedisCluster)\n    cluster.cluster_response_callbacks = {}\n    cluster.RedisClusterRequestTTL = 3  # Set the TTL to avoid infinite loops\n    cluster.nodes_manager = mock.MagicMock()\n    node = mock.MagicMock()\n\n    # Mock get_redis_connection to return a mock Redis client\n    redis_conn = mock.MagicMock()\n    cluster.get_redis_connection.return_value = redis_conn\n\n    # Setup get_connection to be called and return a connection that will raise\n    connection = mock.MagicMock()\n\n    # Patch the get_connection function in the cluster module\n    with mock.patch(\"redis.cluster.get_connection\", return_value=connection):\n        # Test MaxConnectionsError\n        connection.send_command.side_effect = redis.MaxConnectionsError(\n            \"Too many connections\"\n        )\n\n        # Call the method and check that the exception is raised\n        with pytest.raises(redis.MaxConnectionsError):\n            redis.RedisCluster._execute_command(cluster, node, \"GET\", \"key\")\n\n        # Verify nodes_manager.initialize was NOT called\n        cluster.nodes_manager.initialize.assert_not_called()\n\n        # Reset the mock for the next test\n        cluster.nodes_manager.initialize.reset_mock()\n\n        # Now test with regular ConnectionError to ensure it DOES reinitialize\n        connection.send_command.side_effect = redis.ConnectionError(\"Connection lost\")\n\n        with pytest.raises(redis.ConnectionError):\n            redis.RedisCluster._execute_command(cluster, node, \"GET\", \"key\")\n\n        # Verify nodes_manager.initialize WAS called\n        cluster.nodes_manager.initialize.assert_called_once()\n"
  },
  {
    "path": "tests/test_monitor.py",
    "content": "import pytest\n\nfrom .conftest import (\n    skip_if_redis_enterprise,\n    skip_ifnot_redis_enterprise,\n    wait_for_command,\n)\n\n\n@pytest.mark.onlynoncluster\nclass TestMonitor:\n    def test_wait_command_not_found(self, r):\n        \"Make sure the wait_for_command func works when command is not found\"\n        with r.monitor() as m:\n            response = wait_for_command(r, m, \"nothing\")\n            assert response is None\n\n    def test_response_values(self, r):\n        db = r.connection_pool.connection_kwargs.get(\"db\", 0)\n        with r.monitor() as m:\n            r.ping()\n            response = wait_for_command(r, m, \"PING\")\n            assert isinstance(response[\"time\"], float)\n            assert response[\"db\"] == db\n            assert response[\"client_type\"] in (\"tcp\", \"unix\")\n            assert isinstance(response[\"client_address\"], str)\n            assert isinstance(response[\"client_port\"], str)\n            assert response[\"command\"] == \"PING\"\n\n    def test_command_with_quoted_key(self, r):\n        with r.monitor() as m:\n            r.get('foo\"bar')\n            response = wait_for_command(r, m, 'GET foo\"bar')\n            assert response[\"command\"] == 'GET foo\"bar'\n\n    def test_command_with_binary_data(self, r):\n        with r.monitor() as m:\n            byte_string = b\"foo\\x92\"\n            r.get(byte_string)\n            response = wait_for_command(r, m, \"GET foo\\\\x92\")\n            assert response[\"command\"] == \"GET foo\\\\x92\"\n\n    def test_command_with_escaped_data(self, r):\n        with r.monitor() as m:\n            byte_string = b\"foo\\\\x92\"\n            r.get(byte_string)\n            response = wait_for_command(r, m, \"GET foo\\\\\\\\x92\")\n            assert response[\"command\"] == \"GET foo\\\\\\\\x92\"\n\n    @skip_if_redis_enterprise()\n    def test_lua_script(self, r):\n        with r.monitor() as m:\n            script = 'return redis.call(\"GET\", \"foo\")'\n            assert r.eval(script, 0) is None\n            response = wait_for_command(r, m, \"GET foo\")\n            assert response[\"command\"] == \"GET foo\"\n            assert response[\"client_type\"] == \"lua\"\n            assert response[\"client_address\"] == \"lua\"\n            assert response[\"client_port\"] == \"\"\n\n    @skip_ifnot_redis_enterprise()\n    def test_lua_script_in_enterprise(self, r):\n        with r.monitor() as m:\n            script = 'return redis.call(\"GET\", \"foo\")'\n            assert r.eval(script, 0) is None\n            response = wait_for_command(r, m, \"GET foo\")\n            assert response is None\n"
  },
  {
    "path": "tests/test_multidb/__init__.py",
    "content": ""
  },
  {
    "path": "tests/test_multidb/conftest.py",
    "content": "from unittest.mock import Mock, AsyncMock, patch\n\nimport pytest\n\nfrom redis import Redis, ConnectionPool\nfrom redis.data_structure import WeightedList\nfrom redis.multidb.circuit import State as CBState, CircuitBreaker\nfrom redis.multidb.config import (\n    MultiDbConfig,\n    DatabaseConfig,\n    DEFAULT_HEALTH_CHECK_INTERVAL,\n    DEFAULT_AUTO_FALLBACK_INTERVAL,\n    InitialHealthCheck,\n)\nfrom redis.multidb.database import Database, Databases\nfrom redis.multidb.failover import FailoverStrategy\nfrom redis.multidb.failure_detector import FailureDetector\nfrom redis.asyncio.multidb.healthcheck import (\n    HealthCheck,\n    AbstractHealthCheckPolicy,\n    DEFAULT_HEALTH_CHECK_PROBES,\n    DEFAULT_HEALTH_CHECK_POLICY,\n    DEFAULT_HEALTH_CHECK_TIMEOUT,\n)\n\n\n@pytest.fixture(autouse=True)\ndef mock_health_check_connections():\n    \"\"\"\n    Mock clients for health check policies.\n    Uses real policy classes but mocks only the client layer.\n    \"\"\"\n\n    async def mock_get_client(self, database):\n        mock_client = AsyncMock()\n        mock_client.ping = AsyncMock(return_value=True)\n        mock_client.aclose = AsyncMock()\n        return mock_client\n\n    with patch.object(AbstractHealthCheckPolicy, \"get_client\", mock_get_client):\n        yield\n\n\n@pytest.fixture()\ndef mock_client() -> Redis:\n    return Mock(spec=Redis)\n\n\n@pytest.fixture()\ndef mock_cb() -> CircuitBreaker:\n    return Mock(spec=CircuitBreaker)\n\n\n@pytest.fixture()\ndef mock_fd() -> FailureDetector:\n    return Mock(spec=FailureDetector)\n\n\n@pytest.fixture()\ndef mock_fs() -> FailoverStrategy:\n    return Mock(spec=FailoverStrategy)\n\n\n@pytest.fixture()\ndef mock_hc() -> HealthCheck:\n    from unittest.mock import AsyncMock\n\n    mock = Mock(spec=HealthCheck)\n    mock.health_check_probes = DEFAULT_HEALTH_CHECK_PROBES\n    # Use minimal delay for faster test execution\n    mock.health_check_delay = 0.01\n    mock.health_check_timeout = DEFAULT_HEALTH_CHECK_TIMEOUT\n    # check_health is now async, so use AsyncMock\n    mock.check_health = AsyncMock(return_value=True)\n    return mock\n\n\ndef _create_mock_db(request) -> Database:\n    \"\"\"Helper to create a mock Database with proper client setup.\"\"\"\n    db = Mock(spec=Database)\n    db.weight = request.param.get(\"weight\", 1.0)\n    db.client = Mock(spec=Redis)\n    db.client.connection_pool = Mock(spec=ConnectionPool)\n\n    cb = request.param.get(\"circuit\", {})\n    mock_cb = Mock(spec=CircuitBreaker)\n    mock_cb.grace_period = cb.get(\"grace_period\", 1.0)\n    mock_cb.state = cb.get(\"state\", CBState.CLOSED)\n\n    db.circuit = mock_cb\n    return db\n\n\n@pytest.fixture()\ndef mock_db(request) -> Database:\n    return _create_mock_db(request)\n\n\n@pytest.fixture()\ndef mock_db1(request) -> Database:\n    return _create_mock_db(request)\n\n\n@pytest.fixture()\ndef mock_db2(request) -> Database:\n    return _create_mock_db(request)\n\n\n@pytest.fixture()\ndef mock_multi_db_config(request, mock_fd, mock_fs, mock_hc, mock_ed) -> MultiDbConfig:\n    hc_interval = request.param.get(\"hc_interval\", DEFAULT_HEALTH_CHECK_INTERVAL)\n    auto_fallback_interval = request.param.get(\n        \"auto_fallback_interval\", DEFAULT_AUTO_FALLBACK_INTERVAL\n    )\n    health_check_policy = request.param.get(\n        \"health_check_policy\", DEFAULT_HEALTH_CHECK_POLICY\n    )\n    health_check_probes = request.param.get(\n        \"health_check_probes\", DEFAULT_HEALTH_CHECK_PROBES\n    )\n    initial_health_check_policy = request.param.get(\n        \"initial_health_check_policy\", InitialHealthCheck.ALL_AVAILABLE\n    )\n\n    config = MultiDbConfig(\n        databases_config=[Mock(spec=DatabaseConfig)],\n        failure_detectors=[mock_fd],\n        health_check_interval=hc_interval,\n        health_check_delay=0.05,\n        health_check_policy=health_check_policy,\n        health_check_probes=health_check_probes,\n        failover_strategy=mock_fs,\n        auto_fallback_interval=auto_fallback_interval,\n        event_dispatcher=mock_ed,\n        initial_health_check_policy=initial_health_check_policy,\n    )\n\n    return config\n\n\ndef create_weighted_list(*databases) -> Databases:\n    dbs = WeightedList()\n\n    for db in databases:\n        dbs.add(db, db.weight)\n\n    return dbs\n"
  },
  {
    "path": "tests/test_multidb/test_circuit.py",
    "content": "import pybreaker\nimport pytest\n\nfrom redis.multidb.circuit import (\n    PBCircuitBreakerAdapter,\n    State as CbState,\n    CircuitBreaker,\n)\n\n\n@pytest.mark.onlynoncluster\nclass TestPBCircuitBreaker:\n    @pytest.mark.parametrize(\n        \"mock_db\",\n        [\n            {\"weight\": 0.7, \"circuit\": {\"state\": CbState.CLOSED}},\n        ],\n        indirect=True,\n    )\n    def test_cb_correctly_configured(self, mock_db):\n        pb_circuit = pybreaker.CircuitBreaker(reset_timeout=5)\n        adapter = PBCircuitBreakerAdapter(cb=pb_circuit)\n        assert adapter.state == CbState.CLOSED\n\n        adapter.state = CbState.OPEN\n        assert adapter.state == CbState.OPEN\n\n        adapter.state = CbState.HALF_OPEN\n        assert adapter.state == CbState.HALF_OPEN\n\n        adapter.state = CbState.CLOSED\n        assert adapter.state == CbState.CLOSED\n\n        assert adapter.grace_period == 5\n        adapter.grace_period = 10\n\n        assert adapter.grace_period == 10\n\n        adapter.database = mock_db\n        assert adapter.database == mock_db\n\n    def test_cb_executes_callback_on_state_changed(self):\n        pb_circuit = pybreaker.CircuitBreaker(reset_timeout=5)\n        adapter = PBCircuitBreakerAdapter(cb=pb_circuit)\n        called_count = 0\n\n        def callback(cb: CircuitBreaker, old_state: CbState, new_state: CbState):\n            nonlocal called_count\n            assert old_state == CbState.CLOSED\n            assert new_state == CbState.HALF_OPEN\n            assert isinstance(cb, PBCircuitBreakerAdapter)\n            called_count += 1\n\n        adapter.on_state_changed(callback)\n        adapter.state = CbState.HALF_OPEN\n\n        assert called_count == 1\n"
  },
  {
    "path": "tests/test_multidb/test_client.py",
    "content": "import asyncio\nimport threading\nimport time\nfrom time import sleep\nfrom unittest.mock import MagicMock, patch, Mock\n\nimport pybreaker\nimport pytest\n\nfrom redis.event import EventDispatcher, OnCommandsFailEvent\nfrom redis.multidb.circuit import State as CBState, PBCircuitBreakerAdapter\nfrom redis.multidb.config import InitialHealthCheck, DatabaseConfig\nfrom redis.multidb.database import SyncDatabase\nfrom redis.multidb.client import MultiDBClient\nfrom redis.multidb.exception import (\n    NoValidDatabaseException,\n    InitialHealthCheckFailedError,\n    UnhealthyDatabaseException,\n)\nfrom redis.multidb.failover import WeightBasedFailoverStrategy\nfrom redis.multidb.failure_detector import FailureDetector\nfrom redis.asyncio.multidb.healthcheck import HealthCheck, AbstractHealthCheck\nfrom tests.helpers import wait_for_condition\nfrom tests.test_multidb.conftest import create_weighted_list\n\n\n@pytest.mark.onlynoncluster\nclass TestMultiDbClient:\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_execute_command_against_correct_db_on_successful_initialization(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db1.client.execute_command.return_value = \"OK1\"\n\n            mock_hc.check_health.return_value = True\n\n            client = MultiDBClient(mock_multi_db_config)\n            try:\n                assert (\n                    mock_multi_db_config.failover_strategy.set_databases.call_count == 1\n                )\n                assert client.set(\"key\", \"value\") == \"OK1\"\n                # 3 databases × 3 probes = 9 calls minimum\n                assert len(mock_hc.check_health.call_args_list) >= 9\n\n                assert mock_db.circuit.state == CBState.CLOSED\n                assert mock_db1.circuit.state == CBState.CLOSED\n                assert mock_db2.circuit.state == CBState.CLOSED\n            finally:\n                client.close()\n\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {\"initial_health_check_policy\": InitialHealthCheck.MAJORITY_AVAILABLE},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.OPEN}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_execute_command_against_correct_db_and_closed_circuit(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        \"\"\"\n        Validates that commands are executed against the correct\n        database when one database becomes unhealthy during initialization.\n        Ensures the client selects the highest-weighted\n        healthy database (mock_db1) and executes commands against it\n        with a CLOSED circuit.\n        \"\"\"\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db.client.execute_command = MagicMock(\n                return_value=\"NOT_OK-->Response from unexpected db - mock_db\"\n            )\n            mock_db1.client.execute_command = MagicMock(return_value=\"OK1\")\n            mock_db2.client.execute_command = MagicMock(\n                return_value=\"NOT_OK-->Response from unexpected db - mock_db2\"\n            )\n\n            async def mock_check_health(database, connection=None):\n                if database == mock_db2:\n                    return False\n                else:\n                    return True\n\n            mock_hc.check_health.side_effect = mock_check_health\n\n            client = MultiDBClient(mock_multi_db_config)\n            try:\n                assert (\n                    mock_multi_db_config.failover_strategy.set_databases.call_count == 1\n                )\n                result = client.set(\"key\", \"value\")\n                assert result == \"OK1\"\n                # 3 databases × 3 probes = 9 calls minimum (but one db fails, so >= 7)\n                assert len(mock_hc.check_health.call_args_list) >= 7\n\n                assert mock_db.circuit.state == CBState.CLOSED\n                assert mock_db1.circuit.state == CBState.CLOSED\n                assert mock_db2.circuit.state == CBState.OPEN\n            finally:\n                client.close()\n\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_execute_command_against_correct_db_on_background_health_check_determine_active_db_unhealthy(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        cb = PBCircuitBreakerAdapter(pybreaker.CircuitBreaker(reset_timeout=0.1))\n        cb.database = mock_db\n        mock_db.circuit = cb\n\n        cb1 = PBCircuitBreakerAdapter(pybreaker.CircuitBreaker(reset_timeout=0.1))\n        cb1.database = mock_db1\n        mock_db1.circuit = cb1\n\n        cb2 = PBCircuitBreakerAdapter(pybreaker.CircuitBreaker(reset_timeout=0.1))\n        cb2.database = mock_db2\n        mock_db2.circuit = cb2\n\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n\n        # Track health check rounds per database\n        # A round is a complete health check cycle (initial or background)\n        db_rounds = {id(mock_db): 0, id(mock_db1): 0, id(mock_db2): 0}\n        db_probes_in_round = {id(mock_db): 0, id(mock_db1): 0, id(mock_db2): 0}\n\n        # Create events for each failover scenario\n        db1_became_unhealthy = threading.Event()\n        db2_became_unhealthy = threading.Event()\n        db_became_unhealthy = threading.Event()\n        counter_lock = asyncio.Lock()\n\n        async def mock_check_health(database, connection=None):\n            async with counter_lock:\n                db_probes_in_round[id(database)] += 1\n                # After 3 probes, increment the round counter\n                if db_probes_in_round[id(database)] > 3:\n                    db_rounds[id(database)] += 1\n                    db_probes_in_round[id(database)] = 1\n                current_round = db_rounds[id(database)]\n\n            # Round 0 (initial health check): All databases healthy\n            if current_round == 0:\n                return True\n\n            # Round 1: mock_db1 becomes unhealthy, others healthy\n            if current_round == 1:\n                if database == mock_db1:\n                    db1_became_unhealthy.set()\n                    return False\n                return True\n\n            # Round 2: mock_db2 also becomes unhealthy, mock_db healthy\n            if current_round == 2:\n                if database == mock_db1:\n                    return False\n                if database == mock_db2:\n                    db2_became_unhealthy.set()\n                    return False\n                return True\n\n            # Round 3: mock_db also becomes unhealthy, but mock_db1 recovers\n            if current_round == 3:\n                if database == mock_db:\n                    db_became_unhealthy.set()\n                    return False\n                if database == mock_db2:\n                    return False\n                # mock_db1 recovers\n                return True\n\n            # Round 4+: mock_db1 is healthy, others unhealthy\n            if database == mock_db1:\n                return True\n            return False\n\n        mock_hc.check_health.side_effect = mock_check_health\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_multi_db_config.health_check_interval = 0.05\n            mock_multi_db_config.failover_strategy = WeightBasedFailoverStrategy()\n            mock_db.client.execute_command.return_value = \"OK\"\n            mock_db1.client.execute_command.return_value = \"OK1\"\n            mock_db2.client.execute_command.return_value = \"OK2\"\n\n            client = MultiDBClient(mock_multi_db_config)\n            try:\n                assert client.set(\"key\", \"value\") == \"OK1\"\n\n                # Wait for mock_db1 to become unhealthy\n                assert db1_became_unhealthy.wait(timeout=1.0), (\n                    \"Timeout waiting for mock_db1 to become unhealthy\"\n                )\n\n                # Wait for circuit breaker state to actually reflect the unhealthy status\n                # (instead of just sleeping)\n                wait_for_condition(\n                    lambda: cb1.state == CBState.OPEN,\n                    timeout=0.5,\n                    error_message=\"Timeout waiting for cb1 to open\",\n                )\n\n                assert client.set(\"key\", \"value\") == \"OK2\"\n\n                # Wait for mock_db2 to become unhealthy\n                assert db2_became_unhealthy.wait(timeout=1.0), (\n                    \"Timeout waiting for mock_db2 to become unhealthy\"\n                )\n\n                # Wait for circuit breaker state to actually reflect the unhealthy status\n                # (instead of just sleeping)\n                wait_for_condition(\n                    lambda: cb2.state == CBState.OPEN,\n                    timeout=0.5,\n                    error_message=\"Timeout waiting for cb2 to open\",\n                )\n\n                assert client.set(\"key\", \"value\") == \"OK\"\n\n                # Wait for mock_db to become unhealthy\n                assert db_became_unhealthy.wait(timeout=1.0), (\n                    \"Timeout waiting for mock_db to become unhealthy\"\n                )\n                wait_for_condition(\n                    lambda: cb.state == CBState.OPEN,\n                    timeout=0.5,\n                    error_message=\"Timeout waiting for cb to open\",\n                )\n\n                # Wait for mock_db1 to recover (circuit breaker to close)\n                wait_for_condition(\n                    lambda: cb1.state == CBState.CLOSED,\n                    timeout=1.0,\n                    error_message=\"Timeout waiting for cb1 to close (mock_db1 to recover)\",\n                )\n\n                assert client.set(\"key\", \"value\") == \"OK1\"\n            finally:\n                client.close()\n\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_execute_command_auto_fallback_to_highest_weight_db(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n\n        # Track health check rounds per database\n        db_rounds = {id(mock_db): 0, id(mock_db1): 0, id(mock_db2): 0}\n        db_probes_in_round = {id(mock_db): 0, id(mock_db1): 0, id(mock_db2): 0}\n        db1_became_unhealthy = threading.Event()\n        counter_lock = asyncio.Lock()\n\n        async def mock_check_health(database, connection=None):\n            async with counter_lock:\n                db_probes_in_round[id(database)] += 1\n                if db_probes_in_round[id(database)] > 3:\n                    db_rounds[id(database)] += 1\n                    db_probes_in_round[id(database)] = 1\n                current_round = db_rounds[id(database)]\n\n            # Round 0 (initial health check): All databases healthy\n            if current_round == 0:\n                return True\n\n            # Round 1: mock_db1 becomes unhealthy, others healthy\n            if current_round == 1:\n                if database == mock_db1:\n                    db1_became_unhealthy.set()\n                    return False\n                return True\n\n            # Round 2+: All databases healthy (mock_db1 recovers)\n            return True\n\n        mock_hc.check_health.side_effect = mock_check_health\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db.client.execute_command.return_value = \"OK\"\n            mock_db1.client.execute_command.return_value = \"OK1\"\n            mock_db2.client.execute_command.return_value = \"OK2\"\n            mock_multi_db_config.health_check_interval = 0.05\n            mock_multi_db_config.auto_fallback_interval = 0.1\n            mock_multi_db_config.failover_strategy = WeightBasedFailoverStrategy()\n\n            client = MultiDBClient(mock_multi_db_config)\n            try:\n                assert client.set(\"key\", \"value\") == \"OK1\"\n\n                # Wait for mock_db1 to become unhealthy\n                assert db1_became_unhealthy.wait(timeout=1.0), (\n                    \"Timeout waiting for mock_db1 to become unhealthy\"\n                )\n\n                # Wait for circuit breaker to actually open\n                wait_for_condition(\n                    lambda: mock_db1.circuit.state == CBState.OPEN,\n                    timeout=0.5,\n                    error_message=\"Timeout waiting for cb1 to open after error event.\",\n                )\n\n                # Now the failover strategy will select mock_db2\n                assert client.set(\"key\", \"value\") == \"OK2\"\n\n                # Wait for auto fallback interval to pass (mock_db1 recovers in round 2+)\n                wait_for_condition(\n                    lambda: mock_db1.circuit.state == CBState.CLOSED,\n                    timeout=1.0,\n                    error_message=\"Timeout waiting for mock_db1 to be healthy again.\",\n                )\n\n                # Wait for auto fallback time to pass - this way on next command execution\n                # the active database will be re-evaluated\n                sleep(0.1)\n\n                # Now the failover strategy will select mock_db1 again\n                assert client.set(\"key\", \"value\") == \"OK1\"\n            finally:\n                client.close()\n\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_execute_command_do_not_auto_fallback_to_highest_weight_db(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n\n        # Track health check rounds per database\n        db_rounds = {id(mock_db): 0, id(mock_db1): 0, id(mock_db2): 0}\n        db_probes_in_round = {id(mock_db): 0, id(mock_db1): 0, id(mock_db2): 0}\n        db1_became_unhealthy = threading.Event()\n        counter_lock = asyncio.Lock()\n\n        async def mock_check_health(database, connection=None):\n            async with counter_lock:\n                db_probes_in_round[id(database)] += 1\n                if db_probes_in_round[id(database)] > 3:\n                    db_rounds[id(database)] += 1\n                    db_probes_in_round[id(database)] = 1\n                current_round = db_rounds[id(database)]\n\n            # Round 0 (initial health check): All databases healthy\n            if current_round == 0:\n                return True\n\n            # Round 1+: mock_db1 stays unhealthy (no auto fallback)\n            if database == mock_db1:\n                db1_became_unhealthy.set()\n                return False\n\n            return True\n\n        mock_hc.check_health.side_effect = mock_check_health\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db.client.execute_command.return_value = \"OK\"\n            mock_db1.client.execute_command.return_value = \"OK1\"\n            mock_db2.client.execute_command.return_value = \"OK2\"\n            mock_multi_db_config.health_check_interval = 0.05\n            mock_multi_db_config.auto_fallback_interval = -1\n            mock_multi_db_config.failover_strategy = WeightBasedFailoverStrategy()\n\n            client = MultiDBClient(mock_multi_db_config)\n            try:\n                assert client.set(\"key\", \"value\") == \"OK1\"\n\n                # Wait for mock_db1 to become unhealthy\n                assert db1_became_unhealthy.wait(timeout=1.0), (\n                    \"Timeout waiting for mock_db1 to become unhealthy\"\n                )\n\n                # Wait for circuit breaker state to actually reflect the unhealthy status\n                wait_for_condition(\n                    lambda: mock_db1.circuit.state == CBState.OPEN,\n                    timeout=0.5,\n                    error_message=\"Timeout waiting for cb1 to open after error event.\",\n                )\n\n                assert client.set(\"key\", \"value\") == \"OK2\"\n                # Wait a short time - the active database should not change on command\n                # execution even with higher waits, because auto fallback is disabled\n                sleep(0.15)\n                assert client.set(\"key\", \"value\") == \"OK2\"\n            finally:\n                client.close()\n\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_add_database_makes_new_database_active(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        \"\"\"\n        Test that add_database with a DatabaseConfig creates a new database\n        and makes it active if it has the highest weight.\n        \"\"\"\n\n        databases = create_weighted_list(mock_db, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        # Create a DatabaseConfig for the new database with highest weight\n        new_db_config = DatabaseConfig(\n            weight=0.8,  # Higher than mock_db2's 0.5\n            from_url=\"redis://localhost:6379\",\n        )\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db2.client.execute_command.return_value = \"OK2\"\n            mock_hc.check_health.return_value = True\n\n            client = MultiDBClient(mock_multi_db_config)\n            try:\n                assert (\n                    mock_multi_db_config.failover_strategy.set_databases.call_count == 1\n                )\n\n                # Initially mock_db2 is active (highest weight among initial databases)\n                assert client.set(\"key\", \"value\") == \"OK2\"\n                initial_hc_count = len(mock_hc.check_health.call_args_list)\n\n                # Mock the client class to return a mock client for the new database\n                mock_new_client = Mock()\n                mock_new_client.execute_command.return_value = \"OK_NEW\"\n                mock_new_client.connection_pool = Mock()\n\n                with patch.object(\n                    mock_multi_db_config.client_class,\n                    \"from_url\",\n                    return_value=mock_new_client,\n                ):\n                    client.add_database(new_db_config)\n\n                # Health check should have been called for the new database\n                assert len(mock_hc.check_health.call_args_list) > initial_hc_count\n\n                # New database should be active since it has highest weight\n                assert client.set(\"key\", \"value\") == \"OK_NEW\"\n            finally:\n                client.close()\n\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_add_database_skip_unhealthy_false_raises_exception(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        \"\"\"\n        Test that add_database with skip_unhealthy=False raises an exception\n        when the new database fails health check due to an exception.\n        \"\"\"\n        databases = create_weighted_list(mock_db, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        new_db_config = DatabaseConfig(\n            weight=0.8,\n            from_url=\"redis://localhost:6379\",\n        )\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db2.client.execute_command.return_value = \"OK2\"\n\n            # Health check returns True for existing databases, raises exception for new one\n            async def mock_check_health(database, connection=None):\n                if database in [mock_db, mock_db2]:\n                    return True\n                # Raise an exception for the new database to trigger UnhealthyDatabaseException\n                raise ConnectionError(\"Connection refused\")\n\n            mock_hc.check_health.side_effect = mock_check_health\n\n            client = MultiDBClient(mock_multi_db_config)\n            try:\n                # Initially mock_db2 is active\n                assert client.set(\"key\", \"value\") == \"OK2\"\n\n                mock_new_client = Mock()\n                mock_new_client.execute_command.return_value = \"OK_NEW\"\n                mock_new_client.connection_pool = Mock()\n\n                with patch.object(\n                    mock_multi_db_config.client_class,\n                    \"from_url\",\n                    return_value=mock_new_client,\n                ):\n                    # With skip_unhealthy=False, should raise exception\n                    with pytest.raises(UnhealthyDatabaseException):\n                        client.add_database(\n                            new_db_config, skip_initial_health_check=False\n                        )\n\n                # Database list should remain unchanged\n                assert len(client.get_databases()) == 2\n            finally:\n                client.close()\n\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_remove_highest_weighted_database(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db1.client.execute_command.return_value = \"OK1\"\n            mock_db2.client.execute_command.return_value = \"OK2\"\n\n            mock_hc.check_health.return_value = True\n\n            client = MultiDBClient(mock_multi_db_config)\n            try:\n                assert (\n                    mock_multi_db_config.failover_strategy.set_databases.call_count == 1\n                )\n\n                assert client.set(\"key\", \"value\") == \"OK1\"\n                # 3 databases × 3 probes = 9 calls minimum\n                assert len(mock_hc.check_health.call_args_list) >= 9\n\n                client.remove_database(mock_db1)\n\n                assert client.set(\"key\", \"value\") == \"OK2\"\n            finally:\n                client.close()\n\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_update_database_weight_to_be_highest(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db1.client.execute_command.return_value = \"OK1\"\n            mock_db2.client.execute_command.return_value = \"OK2\"\n\n            mock_hc.check_health.return_value = True\n\n            client = MultiDBClient(mock_multi_db_config)\n            try:\n                assert (\n                    mock_multi_db_config.failover_strategy.set_databases.call_count == 1\n                )\n\n                assert client.set(\"key\", \"value\") == \"OK1\"\n                # 3 databases × 3 probes = 9 calls minimum\n                assert len(mock_hc.check_health.call_args_list) >= 9\n\n                client.update_database_weight(mock_db2, 0.8)\n                assert mock_db2.weight == 0.8\n\n                assert client.set(\"key\", \"value\") == \"OK2\"\n            finally:\n                client.close()\n\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_add_new_failure_detector(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db1.client.execute_command.return_value = \"OK1\"\n            mock_multi_db_config.event_dispatcher = EventDispatcher()\n            mock_fd = mock_multi_db_config.failure_detectors[0]\n\n            # Event fired if command against mock_db1 would fail\n            command_fail_event = OnCommandsFailEvent(\n                commands=(\"SET\", \"key\", \"value\"),\n                exception=Exception(),\n            )\n\n            mock_hc.check_health.return_value = True\n\n            client = MultiDBClient(mock_multi_db_config)\n            try:\n                assert (\n                    mock_multi_db_config.failover_strategy.set_databases.call_count == 1\n                )\n                assert client.set(\"key\", \"value\") == \"OK1\"\n                # 3 databases × 3 probes = 9 calls minimum\n                assert len(mock_hc.check_health.call_args_list) >= 9\n\n                # Simulate failing command events that lead to a failure detection\n                for i in range(5):\n                    mock_multi_db_config.event_dispatcher.dispatch(command_fail_event)\n\n                assert mock_fd.register_failure.call_count == 5\n\n                another_fd = Mock(spec=FailureDetector)\n                client.add_failure_detector(another_fd)\n\n                # Simulate failing command events that lead to a failure detection\n                for i in range(5):\n                    mock_multi_db_config.event_dispatcher.dispatch(command_fail_event)\n\n                assert mock_fd.register_failure.call_count == 10\n                assert another_fd.register_failure.call_count == 5\n            finally:\n                client.close()\n\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_add_new_health_check(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db1.client.execute_command.return_value = \"OK1\"\n\n            mock_hc.check_health.return_value = True\n\n            client = MultiDBClient(mock_multi_db_config)\n            try:\n                assert (\n                    mock_multi_db_config.failover_strategy.set_databases.call_count == 1\n                )\n                assert client.set(\"key\", \"value\") == \"OK1\"\n                # 3 databases × 3 probes = 9 calls\n                assert len(mock_hc.check_health.call_args_list) == 9\n\n                another_hc = Mock(spec=HealthCheck)\n                another_hc.check_health.return_value = True\n                another_hc.health_check_probes = 3\n                another_hc.health_check_delay = 0.01\n                another_hc.health_check_timeout = 1.0\n\n                client.add_health_check(another_hc)\n                asyncio.run(client._check_db_health(mock_db1))\n\n                # 3 databases × 3 probes + 1 database × 3 probes = 12 calls\n                assert len(mock_hc.check_health.call_args_list) == 12\n                assert len(another_hc.check_health.call_args_list) == 3\n\n            finally:\n                client.close()\n\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_set_active_database(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db1.client.execute_command.return_value = \"OK1\"\n            mock_db.client.execute_command.return_value = \"OK\"\n\n            mock_hc.check_health.return_value = True\n\n            client = MultiDBClient(mock_multi_db_config)\n            try:\n                assert (\n                    mock_multi_db_config.failover_strategy.set_databases.call_count == 1\n                )\n                assert client.set(\"key\", \"value\") == \"OK1\"\n                # 3 databases × 3 probes = 9 calls\n                assert len(mock_hc.check_health.call_args_list) == 9\n\n                client.set_active_database(mock_db)\n                assert client.set(\"key\", \"value\") == \"OK\"\n\n                with pytest.raises(\n                    ValueError, match=\"Given database is not a member of database list\"\n                ):\n                    client.set_active_database(Mock(spec=SyncDatabase))\n\n                mock_hc.check_health.return_value = False\n\n                with pytest.raises(\n                    NoValidDatabaseException,\n                    match=\"Cannot set active database, database is unhealthy\",\n                ):\n                    client.set_active_database(mock_db1)\n            finally:\n                client.close()\n\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_custom_health_check_parameters_are_respected(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2\n    ):\n        \"\"\"\n        Test that custom health check parameters (probes, delay, timeout)\n        override the default values and are properly used during health checks.\n        \"\"\"\n\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n\n        # Track actual delays between probes\n        probe_timestamps = []\n        probe_lock = asyncio.Lock()\n\n        class CustomHealthCheck(AbstractHealthCheck):\n            \"\"\"Custom health check with non-default parameters.\"\"\"\n\n            def __init__(self):\n                # Use custom values: 5 probes, 0.02s delay, 2.0s timeout\n                super().__init__(\n                    health_check_probes=5,\n                    health_check_delay=0.02,\n                    health_check_timeout=2.0,\n                )\n\n            async def check_health(self, database, connection=None) -> bool:\n                async with probe_lock:\n                    probe_timestamps.append(time.time())\n                return True\n\n        custom_hc = CustomHealthCheck()\n\n        # Verify custom parameters are set correctly\n        assert custom_hc.health_check_probes == 5\n        assert custom_hc.health_check_delay == 0.02\n        assert custom_hc.health_check_timeout == 2.0\n\n        mock_multi_db_config.health_checks = [custom_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db1.client.execute_command.return_value = \"OK1\"\n\n            client = MultiDBClient(mock_multi_db_config)\n            try:\n                # Client should initialize successfully\n                assert client.set(\"key\", \"value\") == \"OK1\"\n\n                # With 3 databases and 5 probes each, we should have 15 probes total\n                # (executed in parallel per database, but sequentially within each db)\n                assert len(probe_timestamps) == 15\n\n                # Verify delays between probes within each database\n                # Since probes run in parallel across databases, we need to check\n                # that the minimum delay between consecutive probes is approximately\n                # the configured delay (0.02s)\n                # Sort timestamps and check that there are gaps of ~0.02s\n                sorted_timestamps = sorted(probe_timestamps)\n\n                # With 3 databases running in parallel, each doing 5 probes with 0.02s delay,\n                # the total time should be approximately 4 * 0.02 = 0.08s per database\n                # (4 delays between 5 probes)\n                total_duration = sorted_timestamps[-1] - sorted_timestamps[0]\n                # Should be at least 4 delays worth (0.08s) but not too long\n                assert total_duration >= 0.04, (\n                    f\"Total duration {total_duration}s is too short, \"\n                    f\"expected at least 0.04s for 5 probes with 0.02s delay\"\n                )\n            finally:\n                client.close()\n\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_custom_health_check_timeout_triggers_unhealthy(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2\n    ):\n        \"\"\"\n        Test that a custom health check timeout is respected and triggers\n        UnhealthyDatabaseException when exceeded.\n        \"\"\"\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n\n        class SlowHealthCheck(AbstractHealthCheck):\n            \"\"\"Health check that takes longer than the configured timeout.\"\"\"\n\n            def __init__(self):\n                # Short timeout (0.1s) but health check will take longer (0.5s)\n                super().__init__(\n                    health_check_probes=1,\n                    health_check_delay=0.01,\n                    health_check_timeout=0.1,\n                )\n\n            async def check_health(self, database) -> bool:\n                # Sleep longer than the timeout\n                await asyncio.sleep(0.5)\n                return True\n\n        slow_hc = SlowHealthCheck()\n\n        # Verify custom timeout is set\n        assert slow_hc.health_check_timeout == 0.1\n\n        mock_multi_db_config.health_checks = [slow_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db1.client.execute_command.return_value = \"OK1\"\n\n            client = MultiDBClient(mock_multi_db_config)\n            try:\n                # Executing a command triggers initialize() which runs health checks\n                # The health check should timeout and raise InitialHealthCheckFailedError\n                with pytest.raises(InitialHealthCheckFailedError):\n                    client.set(\"key\", \"value\")\n            finally:\n                client.close()\n\n\n@pytest.mark.onlynoncluster\nclass TestGeoFailoverMetricRecording:\n    \"\"\"Tests for geo failover metric recording in MultiDBClient.\"\"\"\n\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_manual_failover_records_metric(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        \"\"\"\n        Test that set_active_database records geo failover metric with MANUAL reason.\n        \"\"\"\n        from redis.observability.attributes import GeoFailoverReason\n\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db1.client.execute_command.return_value = \"OK1\"\n            mock_db.client.execute_command.return_value = \"OK\"\n            mock_hc.check_health.return_value = True\n\n            client = MultiDBClient(mock_multi_db_config)\n            try:\n                # Initial active database should be mock_db1 (highest weight)\n                assert client.set(\"key\", \"value\") == \"OK1\"\n\n                # Now manually switch to mock_db\n                # Patch at the module where it's imported\n                with patch(\n                    \"redis.multidb.command_executor.record_geo_failover\"\n                ) as mock_record_geo_failover:\n                    client.set_active_database(mock_db)\n\n                    # Verify record_geo_failover was called with correct arguments\n                    mock_record_geo_failover.assert_called_once()\n                    call_kwargs = mock_record_geo_failover.call_args[1]\n                    assert call_kwargs[\"fail_from\"] == mock_db1\n                    assert call_kwargs[\"fail_to\"] == mock_db\n                    assert call_kwargs[\"reason\"] == GeoFailoverReason.MANUAL\n            finally:\n                client.close()\n\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_automatic_failover_records_metric(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        \"\"\"\n        Test that automatic failover records geo failover metric with AUTOMATIC reason.\n        \"\"\"\n        from redis.observability.attributes import GeoFailoverReason\n\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db1.client.execute_command.return_value = \"OK1\"\n            mock_db2.client.execute_command.return_value = \"OK2\"\n            mock_hc.check_health.return_value = True\n\n            client = MultiDBClient(mock_multi_db_config)\n            try:\n                # Initial active database should be mock_db1 (highest weight)\n                assert client.set(\"key\", \"value\") == \"OK1\"\n\n                # Simulate mock_db1 becoming unhealthy (circuit open)\n                mock_db1.circuit.state = CBState.OPEN\n\n                # Configure the failover strategy to return mock_db2 when database() is called\n                # The DefaultFailoverStrategyExecutor calls self._strategy.database()\n                mock_multi_db_config.failover_strategy.database.return_value = mock_db2\n\n                # Patch at the module where it's imported\n                with patch(\n                    \"redis.multidb.command_executor.record_geo_failover\"\n                ) as mock_record_geo_failover:\n                    # Execute a command - this should trigger automatic failover\n                    assert client.set(\"key\", \"value\") == \"OK2\"\n\n                    # Verify record_geo_failover was called with AUTOMATIC reason\n                    mock_record_geo_failover.assert_called_once()\n                    call_kwargs = mock_record_geo_failover.call_args[1]\n                    assert call_kwargs[\"fail_from\"] == mock_db1\n                    assert call_kwargs[\"fail_to\"] == mock_db2\n                    assert call_kwargs[\"reason\"] == GeoFailoverReason.AUTOMATIC\n            finally:\n                client.close()\n\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_no_metric_recorded_when_same_database(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        \"\"\"\n        Test that no geo failover metric is recorded when active database doesn't change.\n        \"\"\"\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db1.client.execute_command.return_value = \"OK1\"\n            mock_hc.check_health.return_value = True\n\n            client = MultiDBClient(mock_multi_db_config)\n            try:\n                # Initial active database should be mock_db1 (highest weight)\n                assert client.set(\"key\", \"value\") == \"OK1\"\n\n                # Patch at the module where it's imported\n                with patch(\n                    \"redis.multidb.command_executor.record_geo_failover\"\n                ) as mock_record_geo_failover:\n                    # Set active database to the same database\n                    client.set_active_database(mock_db1)\n\n                    # Verify record_geo_failover was NOT called\n                    mock_record_geo_failover.assert_not_called()\n            finally:\n                client.close()\n\n\n@pytest.mark.onlynoncluster\nclass TestInitialHealthCheckPolicy:\n    \"\"\"Tests for initial health check policy evaluation.\"\"\"\n\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_all_healthy_policy_succeeds_when_all_databases_healthy(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        \"\"\"\n        Test that ALL_HEALTHY policy succeeds when all databases pass health check.\n        \"\"\"\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db1.client.execute_command.return_value = \"OK1\"\n            mock_hc.check_health.return_value = True\n\n            client = MultiDBClient(mock_multi_db_config)\n\n            try:\n                # Should succeed without raising InitialHealthCheckFailedError\n                assert client.set(\"key\", \"value\") == \"OK1\"\n            finally:\n                client.close()\n\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {\"initial_health_check_policy\": InitialHealthCheck.ALL_AVAILABLE},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_all_healthy_policy_fails_when_one_database_unhealthy(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        \"\"\"\n        Test that ALL_HEALTHY policy fails when any database fails health check.\n        \"\"\"\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n\n            async def mock_check_health(database, connection=None):\n                # mock_db2 is unhealthy\n                return database != mock_db2\n\n            mock_hc.check_health.side_effect = mock_check_health\n\n            client = MultiDBClient(mock_multi_db_config)\n\n            try:\n                with pytest.raises(\n                    InitialHealthCheckFailedError,\n                    match=\"Initial health check failed\",\n                ):\n                    client.set(\"key\", \"value\")\n            finally:\n                client.close()\n\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {\"initial_health_check_policy\": InitialHealthCheck.MAJORITY_AVAILABLE},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_majority_healthy_policy_succeeds_when_majority_healthy(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        \"\"\"\n        Test that MAJORITY_HEALTHY policy succeeds when more than half of databases are healthy.\n        With 3 databases, 2 healthy is a majority.\n        \"\"\"\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db1.client.execute_command.return_value = \"OK1\"\n\n            async def mock_check_health(database, connection=None):\n                # mock_db2 is unhealthy, but 2 out of 3 are healthy (majority)\n                return database != mock_db2\n\n            mock_hc.check_health.side_effect = mock_check_health\n\n            client = MultiDBClient(mock_multi_db_config)\n\n            try:\n                # Should succeed - 2 out of 3 healthy is a majority\n                assert client.set(\"key\", \"value\") == \"OK1\"\n            finally:\n                client.close()\n\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {\"initial_health_check_policy\": InitialHealthCheck.MAJORITY_AVAILABLE},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_majority_healthy_policy_fails_when_minority_healthy(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        \"\"\"\n        Test that MAJORITY_HEALTHY policy fails when less than half of databases are healthy.\n        With 3 databases, only 1 healthy is not a majority.\n        \"\"\"\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n\n            async def mock_check_health(database, connection=None):\n                # Only mock_db is healthy (1 out of 3 is not a majority)\n                return database == mock_db\n\n            mock_hc.check_health.side_effect = mock_check_health\n\n            client = MultiDBClient(mock_multi_db_config)\n\n            try:\n                with pytest.raises(\n                    InitialHealthCheckFailedError,\n                    match=\"Initial health check failed\",\n                ):\n                    client.set(\"key\", \"value\")\n            finally:\n                client.close()\n\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {\"initial_health_check_policy\": InitialHealthCheck.ONE_AVAILABLE},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_any_healthy_policy_succeeds_when_one_database_healthy(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        \"\"\"\n        Test that ANY_HEALTHY policy succeeds when at least one database is healthy.\n        \"\"\"\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_db.client.execute_command.return_value = \"OK\"\n\n            async def mock_check_health(database, connection=None):\n                # Only mock_db is healthy\n                return database == mock_db\n\n            mock_hc.check_health.side_effect = mock_check_health\n\n            client = MultiDBClient(mock_multi_db_config)\n\n            try:\n                # Should succeed - at least one database is healthy\n                assert client.set(\"key\", \"value\") == \"OK\"\n            finally:\n                client.close()\n\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {\"initial_health_check_policy\": InitialHealthCheck.ONE_AVAILABLE},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_any_healthy_policy_fails_when_no_database_healthy(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        \"\"\"\n        Test that ANY_HEALTHY policy fails when no database is healthy.\n        \"\"\"\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n        mock_multi_db_config.health_checks = [mock_hc]\n\n        with patch.object(mock_multi_db_config, \"databases\", return_value=databases):\n            mock_hc.check_health.return_value = False\n\n            client = MultiDBClient(mock_multi_db_config)\n\n            try:\n                with pytest.raises(\n                    InitialHealthCheckFailedError,\n                    match=\"Initial health check failed\",\n                ):\n                    client.set(\"key\", \"value\")\n            finally:\n                client.close()\n"
  },
  {
    "path": "tests/test_multidb/test_command_executor.py",
    "content": "from time import sleep\n\nimport pytest\n\nfrom redis.backoff import NoBackoff\nfrom redis.event import EventDispatcher\nfrom redis.exceptions import ConnectionError\nfrom redis.multidb.circuit import State as CBState\nfrom redis.multidb.command_executor import DefaultCommandExecutor\nfrom redis.multidb.failure_detector import CommandFailureDetector\nfrom redis.observability.attributes import GeoFailoverReason\nfrom redis.retry import Retry\nfrom tests.test_multidb.conftest import create_weighted_list\n\n\n@pytest.mark.onlynoncluster\nclass TestDefaultCommandExecutor:\n    @pytest.mark.parametrize(\n        \"mock_db,mock_db1,mock_db2\",\n        [\n            (\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_execute_command_on_active_database(\n        self, mock_db, mock_db1, mock_db2, mock_fd, mock_fs, mock_ed\n    ):\n        mock_db1.client.execute_command.return_value = \"OK1\"\n        mock_db2.client.execute_command.return_value = \"OK2\"\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n\n        executor = DefaultCommandExecutor(\n            failure_detectors=[mock_fd],\n            databases=databases,\n            failover_strategy=mock_fs,\n            event_dispatcher=mock_ed,\n            command_retry=Retry(NoBackoff(), 0),\n        )\n\n        executor.active_database = (mock_db1, GeoFailoverReason.MANUAL)\n        assert executor.execute_command(\"SET\", \"key\", \"value\") == \"OK1\"\n\n        executor.active_database = (mock_db2, GeoFailoverReason.MANUAL)\n        assert executor.execute_command(\"SET\", \"key\", \"value\") == \"OK2\"\n        assert mock_ed.register_listeners.call_count == 1\n        assert mock_fd.register_command_execution.call_count == 2\n\n    @pytest.mark.parametrize(\n        \"mock_db,mock_db1,mock_db2\",\n        [\n            (\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_execute_command_automatically_select_active_database(\n        self, mock_db, mock_db1, mock_db2, mock_fd, mock_fs, mock_ed\n    ):\n        mock_db1.client.execute_command.return_value = \"OK1\"\n        mock_db2.client.execute_command.return_value = \"OK2\"\n        mock_fs.database.side_effect = [mock_db1, mock_db2]\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n\n        executor = DefaultCommandExecutor(\n            failure_detectors=[mock_fd],\n            databases=databases,\n            failover_strategy=mock_fs,\n            event_dispatcher=mock_ed,\n            command_retry=Retry(NoBackoff(), 0),\n        )\n\n        assert executor.execute_command(\"SET\", \"key\", \"value\") == \"OK1\"\n        mock_db1.circuit.state = CBState.OPEN\n\n        assert executor.execute_command(\"SET\", \"key\", \"value\") == \"OK2\"\n        assert mock_ed.register_listeners.call_count == 1\n        assert mock_fs.database.call_count == 2\n        assert mock_fd.register_command_execution.call_count == 2\n\n    @pytest.mark.parametrize(\n        \"mock_db,mock_db1,mock_db2\",\n        [\n            (\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_execute_command_fallback_to_another_db_after_fallback_interval(\n        self, mock_db, mock_db1, mock_db2, mock_fd, mock_fs, mock_ed\n    ):\n        mock_db1.client.execute_command.return_value = \"OK1\"\n        mock_db2.client.execute_command.return_value = \"OK2\"\n        mock_fs.database.side_effect = [mock_db1, mock_db2, mock_db1]\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n\n        executor = DefaultCommandExecutor(\n            failure_detectors=[mock_fd],\n            databases=databases,\n            failover_strategy=mock_fs,\n            event_dispatcher=mock_ed,\n            auto_fallback_interval=0.1,\n            command_retry=Retry(NoBackoff(), 0),\n        )\n\n        assert executor.execute_command(\"SET\", \"key\", \"value\") == \"OK1\"\n        mock_db1.weight = 0.1\n        sleep(0.15)\n\n        assert executor.execute_command(\"SET\", \"key\", \"value\") == \"OK2\"\n        mock_db1.weight = 0.7\n        sleep(0.15)\n\n        assert executor.execute_command(\"SET\", \"key\", \"value\") == \"OK1\"\n        assert mock_ed.register_listeners.call_count == 1\n        assert mock_fs.database.call_count == 3\n        assert mock_fd.register_command_execution.call_count == 3\n\n    @pytest.mark.parametrize(\n        \"mock_db,mock_db1,mock_db2\",\n        [\n            (\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_execute_command_fallback_to_another_db_after_failure_detection(\n        self, mock_db, mock_db1, mock_db2, mock_fs\n    ):\n        mock_db1.client.execute_command.side_effect = [\n            \"OK1\",\n            ConnectionError,\n            ConnectionError,\n            ConnectionError,\n            \"OK1\",\n        ]\n        mock_db2.client.execute_command.side_effect = [\n            \"OK2\",\n            ConnectionError,\n            ConnectionError,\n            ConnectionError,\n        ]\n        mock_fs.database.side_effect = [mock_db1, mock_db2, mock_db1]\n        threshold = 3\n        fd = CommandFailureDetector(threshold, 0.0, 1)\n        ed = EventDispatcher()\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n\n        executor = DefaultCommandExecutor(\n            failure_detectors=[fd],\n            databases=databases,\n            failover_strategy=mock_fs,\n            event_dispatcher=ed,\n            auto_fallback_interval=0.1,\n            command_retry=Retry(NoBackoff(), threshold),\n        )\n        fd.set_command_executor(command_executor=executor)\n\n        assert executor.execute_command(\"SET\", \"key\", \"value\") == \"OK1\"\n        assert executor.execute_command(\"SET\", \"key\", \"value\") == \"OK2\"\n        assert executor.execute_command(\"SET\", \"key\", \"value\") == \"OK1\"\n        assert mock_fs.database.call_count == 3\n"
  },
  {
    "path": "tests/test_multidb/test_config.py",
    "content": "from unittest.mock import Mock\n\nimport pytest\n\nfrom redis.connection import ConnectionPool\nfrom redis.maint_notifications import MaintNotificationsConfig\nfrom redis.multidb.circuit import (\n    PBCircuitBreakerAdapter,\n    CircuitBreaker,\n    DEFAULT_GRACE_PERIOD,\n)\nfrom redis.multidb.config import (\n    MultiDbConfig,\n    DEFAULT_HEALTH_CHECK_INTERVAL,\n    DEFAULT_AUTO_FALLBACK_INTERVAL,\n    DatabaseConfig,\n)\nfrom redis.multidb.database import Database\nfrom redis.multidb.failure_detector import CommandFailureDetector, FailureDetector\nfrom redis.asyncio.multidb.healthcheck import PingHealthCheck, HealthCheck\nfrom redis.multidb.failover import WeightBasedFailoverStrategy, FailoverStrategy\nfrom redis.retry import Retry\n\n\n@pytest.mark.onlynoncluster\nclass TestMultiDbConfig:\n    def test_default_config(self):\n        db_configs = [\n            DatabaseConfig(\n                client_kwargs={\"host\": \"host1\", \"port\": \"port1\"}, weight=1.0\n            ),\n            DatabaseConfig(\n                client_kwargs={\"host\": \"host2\", \"port\": \"port2\"}, weight=0.9\n            ),\n            DatabaseConfig(\n                client_kwargs={\"host\": \"host3\", \"port\": \"port3\"}, weight=0.8\n            ),\n        ]\n\n        config = MultiDbConfig(databases_config=db_configs)\n\n        assert config.databases_config == db_configs\n        databases = config.databases()\n        assert len(databases) == 3\n\n        i = 0\n        for db, weight in databases:\n            assert isinstance(db, Database)\n            assert weight == db_configs[i].weight\n            assert db.circuit.grace_period == DEFAULT_GRACE_PERIOD\n            assert db.client.get_retry() is not config.command_retry\n            i += 1\n\n        assert len(config.default_failure_detectors()) == 1\n        assert isinstance(config.default_failure_detectors()[0], CommandFailureDetector)\n        assert len(config.default_health_checks()) == 1\n        assert isinstance(config.default_health_checks()[0], PingHealthCheck)\n        assert config.health_check_interval == DEFAULT_HEALTH_CHECK_INTERVAL\n        assert isinstance(\n            config.default_failover_strategy(), WeightBasedFailoverStrategy\n        )\n        assert config.auto_fallback_interval == DEFAULT_AUTO_FALLBACK_INTERVAL\n        assert isinstance(config.command_retry, Retry)\n\n    def test_overridden_config(self):\n        grace_period = 2\n        mock_connection_pools = [\n            Mock(spec=ConnectionPool),\n            Mock(spec=ConnectionPool),\n            Mock(spec=ConnectionPool),\n        ]\n        mock_connection_pools[0].connection_kwargs = {}\n        mock_connection_pools[1].connection_kwargs = {}\n        mock_connection_pools[2].connection_kwargs = {}\n        mock_cb1 = Mock(spec=CircuitBreaker)\n        mock_cb1.grace_period = grace_period\n        mock_cb2 = Mock(spec=CircuitBreaker)\n        mock_cb2.grace_period = grace_period\n        mock_cb3 = Mock(spec=CircuitBreaker)\n        mock_cb3.grace_period = grace_period\n        mock_failure_detectors = [\n            Mock(spec=FailureDetector),\n            Mock(spec=FailureDetector),\n        ]\n        mock_health_checks = [Mock(spec=HealthCheck), Mock(spec=HealthCheck)]\n        health_check_interval = 10\n        mock_failover_strategy = Mock(spec=FailoverStrategy)\n        auto_fallback_interval = 10\n        db_configs = [\n            DatabaseConfig(\n                client_kwargs={\"connection_pool\": mock_connection_pools[0]},\n                weight=1.0,\n                circuit=mock_cb1,\n            ),\n            DatabaseConfig(\n                client_kwargs={\"connection_pool\": mock_connection_pools[1]},\n                weight=0.9,\n                circuit=mock_cb2,\n            ),\n            DatabaseConfig(\n                client_kwargs={\"connection_pool\": mock_connection_pools[2]},\n                weight=0.8,\n                circuit=mock_cb3,\n            ),\n        ]\n\n        config = MultiDbConfig(\n            databases_config=db_configs,\n            failure_detectors=mock_failure_detectors,\n            health_checks=mock_health_checks,\n            health_check_interval=health_check_interval,\n            failover_strategy=mock_failover_strategy,\n            auto_fallback_interval=auto_fallback_interval,\n        )\n\n        assert config.databases_config == db_configs\n        databases = config.databases()\n        assert len(databases) == 3\n\n        i = 0\n        for db, weight in databases:\n            assert isinstance(db, Database)\n            assert weight == db_configs[i].weight\n            assert db.client.connection_pool == mock_connection_pools[i]\n            assert db.circuit.grace_period == grace_period\n            i += 1\n\n        assert len(config.failure_detectors) == 2\n        assert config.failure_detectors[0] == mock_failure_detectors[0]\n        assert config.failure_detectors[1] == mock_failure_detectors[1]\n        assert len(config.health_checks) == 2\n        assert config.health_checks[0] == mock_health_checks[0]\n        assert config.health_checks[1] == mock_health_checks[1]\n        assert config.health_check_interval == health_check_interval\n        assert config.failover_strategy == mock_failover_strategy\n        assert config.auto_fallback_interval == auto_fallback_interval\n\n    def test_underlying_clients_have_disabled_retry_and_maint_notifications(self):\n        \"\"\"\n        Test that underlying clients have retry disabled (0 retries)\n        and maintenance notifications disabled.\n        \"\"\"\n        db_configs = [\n            DatabaseConfig(\n                client_kwargs={\"host\": \"host1\", \"port\": \"port1\"},\n                weight=1.0,\n            ),\n            DatabaseConfig(\n                client_kwargs={\"host\": \"host2\", \"port\": \"port2\"},\n                weight=0.9,\n            ),\n        ]\n\n        config = MultiDbConfig(databases_config=db_configs)\n        databases = config.databases()\n\n        assert len(databases) == 2\n\n        for db, weight in databases:\n            # Verify retry is disabled (0 retries)\n            retry = db.client.get_retry()\n            assert retry is not None\n            assert retry.get_retries() == 0\n\n            # Verify maint_notifications_config is disabled\n            # When maint_notifications_config.enabled is False, the pool handler is None\n            pool = db.client.connection_pool\n            assert pool._maint_notifications_pool_handler is None\n\n    def test_user_provided_maint_notifications_config_is_respected(self):\n        \"\"\"\n        Test that user-provided maint_notifications_config is not overwritten.\n        \"\"\"\n        user_maint_config = MaintNotificationsConfig(enabled=True)\n        db_configs = [\n            DatabaseConfig(\n                client_kwargs={\n                    \"host\": \"host1\",\n                    \"port\": \"port1\",\n                    \"protocol\": 3,  # Required for maint notifications\n                    \"maint_notifications_config\": user_maint_config,\n                },\n                weight=1.0,\n            ),\n        ]\n\n        config = MultiDbConfig(databases_config=db_configs)\n        databases = config.databases()\n\n        assert len(databases) == 1\n\n        db, weight = databases[0]\n        # Verify user-provided maint_notifications_config is respected\n        pool = db.client.connection_pool\n        assert pool._maint_notifications_pool_handler is not None\n        assert pool._maint_notifications_pool_handler.config.enabled is True\n\n\n@pytest.mark.onlynoncluster\nclass TestDatabaseConfig:\n    def test_default_config(self):\n        config = DatabaseConfig(\n            client_kwargs={\"host\": \"host1\", \"port\": \"port1\"}, weight=1.0\n        )\n\n        assert config.client_kwargs == {\"host\": \"host1\", \"port\": \"port1\"}\n        assert config.weight == 1.0\n        assert isinstance(config.default_circuit_breaker(), PBCircuitBreakerAdapter)\n\n    def test_overridden_config(self):\n        mock_connection_pool = Mock(spec=ConnectionPool)\n        mock_circuit = Mock(spec=CircuitBreaker)\n\n        config = DatabaseConfig(\n            client_kwargs={\"connection_pool\": mock_connection_pool},\n            weight=1.0,\n            circuit=mock_circuit,\n        )\n\n        assert config.client_kwargs == {\"connection_pool\": mock_connection_pool}\n        assert config.weight == 1.0\n        assert config.circuit == mock_circuit\n"
  },
  {
    "path": "tests/test_multidb/test_failover.py",
    "content": "from time import sleep\n\nimport pytest\n\nfrom redis.data_structure import WeightedList\nfrom redis.multidb.circuit import State as CBState\nfrom redis.multidb.exception import (\n    NoValidDatabaseException,\n    TemporaryUnavailableException,\n)\nfrom redis.multidb.failover import (\n    WeightBasedFailoverStrategy,\n    DefaultFailoverStrategyExecutor,\n)\n\n\n@pytest.mark.onlynoncluster\nclass TestWeightBasedFailoverStrategy:\n    @pytest.mark.parametrize(\n        \"mock_db,mock_db1,mock_db2\",\n        [\n            (\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n            (\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.OPEN}},\n            ),\n        ],\n        ids=[\"all closed - highest weight\", \"highest weight - open\"],\n        indirect=True,\n    )\n    def test_get_valid_database(self, mock_db, mock_db1, mock_db2):\n        databases = WeightedList()\n        databases.add(mock_db, mock_db.weight)\n        databases.add(mock_db1, mock_db1.weight)\n        databases.add(mock_db2, mock_db2.weight)\n\n        failover_strategy = WeightBasedFailoverStrategy()\n        failover_strategy.set_databases(databases)\n\n        assert failover_strategy.database() == mock_db1\n\n    @pytest.mark.parametrize(\n        \"mock_db,mock_db1,mock_db2\",\n        [\n            (\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.OPEN}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.OPEN}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.OPEN}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_throws_exception_on_empty_databases(self, mock_db, mock_db1, mock_db2):\n        failover_strategy = WeightBasedFailoverStrategy()\n\n        with pytest.raises(\n            NoValidDatabaseException,\n            match=\"No valid database available for communication\",\n        ):\n            assert failover_strategy.database()\n\n\n@pytest.mark.onlynoncluster\nclass TestDefaultStrategyExecutor:\n    @pytest.mark.parametrize(\n        \"mock_db\",\n        [\n            {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n        ],\n        indirect=True,\n    )\n    def test_execute_returns_valid_database_with_failover_attempts(\n        self, mock_db, mock_fs\n    ):\n        failover_attempts = 3\n        mock_fs.database.side_effect = [\n            NoValidDatabaseException,\n            NoValidDatabaseException,\n            NoValidDatabaseException,\n            mock_db,\n        ]\n        executor = DefaultFailoverStrategyExecutor(\n            mock_fs, failover_attempts=failover_attempts, failover_delay=0.1\n        )\n\n        for i in range(failover_attempts + 1):\n            try:\n                database = executor.execute()\n                assert database == mock_db\n            except TemporaryUnavailableException as e:\n                assert e.args[0] == (\n                    \"No database connections currently available. \"\n                    \"This is a temporary condition - please retry the operation.\"\n                )\n                sleep(0.11)\n                pass\n\n        assert mock_fs.database.call_count == 4\n\n    def test_execute_throws_exception_on_attempts_exceed(self, mock_fs):\n        failover_attempts = 3\n        mock_fs.database.side_effect = [\n            NoValidDatabaseException,\n            NoValidDatabaseException,\n            NoValidDatabaseException,\n            NoValidDatabaseException,\n        ]\n        executor = DefaultFailoverStrategyExecutor(\n            mock_fs, failover_attempts=failover_attempts, failover_delay=0.1\n        )\n\n        with pytest.raises(NoValidDatabaseException):\n            for i in range(failover_attempts + 1):\n                try:\n                    executor.execute()\n                except TemporaryUnavailableException as e:\n                    assert e.args[0] == (\n                        \"No database connections currently available. \"\n                        \"This is a temporary condition - please retry the operation.\"\n                    )\n                    sleep(0.11)\n                    pass\n\n            assert mock_fs.database.call_count == 4\n\n    def test_execute_throws_exception_on_attempts_does_not_exceed_delay(self, mock_fs):\n        failover_attempts = 3\n        mock_fs.database.side_effect = [\n            NoValidDatabaseException,\n            NoValidDatabaseException,\n            NoValidDatabaseException,\n            NoValidDatabaseException,\n        ]\n        executor = DefaultFailoverStrategyExecutor(\n            mock_fs, failover_attempts=failover_attempts, failover_delay=0.1\n        )\n\n        with pytest.raises(\n            TemporaryUnavailableException,\n            match=(\n                \"No database connections currently available. \"\n                \"This is a temporary condition - please retry the operation.\"\n            ),\n        ):\n            for i in range(failover_attempts + 1):\n                try:\n                    executor.execute()\n                except TemporaryUnavailableException as e:\n                    assert e.args[0] == (\n                        \"No database connections currently available. \"\n                        \"This is a temporary condition - please retry the operation.\"\n                    )\n                    if i == failover_attempts:\n                        raise e\n\n            assert mock_fs.database.call_count == 4\n"
  },
  {
    "path": "tests/test_multidb/test_failure_detector.py",
    "content": "from time import sleep\nfrom unittest.mock import Mock\n\nimport pytest\n\nfrom redis.multidb.command_executor import SyncCommandExecutor\nfrom redis.multidb.database import Database\nfrom redis.multidb.failure_detector import CommandFailureDetector\nfrom redis.multidb.circuit import State as CBState\nfrom redis.exceptions import ConnectionError\n\n\n@pytest.mark.onlynoncluster\nclass TestCommandFailureDetector:\n    @pytest.mark.parametrize(\n        \"min_num_failures,failure_rate_threshold,circuit_state\",\n        [\n            (2, 0.4, CBState.OPEN),\n            (2, 0, CBState.OPEN),\n            (0, 0.4, CBState.OPEN),\n            (3, 0.4, CBState.CLOSED),\n            (2, 0.41, CBState.CLOSED),\n        ],\n        ids=[\n            \"exceeds min num failures AND failures rate\",\n            \"exceeds min num failures AND failures rate == 0\",\n            \"min num failures == 0 AND exceeds failures rate\",\n            \"do not exceeds min num failures\",\n            \"do not exceeds failures rate\",\n        ],\n    )\n    def test_failure_detector_correctly_reacts_to_failures(\n        self, min_num_failures, failure_rate_threshold, circuit_state\n    ):\n        fd = CommandFailureDetector(min_num_failures, failure_rate_threshold)\n        mock_db = Mock(spec=Database)\n        mock_db.circuit.state = CBState.CLOSED\n        mock_ce = Mock(spec=SyncCommandExecutor)\n        mock_ce.active_database = mock_db\n        fd.set_command_executor(mock_ce)\n\n        fd.register_command_execution((\"GET\", \"key\"))\n        fd.register_command_execution((\"GET\", \"key\"))\n        fd.register_failure(Exception(), (\"GET\", \"key\"))\n\n        fd.register_command_execution((\"GET\", \"key\"))\n        fd.register_command_execution((\"GET\", \"key\"))\n        fd.register_command_execution((\"GET\", \"key\"))\n        fd.register_failure(Exception(), (\"GET\", \"key\"))\n\n        assert mock_db.circuit.state == circuit_state\n\n    @pytest.mark.parametrize(\n        \"min_num_failures,failure_rate_threshold\",\n        [\n            (3, 0.0),\n            (3, 0.6),\n        ],\n        ids=[\n            \"do not exceeds min num failures, during interval\",\n            \"do not exceeds min num failures AND failure rate, during interval\",\n        ],\n    )\n    def test_failure_detector_do_not_open_circuit_on_interval_exceed(\n        self, min_num_failures, failure_rate_threshold\n    ):\n        fd = CommandFailureDetector(min_num_failures, failure_rate_threshold, 0.3)\n        mock_db = Mock(spec=Database)\n        mock_db.circuit.state = CBState.CLOSED\n        mock_ce = Mock(spec=SyncCommandExecutor)\n        mock_ce.active_database = mock_db\n        fd.set_command_executor(mock_ce)\n        assert mock_db.circuit.state == CBState.CLOSED\n\n        fd.register_command_execution((\"GET\", \"key\"))\n        fd.register_failure(Exception(), (\"GET\", \"key\"))\n        sleep(0.16)\n        fd.register_command_execution((\"GET\", \"key\"))\n        fd.register_command_execution((\"GET\", \"key\"))\n        fd.register_command_execution((\"GET\", \"key\"))\n        fd.register_failure(Exception(), (\"GET\", \"key\"))\n        sleep(0.16)\n        fd.register_command_execution((\"GET\", \"key\"))\n        fd.register_failure(Exception(), (\"GET\", \"key\"))\n\n        assert mock_db.circuit.state == CBState.CLOSED\n\n        # 2 more failure as last one already refreshed timer\n        fd.register_command_execution((\"GET\", \"key\"))\n        fd.register_failure(Exception(), (\"GET\", \"key\"))\n        fd.register_command_execution((\"GET\", \"key\"))\n        fd.register_failure(Exception(), (\"GET\", \"key\"))\n\n        assert mock_db.circuit.state == CBState.OPEN\n\n    def test_failure_detector_open_circuit_on_specific_exception_threshold_exceed(self):\n        fd = CommandFailureDetector(5, 1, error_types=[ConnectionError])\n        mock_db = Mock(spec=Database)\n        mock_db.circuit.state = CBState.CLOSED\n        mock_ce = Mock(spec=SyncCommandExecutor)\n        mock_ce.active_database = mock_db\n        fd.set_command_executor(mock_ce)\n        assert mock_db.circuit.state == CBState.CLOSED\n\n        fd.register_failure(Exception(), (\"SET\", \"key1\", \"value1\"))\n        fd.register_failure(ConnectionError(), (\"SET\", \"key1\", \"value1\"))\n        fd.register_failure(ConnectionError(), (\"SET\", \"key1\", \"value1\"))\n        fd.register_failure(Exception(), (\"SET\", \"key1\", \"value1\"))\n        fd.register_failure(Exception(), (\"SET\", \"key1\", \"value1\"))\n\n        assert mock_db.circuit.state == CBState.CLOSED\n\n        fd.register_failure(ConnectionError(), (\"SET\", \"key1\", \"value1\"))\n        fd.register_failure(ConnectionError(), (\"SET\", \"key1\", \"value1\"))\n        fd.register_failure(ConnectionError(), (\"SET\", \"key1\", \"value1\"))\n\n        assert mock_db.circuit.state == CBState.OPEN\n"
  },
  {
    "path": "tests/test_multidb/test_pipeline.py",
    "content": "import threading\nfrom unittest.mock import patch, Mock\n\nimport pybreaker\nimport pytest\n\nfrom redis.client import Pipeline\nfrom redis.multidb.circuit import State as CBState, PBCircuitBreakerAdapter\nfrom redis.multidb.client import MultiDBClient\nfrom redis.multidb.config import InitialHealthCheck\nfrom redis.multidb.failover import (\n    WeightBasedFailoverStrategy,\n)\nfrom tests.helpers import wait_for_condition\nfrom tests.test_multidb.conftest import create_weighted_list\n\n\ndef mock_pipe() -> Pipeline:\n    mock_pipe = Mock(spec=Pipeline)\n    mock_pipe.__enter__ = Mock(return_value=mock_pipe)\n    mock_pipe.__exit__ = Mock(return_value=None)\n    return mock_pipe\n\n\n@pytest.mark.onlynoncluster\nclass TestPipeline:\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_executes_pipeline_against_correct_db(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n\n        with (\n            patch.object(mock_multi_db_config, \"databases\", return_value=databases),\n            patch.object(\n                mock_multi_db_config, \"default_health_checks\", return_value=[mock_hc]\n            ),\n        ):\n            pipe = mock_pipe()\n            pipe.execute.return_value = [\"OK1\", \"value1\"]\n            mock_db1.client.pipeline.return_value = pipe\n\n            mock_hc.check_health.return_value = True\n\n            client = MultiDBClient(mock_multi_db_config)\n            try:\n                assert (\n                    mock_multi_db_config.failover_strategy.set_databases.call_count == 1\n                )\n\n                pipe = client.pipeline()\n                pipe.set(\"key1\", \"value1\")\n                pipe.get(\"key1\")\n\n                assert pipe.execute() == [\"OK1\", \"value1\"]\n                # 3 databases × 3 probes = 9 calls\n                assert len(mock_hc.check_health.call_args_list) == 9\n            finally:\n                client.close()\n\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {\"initial_health_check_policy\": InitialHealthCheck.MAJORITY_AVAILABLE},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.OPEN}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_execute_pipeline_against_correct_db_and_closed_circuit(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n\n        with (\n            patch.object(mock_multi_db_config, \"databases\", return_value=databases),\n            patch.object(\n                mock_multi_db_config, \"default_health_checks\", return_value=[mock_hc]\n            ),\n        ):\n            pipe = mock_pipe()\n            pipe.execute.return_value = [\"OK1\", \"value1\"]\n            mock_db1.client.pipeline.return_value = pipe\n\n            async def mock_check_health(database, connection=None):\n                if database == mock_db2:\n                    return False\n                else:\n                    return True\n\n            mock_hc.check_health.side_effect = mock_check_health\n\n            client = MultiDBClient(mock_multi_db_config)\n            try:\n                assert (\n                    mock_multi_db_config.failover_strategy.set_databases.call_count == 1\n                )\n\n                with client.pipeline() as pipe:\n                    pipe.set(\"key1\", \"value1\")\n                    pipe.get(\"key1\")\n\n                assert pipe.execute() == [\"OK1\", \"value1\"]\n\n                assert mock_db.circuit.state == CBState.CLOSED\n                assert mock_db1.circuit.state == CBState.CLOSED\n                assert mock_db2.circuit.state == CBState.OPEN\n            finally:\n                client.close()\n\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_execute_pipeline_against_correct_db_on_background_health_check_determine_active_db_unhealthy(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        cb = PBCircuitBreakerAdapter(pybreaker.CircuitBreaker(reset_timeout=0.1))\n        cb.database = mock_db\n        mock_db.circuit = cb\n\n        cb1 = PBCircuitBreakerAdapter(pybreaker.CircuitBreaker(reset_timeout=0.1))\n        cb1.database = mock_db1\n        mock_db1.circuit = cb1\n\n        cb2 = PBCircuitBreakerAdapter(pybreaker.CircuitBreaker(reset_timeout=0.1))\n        cb2.database = mock_db2\n        mock_db2.circuit = cb2\n\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n\n        # Track health check rounds per database\n        db_rounds = {id(mock_db): 0, id(mock_db1): 0, id(mock_db2): 0}\n        db_probes_in_round = {id(mock_db): 0, id(mock_db1): 0, id(mock_db2): 0}\n\n        # Create events for each failover scenario\n        db1_became_unhealthy = threading.Event()\n        db2_became_unhealthy = threading.Event()\n        db_became_unhealthy = threading.Event()\n        counter_lock = threading.Lock()\n\n        async def mock_check_health(database, connection=None):\n            with counter_lock:\n                db_probes_in_round[id(database)] += 1\n                if db_probes_in_round[id(database)] > 3:\n                    db_rounds[id(database)] += 1\n                    db_probes_in_round[id(database)] = 1\n                current_round = db_rounds[id(database)]\n\n            # Round 0 (initial health check): All databases healthy\n            if current_round == 0:\n                return True\n\n            # Round 1: mock_db1 becomes unhealthy, others healthy\n            if current_round == 1:\n                if database == mock_db1:\n                    db1_became_unhealthy.set()\n                    return False\n                return True\n\n            # Round 2: mock_db2 also becomes unhealthy, mock_db healthy\n            if current_round == 2:\n                if database == mock_db1:\n                    return False\n                if database == mock_db2:\n                    db2_became_unhealthy.set()\n                    return False\n                return True\n\n            # Round 3: mock_db also becomes unhealthy, but mock_db1 recovers\n            if current_round == 3:\n                if database == mock_db:\n                    db_became_unhealthy.set()\n                    return False\n                if database == mock_db2:\n                    return False\n                return True\n\n            # Round 4+: mock_db1 is healthy, others unhealthy\n            if database == mock_db1:\n                return True\n            return False\n\n        mock_hc.check_health.side_effect = mock_check_health\n\n        with (\n            patch.object(mock_multi_db_config, \"databases\", return_value=databases),\n            patch.object(\n                mock_multi_db_config,\n                \"default_health_checks\",\n                return_value=[mock_hc],\n            ),\n        ):\n            pipe = mock_pipe()\n            pipe.execute.return_value = [\"OK\", \"value\"]\n            mock_db.client.pipeline.return_value = pipe\n\n            pipe1 = mock_pipe()\n            pipe1.execute.return_value = [\"OK1\", \"value\"]\n            mock_db1.client.pipeline.return_value = pipe1\n\n            pipe2 = mock_pipe()\n            pipe2.execute.return_value = [\"OK2\", \"value\"]\n            mock_db2.client.pipeline.return_value = pipe2\n\n            mock_multi_db_config.health_check_interval = 0.05\n            mock_multi_db_config.failover_strategy = WeightBasedFailoverStrategy()\n\n            client = MultiDBClient(mock_multi_db_config)\n            try:\n                with client.pipeline() as pipe:\n                    pipe.set(\"key1\", \"value\")\n                    pipe.get(\"key1\")\n\n                    # Run 1: All databases healthy - should use mock_db1 (highest weight 0.7)\n                    assert pipe.execute() == [\"OK1\", \"value\"]\n\n                    # Wait for mock_db1 to become unhealthy\n                    assert db1_became_unhealthy.wait(timeout=1.0), (\n                        \"Timeout waiting for mock_db1 to become unhealthy\"\n                    )\n                    wait_for_condition(\n                        lambda: cb1.state == CBState.OPEN,\n                        timeout=0.5,\n                        error_message=\"Timeout waiting for cb1 to open\",\n                    )\n\n                    # Run 2: mock_db1 unhealthy - should failover to mock_db2 (weight 0.5)\n                    assert pipe.execute() == [\"OK2\", \"value\"]\n\n                    # Wait for mock_db2 to become unhealthy\n                    assert db2_became_unhealthy.wait(timeout=1.0), (\n                        \"Timeout waiting for mock_db2 to become unhealthy\"\n                    )\n                    wait_for_condition(\n                        lambda: cb2.state == CBState.OPEN,\n                        timeout=0.5,\n                        error_message=\"Timeout waiting for cb2 to open\",\n                    )\n\n                    # Run 3: mock_db1 and mock_db2 unhealthy - should use mock_db (weight 0.2)\n                    assert pipe.execute() == [\"OK\", \"value\"]\n\n                    # Wait for mock_db to become unhealthy\n                    assert db_became_unhealthy.wait(timeout=1.0), (\n                        \"Timeout waiting for mock_db to become unhealthy\"\n                    )\n                    wait_for_condition(\n                        lambda: cb.state == CBState.OPEN,\n                        timeout=0.5,\n                        error_message=\"Timeout waiting for cb to open\",\n                    )\n\n                    # Wait for mock_db1 to recover (circuit breaker to close)\n                    wait_for_condition(\n                        lambda: cb1.state == CBState.CLOSED,\n                        timeout=1.0,\n                        error_message=\"Timeout waiting for cb1 to close (mock_db1 to recover)\",\n                    )\n\n                    # Run 4: mock_db unhealthy, others healthy - should use mock_db1 (highest weight)\n                    assert pipe.execute() == [\"OK1\", \"value\"]\n            finally:\n                client.close()\n\n\n@pytest.mark.onlynoncluster\nclass TestTransaction:\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_executes_transaction_against_correct_db(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n\n        with (\n            patch.object(mock_multi_db_config, \"databases\", return_value=databases),\n            patch.object(\n                mock_multi_db_config, \"default_health_checks\", return_value=[mock_hc]\n            ),\n        ):\n            mock_db1.client.transaction.return_value = [\"OK1\", \"value1\"]\n\n            mock_hc.check_health.return_value = True\n\n            client = MultiDBClient(mock_multi_db_config)\n            try:\n                assert (\n                    mock_multi_db_config.failover_strategy.set_databases.call_count == 1\n                )\n\n                def callback(pipe: Pipeline):\n                    pipe.set(\"key1\", \"value1\")\n                    pipe.get(\"key1\")\n\n                assert client.transaction(callback) == [\"OK1\", \"value1\"]\n                # 3 databases × 3 probes = 9 calls minimum\n                assert len(mock_hc.check_health.call_args_list) >= 9\n            finally:\n                client.close()\n\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {\"initial_health_check_policy\": InitialHealthCheck.MAJORITY_AVAILABLE},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.OPEN}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_execute_transaction_against_correct_db_and_closed_circuit(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n\n        with (\n            patch.object(mock_multi_db_config, \"databases\", return_value=databases),\n            patch.object(\n                mock_multi_db_config, \"default_health_checks\", return_value=[mock_hc]\n            ),\n        ):\n            mock_db1.client.transaction.return_value = [\"OK1\", \"value1\"]\n\n            async def mock_check_health(database, connection=None):\n                if database == mock_db2:\n                    return False\n                else:\n                    return True\n\n            mock_hc.check_health.side_effect = mock_check_health\n\n            client = MultiDBClient(mock_multi_db_config)\n            try:\n                assert (\n                    mock_multi_db_config.failover_strategy.set_databases.call_count == 1\n                )\n\n                def callback(pipe: Pipeline):\n                    pipe.set(\"key1\", \"value1\")\n                    pipe.get(\"key1\")\n\n                assert client.transaction(callback) == [\"OK1\", \"value1\"]\n                # 3 databases × 3 probes = 9 calls minimum (but one db fails, so >= 7)\n                assert len(mock_hc.check_health.call_args_list) >= 7\n\n                assert mock_db.circuit.state == CBState.CLOSED\n                assert mock_db1.circuit.state == CBState.CLOSED\n                assert mock_db2.circuit.state == CBState.OPEN\n            finally:\n                client.close()\n\n    @pytest.mark.parametrize(\n        \"mock_multi_db_config,mock_db, mock_db1, mock_db2\",\n        [\n            (\n                {},\n                {\"weight\": 0.2, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.7, \"circuit\": {\"state\": CBState.CLOSED}},\n                {\"weight\": 0.5, \"circuit\": {\"state\": CBState.CLOSED}},\n            ),\n        ],\n        indirect=True,\n    )\n    def test_execute_transaction_against_correct_db_on_background_health_check_determine_active_db_unhealthy(\n        self, mock_multi_db_config, mock_db, mock_db1, mock_db2, mock_hc\n    ):\n        cb = PBCircuitBreakerAdapter(pybreaker.CircuitBreaker(reset_timeout=0.1))\n        cb.database = mock_db\n        mock_db.circuit = cb\n\n        cb1 = PBCircuitBreakerAdapter(pybreaker.CircuitBreaker(reset_timeout=0.1))\n        cb1.database = mock_db1\n        mock_db1.circuit = cb1\n\n        cb2 = PBCircuitBreakerAdapter(pybreaker.CircuitBreaker(reset_timeout=0.1))\n        cb2.database = mock_db2\n        mock_db2.circuit = cb2\n\n        databases = create_weighted_list(mock_db, mock_db1, mock_db2)\n\n        # Track health check rounds per database\n        db_rounds = {id(mock_db): 0, id(mock_db1): 0, id(mock_db2): 0}\n        db_probes_in_round = {id(mock_db): 0, id(mock_db1): 0, id(mock_db2): 0}\n\n        # Create events for each failover scenario\n        db1_became_unhealthy = threading.Event()\n        db2_became_unhealthy = threading.Event()\n        db_became_unhealthy = threading.Event()\n        counter_lock = threading.Lock()\n\n        async def mock_check_health(database, connection=None):\n            with counter_lock:\n                db_probes_in_round[id(database)] += 1\n                if db_probes_in_round[id(database)] > 3:\n                    db_rounds[id(database)] += 1\n                    db_probes_in_round[id(database)] = 1\n                current_round = db_rounds[id(database)]\n\n            # Round 0 (initial health check): All databases healthy\n            if current_round == 0:\n                return True\n\n            # Round 1: mock_db1 becomes unhealthy, others healthy\n            if current_round == 1:\n                if database == mock_db1:\n                    db1_became_unhealthy.set()\n                    return False\n                return True\n\n            # Round 2: mock_db2 also becomes unhealthy, mock_db healthy\n            if current_round == 2:\n                if database == mock_db1:\n                    return False\n                if database == mock_db2:\n                    db2_became_unhealthy.set()\n                    return False\n                return True\n\n            # Round 3: mock_db also becomes unhealthy, but mock_db1 recovers\n            if current_round == 3:\n                if database == mock_db:\n                    db_became_unhealthy.set()\n                    return False\n                if database == mock_db2:\n                    return False\n                return True\n\n            # Round 4+: mock_db1 is healthy, others unhealthy\n            if database == mock_db1:\n                return True\n            return False\n\n        mock_hc.check_health.side_effect = mock_check_health\n\n        with (\n            patch.object(mock_multi_db_config, \"databases\", return_value=databases),\n            patch.object(\n                mock_multi_db_config,\n                \"default_health_checks\",\n                return_value=[mock_hc],\n            ),\n        ):\n            mock_db.client.transaction.return_value = [\"OK\", \"value\"]\n            mock_db1.client.transaction.return_value = [\"OK1\", \"value\"]\n            mock_db2.client.transaction.return_value = [\"OK2\", \"value\"]\n\n            mock_multi_db_config.health_check_interval = 0.05\n            mock_multi_db_config.failover_strategy = WeightBasedFailoverStrategy()\n\n            client = MultiDBClient(mock_multi_db_config)\n            try:\n\n                def callback(pipe: Pipeline):\n                    pipe.set(\"key1\", \"value1\")\n                    pipe.get(\"key1\")\n\n                # Run 1: All databases healthy - should use mock_db1 (highest weight 0.7)\n                assert client.transaction(callback) == [\"OK1\", \"value\"]\n\n                # Wait for mock_db1 to become unhealthy\n                assert db1_became_unhealthy.wait(timeout=1.0), (\n                    \"Timeout waiting for mock_db1 to become unhealthy\"\n                )\n                wait_for_condition(\n                    lambda: cb1.state == CBState.OPEN,\n                    timeout=0.5,\n                    error_message=\"Timeout waiting for cb1 to open\",\n                )\n\n                # Run 2: mock_db1 unhealthy - should failover to mock_db2 (weight 0.5)\n                assert client.transaction(callback) == [\"OK2\", \"value\"]\n\n                # Wait for mock_db2 to become unhealthy\n                assert db2_became_unhealthy.wait(timeout=1.0), (\n                    \"Timeout waiting for mock_db2 to become unhealthy\"\n                )\n                wait_for_condition(\n                    lambda: cb2.state == CBState.OPEN,\n                    timeout=0.5,\n                    error_message=\"Timeout waiting for cb2 to open\",\n                )\n\n                # Run 3: mock_db1 and mock_db2 unhealthy - should use mock_db (weight 0.2)\n                assert client.transaction(callback) == [\"OK\", \"value\"]\n\n                # Wait for mock_db to become unhealthy\n                assert db_became_unhealthy.wait(timeout=1.0), (\n                    \"Timeout waiting for mock_db to become unhealthy\"\n                )\n                wait_for_condition(\n                    lambda: cb.state == CBState.OPEN,\n                    timeout=0.5,\n                    error_message=\"Timeout waiting for cb to open\",\n                )\n\n                # Wait for mock_db1 to recover (circuit breaker to close)\n                wait_for_condition(\n                    lambda: cb1.state == CBState.CLOSED,\n                    timeout=1.0,\n                    error_message=\"Timeout waiting for cb1 to close (mock_db1 to recover)\",\n                )\n\n                # Run 4: mock_db unhealthy, others healthy - should use mock_db1 (highest weight)\n                assert client.transaction(callback) == [\"OK1\", \"value\"]\n            finally:\n                client.close()\n"
  },
  {
    "path": "tests/test_multiprocessing.py",
    "content": "import contextlib\nimport multiprocessing\nimport platform\n\nimport pytest\nimport redis\nfrom redis.connection import Connection, ConnectionPool\nfrom redis.exceptions import ConnectionError\n\nfrom .conftest import _get_client\n\n\n@contextlib.contextmanager\ndef exit_callback(callback, *args):\n    try:\n        yield\n    finally:\n        callback(*args)\n\n\n@pytest.mark.skipif(\n    platform.python_implementation() == \"PyPy\",\n    reason=(\n        \"Pypy has issues with multiprocessing using fork as start method. \"\n        \"Causes processes to hang quite often\"\n    ),\n)\nclass TestMultiprocessing:\n    # On macOS and newly non-macOS POSIX systems (since Python 3.14),\n    # the default method has been changed to forkserver.\n    # The code in this module does not work with it,\n    # hence the explicit change to 'fork'\n    # See https://github.com/python/cpython/issues/125714\n    if multiprocessing.get_start_method() in [\"forkserver\", \"spawn\"]:\n        _mp_context = multiprocessing.get_context(method=\"fork\")\n    else:\n        _mp_context = multiprocessing.get_context()\n\n    # Test connection sharing between forks.\n    # See issue #1085 for details.\n\n    # use a multi-connection client as that's the only type that is\n    # actually fork/process-safe\n    @pytest.fixture()\n    def r(self, request):\n        return _get_client(redis.Redis, request=request, single_connection_client=False)\n\n    def test_close_connection_in_child(self, master_host):\n        \"\"\"\n        A connection owned by a parent and closed by a child doesn't\n        destroy the file descriptors so a parent can still use it.\n        \"\"\"\n        conn = Connection(host=master_host[0], port=master_host[1])\n        conn.send_command(\"ping\")\n        assert conn.read_response() == b\"PONG\"\n\n        def target(conn):\n            conn.send_command(\"ping\")\n            assert conn.read_response() == b\"PONG\"\n            conn.disconnect()\n\n        proc = self._mp_context.Process(target=target, args=(conn,))\n        proc.start()\n        proc.join(3)\n        assert proc.exitcode == 0\n\n        # The connection was created in the parent but disconnected in the\n        # child. The child called socket.close() but did not call\n        # socket.shutdown() because it wasn't the \"owning\" process.\n        # Therefore the connection still works in the parent.\n        conn.send_command(\"ping\")\n        assert conn.read_response() == b\"PONG\"\n\n    def test_close_connection_in_parent(self, master_host):\n        \"\"\"\n        A connection owned by a parent is unusable by a child if the parent\n        (the owning process) closes the connection.\n        \"\"\"\n        conn = Connection(host=master_host[0], port=master_host[1])\n        conn.send_command(\"ping\")\n        assert conn.read_response() == b\"PONG\"\n\n        def target(conn, ev):\n            ev.wait()\n            # the parent closed the connection. because it also created the\n            # connection, the connection is shutdown and the child\n            # cannot use it.\n            with pytest.raises(ConnectionError):\n                conn.send_command(\"ping\")\n\n        ev = multiprocessing.Event()\n        proc = self._mp_context.Process(target=target, args=(conn, ev))\n        proc.start()\n\n        conn.disconnect()\n        ev.set()\n\n        proc.join(3)\n        assert proc.exitcode == 0\n\n    @pytest.mark.parametrize(\"max_connections\", [2, None])\n    def test_release_parent_connection_from_pool_in_child_process(\n        self, max_connections, master_host\n    ):\n        \"\"\"\n        A connection owned by a parent should not decrease the _created_connections\n        counter in child when released - when the child process starts to use the\n        pool it resets all the counters that have been set in the parent process.\n        \"\"\"\n\n        pool = ConnectionPool.from_url(\n            f\"redis://{master_host[0]}:{master_host[1]}\",\n            max_connections=max_connections,\n        )\n\n        parent_conn = pool.get_connection()\n\n        def target(pool, parent_conn):\n            with exit_callback(pool.disconnect):\n                child_conn = pool.get_connection()\n                assert child_conn.pid != parent_conn.pid\n                pool.release(child_conn)\n                assert pool._created_connections == 1\n                assert child_conn in pool._available_connections\n                pool.release(parent_conn)\n                assert pool._created_connections == 1\n                assert child_conn in pool._available_connections\n                assert parent_conn not in pool._available_connections\n\n        proc = self._mp_context.Process(target=target, args=(pool, parent_conn))\n        proc.start()\n        proc.join(3)\n        assert proc.exitcode == 0\n\n    @pytest.mark.parametrize(\"max_connections\", [1, 2, None])\n    def test_pool(self, max_connections, master_host):\n        \"\"\"\n        A child will create its own connections when using a pool created\n        by a parent.\n        \"\"\"\n        pool = ConnectionPool.from_url(\n            f\"redis://{master_host[0]}:{master_host[1]}\",\n            max_connections=max_connections,\n        )\n\n        conn = pool.get_connection()\n        main_conn_pid = conn.pid\n        with exit_callback(pool.release, conn):\n            conn.send_command(\"ping\")\n            assert conn.read_response() == b\"PONG\"\n\n        def target(pool):\n            with exit_callback(pool.disconnect):\n                conn = pool.get_connection()\n                assert conn.pid != main_conn_pid\n                with exit_callback(pool.release, conn):\n                    assert conn.send_command(\"ping\") is None\n                    assert conn.read_response() == b\"PONG\"\n\n        proc = self._mp_context.Process(target=target, args=(pool,))\n        proc.start()\n        proc.join(3)\n        assert proc.exitcode == 0\n\n        # Check that connection is still alive after fork process has exited\n        # and disconnected the connections in its pool\n        conn = pool.get_connection()\n        with exit_callback(pool.release, conn):\n            assert conn.send_command(\"ping\") is None\n            assert conn.read_response() == b\"PONG\"\n\n    @pytest.mark.parametrize(\"max_connections\", [1, 2, None])\n    def test_close_pool_in_main(self, max_connections, master_host):\n        \"\"\"\n        A child process that uses the same pool as its parent isn't affected\n        when the parent disconnects all connections within the pool.\n        \"\"\"\n        pool = ConnectionPool.from_url(\n            f\"redis://{master_host[0]}:{master_host[1]}\",\n            max_connections=max_connections,\n        )\n\n        conn = pool.get_connection()\n        assert conn.send_command(\"ping\") is None\n        assert conn.read_response() == b\"PONG\"\n\n        def target(pool, disconnect_event):\n            conn = pool.get_connection()\n            with exit_callback(pool.release, conn):\n                assert conn.send_command(\"ping\") is None\n                assert conn.read_response() == b\"PONG\"\n                disconnect_event.wait()\n                assert conn.send_command(\"ping\") is None\n                assert conn.read_response() == b\"PONG\"\n\n        ev = multiprocessing.Event()\n\n        proc = self._mp_context.Process(target=target, args=(pool, ev))\n        proc.start()\n\n        pool.disconnect()\n        ev.set()\n        proc.join(3)\n        assert proc.exitcode == 0\n\n    def test_redis_client(self, r):\n        \"A redis client created in a parent can also be used in a child\"\n        assert r.ping() is True\n\n        def target(client):\n            assert client.ping() is True\n            del client\n\n        proc = self._mp_context.Process(target=target, args=(r,))\n        proc.start()\n        proc.join(3)\n        assert proc.exitcode == 0\n\n        assert r.ping() is True\n"
  },
  {
    "path": "tests/test_observability/__init__.py",
    "content": ""
  },
  {
    "path": "tests/test_observability/test_cluster_metrics_error_handling.py",
    "content": "\"\"\"\nUnit tests for cluster metrics recording during error handling.\n\nThese tests verify that the cluster error handling correctly sets the connection\nattribute on exceptions for metrics reporting, even when the connection is not\nyet established or when using ClusterNode objects.\n\"\"\"\n\nimport pytest\nfrom unittest.mock import MagicMock, patch\nfrom redis.cluster import RedisCluster, ClusterNode\nfrom redis.exceptions import (\n    AuthenticationError,\n    MaxConnectionsError,\n    ConnectionError as RedisConnectionError,\n    ResponseError,\n    TimeoutError as RedisTimeoutError,\n    ClusterDownError,\n    SlotNotCoveredError,\n)\n\n\n@pytest.mark.onlycluster\nclass TestClusterMetricsRecordingDuringErrorHandling:\n    \"\"\"\n    Tests for cluster metrics recording during error handling.\n\n    These tests verify that when exceptions occur during command execution,\n    metrics are recorded correctly using either the Connection object (when\n    available) or the ClusterNode (as fallback when connection is None).\n    \"\"\"\n\n    def test_authentication_error_uses_target_node_for_metrics(self):\n        \"\"\"\n        Test that AuthenticationError uses target_node for metrics when connection is None.\n\n        AuthenticationError typically occurs during get_connection() when the connection\n        is being established and authenticated. Since the error is raised before\n        get_connection() returns, the connection variable is still None, so we\n        fall back to target_node for metrics.\n        \"\"\"\n        target_node = ClusterNode(host=\"127.0.0.1\", port=7000, server_type=\"primary\")\n\n        with patch(\"redis.cluster.NodesManager\") as MockNodesManager:\n            mock_nodes_manager = MagicMock()\n            mock_nodes_manager.initialize.return_value = None\n            mock_nodes_manager.default_node = target_node\n            MockNodesManager.return_value = mock_nodes_manager\n\n            with patch(\"redis.cluster.CommandsParser\"):\n                cluster = RedisCluster(host=\"127.0.0.1\", port=7000)\n\n                mock_redis_conn = MagicMock()\n\n                with patch.object(\n                    cluster, \"get_redis_connection\", return_value=mock_redis_conn\n                ):\n                    with patch(\n                        \"redis.cluster.get_connection\",\n                        side_effect=AuthenticationError(\"Auth failed\"),\n                    ):\n                        with patch(\n                            \"redis.cluster.record_operation_duration\"\n                        ) as mock_record:\n                            with pytest.raises(AuthenticationError) as exc_info:\n                                cluster.execute_command(\n                                    \"GET\", \"key\", target_nodes=target_node\n                                )\n\n                            assert hasattr(exc_info.value, \"connection\")\n                            assert exc_info.value.connection == target_node\n\n                            mock_record.assert_called_once()\n                            call_kwargs = mock_record.call_args.kwargs\n                            assert call_kwargs[\"command_name\"] == \"GET\"\n                            assert call_kwargs[\"server_address\"] == \"127.0.0.1\"\n                            assert call_kwargs[\"server_port\"] == 7000\n                            assert call_kwargs[\"db_namespace\"] == \"0\"\n                            assert isinstance(call_kwargs[\"error\"], AuthenticationError)\n\n    def test_connection_error_uses_connection_when_available(self):\n        \"\"\"\n        Test that ConnectionError uses actual connection if available.\n\n        When the error occurs AFTER get_connection() returns (e.g., during\n        parse_response), the connection object is available for metrics.\n        \"\"\"\n        target_node = ClusterNode(host=\"127.0.0.1\", port=7000, server_type=\"primary\")\n\n        with patch(\"redis.cluster.NodesManager\") as MockNodesManager:\n            mock_nodes_manager = MagicMock()\n            mock_nodes_manager.initialize.return_value = None\n            mock_nodes_manager.default_node = target_node\n            mock_nodes_manager.move_node_to_end_of_cached_nodes = MagicMock()\n            MockNodesManager.return_value = mock_nodes_manager\n\n            with patch(\"redis.cluster.CommandsParser\"):\n                cluster = RedisCluster(host=\"127.0.0.1\", port=7000)\n\n                mock_redis_conn = MagicMock()\n                mock_redis_conn.parse_response.side_effect = RedisConnectionError(\n                    \"Connection lost\"\n                )\n\n                mock_connection = MagicMock()\n                mock_connection.host = \"192.168.1.100\"\n                mock_connection.port = 6379\n                mock_connection.db = 3\n\n                with patch.object(\n                    cluster, \"get_redis_connection\", return_value=mock_redis_conn\n                ):\n                    with patch(\n                        \"redis.cluster.get_connection\", return_value=mock_connection\n                    ):\n                        with patch(\n                            \"redis.cluster.record_operation_duration\"\n                        ) as mock_record:\n                            with pytest.raises(RedisConnectionError) as exc_info:\n                                cluster.execute_command(\n                                    \"GET\", \"key\", target_nodes=target_node\n                                )\n\n                            assert hasattr(exc_info.value, \"connection\")\n                            assert exc_info.value.connection == mock_connection\n\n                            mock_record.assert_called_once()\n                            call_kwargs = mock_record.call_args.kwargs\n                            assert call_kwargs[\"command_name\"] == \"GET\"\n                            assert call_kwargs[\"server_address\"] == \"192.168.1.100\"\n                            assert call_kwargs[\"server_port\"] == 6379\n                            assert call_kwargs[\"db_namespace\"] == \"3\"\n                            assert isinstance(\n                                call_kwargs[\"error\"], RedisConnectionError\n                            )\n\n    def test_connection_error_uses_target_node_when_no_connection(self):\n        \"\"\"\n        Test that ConnectionError uses target_node when connection is not available.\n\n        When ConnectionError occurs DURING get_connection() (before it returns),\n        the connection variable is None. The code should fall back to using\n        target_node for metrics to provide valid host/port information.\n        \"\"\"\n        target_node = ClusterNode(host=\"10.0.0.50\", port=7001, server_type=\"primary\")\n\n        with patch(\"redis.cluster.NodesManager\") as MockNodesManager:\n            mock_nodes_manager = MagicMock()\n            mock_nodes_manager.initialize.return_value = None\n            mock_nodes_manager.default_node = target_node\n            mock_nodes_manager.move_node_to_end_of_cached_nodes = MagicMock()\n            MockNodesManager.return_value = mock_nodes_manager\n\n            with patch(\"redis.cluster.CommandsParser\"):\n                cluster = RedisCluster(host=\"127.0.0.1\", port=7000)\n\n                mock_redis_conn = MagicMock()\n\n                with patch.object(\n                    cluster, \"get_redis_connection\", return_value=mock_redis_conn\n                ):\n                    with patch(\n                        \"redis.cluster.get_connection\",\n                        side_effect=RedisConnectionError(\"Cannot connect\"),\n                    ):\n                        with patch(\n                            \"redis.cluster.record_operation_duration\"\n                        ) as mock_record:\n                            with pytest.raises(RedisConnectionError) as exc_info:\n                                cluster.execute_command(\n                                    \"GET\", \"key\", target_nodes=target_node\n                                )\n\n                            assert hasattr(exc_info.value, \"connection\")\n                            assert exc_info.value.connection == target_node\n\n                            mock_record.assert_called_once()\n                            call_kwargs = mock_record.call_args.kwargs\n                            assert call_kwargs[\"command_name\"] == \"GET\"\n                            assert call_kwargs[\"server_address\"] == \"10.0.0.50\"\n                            assert call_kwargs[\"server_port\"] == 7001\n                            assert call_kwargs[\"db_namespace\"] == \"0\"\n                            assert isinstance(\n                                call_kwargs[\"error\"], RedisConnectionError\n                            )\n\n    def test_response_error_uses_connection(self):\n        \"\"\"\n        Test that ResponseError uses the actual connection for metrics.\n\n        ResponseError typically occurs after get_connection() succeeds (during\n        parse_response), so we should have a valid connection for metrics.\n        \"\"\"\n        target_node = ClusterNode(host=\"127.0.0.1\", port=7000, server_type=\"primary\")\n\n        with patch(\"redis.cluster.NodesManager\") as MockNodesManager:\n            mock_nodes_manager = MagicMock()\n            mock_nodes_manager.initialize.return_value = None\n            mock_nodes_manager.default_node = target_node\n            MockNodesManager.return_value = mock_nodes_manager\n\n            with patch(\"redis.cluster.CommandsParser\"):\n                cluster = RedisCluster(host=\"127.0.0.1\", port=7000)\n\n                mock_redis_conn = MagicMock()\n                mock_redis_conn.parse_response.side_effect = ResponseError(\"WRONGTYPE\")\n\n                mock_connection = MagicMock()\n                mock_connection.host = \"172.16.0.10\"\n                mock_connection.port = 6380\n                mock_connection.db = 2\n\n                with patch.object(\n                    cluster, \"get_redis_connection\", return_value=mock_redis_conn\n                ):\n                    with patch(\n                        \"redis.cluster.get_connection\", return_value=mock_connection\n                    ):\n                        with patch(\n                            \"redis.cluster.record_operation_duration\"\n                        ) as mock_record:\n                            with pytest.raises(ResponseError) as exc_info:\n                                cluster.execute_command(\n                                    \"GET\", \"key\", target_nodes=target_node\n                                )\n\n                            assert hasattr(exc_info.value, \"connection\")\n                            assert exc_info.value.connection == mock_connection\n\n                            mock_record.assert_called_once()\n                            call_kwargs = mock_record.call_args.kwargs\n                            assert call_kwargs[\"command_name\"] == \"GET\"\n                            assert call_kwargs[\"server_address\"] == \"172.16.0.10\"\n                            assert call_kwargs[\"server_port\"] == 6380\n                            assert call_kwargs[\"db_namespace\"] == \"2\"\n                            assert isinstance(call_kwargs[\"error\"], ResponseError)\n\n    def test_max_connections_error_records_metrics_with_cluster_node(self):\n        \"\"\"\n        Test that MaxConnectionsError records metrics using ClusterNode info.\n\n        When MaxConnectionsError occurs, connection is None because we couldn't\n        get a connection from the pool. The code sets e.connection = target_node\n        and metrics should be recorded using the ClusterNode's host/port.\n        \"\"\"\n        target_node = ClusterNode(host=\"127.0.0.1\", port=7000, server_type=\"primary\")\n\n        assert not hasattr(target_node, \"db\")\n        assert hasattr(target_node, \"host\")\n        assert hasattr(target_node, \"port\")\n\n        with patch(\"redis.cluster.NodesManager\") as MockNodesManager:\n            mock_nodes_manager = MagicMock()\n            mock_nodes_manager.initialize.return_value = None\n            mock_nodes_manager.default_node = target_node\n            MockNodesManager.return_value = mock_nodes_manager\n\n            with patch(\"redis.cluster.CommandsParser\"):\n                cluster = RedisCluster(host=\"127.0.0.1\", port=7000)\n\n                mock_redis_conn = MagicMock()\n                with patch.object(\n                    cluster, \"get_redis_connection\", return_value=mock_redis_conn\n                ):\n                    with patch(\n                        \"redis.cluster.get_connection\",\n                        side_effect=MaxConnectionsError(\"Pool exhausted\"),\n                    ):\n                        with patch(\n                            \"redis.cluster.record_operation_duration\"\n                        ) as mock_record_duration:\n                            with pytest.raises(MaxConnectionsError):\n                                cluster.execute_command(\n                                    \"GET\", \"key\", target_nodes=target_node\n                                )\n\n                            mock_record_duration.assert_called()\n                            call_kwargs = mock_record_duration.call_args[1]\n                            assert call_kwargs[\"server_address\"] == \"127.0.0.1\"\n                            assert call_kwargs[\"server_port\"] == 7000\n                            assert call_kwargs[\"db_namespace\"] == \"0\"\n\n    def test_successful_command_records_metrics_with_connection_db(self):\n        \"\"\"\n        Test that successful command execution records metrics with Connection's db.\n\n        When a command succeeds, we have an actual Connection object which has\n        a db attribute. Verify the metrics use the actual db value.\n        \"\"\"\n        target_node = ClusterNode(host=\"127.0.0.1\", port=7000, server_type=\"primary\")\n\n        with patch(\"redis.cluster.NodesManager\") as MockNodesManager:\n            mock_nodes_manager = MagicMock()\n            mock_nodes_manager.initialize.return_value = None\n            mock_nodes_manager.default_node = target_node\n            MockNodesManager.return_value = mock_nodes_manager\n\n            with patch(\"redis.cluster.CommandsParser\"):\n                cluster = RedisCluster(host=\"127.0.0.1\", port=7000)\n\n                mock_redis_conn = MagicMock()\n                mock_redis_conn.parse_response.return_value = b\"value\"\n\n                mock_connection = MagicMock()\n                mock_connection.host = \"127.0.0.1\"\n                mock_connection.port = 7000\n                mock_connection.db = 5\n\n                with patch.object(\n                    cluster, \"get_redis_connection\", return_value=mock_redis_conn\n                ):\n                    with patch(\n                        \"redis.cluster.get_connection\", return_value=mock_connection\n                    ):\n                        with patch(\n                            \"redis.cluster.record_operation_duration\"\n                        ) as mock_record_duration:\n                            cluster.execute_command(\n                                \"GET\", \"key\", target_nodes=target_node\n                            )\n\n                            call_kwargs = mock_record_duration.call_args[1]\n                            assert call_kwargs[\"db_namespace\"] == \"5\"\n\n    def test_timeout_error_uses_target_node_for_metrics(self):\n        \"\"\"\n        Test that TimeoutError uses target_node for metrics when connection is None.\n\n        When TimeoutError occurs during get_connection(), connection is None.\n        The code uses target_node for metrics to provide valid host/port info.\n        \"\"\"\n        target_node = ClusterNode(host=\"10.0.0.100\", port=7003, server_type=\"primary\")\n\n        with patch(\"redis.cluster.NodesManager\") as MockNodesManager:\n            mock_nodes_manager = MagicMock()\n            mock_nodes_manager.initialize.return_value = None\n            mock_nodes_manager.default_node = target_node\n            mock_nodes_manager.move_node_to_end_of_cached_nodes = MagicMock()\n            MockNodesManager.return_value = mock_nodes_manager\n\n            with patch(\"redis.cluster.CommandsParser\"):\n                cluster = RedisCluster(host=\"127.0.0.1\", port=7000)\n\n                mock_redis_conn = MagicMock()\n                with patch.object(\n                    cluster, \"get_redis_connection\", return_value=mock_redis_conn\n                ):\n                    with patch(\n                        \"redis.cluster.get_connection\",\n                        side_effect=RedisTimeoutError(\"Timeout connecting to server\"),\n                    ):\n                        with patch(\n                            \"redis.cluster.record_operation_duration\"\n                        ) as mock_record:\n                            with pytest.raises(RedisTimeoutError) as exc_info:\n                                cluster.execute_command(\n                                    \"GET\", \"key\", target_nodes=target_node\n                                )\n\n                            assert \"Timeout\" in str(exc_info.value)\n\n                            mock_record.assert_called_once()\n                            call_kwargs = mock_record.call_args[1]\n                            assert call_kwargs[\"server_address\"] == \"10.0.0.100\"\n                            assert call_kwargs[\"server_port\"] == 7003\n                            assert call_kwargs[\"db_namespace\"] == \"0\"\n\n    def test_cluster_down_error_with_cluster_node_metrics(self):\n        \"\"\"\n        Test that ClusterDownError records metrics correctly when connection is None.\n\n        When ClusterDownError occurs before connection is established,\n        e.connection is set to target_node (ClusterNode), and metrics should\n        be recorded with valid host/port from target_node.\n        \"\"\"\n        target_node = ClusterNode(host=\"172.20.0.10\", port=7004, server_type=\"primary\")\n\n        with patch(\"redis.cluster.NodesManager\") as MockNodesManager:\n            mock_nodes_manager = MagicMock()\n            mock_nodes_manager.initialize.return_value = None\n            mock_nodes_manager.default_node = target_node\n            MockNodesManager.return_value = mock_nodes_manager\n\n            with patch(\"redis.cluster.CommandsParser\"):\n                cluster = RedisCluster(host=\"127.0.0.1\", port=7000)\n\n                mock_redis_conn = MagicMock()\n                with patch.object(\n                    cluster, \"get_redis_connection\", return_value=mock_redis_conn\n                ):\n                    with patch(\n                        \"redis.cluster.get_connection\",\n                        side_effect=ClusterDownError(\"CLUSTERDOWN\"),\n                    ):\n                        with patch(\n                            \"redis.cluster.record_operation_duration\"\n                        ) as mock_record:\n                            with pytest.raises(ClusterDownError):\n                                cluster.execute_command(\n                                    \"GET\", \"key\", target_nodes=target_node\n                                )\n\n                            mock_record.assert_called_once()\n                            call_kwargs = mock_record.call_args[1]\n                            assert call_kwargs[\"server_address\"] == \"172.20.0.10\"\n                            assert call_kwargs[\"server_port\"] == 7004\n                            assert call_kwargs[\"db_namespace\"] == \"0\"\n                            assert isinstance(call_kwargs[\"error\"], ClusterDownError)\n\n    def test_slot_not_covered_error_with_cluster_node_metrics(self):\n        \"\"\"\n        Test that SlotNotCoveredError records metrics correctly when connection is None.\n\n        When SlotNotCoveredError occurs before connection is established,\n        e.connection is set to target_node, and metrics should be recorded\n        with valid host/port from target_node.\n        \"\"\"\n        target_node = ClusterNode(host=\"172.20.0.20\", port=7005, server_type=\"primary\")\n\n        with patch(\"redis.cluster.NodesManager\") as MockNodesManager:\n            mock_nodes_manager = MagicMock()\n            mock_nodes_manager.initialize.return_value = None\n            mock_nodes_manager.default_node = target_node\n            MockNodesManager.return_value = mock_nodes_manager\n\n            with patch(\"redis.cluster.CommandsParser\"):\n                cluster = RedisCluster(host=\"127.0.0.1\", port=7000)\n\n                mock_redis_conn = MagicMock()\n                with patch.object(\n                    cluster, \"get_redis_connection\", return_value=mock_redis_conn\n                ):\n                    with patch(\n                        \"redis.cluster.get_connection\",\n                        side_effect=SlotNotCoveredError(\"Slot 1234 not covered\"),\n                    ):\n                        with patch(\n                            \"redis.cluster.record_operation_duration\"\n                        ) as mock_record:\n                            with pytest.raises(SlotNotCoveredError):\n                                cluster.execute_command(\n                                    \"GET\", \"key\", target_nodes=target_node\n                                )\n\n                            mock_record.assert_called_once()\n                            call_kwargs = mock_record.call_args[1]\n                            assert call_kwargs[\"server_address\"] == \"172.20.0.20\"\n                            assert call_kwargs[\"server_port\"] == 7005\n                            assert call_kwargs[\"db_namespace\"] == \"0\"\n                            assert isinstance(call_kwargs[\"error\"], SlotNotCoveredError)\n"
  },
  {
    "path": "tests/test_observability/test_config.py",
    "content": "\"\"\"\nUnit tests for redis.observability.config module.\n\nThese tests verify the OTelConfig class behavior including:\n- Default configuration values\n- Custom configuration via constructor parameters\n- Validation of configuration values\n- Command filtering (include/exclude lists)\n- Runtime configuration changes\n\"\"\"\n\nfrom redis.observability.config import OTelConfig, MetricGroup, TelemetryOption\n\n\nclass TestOTelConfigDefaults:\n    \"\"\"Tests for OTelConfig default values.\"\"\"\n\n    def test_default_enabled_telemetry(self):\n        \"\"\"Test that default telemetry is METRICS only.\"\"\"\n        config = OTelConfig()\n        assert config.enabled_telemetry == TelemetryOption.METRICS\n\n    def test_default_metric_groups(self):\n        \"\"\"Test that default metric groups are COMMAND, CONNECTION_BASIC, RESILIENCY.\"\"\"\n        config = OTelConfig()\n        expected = MetricGroup.CONNECTION_BASIC | MetricGroup.RESILIENCY\n        assert config.metric_groups == expected\n\n    def test_default_include_commands_is_none(self):\n        \"\"\"Test that include_commands is None by default.\"\"\"\n        config = OTelConfig()\n        assert config.include_commands is None\n\n    def test_default_exclude_commands_is_empty_set(self):\n        \"\"\"Test that exclude_commands is empty set by default.\"\"\"\n        config = OTelConfig()\n        assert config.exclude_commands == set()\n\n    def test_is_enabled_returns_true_by_default(self):\n        \"\"\"Test that is_enabled returns True with default config.\"\"\"\n        config = OTelConfig()\n        assert config.is_enabled() is True\n\n\nclass TestOTelConfigEnabledTelemetry:\n    \"\"\"Tests for enabled_telemetry configuration.\"\"\"\n\n    def test_single_telemetry_option(self):\n        \"\"\"Test setting a single telemetry option.\"\"\"\n        config = OTelConfig(enabled_telemetry=[TelemetryOption.METRICS])\n        assert config.enabled_telemetry == TelemetryOption.METRICS\n\n    def test_empty_telemetry_list_disables_all(self):\n        \"\"\"Test that empty telemetry list disables all telemetry.\"\"\"\n        config = OTelConfig(enabled_telemetry=[])\n        assert config.enabled_telemetry == TelemetryOption(0)\n        assert config.is_enabled() is False\n\n\nclass TestOTelConfigMetricGroups:\n    \"\"\"Tests for metric_groups configuration.\"\"\"\n\n    def test_single_metric_group(self):\n        \"\"\"Test setting a single metric group.\"\"\"\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND])\n        assert config.metric_groups == MetricGroup.COMMAND\n\n    def test_multiple_metric_groups(self):\n        \"\"\"Test setting multiple metric groups.\"\"\"\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND, MetricGroup.PUBSUB])\n        assert MetricGroup.COMMAND in config.metric_groups\n        assert MetricGroup.PUBSUB in config.metric_groups\n\n    def test_all_metric_groups(self):\n        \"\"\"Test setting all metric groups.\"\"\"\n        config = OTelConfig(\n            metric_groups=[\n                MetricGroup.RESILIENCY,\n                MetricGroup.CONNECTION_BASIC,\n                MetricGroup.CONNECTION_ADVANCED,\n                MetricGroup.COMMAND,\n                MetricGroup.CSC,\n                MetricGroup.STREAMING,\n                MetricGroup.PUBSUB,\n            ]\n        )\n        assert MetricGroup.RESILIENCY in config.metric_groups\n        assert MetricGroup.CONNECTION_BASIC in config.metric_groups\n        assert MetricGroup.CONNECTION_ADVANCED in config.metric_groups\n        assert MetricGroup.COMMAND in config.metric_groups\n        assert MetricGroup.CSC in config.metric_groups\n        assert MetricGroup.STREAMING in config.metric_groups\n        assert MetricGroup.PUBSUB in config.metric_groups\n\n    def test_empty_metric_groups_list(self):\n        \"\"\"Test that empty metric groups list results in no groups enabled.\"\"\"\n        config = OTelConfig(metric_groups=[])\n        assert config.metric_groups == MetricGroup(0)\n\n\nclass TestOTelConfigIncludeCommands:\n    \"\"\"Tests for include_commands configuration.\"\"\"\n\n    def test_include_commands_single(self):\n        \"\"\"Test include_commands with single command.\"\"\"\n        config = OTelConfig(include_commands=[\"GET\"])\n        assert config.include_commands == {\"GET\"}\n\n    def test_include_commands_multiple(self):\n        \"\"\"Test include_commands with multiple commands.\"\"\"\n        config = OTelConfig(include_commands=[\"GET\", \"SET\", \"DEL\"])\n        assert config.include_commands == {\"GET\", \"SET\", \"DEL\"}\n\n    def test_include_commands_empty_list(self):\n        \"\"\"Test include_commands with empty list results in empty set.\"\"\"\n        config = OTelConfig(include_commands=[])\n        assert config.include_commands is None\n\n\nclass TestOTelConfigExcludeCommands:\n    \"\"\"Tests for exclude_commands configuration.\"\"\"\n\n    def test_exclude_commands_single(self):\n        \"\"\"Test exclude_commands with single command.\"\"\"\n        config = OTelConfig(exclude_commands=[\"PING\"])\n        assert config.exclude_commands == {\"PING\"}\n\n    def test_exclude_commands_multiple(self):\n        \"\"\"Test exclude_commands with multiple commands.\"\"\"\n        config = OTelConfig(exclude_commands=[\"PING\", \"INFO\", \"DEBUG\"])\n        assert config.exclude_commands == {\"PING\", \"INFO\", \"DEBUG\"}\n\n    def test_exclude_commands_empty_list(self):\n        \"\"\"Test exclude_commands with empty list results in empty set.\"\"\"\n        config = OTelConfig(exclude_commands=[])\n        assert config.exclude_commands == set()\n\n\nclass TestOTelConfigShouldTrackCommand:\n    \"\"\"Tests for should_track_command method.\"\"\"\n\n    def test_should_track_command_default_tracks_all(self):\n        \"\"\"Test that all commands are tracked by default.\"\"\"\n        config = OTelConfig()\n        assert config.should_track_command(\"GET\") is True\n        assert config.should_track_command(\"SET\") is True\n        assert config.should_track_command(\"PING\") is True\n\n    def test_should_track_command_case_insensitive(self):\n        \"\"\"Test that command matching is case-insensitive.\"\"\"\n        config = OTelConfig(include_commands=[\"GET\"])\n        assert config.should_track_command(\"GET\") is True\n        assert config.should_track_command(\"get\") is True\n        assert config.should_track_command(\"Get\") is True\n\n    def test_should_track_command_with_include_list(self):\n        \"\"\"Test that only included commands are tracked.\"\"\"\n        config = OTelConfig(include_commands=[\"GET\", \"SET\"])\n        assert config.should_track_command(\"GET\") is True\n        assert config.should_track_command(\"SET\") is True\n        assert config.should_track_command(\"DEL\") is False\n        assert config.should_track_command(\"PING\") is False\n\n    def test_should_track_command_with_exclude_list(self):\n        \"\"\"Test that excluded commands are not tracked.\"\"\"\n        config = OTelConfig(exclude_commands=[\"PING\", \"INFO\"])\n        assert config.should_track_command(\"GET\") is True\n        assert config.should_track_command(\"SET\") is True\n        assert config.should_track_command(\"PING\") is False\n        assert config.should_track_command(\"INFO\") is False\n\n    def test_should_track_command_include_takes_precedence(self):\n        \"\"\"Test that include_commands takes precedence over exclude_commands.\"\"\"\n        # When include_commands is set, exclude_commands is ignored\n        config = OTelConfig(\n            include_commands=[\"GET\", \"SET\"],\n            exclude_commands=[\"GET\"],  # This should be ignored\n        )\n        assert config.should_track_command(\"GET\") is True\n        assert config.should_track_command(\"SET\") is True\n        assert config.should_track_command(\"DEL\") is False\n\n    def test_should_track_command_empty_include_tracks_all(self):\n        \"\"\"Test that empty include list tracks all commands.\"\"\"\n        config = OTelConfig(include_commands=[])\n        assert config.should_track_command(\"GET\") is True\n        assert config.should_track_command(\"SET\") is True\n\n\nclass TestOTelConfigRepr:\n    \"\"\"Tests for __repr__ method.\"\"\"\n\n    def test_repr_contains_enabled_telemetry(self):\n        \"\"\"Test that repr contains enabled_telemetry.\"\"\"\n        config = OTelConfig()\n        repr_str = repr(config)\n        assert \"enabled_telemetry\" in repr_str\n\n\nclass TestOTelConfigPrivacyControls:\n    \"\"\"Tests for privacy control configuration options.\"\"\"\n\n    def test_default_hide_pubsub_channel_names_is_false(self):\n        \"\"\"Test that hide_pubsub_channel_names is False by default.\"\"\"\n        config = OTelConfig()\n        assert config.hide_pubsub_channel_names is False\n\n    def test_default_hide_stream_names_is_false(self):\n        \"\"\"Test that hide_stream_names is False by default.\"\"\"\n        config = OTelConfig()\n        assert config.hide_stream_names is False\n\n    def test_hide_pubsub_channel_names_can_be_enabled(self):\n        \"\"\"Test that hide_pubsub_channel_names can be set to True.\"\"\"\n        config = OTelConfig(hide_pubsub_channel_names=True)\n        assert config.hide_pubsub_channel_names is True\n\n    def test_hide_stream_names_can_be_enabled(self):\n        \"\"\"Test that hide_stream_names can be set to True.\"\"\"\n        config = OTelConfig(hide_stream_names=True)\n        assert config.hide_stream_names is True\n\n    def test_both_privacy_controls_can_be_enabled(self):\n        \"\"\"Test that both privacy controls can be enabled together.\"\"\"\n        config = OTelConfig(\n            hide_pubsub_channel_names=True,\n            hide_stream_names=True,\n        )\n        assert config.hide_pubsub_channel_names is True\n        assert config.hide_stream_names is True\n\n\nclass TestOTelConfigHistogramBuckets:\n    \"\"\"Tests for custom histogram bucket boundary configuration.\"\"\"\n\n    def test_default_operation_duration_buckets(self):\n        \"\"\"Test that default operation duration buckets are set correctly.\"\"\"\n        from redis.observability.config import default_operation_duration_buckets\n\n        config = OTelConfig()\n        assert config.buckets_operation_duration == default_operation_duration_buckets()\n\n    def test_default_stream_processing_duration_buckets(self):\n        \"\"\"Test that default stream processing duration buckets are set correctly.\"\"\"\n        from redis.observability.config import default_histogram_buckets\n\n        config = OTelConfig()\n        assert config.buckets_stream_processing_duration == default_histogram_buckets()\n\n    def test_default_connection_create_time_buckets(self):\n        \"\"\"Test that default connection create time buckets are set correctly.\"\"\"\n        from redis.observability.config import default_histogram_buckets\n\n        config = OTelConfig()\n        assert config.buckets_connection_create_time == default_histogram_buckets()\n\n    def test_default_connection_wait_time_buckets(self):\n        \"\"\"Test that default connection wait time buckets are set correctly.\"\"\"\n        from redis.observability.config import default_histogram_buckets\n\n        config = OTelConfig()\n        assert config.buckets_connection_wait_time == default_histogram_buckets()\n\n    def test_custom_operation_duration_buckets(self):\n        \"\"\"Test that custom operation duration buckets can be set.\"\"\"\n        custom_buckets = [0.001, 0.01, 0.1, 1.0, 10.0]\n        config = OTelConfig(buckets_operation_duration=custom_buckets)\n        assert config.buckets_operation_duration == custom_buckets\n\n    def test_custom_stream_processing_duration_buckets(self):\n        \"\"\"Test that custom stream processing duration buckets can be set.\"\"\"\n        custom_buckets = [0.01, 0.1, 1.0, 5.0]\n        config = OTelConfig(buckets_stream_processing_duration=custom_buckets)\n        assert config.buckets_stream_processing_duration == custom_buckets\n\n    def test_custom_connection_create_time_buckets(self):\n        \"\"\"Test that custom connection create time buckets can be set.\"\"\"\n        custom_buckets = [0.001, 0.005, 0.01, 0.05, 0.1]\n        config = OTelConfig(buckets_connection_create_time=custom_buckets)\n        assert config.buckets_connection_create_time == custom_buckets\n\n    def test_custom_connection_wait_time_buckets(self):\n        \"\"\"Test that custom connection wait time buckets can be set.\"\"\"\n        custom_buckets = [0.0001, 0.001, 0.01, 0.1]\n        config = OTelConfig(buckets_connection_wait_time=custom_buckets)\n        assert config.buckets_connection_wait_time == custom_buckets\n\n    def test_all_custom_buckets_can_be_set_together(self):\n        \"\"\"Test that all custom bucket configurations can be set together.\"\"\"\n        op_buckets = [0.001, 0.01, 0.1]\n        stream_buckets = [0.01, 0.1, 1.0]\n        create_buckets = [0.001, 0.005, 0.01]\n        wait_buckets = [0.0001, 0.001, 0.01]\n\n        config = OTelConfig(\n            buckets_operation_duration=op_buckets,\n            buckets_stream_processing_duration=stream_buckets,\n            buckets_connection_create_time=create_buckets,\n            buckets_connection_wait_time=wait_buckets,\n        )\n\n        assert config.buckets_operation_duration == op_buckets\n        assert config.buckets_stream_processing_duration == stream_buckets\n        assert config.buckets_connection_create_time == create_buckets\n        assert config.buckets_connection_wait_time == wait_buckets\n\n    def test_empty_buckets_list_is_allowed(self):\n        \"\"\"Test that empty bucket lists are allowed (OTel SDK will use defaults).\"\"\"\n        config = OTelConfig(buckets_operation_duration=[])\n        assert config.buckets_operation_duration == []\n\n    def test_single_bucket_boundary_is_allowed(self):\n        \"\"\"Test that a single bucket boundary is allowed.\"\"\"\n        config = OTelConfig(buckets_operation_duration=[1.0])\n        assert config.buckets_operation_duration == [1.0]\n\n\nclass TestDefaultBucketFunctions:\n    \"\"\"Tests for default bucket boundary functions.\"\"\"\n\n    def test_default_operation_duration_buckets_returns_sequence(self):\n        \"\"\"Test that default_operation_duration_buckets returns a sequence.\"\"\"\n        from redis.observability.config import default_operation_duration_buckets\n\n        buckets = default_operation_duration_buckets()\n        assert isinstance(buckets, (list, tuple))\n        assert len(buckets) > 0\n\n    def test_default_histogram_buckets_returns_sequence(self):\n        \"\"\"Test that default_histogram_buckets returns a sequence.\"\"\"\n        from redis.observability.config import default_histogram_buckets\n\n        buckets = default_histogram_buckets()\n        assert isinstance(buckets, (list, tuple))\n        assert len(buckets) > 0\n\n    def test_default_operation_duration_buckets_are_sorted(self):\n        \"\"\"Test that default operation duration buckets are in ascending order.\"\"\"\n        from redis.observability.config import default_operation_duration_buckets\n\n        buckets = list(default_operation_duration_buckets())\n        assert buckets == sorted(buckets)\n\n    def test_default_histogram_buckets_are_sorted(self):\n        \"\"\"Test that default histogram buckets are in ascending order.\"\"\"\n        from redis.observability.config import default_histogram_buckets\n\n        buckets = list(default_histogram_buckets())\n        assert buckets == sorted(buckets)\n\n    def test_default_operation_duration_buckets_are_positive(self):\n        \"\"\"Test that all default operation duration bucket values are positive.\"\"\"\n        from redis.observability.config import default_operation_duration_buckets\n\n        buckets = default_operation_duration_buckets()\n        assert all(b > 0 for b in buckets)\n\n    def test_default_histogram_buckets_are_positive(self):\n        \"\"\"Test that all default histogram bucket values are positive.\"\"\"\n        from redis.observability.config import default_histogram_buckets\n\n        buckets = default_histogram_buckets()\n        assert all(b > 0 for b in buckets)\n\n\nclass TestMetricGroupEnum:\n    \"\"\"Tests for MetricGroup IntFlag enum.\"\"\"\n\n    def test_metric_group_values_are_unique(self):\n        \"\"\"Test that all MetricGroup values are unique powers of 2.\"\"\"\n        values = [\n            MetricGroup.RESILIENCY,\n            MetricGroup.CONNECTION_BASIC,\n            MetricGroup.CONNECTION_ADVANCED,\n            MetricGroup.COMMAND,\n            MetricGroup.CSC,\n            MetricGroup.STREAMING,\n            MetricGroup.PUBSUB,\n        ]\n        # Each value should be a power of 2\n        for value in values:\n            assert value & (value - 1) == 0  # Power of 2 check\n\n    def test_metric_group_can_be_combined(self):\n        \"\"\"Test that MetricGroup values can be combined with bitwise OR.\"\"\"\n        combined = MetricGroup.COMMAND | MetricGroup.PUBSUB\n        assert MetricGroup.COMMAND in combined\n        assert MetricGroup.PUBSUB in combined\n        assert MetricGroup.STREAMING not in combined\n\n    def test_metric_group_membership_check(self):\n        \"\"\"Test checking membership in combined MetricGroup.\"\"\"\n        combined = MetricGroup.RESILIENCY | MetricGroup.CONNECTION_BASIC\n        assert bool(combined & MetricGroup.RESILIENCY)\n        assert bool(combined & MetricGroup.CONNECTION_BASIC)\n        assert not bool(combined & MetricGroup.COMMAND)\n"
  },
  {
    "path": "tests/test_observability/test_metrics_connection_attributes.py",
    "content": "\"\"\"\nUnit tests for metrics recording with connections that don't have host/port attributes.\n\nThese tests verify that the changes to use getattr() for accessing host and port\nattributes work correctly with connections that don't have these attributes.\n\"\"\"\n\nimport pytest\nimport os\nfrom unittest.mock import MagicMock, patch\nfrom redis.connection import ConnectionInterface, ConnectionPool\nfrom redis.observability import recorder\nfrom redis.observability.config import OTelConfig, MetricGroup\nfrom redis.observability.metrics import RedisMetricsCollector\nfrom redis import Redis\nfrom redis.retry import Retry\nfrom redis.backoff import NoBackoff\n\n\nclass MockConnectionWithoutHostPort(ConnectionInterface):\n    \"\"\"\n    A mock connection class that implements ConnectionInterface but doesn't have\n    host and port attributes. This simulates connections like UnixDomainSocketConnection\n    or other custom connection types.\n    \"\"\"\n\n    def __init__(self, db=0, **kwargs):\n        self.db = db\n        self._sock = None\n        self.kwargs = kwargs\n        # Add required attributes that connections need\n        self.pid = os.getpid()\n        self.retry = Retry(NoBackoff(), 0)  # No retries for testing\n        self.encoder = None\n        self.client_name = None\n\n    def repr_pieces(self):\n        return [(\"db\", self.db)]\n\n    def register_connect_callback(self, callback):\n        pass\n\n    def deregister_connect_callback(self, callback):\n        pass\n\n    def set_parser(self, parser_class):\n        pass\n\n    def get_protocol(self):\n        return 2\n\n    def connect(self):\n        pass\n\n    def on_connect(self):\n        pass\n\n    def disconnect(self, *args, **kwargs):\n        pass\n\n    def check_health(self):\n        return True\n\n    def send_packed_command(self, command, check_health=True):\n        pass\n\n    def send_command(self, *args, **kwargs):\n        pass\n\n    def can_read(self, timeout=0):\n        return False\n\n    def read_response(\n        self, disable_decoding=False, *, disconnect_on_error=True, push_request=False\n    ):\n        return \"OK\"\n\n    def pack_command(self, *args):\n        return b\"\"\n\n    def pack_commands(self, commands):\n        return b\"\"\n\n    @property\n    def handshake_metadata(self):\n        return {}\n\n    def set_re_auth_token(self, token):\n        pass\n\n    def re_auth(self):\n        pass\n\n    def mark_for_reconnect(self):\n        pass\n\n    def should_reconnect(self):\n        return False\n\n    def reset_should_reconnect(self):\n        pass\n\n\nclass TestConnectionAttributesWithoutHostPort:\n    \"\"\"Tests for metrics recording with connections lacking host/port attributes.\"\"\"\n\n    @pytest.fixture\n    def mock_meter(self):\n        \"\"\"Create a mock meter for testing.\"\"\"\n        return MagicMock()\n\n    @pytest.fixture\n    def setup_client_and_pool(self, mock_meter):\n        \"\"\"Setup common test infrastructure: pool, client, collector.\"\"\"\n        recorder.reset_collector()\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND, MetricGroup.PUBSUB])\n\n        with patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        # Create a ConnectionPool with MockConnectionWithoutHostPort as connection_class\n        # Similar to how UnixDomainSocketConnection is used\n        pool = ConnectionPool(\n            connection_class=MockConnectionWithoutHostPort,\n            db=0,\n        )\n\n        with patch.object(recorder, \"_get_or_create_collector\", return_value=collector):\n            client = Redis(connection_pool=pool)\n            yield client, pool\n\n        # Cleanup\n        pool.disconnect()\n\n    def test_client_execute_command_with_connection_without_host_port(\n        self, setup_client_and_pool\n    ):\n        \"\"\"Test Redis client execute_command with connection without host/port.\"\"\"\n        client, pool = setup_client_and_pool\n\n        # Get a connection from the pool\n        conn = pool.get_connection()\n\n        try:\n            # Mock the connection's methods to simulate a successful command\n            with patch.object(conn, \"read_response\", return_value=b\"OK\"):\n                with patch.object(conn, \"send_packed_command\"):\n                    # This should not raise an AttributeError even though connection has no host/port\n                    # The getattr() calls in client.py should handle missing attributes gracefully\n                    client.execute_command(\"PING\")\n        finally:\n            pool.release(conn)\n\n    def test_pubsub_execute_command_with_connection_without_host_port(\n        self, setup_client_and_pool\n    ):\n        \"\"\"Test PubSub execute_command with connection without host/port.\"\"\"\n        client, pool = setup_client_and_pool\n        pubsub = client.pubsub()\n\n        # Get a connection from the pool\n        conn = pool.get_connection()\n        pubsub.connection = conn\n\n        try:\n            # Mock the connection's methods to simulate a successful subscribe\n            with patch.object(\n                conn, \"read_response\", return_value=[b\"subscribe\", b\"test\", 1]\n            ):\n                with patch.object(conn, \"send_command\"):\n                    # This should not raise an AttributeError even though connection has no host/port\n                    pubsub.execute_command(\"SUBSCRIBE\", \"test\")\n        finally:\n            pool.release(conn)\n            pubsub.close()\n\n    def test_pipeline_execute_with_connection_without_host_port(\n        self, setup_client_and_pool\n    ):\n        \"\"\"Test Pipeline execute with connection without host/port.\"\"\"\n        client, pool = setup_client_and_pool\n        pipe = client.pipeline()\n\n        # Get a connection from the pool\n        conn = pool.get_connection()\n\n        try:\n            # Add some commands to the pipeline\n            pipe.set(\"key\", \"value\")\n            pipe.get(\"key\")\n\n            # Mock the connection's methods to simulate successful pipeline execution\n            # Use a callable to avoid StopIteration when the mock is called multiple times\n            call_count = [0]\n\n            def mock_read_response(*args, **kwargs):\n                call_count[0] += 1\n                # First call is for MULTI, second is for EXEC which returns results\n                if call_count[0] == 1:\n                    return b\"OK\"  # MULTI response\n                else:\n                    return [b\"OK\", b\"value\"]  # EXEC response with command results\n\n            with patch.object(conn, \"read_response\", side_effect=mock_read_response):\n                with patch.object(conn, \"send_packed_command\"):\n                    with patch.object(pool, \"get_connection\", return_value=conn):\n                        # This should not raise an AttributeError even though connection has no host/port\n                        pipe.execute()\n        finally:\n            pool.release(conn)\n"
  },
  {
    "path": "tests/test_observability/test_provider.py",
    "content": "\"\"\"\nUnit tests for redis.observability.providers module.\n\nThese tests verify the OTelProviderManager and ObservabilityInstance classes including:\n- Provider initialization and configuration\n- MeterProvider retrieval and validation\n- Shutdown and force flush operations\n- Singleton pattern behavior\n- Context manager support\n\"\"\"\n\nimport pytest\nfrom unittest.mock import Mock, patch\n\nfrom redis.observability.config import OTelConfig, TelemetryOption\nfrom redis.observability.providers import (\n    OTelProviderManager,\n    ObservabilityInstance,\n    get_observability_instance,\n)\n\n\nclass TestOTelProviderManagerInit:\n    \"\"\"Tests for OTelProviderManager initialization.\"\"\"\n\n    def test_init_with_config(self):\n        \"\"\"Test that OTelProviderManager initializes with config.\"\"\"\n        config = OTelConfig()\n        manager = OTelProviderManager(config)\n\n        assert manager.config is config\n        assert manager._meter_provider is None\n\n    def test_init_with_custom_config(self):\n        \"\"\"Test initialization with custom config.\"\"\"\n        config = OTelConfig(\n            enabled_telemetry=[TelemetryOption.METRICS],\n            exclude_commands=[\"DEBUG\"],\n        )\n        manager = OTelProviderManager(config)\n\n        assert \"DEBUG\" in manager.config.exclude_commands\n\n\nclass TestOTelProviderManagerGetMeterProvider:\n    \"\"\"Tests for get_meter_provider method.\"\"\"\n\n    def test_get_meter_provider_returns_none_when_disabled(self):\n        \"\"\"Test that get_meter_provider returns None when telemetry is disabled.\"\"\"\n        config = OTelConfig(enabled_telemetry=[])\n        manager = OTelProviderManager(config)\n\n        result = manager.get_meter_provider()\n\n        assert result is None\n\n    def test_get_meter_provider_raises_when_no_global_provider(self):\n        \"\"\"Test that get_meter_provider raises RuntimeError when no global provider is set.\"\"\"\n        config = OTelConfig()\n        manager = OTelProviderManager(config)\n\n        with patch(\"opentelemetry.metrics\") as mock_metrics:\n            from opentelemetry.metrics import NoOpMeterProvider\n\n            mock_metrics.get_meter_provider.return_value = NoOpMeterProvider()\n\n            with pytest.raises(RuntimeError) as exc_info:\n                manager.get_meter_provider()\n\n            assert \"no global MeterProvider is configured\" in str(exc_info.value)\n\n    def test_get_meter_provider_returns_global_provider(self):\n        \"\"\"Test that get_meter_provider returns the global MeterProvider.\"\"\"\n        config = OTelConfig()\n        manager = OTelProviderManager(config)\n\n        mock_provider = Mock()\n        # Make sure it's not a NoOpMeterProvider\n        mock_provider.__class__.__name__ = \"MeterProvider\"\n\n        with patch(\"opentelemetry.metrics\") as mock_metrics:\n            mock_metrics.get_meter_provider.return_value = mock_provider\n            result = manager.get_meter_provider()\n\n        assert result is mock_provider\n\n    def test_get_meter_provider_caches_provider(self):\n        \"\"\"Test that get_meter_provider caches the provider.\"\"\"\n        config = OTelConfig()\n        manager = OTelProviderManager(config)\n\n        mock_provider = Mock()\n\n        with patch(\"opentelemetry.metrics\") as mock_metrics:\n            mock_metrics.get_meter_provider.return_value = mock_provider\n\n            # Call twice\n            result1 = manager.get_meter_provider()\n            result2 = manager.get_meter_provider()\n\n        # Should only call get_meter_provider once due to caching\n        assert mock_metrics.get_meter_provider.call_count == 1\n        assert result1 is result2\n\n\nclass TestOTelProviderManagerShutdown:\n    \"\"\"Tests for shutdown method.\"\"\"\n\n    def test_shutdown_calls_force_flush(self):\n        \"\"\"Test that shutdown calls force_flush.\"\"\"\n        config = OTelConfig()\n        manager = OTelProviderManager(config)\n\n        with patch.object(manager, \"force_flush\", return_value=True) as mock_flush:\n            result = manager.shutdown(timeout_millis=5000)\n\n        mock_flush.assert_called_once_with(timeout_millis=5000)\n        assert result is True\n\n    def test_shutdown_with_default_timeout(self):\n        \"\"\"Test shutdown with default timeout.\"\"\"\n        config = OTelConfig()\n        manager = OTelProviderManager(config)\n\n        with patch.object(manager, \"force_flush\", return_value=True) as mock_flush:\n            manager.shutdown()\n\n        mock_flush.assert_called_once_with(timeout_millis=30000)\n\n\nclass TestOTelProviderManagerForceFlush:\n    \"\"\"Tests for force_flush method.\"\"\"\n\n    def test_force_flush_returns_true_when_no_provider(self):\n        \"\"\"Test that force_flush returns True when no provider is set.\"\"\"\n        config = OTelConfig()\n        manager = OTelProviderManager(config)\n\n        result = manager.force_flush()\n\n        assert result is True\n\n    def test_force_flush_calls_provider_force_flush(self):\n        \"\"\"Test that force_flush calls the provider's force_flush.\"\"\"\n        config = OTelConfig()\n        manager = OTelProviderManager(config)\n\n        mock_provider = Mock()\n        manager._meter_provider = mock_provider\n\n        result = manager.force_flush(timeout_millis=5000)\n\n        mock_provider.force_flush.assert_called_once_with(timeout_millis=5000)\n        assert result is True\n\n    def test_force_flush_returns_false_on_exception(self):\n        \"\"\"Test that force_flush returns False when an exception occurs.\"\"\"\n        config = OTelConfig()\n        manager = OTelProviderManager(config)\n\n        mock_provider = Mock()\n        mock_provider.force_flush.side_effect = Exception(\"Flush failed\")\n        manager._meter_provider = mock_provider\n\n        result = manager.force_flush()\n\n        assert result is False\n\n\nclass TestOTelProviderManagerContextManager:\n    \"\"\"Tests for context manager support.\"\"\"\n\n    def test_context_manager_enter_returns_self(self):\n        \"\"\"Test that __enter__ returns self.\"\"\"\n        config = OTelConfig()\n        manager = OTelProviderManager(config)\n\n        result = manager.__enter__()\n\n        assert result is manager\n\n    def test_context_manager_exit_calls_shutdown(self):\n        \"\"\"Test that __exit__ calls shutdown.\"\"\"\n        config = OTelConfig()\n        manager = OTelProviderManager(config)\n\n        with patch.object(manager, \"shutdown\") as mock_shutdown:\n            manager.__exit__(None, None, None)\n\n        mock_shutdown.assert_called_once()\n\n    def test_context_manager_with_statement(self):\n        \"\"\"Test using OTelProviderManager with 'with' statement.\"\"\"\n        config = OTelConfig()\n\n        with patch.object(OTelProviderManager, \"shutdown\") as mock_shutdown:\n            with OTelProviderManager(config) as manager:\n                assert manager.config is config\n\n            mock_shutdown.assert_called_once()\n\n\nclass TestOTelProviderManagerRepr:\n    \"\"\"Tests for __repr__ method.\"\"\"\n\n    def test_repr_contains_config(self):\n        \"\"\"Test that repr contains config information.\"\"\"\n        config = OTelConfig()\n        manager = OTelProviderManager(config)\n\n        repr_str = repr(manager)\n\n        assert \"OTelProviderManager\" in repr_str\n        assert \"config=\" in repr_str\n\n\nclass TestObservabilityInstanceInit:\n    \"\"\"Tests for ObservabilityInstance initialization.\"\"\"\n\n    def test_init_creates_empty_instance(self):\n        \"\"\"Test that ObservabilityInstance initializes with no provider manager.\"\"\"\n        instance = ObservabilityInstance()\n\n        assert instance._provider_manager is None\n\n    def test_init_method_creates_provider_manager(self):\n        \"\"\"Test that init() creates a provider manager.\"\"\"\n        instance = ObservabilityInstance()\n        config = OTelConfig()\n\n        result = instance.init(config)\n\n        assert instance._provider_manager is not None\n        assert instance._provider_manager.config is config\n        assert result is instance  # Returns self for chaining\n\n    def test_init_method_replaces_existing_manager(self):\n        \"\"\"Test that init() replaces existing provider manager.\"\"\"\n        instance = ObservabilityInstance()\n        config1 = OTelConfig(exclude_commands=[\"DEBUG\"])\n        config2 = OTelConfig(exclude_commands=[\"SLOWLOG\"])\n\n        instance.init(config1)\n        old_manager = instance._provider_manager\n\n        with patch.object(old_manager, \"shutdown\") as mock_shutdown:\n            instance.init(config2)\n\n        mock_shutdown.assert_called_once()\n        assert \"SLOWLOG\" in instance._provider_manager.config.exclude_commands\n\n\nclass TestObservabilityInstanceIsEnabled:\n    \"\"\"Tests for is_enabled method.\"\"\"\n\n    def test_is_enabled_returns_false_when_not_initialized(self):\n        \"\"\"Test that is_enabled returns False when not initialized.\"\"\"\n        instance = ObservabilityInstance()\n\n        assert instance.is_enabled() is False\n\n    def test_is_enabled_returns_true_when_initialized_and_enabled(self):\n        \"\"\"Test that is_enabled returns True when initialized with enabled config.\"\"\"\n        instance = ObservabilityInstance()\n        config = OTelConfig()\n\n        instance.init(config)\n\n        assert instance.is_enabled() is True\n\n    def test_is_enabled_returns_false_when_telemetry_disabled(self):\n        \"\"\"Test that is_enabled returns False when telemetry is disabled.\"\"\"\n        instance = ObservabilityInstance()\n        config = OTelConfig(enabled_telemetry=[])\n\n        instance.init(config)\n\n        assert instance.is_enabled() is False\n\n\nclass TestObservabilityInstanceGetProviderManager:\n    \"\"\"Tests for get_provider_manager method.\"\"\"\n\n    def test_get_provider_manager_returns_none_when_not_initialized(self):\n        \"\"\"Test that get_provider_manager returns None when not initialized.\"\"\"\n        instance = ObservabilityInstance()\n\n        assert instance.get_provider_manager() is None\n\n    def test_get_provider_manager_returns_manager_when_initialized(self):\n        \"\"\"Test that get_provider_manager returns the manager when initialized.\"\"\"\n        instance = ObservabilityInstance()\n        config = OTelConfig()\n\n        instance.init(config)\n\n        manager = instance.get_provider_manager()\n        assert manager is not None\n        assert manager.config is config\n\n\nclass TestObservabilityInstanceShutdown:\n    \"\"\"Tests for shutdown method.\"\"\"\n\n    def test_shutdown_returns_true_when_not_initialized(self):\n        \"\"\"Test that shutdown returns True when not initialized.\"\"\"\n        instance = ObservabilityInstance()\n\n        result = instance.shutdown()\n\n        assert result is True\n\n    def test_shutdown_calls_provider_manager_shutdown(self):\n        \"\"\"Test that shutdown calls the provider manager's shutdown.\"\"\"\n        instance = ObservabilityInstance()\n        config = OTelConfig()\n        instance.init(config)\n\n        with patch.object(\n            instance._provider_manager, \"shutdown\", return_value=True\n        ) as mock_shutdown:\n            result = instance.shutdown(timeout_millis=5000)\n\n        mock_shutdown.assert_called_once_with(5000)\n        assert result is True\n        assert instance._provider_manager is None\n\n    def test_shutdown_clears_provider_manager(self):\n        \"\"\"Test that shutdown clears the provider manager.\"\"\"\n        instance = ObservabilityInstance()\n        config = OTelConfig()\n        instance.init(config)\n\n        with patch.object(instance._provider_manager, \"shutdown\", return_value=True):\n            instance.shutdown()\n\n        assert instance._provider_manager is None\n\n\nclass TestObservabilityInstanceForceFlush:\n    \"\"\"Tests for force_flush method.\"\"\"\n\n    def test_force_flush_returns_true_when_not_initialized(self):\n        \"\"\"Test that force_flush returns True when not initialized.\"\"\"\n        instance = ObservabilityInstance()\n\n        result = instance.force_flush()\n\n        assert result is True\n\n    def test_force_flush_calls_provider_manager_force_flush(self):\n        \"\"\"Test that force_flush calls the provider manager's force_flush.\"\"\"\n        instance = ObservabilityInstance()\n        config = OTelConfig()\n        instance.init(config)\n\n        with patch.object(\n            instance._provider_manager, \"force_flush\", return_value=True\n        ) as mock_flush:\n            result = instance.force_flush(timeout_millis=5000)\n\n        mock_flush.assert_called_once_with(5000)\n        assert result is True\n\n\nclass TestGetObservabilityInstance:\n    \"\"\"Tests for get_observability_instance function.\"\"\"\n\n    def test_get_observability_instance_returns_singleton(self):\n        \"\"\"Test that get_observability_instance returns the same instance.\"\"\"\n        # Reset the global instance for this test\n        import redis.observability.providers as providers\n\n        original_instance = providers._observability_instance\n\n        try:\n            providers._observability_instance = None\n\n            instance1 = get_observability_instance()\n            instance2 = get_observability_instance()\n\n            assert instance1 is instance2\n        finally:\n            # Restore original instance\n            providers._observability_instance = original_instance\n\n    def test_get_observability_instance_creates_new_if_none(self):\n        \"\"\"Test that get_observability_instance creates a new instance if none exists.\"\"\"\n        import redis.observability.providers as providers\n\n        original_instance = providers._observability_instance\n\n        try:\n            providers._observability_instance = None\n\n            instance = get_observability_instance()\n\n            assert instance is not None\n            assert isinstance(instance, ObservabilityInstance)\n        finally:\n            providers._observability_instance = original_instance\n\n    def test_get_observability_instance_returns_existing(self):\n        \"\"\"Test that get_observability_instance returns existing instance.\"\"\"\n        import redis.observability.providers as providers\n\n        original_instance = providers._observability_instance\n\n        try:\n            existing = ObservabilityInstance()\n            providers._observability_instance = existing\n\n            instance = get_observability_instance()\n\n            assert instance is existing\n        finally:\n            providers._observability_instance = original_instance\n\n\nclass TestObservabilityInstanceIntegration:\n    \"\"\"Integration tests for ObservabilityInstance.\"\"\"\n\n    def test_full_lifecycle(self):\n        \"\"\"Test full lifecycle: init -> use -> shutdown.\"\"\"\n        instance = ObservabilityInstance()\n        config = OTelConfig()\n\n        # Initialize\n        result = instance.init(config)\n        assert result is instance\n        assert instance.is_enabled() is True\n\n        # Get provider manager\n        manager = instance.get_provider_manager()\n        assert manager is not None\n\n        # Force flush (with mocked provider)\n        with patch.object(manager, \"force_flush\", return_value=True):\n            flush_result = instance.force_flush()\n            assert flush_result is True\n\n        # Shutdown\n        with patch.object(manager, \"shutdown\", return_value=True):\n            shutdown_result = instance.shutdown()\n            assert shutdown_result is True\n\n        assert instance._provider_manager is None\n        assert instance.is_enabled() is False\n\n    def test_reinitialize_after_shutdown(self):\n        \"\"\"Test that instance can be reinitialized after shutdown.\"\"\"\n        instance = ObservabilityInstance()\n        config1 = OTelConfig(exclude_commands=[\"DEBUG\"])\n        config2 = OTelConfig(exclude_commands=[\"SLOWLOG\"])\n\n        # First initialization\n        instance.init(config1)\n        with patch.object(instance._provider_manager, \"shutdown\", return_value=True):\n            instance.shutdown()\n\n        # Second initialization\n        instance.init(config2)\n\n        assert instance.is_enabled() is True\n        assert \"SLOWLOG\" in instance._provider_manager.config.exclude_commands\n"
  },
  {
    "path": "tests/test_observability/test_public_api.py",
    "content": "\"\"\"\nUnit tests for redis.observability public API exports.\n\nThese tests verify that all symbols exported from redis.observability\nare correctly re-exported and match the original implementations.\n\"\"\"\n\n\nclass TestPublicAPIExports:\n    \"\"\"Tests for public API exports from redis.observability.\"\"\"\n\n    def test_otel_config_reexport(self):\n        \"\"\"Test that OTelConfig is correctly re-exported.\"\"\"\n        from redis.observability import OTelConfig\n        from redis.observability.config import OTelConfig as OriginalOTelConfig\n\n        assert OTelConfig is OriginalOTelConfig\n\n    def test_metric_group_reexport(self):\n        \"\"\"Test that MetricGroup is correctly re-exported.\"\"\"\n        from redis.observability import MetricGroup\n        from redis.observability.config import MetricGroup as OriginalMetricGroup\n\n        assert MetricGroup is OriginalMetricGroup\n\n    def test_telemetry_option_reexport(self):\n        \"\"\"Test that TelemetryOption is correctly re-exported.\"\"\"\n        from redis.observability import TelemetryOption\n        from redis.observability.config import (\n            TelemetryOption as OriginalTelemetryOption,\n        )\n\n        assert TelemetryOption is OriginalTelemetryOption\n\n    def test_observability_instance_reexport(self):\n        \"\"\"Test that ObservabilityInstance is correctly re-exported.\"\"\"\n        from redis.observability import ObservabilityInstance\n        from redis.observability.providers import (\n            ObservabilityInstance as OriginalObservabilityInstance,\n        )\n\n        assert ObservabilityInstance is OriginalObservabilityInstance\n\n    def test_get_observability_instance_reexport(self):\n        \"\"\"Test that get_observability_instance is correctly re-exported.\"\"\"\n        from redis.observability import get_observability_instance\n        from redis.observability.providers import (\n            get_observability_instance as original_get_observability_instance,\n        )\n\n        assert get_observability_instance is original_get_observability_instance\n\n    def test_reset_observability_instance_reexport(self):\n        \"\"\"Test that reset_observability_instance is correctly re-exported.\"\"\"\n        from redis.observability import reset_observability_instance\n        from redis.observability.providers import (\n            reset_observability_instance as original_reset_observability_instance,\n        )\n\n        assert reset_observability_instance is original_reset_observability_instance\n\n    def test_all_exports_defined(self):\n        \"\"\"Test that __all__ contains all expected exports.\"\"\"\n        import redis.observability as obs\n\n        expected_exports = {\n            \"OTelConfig\",\n            \"MetricGroup\",\n            \"TelemetryOption\",\n            \"ObservabilityInstance\",\n            \"get_observability_instance\",\n            \"reset_observability_instance\",\n        }\n\n        assert set(obs.__all__) == expected_exports\n\n    def test_all_exports_are_accessible(self):\n        \"\"\"Test that all items in __all__ are actually accessible.\"\"\"\n        import redis.observability as obs\n\n        for name in obs.__all__:\n            assert hasattr(obs, name), f\"{name} is in __all__ but not accessible\"\n            assert getattr(obs, name) is not None, f\"{name} is None\"\n"
  },
  {
    "path": "tests/test_observability/test_recorder.py",
    "content": "\"\"\"\nUnit tests for redis.observability.recorder module.\n\nThese tests verify that recorder functions correctly pass arguments through\nto the underlying OTel Meter instruments (Counter, Histogram, UpDownCounter).\nThe MeterProvider is mocked to verify the actual integration point where\nmetrics are exported to OTel.\n\"\"\"\n\nimport pytest\nfrom unittest.mock import MagicMock, patch\n\nfrom opentelemetry.metrics import Observation\n\nfrom redis.observability import recorder\nfrom redis.observability.registry import (\n    ObservablesRegistry,\n    get_observables_registry_instance,\n)\nfrom redis.observability.attributes import (\n    ConnectionState,\n    GeoFailoverReason,\n    PubSubDirection,\n    # Connection pool attributes\n    DB_CLIENT_CONNECTION_POOL_NAME,\n    DB_CLIENT_CONNECTION_STATE,\n    SERVER_ADDRESS,\n    SERVER_PORT,\n    # Database attributes\n    DB_NAMESPACE,\n    DB_OPERATION_NAME,\n    DB_RESPONSE_STATUS_CODE,\n    # Error attributes\n    ERROR_TYPE,\n    # Network attributes\n    NETWORK_PEER_ADDRESS,\n    NETWORK_PEER_PORT,\n    # Redis-specific attributes\n    REDIS_CLIENT_OPERATION_RETRY_ATTEMPTS,\n    REDIS_CLIENT_CONNECTION_CLOSE_REASON,\n    REDIS_CLIENT_CONNECTION_NOTIFICATION,\n    REDIS_CLIENT_PUBSUB_MESSAGE_DIRECTION,\n    REDIS_CLIENT_PUBSUB_CHANNEL,\n    REDIS_CLIENT_PUBSUB_SHARDED,\n    # Streaming attributes\n    REDIS_CLIENT_STREAM_NAME,\n    REDIS_CLIENT_CONSUMER_GROUP,\n    DB_CLIENT_GEOFAILOVER_FAIL_FROM,\n    DB_CLIENT_GEOFAILOVER_FAIL_TO,\n    DB_CLIENT_GEOFAILOVER_REASON,\n)\nfrom redis.observability.config import OTelConfig, MetricGroup\nfrom redis.observability.metrics import RedisMetricsCollector, CloseReason\nfrom redis.observability.recorder import (\n    record_connection_closed,\n    record_connection_create_time,\n    record_connection_handoff,\n    record_connection_relaxed_timeout,\n    record_connection_timeout,\n    record_connection_wait_time,\n    record_error_count,\n    record_geo_failover,\n    record_operation_duration,\n    record_pubsub_message,\n    record_streaming_lag,\n    reset_collector,\n)\n\n\nclass MockInstruments:\n    \"\"\"Container for mock OTel instruments.\"\"\"\n\n    def __init__(self):\n        # Counters\n        self.client_errors = MagicMock()\n        self.maintenance_notifications = MagicMock()\n        self.connection_timeouts = MagicMock()\n        self.connection_closed = MagicMock()\n        self.connection_handoff = MagicMock()\n        self.pubsub_messages = MagicMock()\n        self.geo_failovers = MagicMock()\n\n        # Gauges\n        self.connection_count = MagicMock()\n\n        # UpDownCounters\n        self.connection_relaxed_timeout = MagicMock()\n\n        # Histograms\n        self.connection_create_time = MagicMock()\n        self.connection_wait_time = MagicMock()\n        self.connection_use_time = MagicMock()\n        self.operation_duration = MagicMock()\n        self.stream_lag = MagicMock()\n\n\n@pytest.fixture\ndef mock_instruments():\n    \"\"\"Create mock OTel instruments.\"\"\"\n    return MockInstruments()\n\n\n@pytest.fixture\ndef mock_meter(mock_instruments):\n    \"\"\"Create a mock Meter that returns our mock instruments.\"\"\"\n    meter = MagicMock()\n\n    def create_counter_side_effect(name, **kwargs):\n        instrument_map = {\n            \"redis.client.errors\": mock_instruments.client_errors,\n            \"redis.client.maintenance.notifications\": mock_instruments.maintenance_notifications,\n            \"db.client.connection.timeouts\": mock_instruments.connection_timeouts,\n            \"redis.client.connection.closed\": mock_instruments.connection_closed,\n            \"redis.client.connection.handoff\": mock_instruments.connection_handoff,\n            \"redis.client.pubsub.messages\": mock_instruments.pubsub_messages,\n            \"redis.client.geofailover.failovers\": mock_instruments.geo_failovers,\n        }\n        return instrument_map.get(name, MagicMock())\n\n    def create_gauge_side_effect(name, **kwargs):\n        instrument_map = {\n            \"db.client.connection.count\": mock_instruments.connection_count,\n        }\n        return instrument_map.get(name, MagicMock())\n\n    def create_up_down_counter_side_effect(name, **kwargs):\n        instrument_map = {\n            \"redis.client.connection.relaxed_timeout\": mock_instruments.connection_relaxed_timeout,\n        }\n        return instrument_map.get(name, MagicMock())\n\n    def create_histogram_side_effect(name, **kwargs):\n        instrument_map = {\n            \"db.client.connection.create_time\": mock_instruments.connection_create_time,\n            \"db.client.connection.wait_time\": mock_instruments.connection_wait_time,\n            \"db.client.connection.use_time\": mock_instruments.connection_use_time,\n            \"db.client.operation.duration\": mock_instruments.operation_duration,\n            \"redis.client.stream.lag\": mock_instruments.stream_lag,\n        }\n        return instrument_map.get(name, MagicMock())\n\n    meter.create_counter.side_effect = create_counter_side_effect\n    meter.create_gauge.side_effect = create_gauge_side_effect\n    meter.create_observable_gauge.side_effect = create_gauge_side_effect\n    meter.create_up_down_counter.side_effect = create_up_down_counter_side_effect\n    meter.create_histogram.side_effect = create_histogram_side_effect\n\n    return meter\n\n\n@pytest.fixture\ndef mock_config():\n    \"\"\"Create a config with all metric groups enabled.\"\"\"\n    config = OTelConfig(\n        metric_groups=[\n            MetricGroup.RESILIENCY,\n            MetricGroup.CONNECTION_BASIC,\n            MetricGroup.CONNECTION_ADVANCED,\n            MetricGroup.COMMAND,\n            MetricGroup.PUBSUB,\n            MetricGroup.STREAMING,\n        ]\n    )\n    return config\n\n\n@pytest.fixture\ndef metrics_collector(mock_meter, mock_config):\n    \"\"\"Create a real RedisMetricsCollector with mocked Meter.\"\"\"\n    with patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n        from redis.observability.metrics import RedisMetricsCollector\n\n        collector = RedisMetricsCollector(mock_meter, mock_config)\n        return collector\n\n\n@pytest.fixture\ndef setup_recorder(metrics_collector, mock_instruments):\n    \"\"\"\n    Setup the recorder module with our collector that has mocked instruments.\n    \"\"\"\n    from redis.observability import recorder\n\n    # Reset the global collector before test\n    recorder.reset_collector()\n\n    # Patch _get_or_create_collector to return our collector with mocked instruments\n    with patch.object(\n        recorder, \"_get_or_create_collector\", return_value=metrics_collector\n    ):\n        yield mock_instruments\n\n    # Reset after test\n    recorder.reset_collector()\n\n\nclass TestRecordOperationDuration:\n    \"\"\"Tests for record_operation_duration - verifies Histogram.record() calls.\"\"\"\n\n    def test_record_operation_duration_success(self, setup_recorder):\n        \"\"\"Test that operation duration is recorded to the histogram with correct attributes.\"\"\"\n\n        instruments = setup_recorder\n\n        record_operation_duration(\n            command_name=\"SET\",\n            duration_seconds=0.005,\n            server_address=\"localhost\",\n            server_port=6379,\n            db_namespace=\"0\",\n            error=None,\n        )\n\n        # Verify histogram.record() was called\n        instruments.operation_duration.record.assert_called_once()\n        call_args = instruments.operation_duration.record.call_args\n\n        # Verify duration value\n        assert call_args[0][0] == 0.005\n\n        # Verify attributes\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[SERVER_ADDRESS] == \"localhost\"\n        assert attrs[SERVER_PORT] == 6379\n        assert attrs[DB_NAMESPACE] == \"0\"\n        assert attrs[DB_OPERATION_NAME] == \"SET\"\n\n    def test_record_operation_duration_with_error(self, setup_recorder):\n        \"\"\"Test that error information is included in attributes.\"\"\"\n\n        instruments = setup_recorder\n\n        error = ConnectionError(\"Connection refused\")\n        record_operation_duration(\n            command_name=\"GET\",\n            duration_seconds=0.001,\n            server_address=\"localhost\",\n            server_port=6379,\n            error=error,\n        )\n\n        instruments.operation_duration.record.assert_called_once()\n        call_args = instruments.operation_duration.record.call_args\n\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[DB_OPERATION_NAME] == \"GET\"\n        assert attrs[DB_RESPONSE_STATUS_CODE] == \"error\"\n        assert attrs[ERROR_TYPE] == \"ConnectionError\"\n\n    def test_record_operation_duration_with_bytes_command_name(self, setup_recorder):\n        \"\"\"Test that bytes command names are converted to strings for OTel exporters.\"\"\"\n\n        instruments = setup_recorder\n\n        # Pass command_name as bytes (as it can come from args[0])\n        record_operation_duration(\n            command_name=b\"SET\",\n            duration_seconds=0.005,\n            server_address=\"localhost\",\n            server_port=6379,\n            db_namespace=\"0\",\n            error=None,\n        )\n\n        instruments.operation_duration.record.assert_called_once()\n        call_args = instruments.operation_duration.record.call_args\n\n        attrs = call_args[1][\"attributes\"]\n        # Verify command_name is converted to uppercase string, not bytes\n        assert attrs[DB_OPERATION_NAME] == \"SET\"\n        assert isinstance(attrs[DB_OPERATION_NAME], str)\n\n\nclass TestRecordConnectionCreateTime:\n    \"\"\"Tests for record_connection_create_time - verifies Histogram.record() calls.\"\"\"\n\n    def test_record_connection_create_time(self, setup_recorder):\n        \"\"\"Test that connection creation time is recorded with pool name.\"\"\"\n        from unittest.mock import MagicMock\n\n        instruments = setup_recorder\n\n        # Create a mock connection pool\n        mock_pool = MagicMock()\n        mock_pool.__class__.__name__ = \"ConnectionPool\"\n        mock_pool.connection_kwargs = {\"host\": \"localhost\", \"port\": 6379, \"db\": 0}\n        mock_pool._pool_id = \"a1b2c3d4\"  # Mock the unique pool ID\n\n        record_connection_create_time(\n            connection_pool=mock_pool,\n            duration_seconds=0.025,\n        )\n\n        instruments.connection_create_time.record.assert_called_once()\n        call_args = instruments.connection_create_time.record.call_args\n\n        # Verify duration value\n        assert call_args[0][0] == 0.025\n\n        # Verify attributes\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[DB_CLIENT_CONNECTION_POOL_NAME] == \"localhost:6379_a1b2c3d4\"\n\n\nclass TestRecordConnectionTimeout:\n    \"\"\"Tests for record_connection_timeout - verifies Counter.add() calls.\"\"\"\n\n    def test_record_connection_timeout(self, setup_recorder):\n        \"\"\"Test recording connection timeout event.\"\"\"\n\n        instruments = setup_recorder\n\n        record_connection_timeout(\n            pool_name=\"ConnectionPool<localhost:6379>\",\n        )\n\n        instruments.connection_timeouts.add.assert_called_once()\n        call_args = instruments.connection_timeouts.add.call_args\n\n        # Counter increments by 1\n        assert call_args[0][0] == 1\n\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[DB_CLIENT_CONNECTION_POOL_NAME] == \"ConnectionPool<localhost:6379>\"\n\n\nclass TestRecordConnectionWaitTime:\n    \"\"\"Tests for record_connection_wait_time - verifies Histogram.record() calls.\"\"\"\n\n    def test_record_connection_wait_time(self, setup_recorder):\n        \"\"\"Test recording connection wait time.\"\"\"\n\n        instruments = setup_recorder\n\n        record_connection_wait_time(\n            pool_name=\"ConnectionPool<localhost:6379>\",\n            duration_seconds=0.010,\n        )\n\n        instruments.connection_wait_time.record.assert_called_once()\n        call_args = instruments.connection_wait_time.record.call_args\n\n        assert call_args[0][0] == 0.010\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[DB_CLIENT_CONNECTION_POOL_NAME] == \"ConnectionPool<localhost:6379>\"\n\n\nclass TestRecordConnectionClosed:\n    \"\"\"Tests for record_connection_closed - verifies Counter.add() calls.\"\"\"\n\n    def test_record_connection_closed_with_reason(self, setup_recorder):\n        \"\"\"Test recording connection closed with reason.\"\"\"\n\n        instruments = setup_recorder\n\n        record_connection_closed(\n            close_reason=CloseReason.HEALTHCHECK_FAILED,\n        )\n\n        instruments.connection_closed.add.assert_called_once()\n        call_args = instruments.connection_closed.add.call_args\n\n        assert call_args[0][0] == 1\n        attrs = call_args[1][\"attributes\"]\n        assert (\n            attrs[REDIS_CLIENT_CONNECTION_CLOSE_REASON]\n            == CloseReason.HEALTHCHECK_FAILED.value\n        )\n\n    def test_record_connection_closed_with_error(self, setup_recorder):\n        \"\"\"Test recording connection closed with error type.\"\"\"\n\n        instruments = setup_recorder\n\n        error = ConnectionResetError(\"Connection reset by peer\")\n        record_connection_closed(\n            close_reason=CloseReason.ERROR,\n            error_type=error,\n        )\n\n        instruments.connection_closed.add.assert_called_once()\n        attrs = instruments.connection_closed.add.call_args[1][\"attributes\"]\n        assert attrs[REDIS_CLIENT_CONNECTION_CLOSE_REASON] == \"error\"\n        assert attrs[ERROR_TYPE] == \"ConnectionResetError\"\n\n\nclass TestRecordConnectionRelaxedTimeout:\n    \"\"\"Tests for record_connection_relaxed_timeout - verifies UpDownCounter.add() calls.\"\"\"\n\n    def test_record_connection_relaxed_timeout_relaxed(self, setup_recorder):\n        \"\"\"Test recording relaxed timeout increments counter by 1.\"\"\"\n\n        instruments = setup_recorder\n\n        record_connection_relaxed_timeout(\n            connection_name=\"localhost:6379_abc123\",\n            maint_notification=\"MOVING\",\n            relaxed=True,\n        )\n\n        instruments.connection_relaxed_timeout.add.assert_called_once()\n        call_args = instruments.connection_relaxed_timeout.add.call_args\n\n        # relaxed=True means count up (+1)\n        assert call_args[0][0] == 1\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[DB_CLIENT_CONNECTION_POOL_NAME] == \"localhost:6379_abc123\"\n        assert attrs[REDIS_CLIENT_CONNECTION_NOTIFICATION] == \"MOVING\"\n\n    def test_record_connection_relaxed_timeout_unrelaxed(self, setup_recorder):\n        \"\"\"Test recording unrelaxed timeout decrements counter by 1.\"\"\"\n\n        instruments = setup_recorder\n\n        record_connection_relaxed_timeout(\n            connection_name=\"localhost:6379_abc123\",\n            maint_notification=\"MIGRATING\",\n            relaxed=False,\n        )\n\n        instruments.connection_relaxed_timeout.add.assert_called_once()\n        call_args = instruments.connection_relaxed_timeout.add.call_args\n\n        # relaxed=False means count down (-1)\n        assert call_args[0][0] == -1\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[REDIS_CLIENT_CONNECTION_NOTIFICATION] == \"MIGRATING\"\n\n\nclass TestRecordConnectionHandoff:\n    \"\"\"Tests for record_connection_handoff - verifies Counter.add() calls.\"\"\"\n\n    def test_record_connection_handoff(self, setup_recorder):\n        \"\"\"Test recording connection handoff event.\"\"\"\n\n        instruments = setup_recorder\n\n        record_connection_handoff(\n            pool_name=\"ConnectionPool<localhost:6379>\",\n        )\n\n        instruments.connection_handoff.add.assert_called_once()\n        call_args = instruments.connection_handoff.add.call_args\n\n        assert call_args[0][0] == 1\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[DB_CLIENT_CONNECTION_POOL_NAME] == \"ConnectionPool<localhost:6379>\"\n\n\nclass TestRecordErrorCount:\n    \"\"\"Tests for record_error_count - verifies Counter.add() calls.\"\"\"\n\n    def test_record_error_count(self, setup_recorder):\n        \"\"\"Test recording error count with all attributes.\"\"\"\n\n        instruments = setup_recorder\n\n        error = ConnectionError(\"Connection refused\")\n        record_error_count(\n            server_address=\"localhost\",\n            server_port=6379,\n            network_peer_address=\"127.0.0.1\",\n            network_peer_port=6379,\n            error_type=error,\n            retry_attempts=3,\n            is_internal=True,\n        )\n\n        instruments.client_errors.add.assert_called_once()\n        call_args = instruments.client_errors.add.call_args\n\n        assert call_args[0][0] == 1\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[SERVER_ADDRESS] == \"localhost\"\n        assert attrs[SERVER_PORT] == 6379\n        assert attrs[NETWORK_PEER_ADDRESS] == \"127.0.0.1\"\n        assert attrs[NETWORK_PEER_PORT] == 6379\n        assert attrs[ERROR_TYPE] == \"ConnectionError\"\n        assert attrs[REDIS_CLIENT_OPERATION_RETRY_ATTEMPTS] == 3\n\n    def test_record_error_count_with_is_internal_false(self, setup_recorder):\n        \"\"\"Test recording error count with is_internal=False.\"\"\"\n\n        instruments = setup_recorder\n\n        error = TimeoutError(\"Connection timed out\")\n        record_error_count(\n            server_address=\"localhost\",\n            server_port=6379,\n            network_peer_address=\"127.0.0.1\",\n            network_peer_port=6379,\n            error_type=error,\n            retry_attempts=2,\n            is_internal=False,\n        )\n\n        instruments.client_errors.add.assert_called_once()\n        call_args = instruments.client_errors.add.call_args\n\n        assert call_args[0][0] == 1\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[ERROR_TYPE] == \"TimeoutError\"\n        assert attrs[REDIS_CLIENT_OPERATION_RETRY_ATTEMPTS] == 2\n\n\nclass TestRecordMaintNotificationCount:\n    \"\"\"Tests for record_maint_notification_count - verifies Counter.add() calls.\"\"\"\n\n    def test_record_maint_notification_count(self, setup_recorder):\n        \"\"\"Test recording maintenance notification count with all attributes.\"\"\"\n\n        instruments = setup_recorder\n\n        recorder.record_maint_notification_count(\n            server_address=\"localhost\",\n            server_port=6379,\n            network_peer_address=\"127.0.0.1\",\n            network_peer_port=6379,\n            maint_notification=\"MOVING\",\n        )\n\n        instruments.maintenance_notifications.add.assert_called_once()\n        call_args = instruments.maintenance_notifications.add.call_args\n\n        assert call_args[0][0] == 1\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[SERVER_ADDRESS] == \"localhost\"\n        assert attrs[SERVER_PORT] == 6379\n        assert attrs[NETWORK_PEER_ADDRESS] == \"127.0.0.1\"\n        assert attrs[NETWORK_PEER_PORT] == 6379\n        assert attrs[REDIS_CLIENT_CONNECTION_NOTIFICATION] == \"MOVING\"\n\n    def test_record_maint_notification_count_migrating(self, setup_recorder):\n        \"\"\"Test recording maintenance notification count with MIGRATING type.\"\"\"\n\n        instruments = setup_recorder\n\n        recorder.record_maint_notification_count(\n            server_address=\"redis-primary\",\n            server_port=6380,\n            network_peer_address=\"10.0.0.1\",\n            network_peer_port=6380,\n            maint_notification=\"MIGRATING\",\n        )\n\n        instruments.maintenance_notifications.add.assert_called_once()\n        call_args = instruments.maintenance_notifications.add.call_args\n\n        assert call_args[0][0] == 1\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[SERVER_ADDRESS] == \"redis-primary\"\n        assert attrs[SERVER_PORT] == 6380\n        assert attrs[REDIS_CLIENT_CONNECTION_NOTIFICATION] == \"MIGRATING\"\n\n\nclass TestRecordGeoFailover:\n    \"\"\"Tests for record_geo_failover - verifies Counter.add() calls.\"\"\"\n\n    @pytest.fixture\n    def mock_database(self):\n        \"\"\"Create a mock database with required attributes.\"\"\"\n        mock_db = MagicMock()\n        mock_db.client.get_connection_kwargs.return_value = {\n            \"host\": \"localhost\",\n            \"port\": 6379,\n        }\n        mock_db.weight = 1.0\n        return mock_db\n\n    @pytest.fixture\n    def mock_database_secondary(self):\n        \"\"\"Create a secondary mock database with different attributes.\"\"\"\n        mock_db = MagicMock()\n        mock_db.client.get_connection_kwargs.return_value = {\n            \"host\": \"redis-secondary\",\n            \"port\": 6380,\n        }\n        mock_db.weight = 0.5\n        return mock_db\n\n    def test_record_geo_failover_automatic(\n        self, setup_recorder, mock_database, mock_database_secondary\n    ):\n        \"\"\"Test recording automatic geo failover.\"\"\"\n        instruments = setup_recorder\n\n        record_geo_failover(\n            fail_from=mock_database,\n            fail_to=mock_database_secondary,\n            reason=GeoFailoverReason.AUTOMATIC,\n        )\n\n        instruments.geo_failovers.add.assert_called_once()\n        call_args = instruments.geo_failovers.add.call_args\n\n        # Counter increments by 1\n        assert call_args[0][0] == 1\n\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[DB_CLIENT_GEOFAILOVER_FAIL_FROM] == \"localhost:6379/1.0\"\n        assert attrs[DB_CLIENT_GEOFAILOVER_FAIL_TO] == \"redis-secondary:6380/0.5\"\n        assert attrs[DB_CLIENT_GEOFAILOVER_REASON] == \"automatic\"\n\n    def test_record_geo_failover_manual(\n        self, setup_recorder, mock_database, mock_database_secondary\n    ):\n        \"\"\"Test recording manual geo failover.\"\"\"\n        instruments = setup_recorder\n\n        record_geo_failover(\n            fail_from=mock_database_secondary,\n            fail_to=mock_database,\n            reason=GeoFailoverReason.MANUAL,\n        )\n\n        instruments.geo_failovers.add.assert_called_once()\n        call_args = instruments.geo_failovers.add.call_args\n\n        assert call_args[0][0] == 1\n\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[DB_CLIENT_GEOFAILOVER_FAIL_FROM] == \"redis-secondary:6380/0.5\"\n        assert attrs[DB_CLIENT_GEOFAILOVER_FAIL_TO] == \"localhost:6379/1.0\"\n        assert attrs[DB_CLIENT_GEOFAILOVER_REASON] == \"manual\"\n\n\nclass TestRecordPubsubMessage:\n    \"\"\"Tests for record_pubsub_message - verifies Counter.add() calls.\"\"\"\n\n    def test_record_pubsub_message_publish(self, setup_recorder):\n        \"\"\"Test recording published message.\"\"\"\n\n        instruments = setup_recorder\n\n        record_pubsub_message(\n            direction=PubSubDirection.PUBLISH,\n            channel=\"my-channel\",\n            sharded=False,\n        )\n\n        instruments.pubsub_messages.add.assert_called_once()\n        call_args = instruments.pubsub_messages.add.call_args\n\n        assert call_args[0][0] == 1\n        attrs = call_args[1][\"attributes\"]\n        assert (\n            attrs[REDIS_CLIENT_PUBSUB_MESSAGE_DIRECTION]\n            == PubSubDirection.PUBLISH.value\n        )\n        assert attrs[REDIS_CLIENT_PUBSUB_CHANNEL] == \"my-channel\"\n        assert attrs[REDIS_CLIENT_PUBSUB_SHARDED] is False\n\n    def test_record_pubsub_message_receive_sharded(self, setup_recorder):\n        \"\"\"Test recording received message on sharded channel.\"\"\"\n\n        instruments = setup_recorder\n\n        record_pubsub_message(\n            direction=PubSubDirection.RECEIVE,\n            channel=\"sharded-channel\",\n            sharded=True,\n        )\n\n        instruments.pubsub_messages.add.assert_called_once()\n        attrs = instruments.pubsub_messages.add.call_args[1][\"attributes\"]\n        assert (\n            attrs[REDIS_CLIENT_PUBSUB_MESSAGE_DIRECTION]\n            == PubSubDirection.RECEIVE.value\n        )\n        assert attrs[REDIS_CLIENT_PUBSUB_CHANNEL] == \"sharded-channel\"\n        assert attrs[REDIS_CLIENT_PUBSUB_SHARDED] is True\n\n\nclass TestRecordStreamingLag:\n    \"\"\"Tests for record_streaming_lag - verifies Histogram.record() calls.\"\"\"\n\n    def test_record_streaming_lag_with_all_attributes(self, setup_recorder):\n        \"\"\"Test recording streaming lag with all attributes.\"\"\"\n\n        instruments = setup_recorder\n\n        record_streaming_lag(\n            lag_seconds=0.150,\n            stream_name=\"my-stream\",\n            consumer_group=\"my-group\",\n        )\n\n        instruments.stream_lag.record.assert_called_once()\n        call_args = instruments.stream_lag.record.call_args\n\n        # Verify lag value\n        assert call_args[0][0] == 0.150\n\n        # Verify attributes\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[REDIS_CLIENT_STREAM_NAME] == \"my-stream\"\n        assert attrs[REDIS_CLIENT_CONSUMER_GROUP] == \"my-group\"\n\n    def test_record_streaming_lag_minimal(self, setup_recorder):\n        \"\"\"Test recording streaming lag with only required attributes.\"\"\"\n\n        instruments = setup_recorder\n\n        record_streaming_lag(\n            lag_seconds=0.025,\n        )\n\n        instruments.stream_lag.record.assert_called_once()\n        call_args = instruments.stream_lag.record.call_args\n\n        # Verify lag value\n        assert call_args[0][0] == 0.025\n\n    def test_record_streaming_lag_with_stream_only(self, setup_recorder):\n        \"\"\"Test recording streaming lag with stream name only.\"\"\"\n\n        instruments = setup_recorder\n\n        record_streaming_lag(\n            lag_seconds=0.500,\n            stream_name=\"events-stream\",\n        )\n\n        instruments.stream_lag.record.assert_called_once()\n        attrs = instruments.stream_lag.record.call_args[1][\"attributes\"]\n        assert attrs[REDIS_CLIENT_STREAM_NAME] == \"events-stream\"\n\n\nclass TestHidePubSubChannelNames:\n    \"\"\"Tests for hide_pubsub_channel_names configuration option.\"\"\"\n\n    @pytest.fixture\n    def setup_recorder_with_hidden_channels(self, mock_meter, mock_instruments):\n        \"\"\"Setup recorder with hide_pubsub_channel_names=True.\"\"\"\n        config = OTelConfig(\n            metric_groups=[MetricGroup.PUBSUB],\n            hide_pubsub_channel_names=True,\n        )\n\n        recorder.reset_collector()\n\n        with patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        with patch.object(recorder, \"_get_or_create_collector\", return_value=collector):\n            with patch.object(recorder, \"_get_config\", return_value=config):\n                yield mock_instruments\n\n        recorder.reset_collector()\n\n    def test_channel_name_hidden_when_configured(\n        self, setup_recorder_with_hidden_channels\n    ):\n        \"\"\"Test that channel name is hidden when hide_pubsub_channel_names=True.\"\"\"\n        instruments = setup_recorder_with_hidden_channels\n\n        record_pubsub_message(\n            direction=PubSubDirection.PUBLISH,\n            channel=\"secret-channel\",\n            sharded=False,\n        )\n\n        instruments.pubsub_messages.add.assert_called_once()\n        attrs = instruments.pubsub_messages.add.call_args[1][\"attributes\"]\n        assert (\n            attrs[REDIS_CLIENT_PUBSUB_MESSAGE_DIRECTION]\n            == PubSubDirection.PUBLISH.value\n        )\n        # Channel should NOT be in attributes when hidden\n        assert REDIS_CLIENT_PUBSUB_CHANNEL not in attrs\n        assert attrs[REDIS_CLIENT_PUBSUB_SHARDED] is False\n\n    def test_channel_name_visible_when_not_configured(self, setup_recorder):\n        \"\"\"Test that channel name is visible when hide_pubsub_channel_names=False (default).\"\"\"\n        instruments = setup_recorder\n\n        record_pubsub_message(\n            direction=PubSubDirection.PUBLISH,\n            channel=\"visible-channel\",\n            sharded=False,\n        )\n\n        instruments.pubsub_messages.add.assert_called_once()\n        attrs = instruments.pubsub_messages.add.call_args[1][\"attributes\"]\n        assert attrs[REDIS_CLIENT_PUBSUB_CHANNEL] == \"visible-channel\"\n\n\nclass TestHideStreamNames:\n    \"\"\"Tests for hide_stream_names configuration option.\"\"\"\n\n    @pytest.fixture\n    def setup_recorder_with_hidden_streams(self, mock_meter, mock_instruments):\n        \"\"\"Setup recorder with hide_stream_names=True.\"\"\"\n        config = OTelConfig(\n            metric_groups=[MetricGroup.STREAMING],\n            hide_stream_names=True,\n        )\n\n        recorder.reset_collector()\n\n        with patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        with patch.object(recorder, \"_get_or_create_collector\", return_value=collector):\n            with patch.object(recorder, \"_get_config\", return_value=config):\n                yield mock_instruments\n\n        recorder.reset_collector()\n\n    def test_stream_name_hidden_when_configured(\n        self, setup_recorder_with_hidden_streams\n    ):\n        \"\"\"Test that stream name is hidden when hide_stream_names=True.\"\"\"\n        instruments = setup_recorder_with_hidden_streams\n\n        record_streaming_lag(\n            lag_seconds=0.150,\n            stream_name=\"secret-stream\",\n            consumer_group=\"my-group\",\n        )\n\n        instruments.stream_lag.record.assert_called_once()\n        attrs = instruments.stream_lag.record.call_args[1][\"attributes\"]\n        # Stream name should NOT be in attributes when hidden\n        assert REDIS_CLIENT_STREAM_NAME not in attrs\n        assert attrs[REDIS_CLIENT_CONSUMER_GROUP] == \"my-group\"\n\n    def test_stream_name_visible_when_not_configured(self, setup_recorder):\n        \"\"\"Test that stream name is visible when hide_stream_names=False (default).\"\"\"\n        instruments = setup_recorder\n\n        record_streaming_lag(\n            lag_seconds=0.150, stream_name=\"visible-stream\", consumer_group=\"my-group\"\n        )\n\n        instruments.stream_lag.record.assert_called_once()\n        attrs = instruments.stream_lag.record.call_args[1][\"attributes\"]\n        assert attrs[REDIS_CLIENT_STREAM_NAME] == \"visible-stream\"\n\n    def test_stream_name_hidden_in_record_streaming_lag_from_response_resp3(\n        self, setup_recorder_with_hidden_streams\n    ):\n        \"\"\"Test that stream names are hidden in record_streaming_lag_from_response for RESP3 format.\"\"\"\n        instruments = setup_recorder_with_hidden_streams\n\n        # RESP3 format: dict with stream name as key\n        # Message ID format: timestamp-sequence (e.g., \"1234567890123-0\")\n        import time\n\n        current_time_ms = int(time.time() * 1000)\n        message_id = f\"{current_time_ms}-0\"\n\n        response = {\n            \"secret-stream\": [\n                [\n                    (message_id, {\"field\": \"value\"}),\n                ]\n            ]\n        }\n\n        from redis.observability.recorder import record_streaming_lag_from_response\n\n        record_streaming_lag_from_response(response=response, consumer_group=\"my-group\")\n\n        instruments.stream_lag.record.assert_called_once()\n        attrs = instruments.stream_lag.record.call_args[1][\"attributes\"]\n        # Stream name should NOT be in attributes when hidden\n        assert REDIS_CLIENT_STREAM_NAME not in attrs\n\n    def test_stream_name_hidden_in_record_streaming_lag_from_response_resp2(\n        self, setup_recorder_with_hidden_streams\n    ):\n        \"\"\"Test that stream names are hidden in record_streaming_lag_from_response for RESP2 format.\"\"\"\n        instruments = setup_recorder_with_hidden_streams\n\n        # RESP2 format: list of [stream_name, messages]\n        import time\n\n        current_time_ms = int(time.time() * 1000)\n        message_id = f\"{current_time_ms}-0\"\n\n        response = [\n            [\n                b\"secret-stream\",\n                [\n                    (message_id, {\"field\": \"value\"}),\n                ],\n            ]\n        ]\n\n        from redis.observability.recorder import record_streaming_lag_from_response\n\n        record_streaming_lag_from_response(response=response, consumer_group=\"my-group\")\n\n        instruments.stream_lag.record.assert_called_once()\n        attrs = instruments.stream_lag.record.call_args[1][\"attributes\"]\n        # Stream name should NOT be in attributes when hidden\n        assert REDIS_CLIENT_STREAM_NAME not in attrs\n\n\nclass TestRecorderDisabled:\n    \"\"\"Tests for recorder behavior when observability is disabled.\"\"\"\n\n    def test_record_operation_duration_when_disabled(self):\n        \"\"\"Test that recording does nothing when collector is None.\"\"\"\n\n        reset_collector()\n\n        with patch.object(recorder, \"_get_or_create_collector\", return_value=None):\n            # Should not raise any exception\n            record_operation_duration(\n                command_name=\"SET\",\n                duration_seconds=0.005,\n                server_address=\"localhost\",\n                server_port=6379,\n            )\n\n        reset_collector()\n\n    def test_is_enabled_returns_false_when_disabled(self):\n        \"\"\"Test is_enabled returns False when collector is None.\"\"\"\n        reset_collector()\n\n        with patch.object(recorder, \"_get_or_create_collector\", return_value=None):\n            assert recorder.is_enabled() is False\n\n        recorder.reset_collector()\n\n    def test_all_record_functions_safe_when_disabled(self):\n        \"\"\"Test that all record functions are safe to call when disabled.\"\"\"\n\n        reset_collector()\n\n        with patch.object(recorder, \"_get_or_create_collector\", return_value=None):\n            # None of these should raise\n            recorder.record_connection_create_time(\"pool\", 0.1)\n            recorder.record_connection_timeout(\"pool\")\n            recorder.record_connection_wait_time(\"pool\", 0.1)\n            recorder.record_connection_closed(\"pool\")\n            recorder.record_connection_relaxed_timeout(\"pool\", \"MOVING\", True)\n            recorder.record_connection_handoff(\"pool\")\n            recorder.record_error_count(\"host\", 6379, \"127.0.0.1\", 6379, Exception(), 0)\n            recorder.record_maint_notification_count(\n                \"host\", 6379, \"127.0.0.1\", 6379, \"MOVING\"\n            )\n            recorder.record_pubsub_message(PubSubDirection.PUBLISH)\n            recorder.record_streaming_lag(0.1, \"stream\", \"group\", \"consumer\")\n\n        recorder.reset_collector()\n\n\nclass TestResetCollector:\n    \"\"\"Tests for reset_collector function.\"\"\"\n\n    def test_reset_collector_clears_global(self):\n        \"\"\"Test that reset_collector clears the global collector.\"\"\"\n\n        reset_collector()\n        assert recorder._metrics_collector is None\n\n\nclass TestMetricGroupsDisabled:\n    \"\"\"Tests for verifying metrics are not sent to Meter when their MetricGroup is disabled.\n\n    These tests call recorder.record_*() functions and verify that no calls\n    are made to the underlying Meter instruments (.add() or .record()).\n    \"\"\"\n\n    def _create_collector_with_disabled_groups(self, mock_instruments, enabled_groups):\n        \"\"\"Helper to create a collector with specific metric groups enabled.\"\"\"\n        mock_meter = MagicMock()\n\n        def create_counter_side_effect(name, **kwargs):\n            instrument_map = {\n                \"redis.client.errors\": mock_instruments.client_errors,\n                \"redis.client.maintenance.notifications\": mock_instruments.maintenance_notifications,\n                \"db.client.connection.timeouts\": mock_instruments.connection_timeouts,\n                \"redis.client.connection.closed\": mock_instruments.connection_closed,\n                \"redis.client.connection.handoff\": mock_instruments.connection_handoff,\n                \"redis.client.pubsub.messages\": mock_instruments.pubsub_messages,\n            }\n            return instrument_map.get(name, MagicMock())\n\n        def create_gauge_side_effect(name, **kwargs):\n            instrument_map = {\n                \"db.client.connection.count\": mock_instruments.connection_count,\n            }\n            return instrument_map.get(name, MagicMock())\n\n        def create_up_down_counter_side_effect(name, **kwargs):\n            instrument_map = {\n                \"redis.client.connection.relaxed_timeout\": mock_instruments.connection_relaxed_timeout,\n            }\n            return instrument_map.get(name, MagicMock())\n\n        def create_histogram_side_effect(name, **kwargs):\n            instrument_map = {\n                \"db.client.connection.create_time\": mock_instruments.connection_create_time,\n                \"db.client.connection.wait_time\": mock_instruments.connection_wait_time,\n                \"db.client.connection.use_time\": mock_instruments.connection_use_time,\n                \"db.client.operation.duration\": mock_instruments.operation_duration,\n                \"redis.client.stream.lag\": mock_instruments.stream_lag,\n            }\n            return instrument_map.get(name, MagicMock())\n\n        mock_meter.create_counter.side_effect = create_counter_side_effect\n        # The RedisMetricsCollector uses create_observable_gauge in the implementation,\n        # so we need to mock that here to ensure the tests observe the correct behavior.\n        mock_meter.create_observable_gauge.side_effect = create_gauge_side_effect\n        # Keep create_gauge mocked as well in case it is used elsewhere.\n        mock_meter.create_gauge.side_effect = create_gauge_side_effect\n        mock_meter.create_up_down_counter.side_effect = (\n            create_up_down_counter_side_effect\n        )\n        mock_meter.create_histogram.side_effect = create_histogram_side_effect\n\n        config = OTelConfig(metric_groups=enabled_groups)\n\n        with patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            return RedisMetricsCollector(mock_meter, config)\n\n    def test_record_operation_duration_no_meter_call_when_command_disabled(self):\n        \"\"\"Test that record_operation_duration makes no Meter calls when COMMAND group is disabled.\"\"\"\n        instruments = MockInstruments()\n        collector = self._create_collector_with_disabled_groups(\n            instruments,\n            [MetricGroup.RESILIENCY],  # No COMMAND\n        )\n\n        recorder.reset_collector()\n        with patch.object(recorder, \"_get_or_create_collector\", return_value=collector):\n            record_operation_duration(\n                command_name=\"SET\",\n                duration_seconds=0.005,\n                server_address=\"localhost\",\n                server_port=6379,\n            )\n\n        # Verify no call to the histogram's record method\n        instruments.operation_duration.record.assert_not_called()\n\n    def test_record_connection_create_time_no_meter_call_when_connection_basic_disabled(\n        self,\n    ):\n        \"\"\"Test that record_connection_create_time makes no Meter calls when CONNECTION_BASIC is disabled.\"\"\"\n        instruments = MockInstruments()\n        collector = self._create_collector_with_disabled_groups(\n            instruments,\n            [MetricGroup.COMMAND],  # No CONNECTION_BASIC\n        )\n\n        recorder.reset_collector()\n        with patch.object(recorder, \"_get_or_create_collector\", return_value=collector):\n            record_connection_create_time(\n                connection_pool=\"test-pool\",\n                duration_seconds=0.050,\n            )\n\n        # Verify no call to the histogram's record method\n        instruments.connection_create_time.record.assert_not_called()\n\n    def test_record_connection_wait_time_no_meter_call_when_connection_advanced_disabled(\n        self,\n    ):\n        \"\"\"Test that record_connection_wait_time makes no Meter calls when CONNECTION_ADVANCED is disabled.\"\"\"\n        instruments = MockInstruments()\n        collector = self._create_collector_with_disabled_groups(\n            instruments,\n            [MetricGroup.COMMAND],  # No CONNECTION_ADVANCED\n        )\n\n        recorder.reset_collector()\n        with patch.object(recorder, \"_get_or_create_collector\", return_value=collector):\n            record_connection_wait_time(\n                pool_name=\"test-pool\",\n                duration_seconds=0.010,\n            )\n\n        # Verify no call to the histogram's record method\n        instruments.connection_wait_time.record.assert_not_called()\n\n    def test_record_connection_closed_no_meter_call_when_connection_advanced_disabled(\n        self,\n    ):\n        \"\"\"Test that record_connection_closed makes no Meter calls when CONNECTION_ADVANCED is disabled.\"\"\"\n        instruments = MockInstruments()\n        collector = self._create_collector_with_disabled_groups(\n            instruments,\n            [MetricGroup.COMMAND],  # No CONNECTION_ADVANCED\n        )\n\n        recorder.reset_collector()\n        with patch.object(recorder, \"_get_or_create_collector\", return_value=collector):\n            record_connection_closed(\n                close_reason=CloseReason.APPLICATION_CLOSE,\n            )\n\n        # Verify no call to the counter's add method\n        instruments.connection_closed.add.assert_not_called()\n\n    def test_record_connection_relaxed_timeout_no_meter_call_when_connection_basic_disabled(\n        self,\n    ):\n        \"\"\"Test that record_connection_relaxed_timeout makes no Meter calls when CONNECTION_BASIC is disabled.\"\"\"\n        instruments = MockInstruments()\n        collector = self._create_collector_with_disabled_groups(\n            instruments,\n            [MetricGroup.COMMAND],  # No CONNECTION_BASIC\n        )\n\n        recorder.reset_collector()\n        with patch.object(recorder, \"_get_or_create_collector\", return_value=collector):\n            record_connection_relaxed_timeout(\n                connection_name=\"test-pool\",\n                maint_notification=\"MOVING\",\n                relaxed=True,\n            )\n\n        # Verify no call to the up_down_counter's add method\n        instruments.connection_relaxed_timeout.add.assert_not_called()\n\n    def test_record_pubsub_message_no_meter_call_when_pubsub_disabled(self):\n        \"\"\"Test that record_pubsub_message makes no Meter calls when PUBSUB group is disabled.\"\"\"\n        instruments = MockInstruments()\n        collector = self._create_collector_with_disabled_groups(\n            instruments,\n            [MetricGroup.COMMAND],  # No PUBSUB\n        )\n\n        recorder.reset_collector()\n        with patch.object(recorder, \"_get_or_create_collector\", return_value=collector):\n            record_pubsub_message(\n                direction=PubSubDirection.PUBLISH,\n                channel=\"test-channel\",\n            )\n\n        # Verify no call to the counter's add method\n        instruments.pubsub_messages.add.assert_not_called()\n\n    def test_record_streaming_lag_no_meter_call_when_streaming_disabled(self):\n        \"\"\"Test that record_streaming_lag makes no Meter calls when STREAMING group is disabled.\"\"\"\n        instruments = MockInstruments()\n        collector = self._create_collector_with_disabled_groups(\n            instruments,\n            [MetricGroup.COMMAND],  # No STREAMING\n        )\n\n        recorder.reset_collector()\n        with patch.object(recorder, \"_get_or_create_collector\", return_value=collector):\n            record_streaming_lag(\n                lag_seconds=0.150,\n                stream_name=\"test-stream\",\n                consumer_group=\"test-group\",\n            )\n\n        # Verify no call to the histogram's record method\n        instruments.stream_lag.record.assert_not_called()\n\n    def test_record_error_count_no_meter_call_when_resiliency_disabled(self):\n        \"\"\"Test that record_error_count makes no Meter calls when RESILIENCY group is disabled.\"\"\"\n        instruments = MockInstruments()\n        collector = self._create_collector_with_disabled_groups(\n            instruments,\n            [MetricGroup.COMMAND],  # No RESILIENCY\n        )\n\n        recorder.reset_collector()\n        with patch.object(recorder, \"_get_or_create_collector\", return_value=collector):\n            record_error_count(\n                server_address=\"localhost\",\n                server_port=6379,\n                network_peer_address=\"127.0.0.1\",\n                network_peer_port=6379,\n                error_type=Exception(\"test error\"),\n                retry_attempts=0,\n            )\n\n        # Verify no call to the counter's add method\n        instruments.client_errors.add.assert_not_called()\n\n    def test_record_maint_notification_count_no_meter_call_when_resiliency_disabled(\n        self,\n    ):\n        \"\"\"Test that record_maint_notification_count makes no Meter calls when RESILIENCY group is disabled.\"\"\"\n        instruments = MockInstruments()\n        collector = self._create_collector_with_disabled_groups(\n            instruments,\n            [MetricGroup.COMMAND],  # No RESILIENCY\n        )\n\n        recorder.reset_collector()\n        with patch.object(recorder, \"_get_or_create_collector\", return_value=collector):\n            recorder.record_maint_notification_count(\n                server_address=\"localhost\",\n                server_port=6379,\n                network_peer_address=\"127.0.0.1\",\n                network_peer_port=6379,\n                maint_notification=\"MOVING\",\n            )\n\n        # Verify no call to the counter's add method\n        instruments.maintenance_notifications.add.assert_not_called()\n\n    def test_all_record_functions_no_meter_calls_when_all_groups_disabled(self):\n        \"\"\"Test that all record_* functions make no Meter calls when all groups are disabled.\"\"\"\n        instruments = MockInstruments()\n        collector = self._create_collector_with_disabled_groups(\n            instruments,\n            [],  # No metric groups enabled\n        )\n\n        recorder.reset_collector()\n        with patch.object(recorder, \"_get_or_create_collector\", return_value=collector):\n            # Call all record functions\n            record_operation_duration(\"GET\", 0.001, \"localhost\", 6379)\n            record_connection_create_time(\"pool\", 0.050)\n            record_connection_timeout(\"pool\")\n            record_connection_wait_time(\"pool\", 0.010)\n            record_connection_closed(\"pool\", \"shutdown\")\n            record_connection_relaxed_timeout(\"pool\", \"MOVING\", True)\n            record_connection_handoff(\"pool\")\n            record_error_count(\n                \"localhost\", 6379, \"127.0.0.1\", 6379, Exception(\"err\"), 0\n            )\n            recorder.record_maint_notification_count(\n                \"localhost\", 6379, \"127.0.0.1\", 6379, \"MOVING\"\n            )\n            record_pubsub_message(PubSubDirection.PUBLISH, \"channel\")\n            record_streaming_lag(0.150, \"stream\", \"group\", \"consumer\")\n\n        # Verify no Meter instrument methods were called\n        instruments.operation_duration.record.assert_not_called()\n        instruments.connection_create_time.record.assert_not_called()\n        instruments.connection_count.set.assert_not_called()\n        instruments.connection_timeouts.add.assert_not_called()\n        instruments.connection_wait_time.record.assert_not_called()\n        instruments.connection_closed.add.assert_not_called()\n        instruments.connection_relaxed_timeout.add.assert_not_called()\n        instruments.connection_handoff.add.assert_not_called()\n        instruments.client_errors.add.assert_not_called()\n        instruments.maintenance_notifications.add.assert_not_called()\n        instruments.pubsub_messages.add.assert_not_called()\n        instruments.stream_lag.record.assert_not_called()\n\n    def test_enabled_group_receives_meter_calls_disabled_group_does_not(self):\n        \"\"\"Test that only enabled groups receive Meter calls.\"\"\"\n        instruments = MockInstruments()\n        collector = self._create_collector_with_disabled_groups(\n            instruments,\n            [\n                MetricGroup.COMMAND,\n                MetricGroup.PUBSUB,\n            ],  # Only COMMAND and PUBSUB enabled\n        )\n\n        recorder.reset_collector()\n        with patch.object(recorder, \"_get_or_create_collector\", return_value=collector):\n            # Call functions from enabled groups\n            record_operation_duration(\"GET\", 0.001, \"localhost\", 6379)\n            record_pubsub_message(PubSubDirection.PUBLISH, \"channel\")\n\n            # Call functions from disabled groups\n            record_error_count(\n                \"localhost\", 6379, \"127.0.0.1\", 6379, Exception(\"err\"), 0\n            )\n            recorder.record_maint_notification_count(\n                \"localhost\", 6379, \"127.0.0.1\", 6379, \"MOVING\"\n            )\n            record_streaming_lag(0.150, \"stream\", \"group\", \"consumer\")\n\n        # Enabled groups should have received Meter calls\n        instruments.operation_duration.record.assert_called_once()\n        instruments.pubsub_messages.add.assert_called_once()\n\n        # Disabled groups should NOT have received Meter calls\n        instruments.connection_count.set.assert_not_called()\n        instruments.client_errors.add.assert_not_called()\n        instruments.maintenance_notifications.add.assert_not_called()\n        instruments.stream_lag.record.assert_not_called()\n\n\nclass TestObservablesRegistry:\n    \"\"\"Tests for ObservablesRegistry singleton and callback registration.\"\"\"\n\n    def test_registry_singleton_returns_same_instance(self):\n        \"\"\"Test that get_observables_registry_instance returns the same instance.\"\"\"\n        registry1 = get_observables_registry_instance()\n        registry2 = get_observables_registry_instance()\n        assert registry1 is registry2\n\n    def test_registry_register_and_get_callbacks(self):\n        \"\"\"Test registering and retrieving callbacks from the registry.\"\"\"\n        registry = ObservablesRegistry()\n\n        callback1 = MagicMock(return_value=[])\n        callback2 = MagicMock(return_value=[])\n\n        registry.register(\"test_metric\", callback1)\n        registry.register(\"test_metric\", callback2)\n\n        callbacks = registry.get(\"test_metric\")\n        assert len(callbacks) == 2\n        assert callback1 in callbacks\n        assert callback2 in callbacks\n\n    def test_registry_get_returns_empty_list_for_unknown_key(self):\n        \"\"\"Test that get returns empty list for unknown metric key.\"\"\"\n        registry = ObservablesRegistry()\n        callbacks = registry.get(\"unknown_metric\")\n        assert callbacks == []\n\n    def test_registry_clear_removes_all_callbacks(self):\n        \"\"\"Test that clear removes all registered callbacks.\"\"\"\n        registry = ObservablesRegistry()\n\n        registry.register(\"metric1\", MagicMock())\n        registry.register(\"metric2\", MagicMock())\n\n        registry.clear()\n\n        assert registry.get(\"metric1\") == []\n        assert registry.get(\"metric2\") == []\n        assert len(registry) == 0\n\n\nclass TestRecordConnectionCount:\n    \"\"\"Tests for record_connection_count (UpDownCounter).\"\"\"\n\n    @pytest.fixture\n    def mock_up_down_counter(self):\n        \"\"\"Create a mock UpDownCounter.\"\"\"\n        return MagicMock()\n\n    @pytest.fixture\n    def mock_meter_with_counter(self, mock_up_down_counter):\n        \"\"\"Create a mock meter that returns our mock UpDownCounter.\"\"\"\n        meter = MagicMock()\n        meter.create_up_down_counter.return_value = mock_up_down_counter\n        meter.create_counter.return_value = MagicMock()\n        meter.create_histogram.return_value = MagicMock()\n        meter.create_observable_gauge.return_value = MagicMock()\n        return meter\n\n    @pytest.fixture\n    def mock_config_with_connection_basic(self):\n        \"\"\"Create a config with CONNECTION_BASIC enabled.\"\"\"\n        return OTelConfig(metric_groups=[MetricGroup.CONNECTION_BASIC])\n\n    @pytest.fixture\n    def setup_connection_count_recorder(\n        self, mock_meter_with_counter, mock_config_with_connection_basic\n    ):\n        \"\"\"Setup recorder with mocked meter for connection count tests.\"\"\"\n        recorder.reset_collector()\n\n        with patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(\n                mock_meter_with_counter, mock_config_with_connection_basic\n            )\n\n        with patch.object(recorder, \"_get_or_create_collector\", return_value=collector):\n            yield mock_meter_with_counter\n\n        recorder.reset_collector()\n\n    def test_record_connection_count_increment(\n        self, setup_connection_count_recorder, mock_up_down_counter\n    ):\n        \"\"\"Test recording connection count increment.\"\"\"\n        recorder.record_connection_count(\n            pool_name=\"localhost:6379_abc123\",\n            connection_state=ConnectionState.IDLE,\n            counter=1,\n        )\n\n        assert mock_up_down_counter.add.call_count == 1\n\n        calls = mock_up_down_counter.add.call_args_list\n        assert calls[0][0][0] == 1\n        assert (\n            calls[0][1][\"attributes\"][DB_CLIENT_CONNECTION_POOL_NAME]\n            == \"localhost:6379_abc123\"\n        )\n        assert calls[0][1][\"attributes\"][DB_CLIENT_CONNECTION_STATE] == \"idle\"\n\n    def test_record_connection_count_decrement(\n        self, setup_connection_count_recorder, mock_up_down_counter\n    ):\n        \"\"\"Test recording connection count decrement.\"\"\"\n        recorder.record_connection_count(\n            pool_name=\"localhost:6379_abc123\",\n            connection_state=ConnectionState.USED,\n            counter=-1,\n        )\n\n        assert mock_up_down_counter.add.call_count == 1\n\n        calls = mock_up_down_counter.add.call_args_list\n        assert calls[0][0][0] == -1\n        assert (\n            calls[0][1][\"attributes\"][DB_CLIENT_CONNECTION_POOL_NAME]\n            == \"localhost:6379_abc123\"\n        )\n        assert calls[0][1][\"attributes\"][DB_CLIENT_CONNECTION_STATE] == \"used\"\n\n    def test_record_connection_count_batch_decrement(\n        self, setup_connection_count_recorder, mock_up_down_counter\n    ):\n        \"\"\"Test recording batch connection count decrement (e.g., pool disconnect).\"\"\"\n        recorder.record_connection_count(\n            pool_name=\"localhost:6379_abc123\",\n            connection_state=ConnectionState.IDLE,\n            counter=-5,\n        )\n\n        assert mock_up_down_counter.add.call_count == 1\n\n        calls = mock_up_down_counter.add.call_args_list\n        assert calls[0][0][0] == -5\n        assert calls[0][1][\"attributes\"][DB_CLIENT_CONNECTION_STATE] == \"idle\"\n\n    def test_record_connection_count_lifecycle_scenario(\n        self, setup_connection_count_recorder, mock_up_down_counter\n    ):\n        \"\"\"Test realistic lifecycle: create connection, acquire, release, re-acquire, disconnect.\"\"\"\n        # 1. New connection created (goes to IDLE)\n        recorder.record_connection_count(\n            pool_name=\"pool1\",\n            connection_state=ConnectionState.IDLE,\n            counter=1,\n        )\n\n        # 2. Connection acquired from pool (transition: IDLE -> USED)\n        recorder.record_connection_count(\n            pool_name=\"pool1\",\n            connection_state=ConnectionState.IDLE,\n            counter=-1,\n        )\n        recorder.record_connection_count(\n            pool_name=\"pool1\",\n            connection_state=ConnectionState.USED,\n            counter=1,\n        )\n\n        # 3. Connection released to pool (transition: USED -> IDLE)\n        recorder.record_connection_count(\n            pool_name=\"pool1\",\n            connection_state=ConnectionState.USED,\n            counter=-1,\n        )\n        recorder.record_connection_count(\n            pool_name=\"pool1\",\n            connection_state=ConnectionState.IDLE,\n            counter=1,\n        )\n\n        # 4. Pool disconnect (IDLE -> destroyed)\n        recorder.record_connection_count(\n            pool_name=\"pool1\",\n            connection_state=ConnectionState.IDLE,\n            counter=-1,\n        )\n\n        # Total calls: 6\n        assert mock_up_down_counter.add.call_count == 6\n\n        calls = mock_up_down_counter.add.call_args_list\n\n        # Step 1: IDLE +1 (creation)\n        assert calls[0][0][0] == 1\n        assert calls[0][1][\"attributes\"][DB_CLIENT_CONNECTION_STATE] == \"idle\"\n\n        # Step 2: IDLE -1, USED +1 (acquire)\n        assert calls[1][0][0] == -1\n        assert calls[1][1][\"attributes\"][DB_CLIENT_CONNECTION_STATE] == \"idle\"\n        assert calls[2][0][0] == 1\n        assert calls[2][1][\"attributes\"][DB_CLIENT_CONNECTION_STATE] == \"used\"\n\n        # Step 3: USED -1, IDLE +1 (release)\n        assert calls[3][0][0] == -1\n        assert calls[3][1][\"attributes\"][DB_CLIENT_CONNECTION_STATE] == \"used\"\n        assert calls[4][0][0] == 1\n        assert calls[4][1][\"attributes\"][DB_CLIENT_CONNECTION_STATE] == \"idle\"\n\n        # Step 4: IDLE -1 (disconnect)\n        assert calls[5][0][0] == -1\n        assert calls[5][1][\"attributes\"][DB_CLIENT_CONNECTION_STATE] == \"idle\"\n\n\nclass TestInitCSCItems:\n    \"\"\"Tests for init_csc_items and register_csc_items_callback.\"\"\"\n\n    @pytest.fixture\n    def mock_observable_gauge(self):\n        \"\"\"Create a mock observable gauge.\"\"\"\n        return MagicMock()\n\n    @pytest.fixture\n    def mock_meter_with_observable(self, mock_observable_gauge):\n        \"\"\"Create a mock meter that returns our mock observable gauge.\"\"\"\n        meter = MagicMock()\n        meter.create_observable_gauge.return_value = mock_observable_gauge\n        return meter\n\n    @pytest.fixture\n    def mock_config_with_csc(self):\n        \"\"\"Create a config with CSC metric group enabled.\"\"\"\n        return OTelConfig(metric_groups=[MetricGroup.CSC])\n\n    @pytest.fixture\n    def setup_csc_recorder(self, mock_meter_with_observable, mock_config_with_csc):\n        \"\"\"Setup recorder with mocked meter for CSC tests.\"\"\"\n        recorder.reset_collector()\n        get_observables_registry_instance().clear()\n\n        with patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(\n                mock_meter_with_observable, mock_config_with_csc\n            )\n\n        with patch.object(recorder, \"_get_or_create_collector\", return_value=collector):\n            yield mock_meter_with_observable\n\n        recorder.reset_collector()\n        get_observables_registry_instance().clear()\n\n    def test_init_csc_items_creates_observable_gauge(self, setup_csc_recorder):\n        \"\"\"Test that init_csc_items creates an observable gauge.\"\"\"\n        mock_meter = setup_csc_recorder\n\n        recorder.init_csc_items()\n\n        mock_meter.create_observable_gauge.assert_called_once()\n        call_args = mock_meter.create_observable_gauge.call_args\n        assert call_args[1][\"name\"] == \"redis.client.csc.items\"\n\n    def test_init_csc_items_callback_aggregates_registry_callbacks(\n        self, setup_csc_recorder\n    ):\n        \"\"\"Test that the CSC observable callback aggregates all registered callbacks.\"\"\"\n        mock_meter = setup_csc_recorder\n\n        recorder.init_csc_items()\n\n        # Get the callback that was passed to create_observable_gauge\n        call_args = mock_meter.create_observable_gauge.call_args\n        observable_callback = call_args[1][\"callbacks\"][0]\n\n        # Register some mock CSC callbacks\n        mock_observation1 = Observation(100, attributes={\"db\": 0})\n        mock_observation2 = Observation(50, attributes={\"db\": 1})\n\n        csc_callback1 = MagicMock(return_value=[mock_observation1])\n        csc_callback2 = MagicMock(return_value=[mock_observation2])\n\n        registry = get_observables_registry_instance()\n        registry.register(recorder.CSC_ITEMS_REGISTRY_KEY, csc_callback1)\n        registry.register(recorder.CSC_ITEMS_REGISTRY_KEY, csc_callback2)\n\n        # Call the observable callback\n        observations = observable_callback(None)\n\n        # Verify both callbacks were called and observations aggregated\n        csc_callback1.assert_called_once()\n        csc_callback2.assert_called_once()\n        assert len(observations) == 2\n        assert mock_observation1 in observations\n        assert mock_observation2 in observations\n\n    def test_register_csc_items_callback_adds_callback_to_registry(\n        self, setup_csc_recorder\n    ):\n        \"\"\"Test that register_csc_items_callback adds a callback to the registry.\"\"\"\n        # Create a mock cache size callback\n        cache_size_callback = MagicMock(return_value=42)\n\n        recorder.register_csc_items_callback(\n            cache_size_callback, pool_name=\"TestPool(localhost:6379/0)\"\n        )\n\n        registry = get_observables_registry_instance()\n        callbacks = registry.get(recorder.CSC_ITEMS_REGISTRY_KEY)\n\n        assert len(callbacks) == 1\n\n        # Call the registered callback and verify it returns an observation\n        observations = callbacks[0]()\n\n        assert len(observations) == 1\n        assert observations[0].value == 42\n        cache_size_callback.assert_called_once()\n\n    def test_register_csc_items_callback_multiple_registrations(\n        self, setup_csc_recorder\n    ):\n        \"\"\"Test registering multiple CSC callbacks.\"\"\"\n        callback1 = MagicMock(return_value=10)\n        callback2 = MagicMock(return_value=20)\n\n        recorder.register_csc_items_callback(\n            callback1, pool_name=\"TestPool(localhost:6379/0)\"\n        )\n        recorder.register_csc_items_callback(\n            callback2, pool_name=\"TestPool(localhost:6379/1)\"\n        )\n\n        registry = get_observables_registry_instance()\n        callbacks = registry.get(recorder.CSC_ITEMS_REGISTRY_KEY)\n\n        assert len(callbacks) == 2\n\n        # Verify each callback returns correct observation\n        obs1 = callbacks[0]()\n        obs2 = callbacks[1]()\n\n        assert obs1[0].value == 10\n        assert obs2[0].value == 20\n\n\nclass TestHistogramBucketBoundaries:\n    \"\"\"Tests for custom histogram bucket boundaries configuration.\"\"\"\n\n    def test_custom_operation_duration_buckets_passed_to_meter(self):\n        \"\"\"Test that custom operation duration buckets are passed to create_histogram.\"\"\"\n        custom_buckets = [0.001, 0.01, 0.1, 1.0]\n        config = OTelConfig(\n            metric_groups=[MetricGroup.COMMAND],\n            buckets_operation_duration=custom_buckets,\n        )\n\n        mock_meter = MagicMock()\n        mock_meter.create_histogram.return_value = MagicMock()\n\n        with patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            RedisMetricsCollector(mock_meter, config)\n\n        # Verify create_histogram was called with the custom buckets\n        mock_meter.create_histogram.assert_called_once()\n        call_kwargs = mock_meter.create_histogram.call_args[1]\n        assert call_kwargs[\"name\"] == \"db.client.operation.duration\"\n        assert call_kwargs[\"explicit_bucket_boundaries_advisory\"] == custom_buckets\n\n    def test_custom_connection_create_time_buckets_passed_to_meter(self):\n        \"\"\"Test that custom connection create time buckets are passed to create_histogram.\"\"\"\n        custom_buckets = [0.0001, 0.001, 0.01, 0.1]\n        config = OTelConfig(\n            metric_groups=[MetricGroup.CONNECTION_BASIC],\n            buckets_connection_create_time=custom_buckets,\n        )\n\n        mock_meter = MagicMock()\n        mock_meter.create_histogram.return_value = MagicMock()\n        mock_meter.create_up_down_counter.return_value = MagicMock()\n        mock_meter.create_counter.return_value = MagicMock()\n\n        with patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            RedisMetricsCollector(mock_meter, config)\n\n        # Find the call for connection_create_time\n        histogram_calls = mock_meter.create_histogram.call_args_list\n        create_time_call = None\n        for c in histogram_calls:\n            if c[1].get(\"name\") == \"db.client.connection.create_time\":\n                create_time_call = c\n                break\n\n        assert create_time_call is not None\n        assert (\n            create_time_call[1][\"explicit_bucket_boundaries_advisory\"] == custom_buckets\n        )\n\n    def test_custom_connection_wait_time_buckets_passed_to_meter(self):\n        \"\"\"Test that custom connection wait time buckets are passed to create_histogram.\"\"\"\n        custom_buckets = [0.00001, 0.0001, 0.001, 0.01]\n        config = OTelConfig(\n            metric_groups=[MetricGroup.CONNECTION_ADVANCED],\n            buckets_connection_wait_time=custom_buckets,\n        )\n\n        mock_meter = MagicMock()\n        mock_meter.create_histogram.return_value = MagicMock()\n        mock_meter.create_counter.return_value = MagicMock()\n\n        with patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            RedisMetricsCollector(mock_meter, config)\n\n        # Find the call for connection_wait_time\n        histogram_calls = mock_meter.create_histogram.call_args_list\n        wait_time_call = None\n        for c in histogram_calls:\n            if c[1].get(\"name\") == \"db.client.connection.wait_time\":\n                wait_time_call = c\n                break\n\n        assert wait_time_call is not None\n        assert (\n            wait_time_call[1][\"explicit_bucket_boundaries_advisory\"] == custom_buckets\n        )\n\n    def test_custom_stream_lag_buckets_passed_to_meter(self):\n        \"\"\"Test that custom stream processing duration buckets are passed to create_histogram.\"\"\"\n        custom_buckets = [0.01, 0.1, 1.0, 10.0]\n        config = OTelConfig(\n            metric_groups=[MetricGroup.STREAMING],\n            buckets_stream_processing_duration=custom_buckets,\n        )\n\n        mock_meter = MagicMock()\n        mock_meter.create_histogram.return_value = MagicMock()\n\n        with patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            RedisMetricsCollector(mock_meter, config)\n\n        # Verify create_histogram was called with the custom buckets\n        mock_meter.create_histogram.assert_called_once()\n        call_kwargs = mock_meter.create_histogram.call_args[1]\n        assert call_kwargs[\"name\"] == \"redis.client.stream.lag\"\n        assert call_kwargs[\"explicit_bucket_boundaries_advisory\"] == custom_buckets\n\n    def test_default_buckets_used_when_not_specified(self):\n        \"\"\"Test that default bucket boundaries are used when not specified.\"\"\"\n        from redis.observability.config import default_operation_duration_buckets\n\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND])\n\n        mock_meter = MagicMock()\n        mock_meter.create_histogram.return_value = MagicMock()\n\n        with patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            RedisMetricsCollector(mock_meter, config)\n\n        call_kwargs = mock_meter.create_histogram.call_args[1]\n        assert (\n            call_kwargs[\"explicit_bucket_boundaries_advisory\"]\n            == default_operation_duration_buckets()\n        )\n"
  },
  {
    "path": "tests/test_parsers/test_errors.py",
    "content": "import socket\nfrom unittest.mock import patch\n\nimport pytest\nfrom redis.client import Redis\nfrom redis.exceptions import ExternalAuthProviderError\n\n\nclass MockSocket:\n    \"\"\"Mock socket that simulates Redis protocol responses.\"\"\"\n\n    def __init__(self):\n        self.sent_data = []\n        self.closed = False\n        self.pending_responses = []\n\n    def connect(self, address):\n        pass\n\n    def send(self, data):\n        \"\"\"Simulate sending data to Redis.\"\"\"\n        if self.closed:\n            raise ConnectionError(\"Socket is closed\")\n        self.sent_data.append(data)\n\n        # Analyze the command and prepare appropriate response\n        if b\"HELLO\" in data:\n            response = b\"%7\\r\\n$6\\r\\nserver\\r\\n$5\\r\\nredis\\r\\n$7\\r\\nversion\\r\\n$5\\r\\n7.4.0\\r\\n$5\\r\\nproto\\r\\n:3\\r\\n$2\\r\\nid\\r\\n:1\\r\\n$4\\r\\nmode\\r\\n$10\\r\\nstandalone\\r\\n$4\\r\\nrole\\r\\n$6\\r\\nmaster\\r\\n$7\\r\\nmodules\\r\\n*0\\r\\n\"\n            self.pending_responses.append(response)\n        elif b\"SET\" in data:\n            response = b\"+OK\\r\\n\"\n            self.pending_responses.append(response)\n        elif b\"GET\" in data:\n            # Extract key and provide appropriate response\n            if b\"hello\" in data:\n                response = b\"$5\\r\\nworld\\r\\n\"\n                self.pending_responses.append(response)\n            # Handle specific keys used in tests\n            elif b\"ldap_error\" in data:\n                self.pending_responses.append(b\"-ERR problem with LDAP service\\r\\n\")\n            else:\n                self.pending_responses.append(b\"$-1\\r\\n\")  # NULL response\n        else:\n            self.pending_responses.append(b\"+OK\\r\\n\")  # Default response\n\n        return len(data)\n\n    def sendall(self, data):\n        \"\"\"Simulate sending all data to Redis.\"\"\"\n        return self.send(data)\n\n    def recv(self, bufsize):\n        \"\"\"Simulate receiving data from Redis.\"\"\"\n        if self.closed:\n            raise ConnectionError(\"Socket is closed\")\n\n        # Use pending responses that were prepared when commands were sent\n        if self.pending_responses:\n            response = self.pending_responses.pop(0)\n            return response[:bufsize]  # Respect buffer size\n        else:\n            # No data available - this should block or raise an exception\n            # For can_read checks, we should indicate no data is available\n            import errno\n\n            raise BlockingIOError(errno.EAGAIN, \"Resource temporarily unavailable\")\n\n    def recv_into(self, buffer, nbytes=0):\n        \"\"\"\n        Receive data from Redis and write it into the provided buffer.\n        Returns the number of bytes written.\n\n        This method is used by the hiredis parser for efficient data reading.\n        \"\"\"\n        if self.closed:\n            raise ConnectionError(\"Socket is closed\")\n\n        # Use pending responses that were prepared when commands were sent\n        if self.pending_responses:\n            response = self.pending_responses.pop(0)\n\n            # Determine how many bytes to write\n            if nbytes == 0:\n                nbytes = len(buffer)\n\n            # Write data into the buffer (up to nbytes or response length)\n            bytes_to_write = min(len(response), nbytes, len(buffer))\n            buffer[:bytes_to_write] = response[:bytes_to_write]\n\n            return bytes_to_write\n        else:\n            # No data available - this should block or raise an exception\n            # For can_read checks, we should indicate no data is available\n            import errno\n\n            raise BlockingIOError(errno.EAGAIN, \"Resource temporarily unavailable\")\n\n    def fileno(self):\n        \"\"\"Return a fake file descriptor for select/poll operations.\"\"\"\n        return 1  # Fake file descriptor\n\n    def close(self):\n        \"\"\"Simulate closing the socket.\"\"\"\n        self.closed = True\n        self.address = None\n        self.timeout = None\n\n    def settimeout(self, timeout):\n        pass\n\n    def setsockopt(self, level, optname, value):\n        pass\n\n    def setblocking(self, blocking):\n        pass\n\n    def shutdown(self, how):\n        pass\n\n\nclass TestErrorParsing:\n    def setup_method(self):\n        \"\"\"Set up test fixtures with mocked sockets.\"\"\"\n        self.mock_sockets = []\n        self.original_socket = socket.socket\n\n        # Mock socket creation to return our mock sockets\n        def mock_socket_factory(*args, **kwargs):\n            mock_sock = MockSocket()\n            self.mock_sockets.append(mock_sock)\n            return mock_sock\n\n        self.socket_patcher = patch(\"socket.socket\", side_effect=mock_socket_factory)\n        self.socket_patcher.start()\n\n        # Mock select.select to simulate data availability for reading\n        def mock_select(rlist, wlist, xlist, timeout=0):\n            # Check if any of the sockets in rlist have data available\n            ready_sockets = []\n            for sock in rlist:\n                if hasattr(sock, \"connected\") and sock.connected and not sock.closed:\n                    # Only return socket as ready if it actually has data to read\n                    if hasattr(sock, \"pending_responses\") and sock.pending_responses:\n                        ready_sockets.append(sock)\n                    # Don't return socket as ready just because it received commands\n                    # Only when there are actual responses available\n            return (ready_sockets, [], [])\n\n        self.select_patcher = patch(\"select.select\", side_effect=mock_select)\n        self.select_patcher.start()\n\n    def teardown_method(self):\n        \"\"\"Clean up test fixtures.\"\"\"\n        self.socket_patcher.stop()\n        self.select_patcher.stop()\n\n    @pytest.mark.parametrize(\"protocol_version\", [2, 3])\n    def test_external_auth_provider_error(self, protocol_version):\n        client = Redis(\n            protocol=protocol_version,\n        )\n        client.set(\"hello\", \"world\")\n\n        with pytest.raises(ExternalAuthProviderError):\n            client.get(\"ldap_error\")\n"
  },
  {
    "path": "tests/test_parsers/test_helpers.py",
    "content": "from redis._parsers.helpers import parse_info, parse_client_list\n\n\ndef test_parse_info():\n    info_output = \"\"\"\n# Modules\nmodule:name=search,ver=999999,api=1,filters=0,usedby=[],using=[ReJSON],options=[handle-io-errors]\n\n# search_fields_statistics\nsearch_fields_text:Text=3\nsearch_fields_tag:Tag=2,Sortable=1\n\n# search_version\nsearch_version:99.99.99\nsearch_redis_version:7.2.2 - oss\n\n# search_runtime_configurations\nsearch_query_timeout_ms:500\n    \"\"\"\n    info = parse_info(info_output)\n\n    assert isinstance(info[\"modules\"], list)\n    assert isinstance(info[\"modules\"][0], dict)\n    assert info[\"modules\"][0][\"name\"] == \"search\"\n\n    assert isinstance(info[\"search_fields_text\"], dict)\n    assert info[\"search_fields_text\"][\"Text\"] == 3\n\n    assert isinstance(info[\"search_fields_tag\"], dict)\n    assert info[\"search_fields_tag\"][\"Tag\"] == 2\n    assert info[\"search_fields_tag\"][\"Sortable\"] == 1\n\n    assert info[\"search_version\"] == \"99.99.99\"\n    assert info[\"search_redis_version\"] == \"7.2.2 - oss\"\n    assert info[\"search_query_timeout_ms\"] == 500\n\n\ndef test_parse_info_list():\n    info_output = \"\"\"\nlist_one:a,\nlist_two:a b,,c,10,1.1\n    \"\"\"\n    info = parse_info(info_output)\n\n    assert isinstance(info[\"list_one\"], list)\n    assert info[\"list_one\"] == [\"a\"]\n\n    assert isinstance(info[\"list_two\"], list)\n    assert info[\"list_two\"] == [\"a b\", \"c\", 10, 1.1]\n\n\ndef test_parse_info_list_dict_mixed():\n    info_output = \"\"\"\nlist_one:a,b=1\nlist_two:a b=foo,,c,d=bar,e,\n    \"\"\"\n    info = parse_info(info_output)\n\n    assert isinstance(info[\"list_one\"], dict)\n    assert info[\"list_one\"] == {\"a\": True, \"b\": 1}\n\n    assert isinstance(info[\"list_two\"], dict)\n    assert info[\"list_two\"] == {\"a b\": \"foo\", \"c\": True, \"d\": \"bar\", \"e\": True}\n\n\ndef test_parse_client_list():\n    response = \"id=7 addr=/tmp/redis sock/redis.sock:0 fd=9 name=test=_complex_[name] age=-1 idle=0 cmd=client|list user=default lib-name=go-redis(,go1.24.4) lib-ver=\"\n    expected = [\n        {\n            \"id\": \"7\",\n            \"addr\": \"/tmp/redis sock/redis.sock:0\",\n            \"fd\": \"9\",\n            \"name\": \"test=_complex_[name]\",\n            \"age\": \"-1\",\n            \"idle\": \"0\",\n            \"cmd\": \"client|list\",\n            \"user\": \"default\",\n            \"lib-name\": \"go-redis(,go1.24.4)\",\n            \"lib-ver\": \"\",\n        }\n    ]\n    clients = parse_client_list(response)\n    assert clients == expected\n"
  },
  {
    "path": "tests/test_pipeline.py",
    "content": "from contextlib import closing\nfrom unittest import mock\n\nimport pytest\nfrom redis import RedisClusterException\nimport redis\nfrom redis.client import Pipeline\nfrom redis.observability import recorder\nfrom redis.observability.config import OTelConfig, MetricGroup\nfrom redis.observability.metrics import RedisMetricsCollector\n\nfrom .conftest import skip_if_server_version_lt, wait_for_command\n\n\nclass TestPipeline:\n    def test_pipeline_is_true(self, r):\n        \"Ensure pipeline instances are not false-y\"\n        with r.pipeline() as pipe:\n            assert pipe\n\n    def test_pipeline(self, r):\n        with r.pipeline() as pipe:\n            (\n                pipe.set(\"a\", \"a1\")\n                .get(\"a\")\n                .zadd(\"z\", {\"z1\": 1})\n                .zadd(\"z\", {\"z2\": 4})\n                .zincrby(\"z\", 1, \"z1\")\n            )\n            assert pipe.execute() == [\n                True,\n                b\"a1\",\n                True,\n                True,\n                2.0,\n            ]\n\n    def test_pipeline_memoryview(self, r):\n        with r.pipeline() as pipe:\n            (pipe.set(\"a\", memoryview(b\"a1\")).get(\"a\"))\n            assert pipe.execute() == [True, b\"a1\"]\n\n    def test_pipeline_length(self, r):\n        with r.pipeline() as pipe:\n            # Initially empty.\n            assert len(pipe) == 0\n\n            # Fill 'er up!\n            pipe.set(\"a\", \"a1\").set(\"b\", \"b1\").set(\"c\", \"c1\")\n            assert len(pipe) == 3\n\n            # Execute calls reset(), so empty once again.\n            pipe.execute()\n            assert len(pipe) == 0\n\n    def test_pipeline_no_transaction(self, r):\n        with r.pipeline(transaction=False) as pipe:\n            pipe.set(\"a\", \"a1\").set(\"b\", \"b1\").set(\"c\", \"c1\")\n            assert pipe.execute() == [True, True, True]\n            assert r[\"a\"] == b\"a1\"\n            assert r[\"b\"] == b\"b1\"\n            assert r[\"c\"] == b\"c1\"\n\n    @pytest.mark.onlynoncluster\n    def test_pipeline_no_transaction_watch(self, r):\n        r[\"a\"] = 0\n\n        with r.pipeline(transaction=False) as pipe:\n            pipe.watch(\"a\")\n            a = pipe.get(\"a\")\n\n            pipe.multi()\n            pipe.set(\"a\", int(a) + 1)\n            assert pipe.execute() == [True]\n\n    @pytest.mark.onlynoncluster\n    def test_pipeline_no_transaction_watch_failure(self, r):\n        r[\"a\"] = 0\n\n        with r.pipeline(transaction=False) as pipe:\n            pipe.watch(\"a\")\n            a = pipe.get(\"a\")\n\n            r[\"a\"] = \"bad\"\n\n            pipe.multi()\n            pipe.set(\"a\", int(a) + 1)\n\n            with pytest.raises(redis.WatchError):\n                pipe.execute()\n\n            assert r[\"a\"] == b\"bad\"\n\n    def test_exec_error_in_response(self, r):\n        \"\"\"\n        an invalid pipeline command at exec time adds the exception instance\n        to the list of returned values\n        \"\"\"\n        r[\"c\"] = \"a\"\n        with r.pipeline() as pipe:\n            pipe.set(\"a\", 1).set(\"b\", 2).lpush(\"c\", 3).set(\"d\", 4)\n            result = pipe.execute(raise_on_error=False)\n\n            assert result[0]\n            assert r[\"a\"] == b\"1\"\n            assert result[1]\n            assert r[\"b\"] == b\"2\"\n\n            # we can't lpush to a key that's a string value, so this should\n            # be a ResponseError exception\n            assert isinstance(result[2], redis.ResponseError)\n            assert r[\"c\"] == b\"a\"\n\n            # since this isn't a transaction, the other commands after the\n            # error are still executed\n            assert result[3]\n            assert r[\"d\"] == b\"4\"\n\n            # make sure the pipe was restored to a working state\n            assert pipe.set(\"z\", \"zzz\").execute() == [True]\n            assert r[\"z\"] == b\"zzz\"\n\n    def test_exec_error_raised(self, r):\n        r[\"c\"] = \"a\"\n        with r.pipeline() as pipe:\n            pipe.set(\"a\", 1).set(\"b\", 2).lpush(\"c\", 3).set(\"d\", 4)\n            with pytest.raises(redis.ResponseError) as ex:\n                pipe.execute()\n            assert str(ex.value).startswith(\n                \"Command # 3 (LPUSH c 3) of pipeline caused error: \"\n            )\n\n            # make sure the pipe was restored to a working state\n            assert pipe.set(\"z\", \"zzz\").execute() == [True]\n            assert r[\"z\"] == b\"zzz\"\n\n    @pytest.mark.onlynoncluster\n    def test_transaction_with_empty_error_command(self, r):\n        \"\"\"\n        Commands with custom EMPTY_ERROR functionality return their default\n        values in the pipeline no matter the raise_on_error preference\n        \"\"\"\n        for error_switch in (True, False):\n            with r.pipeline() as pipe:\n                pipe.set(\"a\", 1).mget([]).set(\"c\", 3)\n                result = pipe.execute(raise_on_error=error_switch)\n\n                assert result[0]\n                assert result[1] == []\n                assert result[2]\n\n    @pytest.mark.onlynoncluster\n    def test_pipeline_with_empty_error_command(self, r):\n        \"\"\"\n        Commands with custom EMPTY_ERROR functionality return their default\n        values in the pipeline no matter the raise_on_error preference\n        \"\"\"\n        for error_switch in (True, False):\n            with r.pipeline(transaction=False) as pipe:\n                pipe.set(\"a\", 1).mget([]).set(\"c\", 3)\n                result = pipe.execute(raise_on_error=error_switch)\n\n                assert result[0]\n                assert result[1] == []\n                assert result[2]\n\n    def test_parse_error_raised(self, r):\n        with r.pipeline() as pipe:\n            # the zrem is invalid because we don't pass any keys to it\n            pipe.set(\"a\", 1).zrem(\"b\").set(\"b\", 2)\n            with pytest.raises(redis.ResponseError) as ex:\n                pipe.execute()\n\n            assert str(ex.value).startswith(\n                \"Command # 2 (ZREM b) of pipeline caused error: \"\n            )\n\n            # make sure the pipe was restored to a working state\n            assert pipe.set(\"z\", \"zzz\").execute() == [True]\n            assert r[\"z\"] == b\"zzz\"\n\n    @pytest.mark.onlynoncluster\n    def test_parse_error_raised_transaction(self, r):\n        with r.pipeline() as pipe:\n            pipe.multi()\n            # the zrem is invalid because we don't pass any keys to it\n            pipe.set(\"a\", 1).zrem(\"b\").set(\"b\", 2)\n            with pytest.raises(redis.ResponseError) as ex:\n                pipe.execute()\n\n            assert str(ex.value).startswith(\n                \"Command # 2 (ZREM b) of pipeline caused error: \"\n            )\n\n            # make sure the pipe was restored to a working state\n            assert pipe.set(\"z\", \"zzz\").execute() == [True]\n            assert r[\"z\"] == b\"zzz\"\n\n    @pytest.mark.onlynoncluster\n    def test_watch_succeed(self, r):\n        r[\"a\"] = 1\n        r[\"b\"] = 2\n\n        with r.pipeline() as pipe:\n            pipe.watch(\"a\", \"b\")\n            assert pipe.watching\n            a_value = pipe.get(\"a\")\n            b_value = pipe.get(\"b\")\n            assert a_value == b\"1\"\n            assert b_value == b\"2\"\n            pipe.multi()\n\n            pipe.set(\"c\", 3)\n            assert pipe.execute() == [True]\n            assert not pipe.watching\n\n    @pytest.mark.onlynoncluster\n    def test_watch_failure(self, r):\n        r[\"a\"] = 1\n        r[\"b\"] = 2\n\n        with r.pipeline() as pipe:\n            pipe.watch(\"a\", \"b\")\n            r[\"b\"] = 3\n            pipe.multi()\n            pipe.get(\"a\")\n            with pytest.raises(redis.WatchError):\n                pipe.execute()\n\n            assert not pipe.watching\n\n    @pytest.mark.onlynoncluster\n    def test_watch_failure_in_empty_transaction(self, r):\n        r[\"a\"] = 1\n        r[\"b\"] = 2\n\n        with r.pipeline() as pipe:\n            pipe.watch(\"a\", \"b\")\n            r[\"b\"] = 3\n            pipe.multi()\n            with pytest.raises(redis.WatchError):\n                pipe.execute()\n\n            assert not pipe.watching\n\n    @pytest.mark.onlynoncluster\n    def test_unwatch(self, r):\n        r[\"a\"] = 1\n        r[\"b\"] = 2\n\n        with r.pipeline() as pipe:\n            pipe.watch(\"a\", \"b\")\n            r[\"b\"] = 3\n            pipe.unwatch()\n            assert not pipe.watching\n            pipe.get(\"a\")\n            assert pipe.execute() == [b\"1\"]\n\n    @pytest.mark.onlynoncluster\n    def test_watch_exec_no_unwatch(self, r):\n        r[\"a\"] = 1\n        r[\"b\"] = 2\n\n        with r.monitor() as m:\n            with r.pipeline() as pipe:\n                pipe.watch(\"a\", \"b\")\n                assert pipe.watching\n                a_value = pipe.get(\"a\")\n                b_value = pipe.get(\"b\")\n                assert a_value == b\"1\"\n                assert b_value == b\"2\"\n                pipe.multi()\n                pipe.set(\"c\", 3)\n                assert pipe.execute() == [True]\n                assert not pipe.watching\n\n            unwatch_command = wait_for_command(r, m, \"UNWATCH\")\n            assert unwatch_command is None, \"should not send UNWATCH\"\n\n    @pytest.mark.onlynoncluster\n    def test_watch_reset_unwatch(self, r):\n        r[\"a\"] = 1\n\n        with r.monitor() as m:\n            with r.pipeline() as pipe:\n                pipe.watch(\"a\")\n                assert pipe.watching\n                pipe.reset()\n                assert not pipe.watching\n\n            unwatch_command = wait_for_command(r, m, \"UNWATCH\")\n            assert unwatch_command is not None\n            assert unwatch_command[\"command\"] == \"UNWATCH\"\n\n    @pytest.mark.onlynoncluster\n    def test_close_is_reset(self, r):\n        with r.pipeline() as pipe:\n            called = 0\n\n            def mock_reset():\n                nonlocal called\n                called += 1\n\n            with mock.patch.object(pipe, \"reset\", mock_reset):\n                pipe.close()\n                assert called == 1\n\n    @pytest.mark.onlynoncluster\n    def test_closing(self, r):\n        with closing(r.pipeline()):\n            pass\n\n    @pytest.mark.onlynoncluster\n    def test_transaction_callable(self, r):\n        r[\"a\"] = 1\n        r[\"b\"] = 2\n        has_run = []\n\n        def my_transaction(pipe):\n            a_value = pipe.get(\"a\")\n            assert a_value in (b\"1\", b\"2\")\n            b_value = pipe.get(\"b\")\n            assert b_value == b\"2\"\n\n            # silly run-once code... incr's \"a\" so WatchError should be raised\n            # forcing this all to run again. this should incr \"a\" once to \"2\"\n            if not has_run:\n                r.incr(\"a\")\n                has_run.append(\"it has\")\n\n            pipe.multi()\n            pipe.set(\"c\", int(a_value) + int(b_value))\n\n        result = r.transaction(my_transaction, \"a\", \"b\")\n        assert result == [True]\n        assert r[\"c\"] == b\"4\"\n\n    @pytest.mark.onlynoncluster\n    def test_transaction_callable_returns_value_from_callable(self, r):\n        def callback(pipe):\n            # No need to do anything here since we only want the return value\n            return \"a\"\n\n        res = r.transaction(callback, \"my-key\", value_from_callable=True)\n        assert res == \"a\"\n\n    def test_exec_error_in_no_transaction_pipeline(self, r):\n        r[\"a\"] = 1\n        with r.pipeline(transaction=False) as pipe:\n            pipe.llen(\"a\")\n            pipe.expire(\"a\", 100)\n\n            with pytest.raises(redis.ResponseError) as ex:\n                pipe.execute()\n\n            assert str(ex.value).startswith(\n                \"Command # 1 (LLEN a) of pipeline caused error: \"\n            )\n\n        assert r[\"a\"] == b\"1\"\n\n    def test_exec_error_in_no_transaction_pipeline_unicode_command(self, r):\n        key = chr(3456) + \"abcd\" + chr(3421)\n        r[key] = 1\n        with r.pipeline(transaction=False) as pipe:\n            pipe.llen(key)\n            pipe.expire(key, 100)\n\n            with pytest.raises(redis.ResponseError) as ex:\n                pipe.execute()\n\n            expected = f\"Command # 1 (LLEN {key}) of pipeline caused error: \"\n            assert str(ex.value).startswith(expected)\n\n        assert r[key] == b\"1\"\n\n    def test_exec_error_in_pipeline_truncated(self, r):\n        key = \"a\" * 50\n        a_value = \"a\" * 20\n        b_value = \"b\" * 20\n\n        r[key] = 1\n        with r.pipeline(transaction=False) as pipe:\n            pipe.hset(key, mapping={\"field_a\": a_value, \"field_b\": b_value})\n            pipe.expire(key, 100)\n\n            with pytest.raises(redis.ResponseError) as ex:\n                pipe.execute()\n\n            expected = f\"Command # 1 (HSET {key} field_a {a_value} field_b...) of pipeline caused error: \"\n            assert str(ex.value).startswith(expected)\n\n    def test_pipeline_with_bitfield(self, r):\n        with r.pipeline() as pipe:\n            pipe.set(\"a\", \"1\")\n            bf = pipe.bitfield(\"b\")\n            pipe2 = (\n                bf.set(\"u8\", 8, 255)\n                .get(\"u8\", 0)\n                .get(\"u4\", 8)  # 1111\n                .get(\"u4\", 12)  # 1111\n                .get(\"u4\", 13)  # 1110\n                .execute()\n            )\n            pipe.get(\"a\")\n            response = pipe.execute()\n\n            assert pipe == pipe2\n            assert response == [True, [0, 0, 15, 15, 14], b\"1\"]\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"2.0.0\")\n    def test_pipeline_discard(self, r):\n        # empty pipeline should raise an error\n        with r.pipeline() as pipe:\n            pipe.set(\"key\", \"someval\")\n            pipe.discard()\n            with pytest.raises(redis.exceptions.ResponseError):\n                pipe.execute()\n\n        # setting a pipeline and discarding should do the same\n        with r.pipeline() as pipe:\n            pipe.set(\"key\", \"someval\")\n            pipe.set(\"someotherkey\", \"val\")\n            response = pipe.execute()\n            pipe.set(\"key\", \"another value!\")\n            pipe.discard()\n            pipe.set(\"key\", \"another vae!\")\n            with pytest.raises(redis.exceptions.ResponseError):\n                pipe.execute()\n\n            pipe.set(\"foo\", \"bar\")\n            response = pipe.execute()\n        assert response[0]\n        assert r.get(\"foo\") == b\"bar\"\n\n    @pytest.mark.onlynoncluster\n    def test_send_set_commands_over_pipeline(self, r: redis.Redis):\n        pipe = r.pipeline()\n        pipe.hset(\"hash:1\", \"foo\", \"bar\")\n        pipe.hset(\"hash:1\", \"bar\", \"foo\")\n        pipe.hset(\"hash:1\", \"baz\", \"bar\")\n        pipe.hgetall(\"hash:1\")\n        resp = pipe.execute()\n        assert resp == [1, 1, 1, {b\"bar\": b\"foo\", b\"baz\": b\"bar\", b\"foo\": b\"bar\"}]\n\n    @pytest.mark.onlycluster\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_pipeline_with_msetex(self, r):\n        r.delete(\"key1\", \"key2\", \"key1_transaction\", \"key2_transaction\")\n\n        p = r.pipeline()\n        with pytest.raises(RedisClusterException):\n            p.msetex({\"key1\": \"value1\", \"key2\": \"value2\"}, ex=1000)\n\n        p_transaction = r.pipeline(transaction=True)\n        with pytest.raises(RedisClusterException):\n            p_transaction.msetex(\n                {\"key1_transaction\": \"value1\", \"key2_transaction\": \"value2\"}, ex=10\n            )\n\n\nclass TestPipelineMetricsRecording:\n    \"\"\"\n    Unit tests that verify metrics are properly recorded from Pipeline\n    and delivered to the Meter through the observability recorder.\n\n    These tests use fully mocked connection and connection pool - no real Redis\n    or OTel integration is used.\n    \"\"\"\n\n    @pytest.fixture\n    def mock_connection(self):\n        \"\"\"Create a mock connection with required attributes.\"\"\"\n        conn = mock.MagicMock()\n        conn.host = \"localhost\"\n        conn.port = 6379\n        conn.db = 0\n\n        # Mock retry to just execute the function directly\n        def mock_call_with_retry(do, fail, is_retryable=None, with_failure_count=False):\n            return do()\n\n        conn.retry.call_with_retry = mock_call_with_retry\n\n        return conn\n\n    @pytest.fixture\n    def mock_connection_pool(self, mock_connection):\n        \"\"\"Create a mock connection pool.\"\"\"\n        pool = mock.MagicMock()\n        pool.get_connection.return_value = mock_connection\n        pool.get_encoder.return_value = mock.MagicMock()\n        return pool\n\n    @pytest.fixture\n    def mock_meter(self):\n        \"\"\"Create a mock Meter that tracks all instrument calls.\"\"\"\n        meter = mock.MagicMock()\n\n        # Create mock histogram for operation duration\n        self.operation_duration = mock.MagicMock()\n        # Create mock counter for client errors\n        self.client_errors = mock.MagicMock()\n\n        def create_histogram_side_effect(name, **kwargs):\n            if name == \"db.client.operation.duration\":\n                return self.operation_duration\n            return mock.MagicMock()\n\n        def create_counter_side_effect(name, **kwargs):\n            if name == \"redis.client.errors\":\n                return self.client_errors\n            return mock.MagicMock()\n\n        meter.create_counter.side_effect = create_counter_side_effect\n        meter.create_up_down_counter.return_value = mock.MagicMock()\n        meter.create_histogram.side_effect = create_histogram_side_effect\n\n        return meter\n\n    @pytest.fixture\n    def setup_pipeline_with_otel(\n        self, mock_connection_pool, mock_connection, mock_meter\n    ):\n        \"\"\"\n        Setup a Pipeline with mocked connection and OTel collector.\n        Returns tuple of (pipeline, operation_duration_mock).\n        \"\"\"\n\n        # Reset any existing collector state\n        recorder.reset_collector()\n\n        # Create config with COMMAND group enabled\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND])\n\n        # Create collector with mocked meter\n        with mock.patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        # Patch the recorder to use our collector\n        with mock.patch.object(\n            recorder, \"_get_or_create_collector\", return_value=collector\n        ):\n            # Create pipeline with mocked connection pool\n            pipeline = Pipeline(\n                connection_pool=mock_connection_pool,\n                response_callbacks={},\n                transaction=True,\n                shard_hint=None,\n            )\n\n            yield pipeline, self.operation_duration\n\n        # Cleanup\n        recorder.reset_collector()\n\n    def test_pipeline_execute_records_metric(self, setup_pipeline_with_otel):\n        \"\"\"\n        Test that executing a pipeline records operation duration metric\n        which is delivered to the Meter's histogram.record() method.\n        \"\"\"\n        pipeline, operation_duration_mock = setup_pipeline_with_otel\n\n        # Mock _execute_transaction to return successful responses\n        pipeline._execute_transaction = mock.MagicMock(\n            return_value=[True, True, b\"value1\"]\n        )\n\n        # Queue commands in the pipeline\n        pipeline.command_stack = [\n            ((\"SET\", \"key1\", \"value1\"), {}),\n            ((\"SET\", \"key2\", \"value2\"), {}),\n            ((\"GET\", \"key1\"), {}),\n        ]\n\n        # Execute the pipeline\n        pipeline.execute()\n\n        # Verify the Meter's histogram.record() was called\n        operation_duration_mock.record.assert_called_once()\n\n        # Get the call arguments\n        call_args = operation_duration_mock.record.call_args\n\n        # Verify duration was recorded (first positional arg)\n        duration = call_args[0][0]\n        assert isinstance(duration, float)\n        assert duration >= 0\n\n        # Verify attributes\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[\"db.operation.name\"] == \"MULTI\"\n        # Note: db.operation.batch.size is no longer included in OTel attributes\n        assert \"db.operation.batch.size\" not in attrs\n        assert attrs[\"server.address\"] == \"localhost\"\n        assert attrs[\"server.port\"] == 6379\n        assert attrs[\"db.namespace\"] == \"0\"\n\n    def test_pipeline_no_transaction_records_pipeline_command_name(\n        self, mock_connection_pool, mock_connection, mock_meter\n    ):\n        \"\"\"\n        Test that executing a pipeline without transaction\n        records metric with command_name='PIPELINE'.\n        \"\"\"\n        recorder.reset_collector()\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND])\n\n        with mock.patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        with mock.patch.object(\n            recorder, \"_get_or_create_collector\", return_value=collector\n        ):\n            # Create pipeline with transaction=False\n            pipeline = Pipeline(\n                connection_pool=mock_connection_pool,\n                response_callbacks={},\n                transaction=False,  # Non-transaction mode\n                shard_hint=None,\n            )\n\n            pipeline._execute_pipeline = mock.MagicMock(return_value=[True, True])\n            pipeline.command_stack = [\n                ((\"SET\", \"key1\", \"value1\"), {}),\n                ((\"SET\", \"key2\", \"value2\"), {}),\n            ]\n\n            pipeline.execute()\n\n            # Verify command name is PIPELINE\n            call_args = self.operation_duration.record.call_args\n            attrs = call_args[1][\"attributes\"]\n            assert attrs[\"db.operation.name\"] == \"PIPELINE\"\n\n        recorder.reset_collector()\n\n    def test_pipeline_error_records_error_count(\n        self, mock_connection_pool, mock_connection, mock_meter\n    ):\n        \"\"\"\n        Test that when a pipeline execution raises an exception,\n        record_error_count is called (not record_operation_duration).\n        Direct failures call record_error_count, not record_operation_duration.\n        \"\"\"\n        recorder.reset_collector()\n        # Enable RESILIENCY metric group for error counting\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND, MetricGroup.RESILIENCY])\n\n        with mock.patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        with mock.patch.object(\n            recorder, \"_get_or_create_collector\", return_value=collector\n        ):\n            pipeline = Pipeline(\n                connection_pool=mock_connection_pool,\n                response_callbacks={},\n                transaction=False,\n                shard_hint=None,\n            )\n\n            # Make execute raise an exception\n            test_error = redis.ResponseError(\"WRONGTYPE Operation error\")\n            pipeline._execute_pipeline = mock.MagicMock(side_effect=test_error)\n            pipeline.command_stack = [((\"LPUSH\", \"string_key\", \"value\"), {})]\n\n            # Execute should raise the error\n            with pytest.raises(redis.ResponseError):\n                pipeline.execute()\n\n            # For direct failures (no retries), record_error_count is called\n            # record_operation_duration is NOT called for direct failures\n            self.operation_duration.record.assert_not_called()\n\n            # Verify record_error_count was called\n            self.client_errors.add.assert_called_once()\n            error_call_args = self.client_errors.add.call_args\n            error_attrs = error_call_args[1][\"attributes\"]\n            assert \"error.type\" in error_attrs\n\n        recorder.reset_collector()\n\n    def test_pipeline_server_attributes_recorded(self, setup_pipeline_with_otel):\n        \"\"\"\n        Test that server address, port, and db namespace are correctly recorded.\n        \"\"\"\n        pipeline, operation_duration_mock = setup_pipeline_with_otel\n\n        pipeline._execute_transaction = mock.MagicMock(return_value=[True])\n        pipeline.command_stack = [((\"PING\",), {})]\n\n        pipeline.execute()\n\n        call_args = operation_duration_mock.record.call_args\n        attrs = call_args[1][\"attributes\"]\n\n        # Verify server attributes match mock connection\n        assert attrs[\"server.address\"] == \"localhost\"\n        assert attrs[\"server.port\"] == 6379\n        assert attrs[\"db.namespace\"] == \"0\"\n\n    def test_multiple_pipeline_executions_record_multiple_metrics(\n        self, mock_connection_pool, mock_connection, mock_meter\n    ):\n        \"\"\"\n        Test that each pipeline execution records a separate metric to the Meter.\n        \"\"\"\n        recorder.reset_collector()\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND])\n\n        with mock.patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        with mock.patch.object(\n            recorder, \"_get_or_create_collector\", return_value=collector\n        ):\n            # First pipeline execution\n            pipeline1 = Pipeline(\n                connection_pool=mock_connection_pool,\n                response_callbacks={},\n                transaction=True,\n                shard_hint=None,\n            )\n            pipeline1._execute_transaction = mock.MagicMock(return_value=[True])\n            pipeline1.command_stack = [((\"SET\", \"key1\", \"value1\"), {})]\n            pipeline1.execute()\n\n            # Second pipeline execution\n            pipeline2 = Pipeline(\n                connection_pool=mock_connection_pool,\n                response_callbacks={},\n                transaction=True,\n                shard_hint=None,\n            )\n            pipeline2._execute_transaction = mock.MagicMock(return_value=[True, True])\n            pipeline2.command_stack = [\n                ((\"SET\", \"key2\", \"value2\"), {}),\n                ((\"SET\", \"key3\", \"value3\"), {}),\n            ]\n            pipeline2.execute()\n\n            # Verify histogram.record() was called twice\n            assert self.operation_duration.record.call_count == 2\n\n        recorder.reset_collector()\n\n    def test_empty_pipeline_does_not_record_metric(\n        self, mock_connection_pool, mock_connection, mock_meter\n    ):\n        \"\"\"\n        Test that an empty pipeline (no commands) does not record a metric.\n        \"\"\"\n        recorder.reset_collector()\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND])\n\n        with mock.patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        with mock.patch.object(\n            recorder, \"_get_or_create_collector\", return_value=collector\n        ):\n            pipeline = Pipeline(\n                connection_pool=mock_connection_pool,\n                response_callbacks={},\n                transaction=True,\n                shard_hint=None,\n            )\n\n            # Empty command stack\n            pipeline.command_stack = []\n\n            # Execute empty pipeline\n            result = pipeline.execute()\n\n            # Should return empty list\n            assert result == []\n\n            # No metric should be recorded for empty pipeline\n            self.operation_duration.record.assert_not_called()\n\n        recorder.reset_collector()\n\n    def test_pipeline_retry_records_metric_on_each_attempt(\n        self, mock_connection_pool, mock_meter\n    ):\n        \"\"\"\n        Test that when a pipeline is retried, operation duration metric\n        is recorded for each retry attempt with retry_attempts attribute.\n        \"\"\"\n        # Create connection with retry behavior\n        mock_connection = mock.MagicMock()\n        mock_connection.host = \"localhost\"\n        mock_connection.port = 6379\n        mock_connection.db = 0\n\n        # Track retry attempts\n        max_retries = 2\n\n        def call_with_retry_impl(do, fail, is_retryable=None, with_failure_count=False):\n            \"\"\"Simulate retry behavior - fail twice, then succeed.\"\"\"\n            for attempt in range(max_retries + 1):\n                try:\n                    return do()\n                except redis.ConnectionError as e:\n                    if attempt < max_retries:\n                        if with_failure_count:\n                            fail(e, attempt + 1)\n                        else:\n                            fail(e)\n                    else:\n                        raise\n\n        mock_connection.retry.call_with_retry = call_with_retry_impl\n        mock_connection.retry.get_retries.return_value = max_retries\n\n        mock_connection_pool.get_connection.return_value = mock_connection\n\n        recorder.reset_collector()\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND])\n\n        with mock.patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        with mock.patch.object(\n            recorder, \"_get_or_create_collector\", return_value=collector\n        ):\n            pipeline = Pipeline(\n                connection_pool=mock_connection_pool,\n                response_callbacks={},\n                transaction=False,\n                shard_hint=None,\n            )\n\n            # Make pipeline fail twice then succeed\n            call_count = [0]\n\n            def execute_pipeline_impl(*args, **kwargs):\n                call_count[0] += 1\n                if call_count[0] <= 2:\n                    raise redis.ConnectionError(\"Connection failed\")\n                return [True, True]\n\n            pipeline._execute_pipeline = mock.MagicMock(\n                side_effect=execute_pipeline_impl\n            )\n            pipeline.command_stack = [\n                ((\"SET\", \"key1\", \"value1\"), {}),\n                ((\"SET\", \"key2\", \"value2\"), {}),\n            ]\n\n            # Execute pipeline - should retry twice then succeed\n            pipeline.execute()\n\n            # Verify histogram.record() was called 3 times:\n            # 2 retry attempts + 1 final success\n            assert self.operation_duration.record.call_count == 3\n\n            calls = self.operation_duration.record.call_args_list\n\n            # First two calls should have error.type (retry attempts)\n            assert \"error.type\" in calls[0][1][\"attributes\"]\n            assert \"error.type\" in calls[1][1][\"attributes\"]\n\n            # Last call should be success (no error.type)\n            assert \"error.type\" not in calls[2][1][\"attributes\"]\n\n            # Note: db.operation.batch.size is no longer included in OTel attributes\n            for call in calls:\n                assert \"db.operation.batch.size\" not in call[1][\"attributes\"]\n\n        recorder.reset_collector()\n\n    def test_pipeline_retry_exhausted_records_final_error_metrics(\n        self, mock_connection_pool, mock_meter\n    ):\n        \"\"\"\n        Test that when all retries are exhausted, metrics are recorded correctly:\n        - record_operation_duration is called for each retry attempt (with error info)\n        - record_error_count is called for the final error (after all retries exhausted)\n        \"\"\"\n        mock_connection = mock.MagicMock()\n        mock_connection.host = \"localhost\"\n        mock_connection.port = 6379\n        mock_connection.db = 0\n\n        max_retries = 2\n\n        def call_with_retry_impl(do, fail, is_retryable=None, with_failure_count=False):\n            \"\"\"Simulate retry behavior - always fail.\"\"\"\n            for attempt in range(max_retries + 1):\n                try:\n                    return do()\n                except redis.ConnectionError as e:\n                    if attempt < max_retries:\n                        if with_failure_count:\n                            fail(e, attempt + 1)\n                        else:\n                            fail(e)\n                    else:\n                        raise\n\n        mock_connection.retry.call_with_retry = call_with_retry_impl\n        mock_connection.retry.get_retries.return_value = max_retries\n\n        mock_connection_pool.get_connection.return_value = mock_connection\n\n        recorder.reset_collector()\n        # Enable both COMMAND and RESILIENCY metric groups\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND, MetricGroup.RESILIENCY])\n\n        with mock.patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        with mock.patch.object(\n            recorder, \"_get_or_create_collector\", return_value=collector\n        ):\n            pipeline = Pipeline(\n                connection_pool=mock_connection_pool,\n                response_callbacks={},\n                transaction=False,\n                shard_hint=None,\n            )\n\n            # Make pipeline always fail\n            pipeline._execute_pipeline = mock.MagicMock(\n                side_effect=redis.ConnectionError(\"Connection failed\")\n            )\n            pipeline.command_stack = [\n                ((\"SET\", \"key1\", \"value1\"), {}),\n            ]\n\n            # Execute pipeline - should fail after all retries\n            with pytest.raises(redis.ConnectionError):\n                pipeline.execute()\n\n            # Verify histogram.record() was called 2 times (for retry attempts only)\n            # Final error does NOT call record_operation_duration\n            assert self.operation_duration.record.call_count == 2\n\n            calls = self.operation_duration.record.call_args_list\n\n            # Both retry calls should have error.type\n            for call in calls:\n                assert \"error.type\" in call[1][\"attributes\"]\n                assert call[1][\"attributes\"][\"db.operation.name\"] == \"PIPELINE\"\n\n            # Verify record_error_count was called once for the final error\n            self.client_errors.add.assert_called_once()\n            error_call_args = self.client_errors.add.call_args\n            error_attrs = error_call_args[1][\"attributes\"]\n            assert \"error.type\" in error_attrs\n\n        recorder.reset_collector()\n"
  },
  {
    "path": "tests/test_pubsub.py",
    "content": "import platform\nimport queue\nimport socket\nimport threading\nimport time\nfrom collections import defaultdict\nfrom unittest import mock\nfrom unittest.mock import patch\n\nimport pytest\nimport redis\nfrom redis.client import PubSub\nfrom redis.event import EventDispatcher\nfrom redis.exceptions import ConnectionError\nfrom redis.observability import recorder\nfrom redis.observability.config import OTelConfig, MetricGroup\nfrom redis.observability.metrics import RedisMetricsCollector\n\nfrom .conftest import (\n    _get_client,\n    is_resp2_connection,\n    skip_if_redis_enterprise,\n    skip_if_server_version_lt,\n)\n\n\ndef wait_for_message(\n    pubsub, timeout=0.5, ignore_subscribe_messages=False, node=None, func=None\n):\n    now = time.monotonic()\n    timeout = now + timeout\n    while now < timeout:\n        if node:\n            message = pubsub.get_sharded_message(\n                ignore_subscribe_messages=ignore_subscribe_messages, target_node=node\n            )\n        elif func:\n            message = func(ignore_subscribe_messages=ignore_subscribe_messages)\n        else:\n            message = pubsub.get_message(\n                ignore_subscribe_messages=ignore_subscribe_messages\n            )\n        if message is not None:\n            return message\n        time.sleep(0.01)\n        now = time.monotonic()\n    return None\n\n\ndef make_message(type, channel, data, pattern=None):\n    return {\n        \"type\": type,\n        \"pattern\": pattern and pattern.encode(\"utf-8\") or None,\n        \"channel\": channel and channel.encode(\"utf-8\") or None,\n        \"data\": data.encode(\"utf-8\") if isinstance(data, str) else data,\n    }\n\n\ndef make_subscribe_test_data(pubsub, type):\n    if type == \"channel\":\n        return {\n            \"p\": pubsub,\n            \"sub_type\": \"subscribe\",\n            \"unsub_type\": \"unsubscribe\",\n            \"sub_func\": pubsub.subscribe,\n            \"unsub_func\": pubsub.unsubscribe,\n            \"keys\": [\"foo\", \"bar\", \"uni\" + chr(4456) + \"code\"],\n        }\n    elif type == \"shard_channel\":\n        return {\n            \"p\": pubsub,\n            \"sub_type\": \"ssubscribe\",\n            \"unsub_type\": \"sunsubscribe\",\n            \"sub_func\": pubsub.ssubscribe,\n            \"unsub_func\": pubsub.sunsubscribe,\n            \"keys\": [\"foo\", \"bar\", \"uni\" + chr(4456) + \"code\"],\n        }\n    elif type == \"pattern\":\n        return {\n            \"p\": pubsub,\n            \"sub_type\": \"psubscribe\",\n            \"unsub_type\": \"punsubscribe\",\n            \"sub_func\": pubsub.psubscribe,\n            \"unsub_func\": pubsub.punsubscribe,\n            \"keys\": [\"f*\", \"b*\", \"uni\" + chr(4456) + \"*\"],\n        }\n    assert False, f\"invalid subscribe type: {type}\"\n\n\nclass TestPubSubSubscribeUnsubscribe:\n    def _test_subscribe_unsubscribe(\n        self, p, sub_type, unsub_type, sub_func, unsub_func, keys\n    ):\n        for key in keys:\n            assert sub_func(key) is None\n\n        # should be a message for each channel/pattern we just subscribed to\n        for i, key in enumerate(keys):\n            assert wait_for_message(p) == make_message(sub_type, key, i + 1)\n\n        for key in keys:\n            assert unsub_func(key) is None\n\n        # should be a message for each channel/pattern we just unsubscribed\n        # from\n        for i, key in enumerate(keys):\n            i = len(keys) - 1 - i\n            assert wait_for_message(p) == make_message(unsub_type, key, i)\n\n    def test_channel_subscribe_unsubscribe(self, r):\n        kwargs = make_subscribe_test_data(r.pubsub(), \"channel\")\n        self._test_subscribe_unsubscribe(**kwargs)\n\n    def test_pattern_subscribe_unsubscribe(self, r):\n        kwargs = make_subscribe_test_data(r.pubsub(), \"pattern\")\n        self._test_subscribe_unsubscribe(**kwargs)\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_shard_channel_subscribe_unsubscribe(self, r):\n        kwargs = make_subscribe_test_data(r.pubsub(), \"shard_channel\")\n        self._test_subscribe_unsubscribe(**kwargs)\n\n    @pytest.mark.onlycluster\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_shard_channel_subscribe_unsubscribe_cluster(self, r):\n        node_channels = defaultdict(int)\n        p = r.pubsub()\n        keys = {\n            \"foo\": r.get_node_from_key(\"foo\"),\n            \"bar\": r.get_node_from_key(\"bar\"),\n            \"uni\" + chr(4456) + \"code\": r.get_node_from_key(\"uni\" + chr(4456) + \"code\"),\n        }\n\n        for key, node in keys.items():\n            assert p.ssubscribe(key) is None\n\n        # should be a message for each shard_channel we just subscribed to\n        for key, node in keys.items():\n            node_channels[node.name] += 1\n            assert wait_for_message(p, node=node) == make_message(\n                \"ssubscribe\", key, node_channels[node.name]\n            )\n\n        for key in keys.keys():\n            assert p.sunsubscribe(key) is None\n\n        # should be a message for each shard_channel we just unsubscribed\n        # from\n        for key, node in keys.items():\n            node_channels[node.name] -= 1\n            assert wait_for_message(p, node=node) == make_message(\n                \"sunsubscribe\", key, node_channels[node.name]\n            )\n\n    def _test_resubscribe_on_reconnection(\n        self, p, sub_type, unsub_type, sub_func, unsub_func, keys\n    ):\n        for key in keys:\n            assert sub_func(key) is None\n\n        # should be a message for each channel/pattern we just subscribed to\n        for i, key in enumerate(keys):\n            assert wait_for_message(p) == make_message(sub_type, key, i + 1)\n\n        # manually disconnect\n        p.connection.disconnect()\n\n        # calling get_message again reconnects and resubscribes\n        # note, we may not re-subscribe to channels in exactly the same order\n        # so we have to do some extra checks to make sure we got them all\n        messages = []\n        for i in range(len(keys)):\n            messages.append(wait_for_message(p))\n\n        unique_channels = set()\n        assert len(messages) == len(keys)\n        for i, message in enumerate(messages):\n            assert message[\"type\"] == sub_type\n            assert message[\"data\"] == i + 1\n            assert isinstance(message[\"channel\"], bytes)\n            channel = message[\"channel\"].decode(\"utf-8\")\n            unique_channels.add(channel)\n\n        assert len(unique_channels) == len(keys)\n        for channel in unique_channels:\n            assert channel in keys\n\n    def test_resubscribe_to_channels_on_reconnection(self, r):\n        kwargs = make_subscribe_test_data(r.pubsub(), \"channel\")\n        self._test_resubscribe_on_reconnection(**kwargs)\n\n    @pytest.mark.onlynoncluster\n    def test_resubscribe_to_patterns_on_reconnection(self, r):\n        kwargs = make_subscribe_test_data(r.pubsub(), \"pattern\")\n        self._test_resubscribe_on_reconnection(**kwargs)\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_resubscribe_to_shard_channels_on_reconnection(self, r):\n        kwargs = make_subscribe_test_data(r.pubsub(), \"shard_channel\")\n        self._test_resubscribe_on_reconnection(**kwargs)\n\n    @pytest.mark.onlynoncluster\n    def test_resubscribe_binary_channel_on_reconnection(self, r):\n        \"\"\"Binary channel names that are not valid UTF-8 must survive\n        reconnection without raising ``UnicodeDecodeError``.\n        See https://github.com/redis/redis-py/issues/3912\n        \"\"\"\n        # b'\\x80\\x81\\x82' is deliberately invalid UTF-8\n        binary_channel = b\"\\x80\\x81\\x82\"\n        p = r.pubsub()\n        p.subscribe(binary_channel)\n        assert wait_for_message(p) is not None  # consume subscribe ack\n\n        # force reconnect\n        p.connection.disconnect()\n\n        # get_message triggers on_connect → re-subscribe; must not raise\n        messages = []\n        for _ in range(1):\n            message = wait_for_message(p)\n            assert message is not None\n            messages.append(message)\n\n        assert len(messages) == 1\n        assert messages[0][\"type\"] == \"subscribe\"\n        assert messages[0][\"channel\"] == binary_channel\n\n    @pytest.mark.onlynoncluster\n    def test_resubscribe_binary_pattern_on_reconnection(self, r):\n        \"\"\"Binary pattern names that are not valid UTF-8 must survive\n        reconnection without raising ``UnicodeDecodeError``.\n        See https://github.com/redis/redis-py/issues/3912\n        \"\"\"\n        binary_pattern = b\"\\x80\\x81*\"\n        p = r.pubsub()\n        p.psubscribe(binary_pattern)\n        assert wait_for_message(p) is not None  # consume psubscribe ack\n\n        # force reconnect\n        p.connection.disconnect()\n\n        messages = []\n        for _ in range(1):\n            message = wait_for_message(p)\n            assert message is not None\n            messages.append(message)\n\n        assert len(messages) == 1\n        assert messages[0][\"type\"] == \"psubscribe\"\n        assert messages[0][\"channel\"] == binary_pattern\n\n    def _test_subscribed_property(\n        self, p, sub_type, unsub_type, sub_func, unsub_func, keys\n    ):\n        assert p.subscribed is False\n        sub_func(keys[0])\n        # we're now subscribed even though we haven't processed the\n        # reply from the server just yet\n        assert p.subscribed is True\n        assert wait_for_message(p) == make_message(sub_type, keys[0], 1)\n        # we're still subscribed\n        assert p.subscribed is True\n\n        # unsubscribe from all channels\n        unsub_func()\n        # we're still technically subscribed until we process the\n        # response messages from the server\n        assert p.subscribed is True\n        assert wait_for_message(p) == make_message(unsub_type, keys[0], 0)\n        # now we're no longer subscribed as no more messages can be delivered\n        # to any channels we were listening to\n        assert p.subscribed is False\n\n        # subscribing again flips the flag back\n        sub_func(keys[0])\n        assert p.subscribed is True\n        assert wait_for_message(p) == make_message(sub_type, keys[0], 1)\n\n        # unsubscribe again\n        unsub_func()\n        assert p.subscribed is True\n        # subscribe to another channel before reading the unsubscribe response\n        sub_func(keys[1])\n        assert p.subscribed is True\n        # read the unsubscribe for key1\n        assert wait_for_message(p) == make_message(unsub_type, keys[0], 0)\n        # we're still subscribed to key2, so subscribed should still be True\n        assert p.subscribed is True\n        # read the key2 subscribe message\n        assert wait_for_message(p) == make_message(sub_type, keys[1], 1)\n        unsub_func()\n        # haven't read the message yet, so we're still subscribed\n        assert p.subscribed is True\n        assert wait_for_message(p) == make_message(unsub_type, keys[1], 0)\n        # now we're finally unsubscribed\n        assert p.subscribed is False\n\n    def test_subscribe_property_with_channels(self, r):\n        kwargs = make_subscribe_test_data(r.pubsub(), \"channel\")\n        self._test_subscribed_property(**kwargs)\n\n    @pytest.mark.onlynoncluster\n    def test_subscribe_property_with_patterns(self, r):\n        kwargs = make_subscribe_test_data(r.pubsub(), \"pattern\")\n        self._test_subscribed_property(**kwargs)\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_subscribe_property_with_shard_channels(self, r):\n        kwargs = make_subscribe_test_data(r.pubsub(), \"shard_channel\")\n        self._test_subscribed_property(**kwargs)\n\n    @pytest.mark.onlycluster\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_subscribe_property_with_shard_channels_cluster(self, r):\n        p = r.pubsub()\n        keys = [\"foo\", \"bar\", \"uni\" + chr(4456) + \"code\"]\n        nodes = [r.get_node_from_key(key) for key in keys]\n        assert p.subscribed is False\n        p.ssubscribe(keys[0])\n        # we're now subscribed even though we haven't processed the\n        # reply from the server just yet\n        assert p.subscribed is True\n        assert wait_for_message(p, node=nodes[0]) == make_message(\n            \"ssubscribe\", keys[0], 1\n        )\n        # we're still subscribed\n        assert p.subscribed is True\n\n        # unsubscribe from all shard_channels\n        p.sunsubscribe()\n        # we're still technically subscribed until we process the\n        # response messages from the server\n        assert p.subscribed is True\n        assert wait_for_message(p, node=nodes[0]) == make_message(\n            \"sunsubscribe\", keys[0], 0\n        )\n        # now we're no longer subscribed as no more messages can be delivered\n        # to any channels we were listening to\n        assert p.subscribed is False\n\n        # subscribing again flips the flag back\n        p.ssubscribe(keys[0])\n        assert p.subscribed is True\n        assert wait_for_message(p, node=nodes[0]) == make_message(\n            \"ssubscribe\", keys[0], 1\n        )\n\n        # unsubscribe again\n        p.sunsubscribe()\n        assert p.subscribed is True\n        # subscribe to another shard_channel before reading the unsubscribe response\n        p.ssubscribe(keys[1])\n        assert p.subscribed is True\n        # read the unsubscribe for key1\n        assert wait_for_message(p, node=nodes[0]) == make_message(\n            \"sunsubscribe\", keys[0], 0\n        )\n        # we're still subscribed to key2, so subscribed should still be True\n        assert p.subscribed is True\n        # read the key2 subscribe message\n        assert wait_for_message(p, node=nodes[1]) == make_message(\n            \"ssubscribe\", keys[1], 1\n        )\n        p.sunsubscribe()\n        # haven't read the message yet, so we're still subscribed\n        assert p.subscribed is True\n        assert wait_for_message(p, node=nodes[1]) == make_message(\n            \"sunsubscribe\", keys[1], 0\n        )\n        # now we're finally unsubscribed\n        assert p.subscribed is False\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_ignore_all_subscribe_messages(self, r):\n        p = r.pubsub(ignore_subscribe_messages=True)\n\n        checks = (\n            (p.subscribe, \"foo\", p.get_message),\n            (p.unsubscribe, \"foo\", p.get_message),\n            (p.psubscribe, \"f*\", p.get_message),\n            (p.punsubscribe, \"f*\", p.get_message),\n            (p.ssubscribe, \"foo\", p.get_sharded_message),\n            (p.sunsubscribe, \"foo\", p.get_sharded_message),\n        )\n\n        assert p.subscribed is False\n        for func, channel, get_func in checks:\n            assert func(channel) is None\n            assert p.subscribed is True\n            assert wait_for_message(p, func=get_func) is None\n        assert p.subscribed is False\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_ignore_individual_subscribe_messages(self, r):\n        p = r.pubsub()\n\n        checks = (\n            (p.subscribe, \"foo\", p.get_message),\n            (p.unsubscribe, \"foo\", p.get_message),\n            (p.psubscribe, \"f*\", p.get_message),\n            (p.punsubscribe, \"f*\", p.get_message),\n            (p.ssubscribe, \"foo\", p.get_sharded_message),\n            (p.sunsubscribe, \"foo\", p.get_sharded_message),\n        )\n\n        assert p.subscribed is False\n        for func, channel, get_func in checks:\n            assert func(channel) is None\n            assert p.subscribed is True\n            message = wait_for_message(p, ignore_subscribe_messages=True, func=get_func)\n            assert message is None\n        assert p.subscribed is False\n\n    def test_sub_unsub_resub_channels(self, r):\n        kwargs = make_subscribe_test_data(r.pubsub(), \"channel\")\n        self._test_sub_unsub_resub(**kwargs)\n\n    @pytest.mark.onlynoncluster\n    def test_sub_unsub_resub_patterns(self, r):\n        kwargs = make_subscribe_test_data(r.pubsub(), \"pattern\")\n        self._test_sub_unsub_resub(**kwargs)\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_sub_unsub_resub_shard_channels(self, r):\n        kwargs = make_subscribe_test_data(r.pubsub(), \"shard_channel\")\n        self._test_sub_unsub_resub(**kwargs)\n\n    def _test_sub_unsub_resub(\n        self, p, sub_type, unsub_type, sub_func, unsub_func, keys\n    ):\n        # https://github.com/andymccurdy/redis-py/issues/764\n        key = keys[0]\n        sub_func(key)\n        unsub_func(key)\n        sub_func(key)\n        assert p.subscribed is True\n        assert wait_for_message(p) == make_message(sub_type, key, 1)\n        assert wait_for_message(p) == make_message(unsub_type, key, 0)\n        assert wait_for_message(p) == make_message(sub_type, key, 1)\n        assert p.subscribed is True\n\n    @pytest.mark.onlycluster\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_sub_unsub_resub_shard_channels_cluster(self, r):\n        p = r.pubsub()\n        key = \"foo\"\n        p.ssubscribe(key)\n        p.sunsubscribe(key)\n        p.ssubscribe(key)\n        assert p.subscribed is True\n        assert wait_for_message(p, func=p.get_sharded_message) == make_message(\n            \"ssubscribe\", key, 1\n        )\n        assert wait_for_message(p, func=p.get_sharded_message) == make_message(\n            \"sunsubscribe\", key, 0\n        )\n        assert wait_for_message(p, func=p.get_sharded_message) == make_message(\n            \"ssubscribe\", key, 1\n        )\n        assert p.subscribed is True\n\n    def test_sub_unsub_all_resub_channels(self, r):\n        kwargs = make_subscribe_test_data(r.pubsub(), \"channel\")\n        self._test_sub_unsub_all_resub(**kwargs)\n\n    def test_sub_unsub_all_resub_patterns(self, r):\n        kwargs = make_subscribe_test_data(r.pubsub(), \"pattern\")\n        self._test_sub_unsub_all_resub(**kwargs)\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_sub_unsub_all_resub_shard_channels(self, r):\n        kwargs = make_subscribe_test_data(r.pubsub(), \"shard_channel\")\n        self._test_sub_unsub_all_resub(**kwargs)\n\n    def _test_sub_unsub_all_resub(\n        self, p, sub_type, unsub_type, sub_func, unsub_func, keys\n    ):\n        # https://github.com/andymccurdy/redis-py/issues/764\n        key = keys[0]\n        sub_func(key)\n        unsub_func()\n        sub_func(key)\n        assert p.subscribed is True\n        assert wait_for_message(p) == make_message(sub_type, key, 1)\n        assert wait_for_message(p) == make_message(unsub_type, key, 0)\n        assert wait_for_message(p) == make_message(sub_type, key, 1)\n        assert p.subscribed is True\n\n    @pytest.mark.onlycluster\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_sub_unsub_all_resub_shard_channels_cluster(self, r):\n        p = r.pubsub()\n        key = \"foo\"\n        p.ssubscribe(key)\n        p.sunsubscribe()\n        p.ssubscribe(key)\n        assert p.subscribed is True\n        assert wait_for_message(p, func=p.get_sharded_message) == make_message(\n            \"ssubscribe\", key, 1\n        )\n        assert wait_for_message(p, func=p.get_sharded_message) == make_message(\n            \"sunsubscribe\", key, 0\n        )\n        assert wait_for_message(p, func=p.get_sharded_message) == make_message(\n            \"ssubscribe\", key, 1\n        )\n        assert p.subscribed is True\n\n\nclass TestPubSubMessages:\n    def setup_method(self, method):\n        self.message = None\n\n    def message_handler(self, message):\n        self.message = message\n\n    def test_published_message_to_channel(self, r):\n        p = r.pubsub()\n        p.subscribe(\"foo\")\n        assert wait_for_message(p) == make_message(\"subscribe\", \"foo\", 1)\n        assert r.publish(\"foo\", \"test message\") == 1\n\n        message = wait_for_message(p)\n        assert isinstance(message, dict)\n        assert message == make_message(\"message\", \"foo\", \"test message\")\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_published_message_to_shard_channel(self, r):\n        p = r.pubsub()\n        p.ssubscribe(\"foo\")\n        assert wait_for_message(p) == make_message(\"ssubscribe\", \"foo\", 1)\n        assert r.spublish(\"foo\", \"test message\") == 1\n\n        message = wait_for_message(p)\n        assert isinstance(message, dict)\n        assert message == make_message(\"smessage\", \"foo\", \"test message\")\n\n    @pytest.mark.onlycluster\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_published_message_to_shard_channel_cluster(self, r):\n        p = r.pubsub()\n        p.ssubscribe(\"foo\")\n        assert wait_for_message(p, func=p.get_sharded_message) == make_message(\n            \"ssubscribe\", \"foo\", 1\n        )\n        assert r.spublish(\"foo\", \"test message\") == 1\n\n        message = wait_for_message(p, func=p.get_sharded_message)\n        assert isinstance(message, dict)\n        assert message == make_message(\"smessage\", \"foo\", \"test message\")\n\n    def test_published_message_to_pattern(self, r):\n        p = r.pubsub()\n        p.subscribe(\"foo\")\n        p.psubscribe(\"f*\")\n        assert wait_for_message(p) == make_message(\"subscribe\", \"foo\", 1)\n        assert wait_for_message(p) == make_message(\"psubscribe\", \"f*\", 2)\n        # 1 to pattern, 1 to channel\n        assert r.publish(\"foo\", \"test message\") == 2\n\n        message1 = wait_for_message(p)\n        message2 = wait_for_message(p)\n        assert isinstance(message1, dict)\n        assert isinstance(message2, dict)\n\n        expected = [\n            make_message(\"message\", \"foo\", \"test message\"),\n            make_message(\"pmessage\", \"foo\", \"test message\", pattern=\"f*\"),\n        ]\n\n        assert message1 in expected\n        assert message2 in expected\n        assert message1 != message2\n\n    def test_channel_message_handler(self, r):\n        p = r.pubsub(ignore_subscribe_messages=True)\n        p.subscribe(foo=self.message_handler)\n        assert wait_for_message(p) is None\n        assert r.publish(\"foo\", \"test message\") == 1\n        assert wait_for_message(p) is None\n        assert self.message == make_message(\"message\", \"foo\", \"test message\")\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_shard_channel_message_handler(self, r):\n        p = r.pubsub(ignore_subscribe_messages=True)\n        p.ssubscribe(foo=self.message_handler)\n        assert wait_for_message(p, func=p.get_sharded_message) is None\n        assert r.spublish(\"foo\", \"test message\") == 1\n        assert wait_for_message(p, func=p.get_sharded_message) is None\n        assert self.message == make_message(\"smessage\", \"foo\", \"test message\")\n\n    @pytest.mark.onlynoncluster\n    def test_pattern_message_handler(self, r):\n        p = r.pubsub(ignore_subscribe_messages=True)\n        p.psubscribe(**{\"f*\": self.message_handler})\n        assert wait_for_message(p) is None\n        assert r.publish(\"foo\", \"test message\") == 1\n        assert wait_for_message(p) is None\n        assert self.message == make_message(\n            \"pmessage\", \"foo\", \"test message\", pattern=\"f*\"\n        )\n\n    def test_unicode_channel_message_handler(self, r):\n        p = r.pubsub(ignore_subscribe_messages=True)\n        channel = \"uni\" + chr(4456) + \"code\"\n        channels = {channel: self.message_handler}\n        p.subscribe(**channels)\n        assert wait_for_message(p) is None\n        assert r.publish(channel, \"test message\") == 1\n        assert wait_for_message(p) is None\n        assert self.message == make_message(\"message\", channel, \"test message\")\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_unicode_shard_channel_message_handler(self, r):\n        p = r.pubsub(ignore_subscribe_messages=True)\n        channel = \"uni\" + chr(4456) + \"code\"\n        channels = {channel: self.message_handler}\n        p.ssubscribe(**channels)\n        assert wait_for_message(p, func=p.get_sharded_message) is None\n        assert r.spublish(channel, \"test message\") == 1\n        assert wait_for_message(p, func=p.get_sharded_message) is None\n        assert self.message == make_message(\"smessage\", channel, \"test message\")\n\n    @pytest.mark.onlynoncluster\n    # see: https://redis-py-cluster.readthedocs.io/en/stable/pubsub.html\n    # #known-limitations-with-pubsub\n    def test_unicode_pattern_message_handler(self, r):\n        p = r.pubsub(ignore_subscribe_messages=True)\n        pattern = \"uni\" + chr(4456) + \"*\"\n        channel = \"uni\" + chr(4456) + \"code\"\n        p.psubscribe(**{pattern: self.message_handler})\n        assert wait_for_message(p) is None\n        assert r.publish(channel, \"test message\") == 1\n        assert wait_for_message(p) is None\n        assert self.message == make_message(\n            \"pmessage\", channel, \"test message\", pattern=pattern\n        )\n\n\nclass TestPubSubRESP3Handler:\n    def my_handler(self, message):\n        self.message = [\"my handler\", message]\n\n    def test_push_handler(self, r):\n        if is_resp2_connection(r):\n            return\n        p = r.pubsub(push_handler_func=self.my_handler)\n        p.subscribe(\"foo\")\n        assert wait_for_message(p) is None\n        assert self.message == [\"my handler\", [b\"subscribe\", b\"foo\", 1]]\n        assert r.publish(\"foo\", \"test message\") == 1\n        assert wait_for_message(p) is None\n        assert self.message == [\"my handler\", [b\"message\", b\"foo\", b\"test message\"]]\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_push_handler_sharded_pubsub(self, r):\n        if is_resp2_connection(r):\n            return\n        p = r.pubsub(push_handler_func=self.my_handler)\n        p.ssubscribe(\"foo\")\n        assert wait_for_message(p, func=p.get_sharded_message) is None\n        assert self.message == [\"my handler\", [b\"ssubscribe\", b\"foo\", 1]]\n        assert r.spublish(\"foo\", \"test message\") == 1\n        assert wait_for_message(p, func=p.get_sharded_message) is None\n        assert self.message == [\"my handler\", [b\"smessage\", b\"foo\", b\"test message\"]]\n\n\nclass TestPubSubAutoDecoding:\n    \"These tests only validate that we get unicode values back\"\n\n    channel = \"uni\" + chr(4456) + \"code\"\n    pattern = \"uni\" + chr(4456) + \"*\"\n    data = \"abc\" + chr(4458) + \"123\"\n\n    def make_message(self, type, channel, data, pattern=None):\n        return {\"type\": type, \"channel\": channel, \"pattern\": pattern, \"data\": data}\n\n    def setup_method(self, method):\n        self.message = None\n\n    def message_handler(self, message):\n        self.message = message\n\n    @pytest.fixture()\n    def r(self, request):\n        return _get_client(redis.Redis, request=request, decode_responses=True)\n\n    def test_channel_subscribe_unsubscribe(self, r):\n        p = r.pubsub()\n        p.subscribe(self.channel)\n        assert wait_for_message(p) == self.make_message(\"subscribe\", self.channel, 1)\n\n        p.unsubscribe(self.channel)\n        assert wait_for_message(p) == self.make_message(\"unsubscribe\", self.channel, 0)\n\n    def test_pattern_subscribe_unsubscribe(self, r):\n        p = r.pubsub()\n        p.psubscribe(self.pattern)\n        assert wait_for_message(p) == self.make_message(\"psubscribe\", self.pattern, 1)\n\n        p.punsubscribe(self.pattern)\n        assert wait_for_message(p) == self.make_message(\"punsubscribe\", self.pattern, 0)\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_shard_channel_subscribe_unsubscribe(self, r):\n        p = r.pubsub()\n        p.ssubscribe(self.channel)\n        assert wait_for_message(p, func=p.get_sharded_message) == self.make_message(\n            \"ssubscribe\", self.channel, 1\n        )\n\n        p.sunsubscribe(self.channel)\n        assert wait_for_message(p, func=p.get_sharded_message) == self.make_message(\n            \"sunsubscribe\", self.channel, 0\n        )\n\n    def test_channel_publish(self, r):\n        p = r.pubsub()\n        p.subscribe(self.channel)\n        assert wait_for_message(p) == self.make_message(\"subscribe\", self.channel, 1)\n        r.publish(self.channel, self.data)\n        assert wait_for_message(p) == self.make_message(\n            \"message\", self.channel, self.data\n        )\n\n    @pytest.mark.onlynoncluster\n    def test_pattern_publish(self, r):\n        p = r.pubsub()\n        p.psubscribe(self.pattern)\n        assert wait_for_message(p) == self.make_message(\"psubscribe\", self.pattern, 1)\n        r.publish(self.channel, self.data)\n        assert wait_for_message(p) == self.make_message(\n            \"pmessage\", self.channel, self.data, pattern=self.pattern\n        )\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_shard_channel_publish(self, r):\n        p = r.pubsub()\n        p.ssubscribe(self.channel)\n        assert wait_for_message(p, func=p.get_sharded_message) == self.make_message(\n            \"ssubscribe\", self.channel, 1\n        )\n        r.spublish(self.channel, self.data)\n        assert wait_for_message(p, func=p.get_sharded_message) == self.make_message(\n            \"smessage\", self.channel, self.data\n        )\n\n    def test_channel_message_handler(self, r):\n        p = r.pubsub(ignore_subscribe_messages=True)\n        p.subscribe(**{self.channel: self.message_handler})\n        assert wait_for_message(p) is None\n        r.publish(self.channel, self.data)\n        assert wait_for_message(p) is None\n        assert self.message == self.make_message(\"message\", self.channel, self.data)\n\n        # test that we reconnected to the correct channel\n        self.message = None\n        p.connection.disconnect()\n        assert wait_for_message(p) is None  # should reconnect\n        new_data = self.data + \"new data\"\n        r.publish(self.channel, new_data)\n        assert wait_for_message(p) is None\n        assert self.message == self.make_message(\"message\", self.channel, new_data)\n\n    def test_pattern_message_handler(self, r):\n        p = r.pubsub(ignore_subscribe_messages=True)\n        p.psubscribe(**{self.pattern: self.message_handler})\n        assert wait_for_message(p) is None\n        r.publish(self.channel, self.data)\n        assert wait_for_message(p) is None\n        assert self.message == self.make_message(\n            \"pmessage\", self.channel, self.data, pattern=self.pattern\n        )\n\n        # test that we reconnected to the correct pattern\n        self.message = None\n        p.connection.disconnect()\n        assert wait_for_message(p) is None  # should reconnect\n        new_data = self.data + \"new data\"\n        r.publish(self.channel, new_data)\n        assert wait_for_message(p) is None\n        assert self.message == self.make_message(\n            \"pmessage\", self.channel, new_data, pattern=self.pattern\n        )\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_shard_channel_message_handler(self, r):\n        p = r.pubsub(ignore_subscribe_messages=True)\n        p.ssubscribe(**{self.channel: self.message_handler})\n        assert wait_for_message(p, func=p.get_sharded_message) is None\n        r.spublish(self.channel, self.data)\n        assert wait_for_message(p, func=p.get_sharded_message) is None\n        assert self.message == self.make_message(\"smessage\", self.channel, self.data)\n\n        # test that we reconnected to the correct channel\n        self.message = None\n        try:\n            # cluster mode\n            p.disconnect()\n        except AttributeError:\n            # standalone mode\n            p.connection.disconnect()\n        # should reconnect\n        assert wait_for_message(p, func=p.get_sharded_message) is None\n        new_data = self.data + \"new data\"\n        r.spublish(self.channel, new_data)\n        assert wait_for_message(p, func=p.get_sharded_message) is None\n        assert self.message == self.make_message(\"smessage\", self.channel, new_data)\n\n    def test_context_manager(self, r):\n        with r.pubsub() as pubsub:\n            pubsub.subscribe(\"foo\")\n            assert pubsub.connection is not None\n\n        assert pubsub.connection is None\n        assert pubsub.channels == {}\n        assert pubsub.patterns == {}\n\n\nclass TestPubSubRedisDown:\n    def test_channel_subscribe(self, r):\n        r = redis.Redis(host=\"localhost\", port=6390)\n        p = r.pubsub()\n        with pytest.raises(ConnectionError):\n            p.subscribe(\"foo\")\n\n\nclass TestPubSubSubcommands:\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"2.8.0\")\n    def test_pubsub_channels(self, r):\n        p = r.pubsub()\n        p.subscribe(\"foo\", \"bar\", \"baz\", \"quux\")\n        for i in range(4):\n            assert wait_for_message(p)[\"type\"] == \"subscribe\"\n        expected = [b\"bar\", b\"baz\", b\"foo\", b\"quux\"]\n        assert all([channel in r.pubsub_channels() for channel in expected])\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_pubsub_shardchannels(self, r):\n        p = r.pubsub()\n        p.ssubscribe(\"foo\", \"bar\", \"baz\", \"quux\")\n        for i in range(4):\n            assert wait_for_message(p)[\"type\"] == \"ssubscribe\"\n        expected = [b\"bar\", b\"baz\", b\"foo\", b\"quux\"]\n        assert all([channel in r.pubsub_shardchannels() for channel in expected])\n\n    @pytest.mark.onlycluster\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_pubsub_shardchannels_cluster(self, r):\n        channels = {\n            b\"foo\": r.get_node_from_key(\"foo\"),\n            b\"bar\": r.get_node_from_key(\"bar\"),\n            b\"baz\": r.get_node_from_key(\"baz\"),\n            b\"quux\": r.get_node_from_key(\"quux\"),\n        }\n        p = r.pubsub()\n        p.ssubscribe(\"foo\", \"bar\", \"baz\", \"quux\")\n        for node in channels.values():\n            assert wait_for_message(p, node=node)[\"type\"] == \"ssubscribe\"\n        for channel, node in channels.items():\n            assert channel in r.pubsub_shardchannels(target_nodes=node)\n        assert all(\n            [\n                channel in r.pubsub_shardchannels(target_nodes=\"all\")\n                for channel in channels.keys()\n            ]\n        )\n\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"2.8.0\")\n    def test_pubsub_numsub(self, r):\n        p1 = r.pubsub()\n        p1.subscribe(\"foo\", \"bar\", \"baz\")\n        for i in range(3):\n            assert wait_for_message(p1)[\"type\"] == \"subscribe\"\n        p2 = r.pubsub()\n        p2.subscribe(\"bar\", \"baz\")\n        for i in range(2):\n            assert wait_for_message(p2)[\"type\"] == \"subscribe\"\n        p3 = r.pubsub()\n        p3.subscribe(\"baz\")\n        assert wait_for_message(p3)[\"type\"] == \"subscribe\"\n\n        channels = [(b\"foo\", 1), (b\"bar\", 2), (b\"baz\", 3)]\n        assert r.pubsub_numsub(\"foo\", \"bar\", \"baz\") == channels\n\n    @skip_if_server_version_lt(\"2.8.0\")\n    def test_pubsub_numpat(self, r):\n        p = r.pubsub()\n        p.psubscribe(\"*oo\", \"*ar\", \"b*z\")\n        for i in range(3):\n            assert wait_for_message(p)[\"type\"] == \"psubscribe\"\n        assert r.pubsub_numpat() == 3\n\n    @pytest.mark.onlycluster\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_pubsub_shardnumsub(self, r):\n        channels = {\n            b\"foo\": r.get_node_from_key(\"foo\"),\n            b\"bar\": r.get_node_from_key(\"bar\"),\n            b\"baz\": r.get_node_from_key(\"baz\"),\n        }\n        p1 = r.pubsub()\n        p1.ssubscribe(*channels.keys())\n        for node in channels.values():\n            assert wait_for_message(p1, node=node)[\"type\"] == \"ssubscribe\"\n        p2 = r.pubsub()\n        p2.ssubscribe(\"bar\", \"baz\")\n        for i in range(2):\n            assert (\n                wait_for_message(p2, func=p2.get_sharded_message)[\"type\"]\n                == \"ssubscribe\"\n            )\n        p3 = r.pubsub()\n        p3.ssubscribe(\"baz\")\n        assert wait_for_message(p3, node=channels[b\"baz\"])[\"type\"] == \"ssubscribe\"\n\n        channels = [(b\"foo\", 1), (b\"bar\", 2), (b\"baz\", 3)]\n        assert r.pubsub_shardnumsub(\"foo\", \"bar\", \"baz\", target_nodes=\"all\") == channels\n\n    @pytest.mark.onlycluster\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_ssubscribe_multiple_channels_different_nodes(self, r):\n        \"\"\"\n        Test subscribing to multiple sharded channels on different nodes.\n        Validates that the generator properly handles multiple node_pubsub_mapping entries.\n        \"\"\"\n        pubsub = r.pubsub()\n        channel1 = \"test-channel:{0}\"\n        channel2 = \"test-channel:{6}\"\n\n        # Subscribe to first channel\n        pubsub.ssubscribe(channel1)\n        msg = wait_for_message(pubsub, timeout=1.0, func=pubsub.get_sharded_message)\n        assert msg is not None\n        assert msg[\"type\"] == \"ssubscribe\"\n\n        # Subscribe to second channel (likely different node)\n        pubsub.ssubscribe(channel2)\n        msg = wait_for_message(pubsub, timeout=1.0, func=pubsub.get_sharded_message)\n        assert msg is not None\n        assert msg[\"type\"] == \"ssubscribe\"\n\n        # Verify both channels are in shard_channels\n        assert channel1.encode() in pubsub.shard_channels\n        assert channel2.encode() in pubsub.shard_channels\n\n        pubsub.close()\n\n    @pytest.mark.onlycluster\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_ssubscribe_multiple_channels_publish_and_read(self, r):\n        \"\"\"\n        Test publishing to multiple sharded channels and reading messages.\n        Validates that _sharded_message_generator properly cycles through\n        multiple node_pubsub_mapping entries.\n        \"\"\"\n        pubsub = r.pubsub()\n        channel1 = \"test-channel:{0}\"\n        channel2 = \"test-channel:{6}\"\n        msg1_data = \"message-1\"\n        msg2_data = \"message-2\"\n\n        # Subscribe to both channels\n        pubsub.ssubscribe(channel1, channel2)\n\n        # Read subscription confirmations\n        for _ in range(2):\n            msg = wait_for_message(pubsub, timeout=1.0, func=pubsub.get_sharded_message)\n            assert msg is not None\n            assert msg[\"type\"] == \"ssubscribe\"\n\n        # Publish messages to both channels\n        r.spublish(channel1, msg1_data)\n        r.spublish(channel2, msg2_data)\n\n        # Read messages - should get both messages\n        messages = []\n        for _ in range(2):\n            msg = wait_for_message(pubsub, timeout=1.0, func=pubsub.get_sharded_message)\n            assert msg is not None\n            assert msg[\"type\"] == \"smessage\"\n            messages.append(msg)\n\n        # Verify we got messages from both channels\n        channels_received = {msg[\"channel\"] for msg in messages}\n        assert channel1.encode() in channels_received\n        assert channel2.encode() in channels_received\n\n        pubsub.close()\n\n    @pytest.mark.onlycluster\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_generator_handles_concurrent_mapping_changes(self, r):\n        \"\"\"\n        Test that the generator properly handles mapping changes during iteration.\n        This validates the fix for the RuntimeError: dictionary changed size during iteration.\n        \"\"\"\n        pubsub = r.pubsub()\n        channel1 = \"test-channel:{0}\"\n        channel2 = \"test-channel:{6}\"\n\n        # Subscribe to first channel\n        pubsub.ssubscribe(channel1)\n        msg = wait_for_message(pubsub, timeout=1.0, func=pubsub.get_sharded_message)\n        assert msg is not None\n        assert msg[\"type\"] == \"ssubscribe\"\n\n        # Get initial mapping size (cluster pubsub only)\n        assert hasattr(pubsub, \"node_pubsub_mapping\"), \"Test requires ClusterPubSub\"\n        initial_size = len(pubsub.node_pubsub_mapping)\n\n        # Subscribe to second channel (modifies mapping during potential iteration)\n        pubsub.ssubscribe(channel2)\n        msg = wait_for_message(pubsub, timeout=1.0, func=pubsub.get_sharded_message)\n        assert msg is not None\n        assert msg[\"type\"] == \"ssubscribe\"\n\n        # Verify mapping was updated\n        assert len(pubsub.node_pubsub_mapping) >= initial_size\n\n        # Publish and read messages - should not raise RuntimeError\n        r.spublish(channel1, \"msg1\")\n        r.spublish(channel2, \"msg2\")\n\n        messages_received = 0\n        for _ in range(2):\n            msg = wait_for_message(pubsub, timeout=1.0, func=pubsub.get_sharded_message)\n            if msg and msg[\"type\"] == \"smessage\":\n                messages_received += 1\n\n        assert messages_received == 2\n        pubsub.close()\n\n\nclass TestPubSubPings:\n    @skip_if_server_version_lt(\"3.0.0\")\n    def test_send_pubsub_ping(self, r):\n        p = r.pubsub(ignore_subscribe_messages=True)\n        p.subscribe(\"foo\")\n        p.ping()\n        assert wait_for_message(p) == make_message(\n            type=\"pong\", channel=None, data=\"\", pattern=None\n        )\n\n    @skip_if_server_version_lt(\"3.0.0\")\n    def test_send_pubsub_ping_message(self, r):\n        p = r.pubsub(ignore_subscribe_messages=True)\n        p.subscribe(\"foo\")\n        p.ping(message=\"hello world\")\n        assert wait_for_message(p) == make_message(\n            type=\"pong\", channel=None, data=\"hello world\", pattern=None\n        )\n\n\n@pytest.mark.onlynoncluster\nclass TestPubSubHealthCheckResponse:\n    \"\"\"Tests for health check response validation with different decode_responses settings\"\"\"\n\n    def test_is_health_check_response_decode_false_list_format(self, r):\n        \"\"\"Test is_health_check_response recognizes list format with decode_responses=False\"\"\"\n        p = r.pubsub()\n        # List format: [b\"pong\", b\"redis-py-health-check\"]\n        assert p.is_health_check_response([b\"pong\", b\"redis-py-health-check\"])\n\n    def test_is_health_check_response_decode_false_bytes_format(self, r):\n        \"\"\"Test is_health_check_response recognizes bytes format with decode_responses=False\"\"\"\n        p = r.pubsub()\n        # Bytes format: b\"redis-py-health-check\"\n        assert p.is_health_check_response(b\"redis-py-health-check\")\n\n    def test_is_health_check_response_decode_false_rejects_string(self, r):\n        \"\"\"Test is_health_check_response rejects string format with decode_responses=False\"\"\"\n        p = r.pubsub()\n        # String format should NOT be recognized when decode_responses=False\n        assert not p.is_health_check_response(\"redis-py-health-check\")\n\n    def test_is_health_check_response_decode_true_list_format(self, request):\n        \"\"\"Test is_health_check_response recognizes list format with decode_responses=True\"\"\"\n        r = _get_client(redis.Redis, request, decode_responses=True)\n        p = r.pubsub()\n        # List format: [\"pong\", \"redis-py-health-check\"]\n        assert p.is_health_check_response([\"pong\", \"redis-py-health-check\"])\n\n    def test_is_health_check_response_decode_true_string_format(self, request):\n        \"\"\"Test is_health_check_response recognizes string format with decode_responses=True\"\"\"\n        r = _get_client(redis.Redis, request, decode_responses=True)\n        p = r.pubsub()\n        # String format: \"redis-py-health-check\" (THE FIX!)\n        assert p.is_health_check_response(\"redis-py-health-check\")\n\n    def test_is_health_check_response_decode_true_rejects_bytes(self, request):\n        \"\"\"Test is_health_check_response rejects bytes format with decode_responses=True\"\"\"\n        r = _get_client(redis.Redis, request, decode_responses=True)\n        p = r.pubsub()\n        # Bytes format should NOT be recognized when decode_responses=True\n        assert not p.is_health_check_response(b\"redis-py-health-check\")\n\n    def test_is_health_check_response_decode_true_rejects_invalid(self, request):\n        \"\"\"Test is_health_check_response rejects invalid responses with decode_responses=True\"\"\"\n        r = _get_client(redis.Redis, request, decode_responses=True)\n        p = r.pubsub()\n        # Invalid responses should be rejected\n        assert not p.is_health_check_response(\"invalid-response\")\n        assert not p.is_health_check_response([\"pong\", \"invalid-response\"])\n        assert not p.is_health_check_response(None)\n\n    def test_is_health_check_response_decode_false_rejects_invalid(self, r):\n        \"\"\"Test is_health_check_response rejects invalid responses with decode_responses=False\"\"\"\n        p = r.pubsub()\n        # Invalid responses should be rejected\n        assert not p.is_health_check_response(b\"invalid-response\")\n        assert not p.is_health_check_response([b\"pong\", b\"invalid-response\"])\n        assert not p.is_health_check_response(None)\n\n\n@pytest.mark.onlynoncluster\nclass TestPubSubConnectionKilled:\n    @skip_if_server_version_lt(\"3.0.0\")\n    @skip_if_redis_enterprise()\n    def test_connection_error_raised_when_connection_dies(self, r):\n        p = r.pubsub()\n        p.subscribe(\"foo\")\n        assert wait_for_message(p) == make_message(\"subscribe\", \"foo\", 1)\n        for client in r.client_list():\n            if client[\"cmd\"] == \"subscribe\":\n                r.client_kill_filter(_id=client[\"id\"])\n        with pytest.raises(ConnectionError):\n            wait_for_message(p)\n\n\nclass TestPubSubTimeouts:\n    def test_get_message_with_timeout_returns_none(self, r):\n        p = r.pubsub()\n        p.subscribe(\"foo\")\n        assert wait_for_message(p) == make_message(\"subscribe\", \"foo\", 1)\n        assert p.get_message(timeout=0.01) is None\n\n    def test_get_message_not_subscribed_return_none(self, r):\n        p = r.pubsub()\n        assert p.subscribed is False\n        assert p.get_message() is None\n        assert p.get_message(timeout=0.1) is None\n        with patch.object(threading.Event, \"wait\") as mock:\n            mock.return_value = False\n            assert p.get_message(timeout=0.01) is None\n            assert mock.called\n\n    def test_get_message_subscribe_during_waiting(self, r):\n        p = r.pubsub()\n\n        def poll(ps, expected_res):\n            assert ps.get_message() is None\n            message = ps.get_message(timeout=1)\n            assert message == expected_res\n\n        subscribe_response = make_message(\"subscribe\", \"foo\", 1)\n        poller = threading.Thread(target=poll, args=(p, subscribe_response))\n        poller.start()\n        time.sleep(0.2)\n        p.subscribe(\"foo\")\n        poller.join()\n\n    def test_get_message_wait_for_subscription_not_being_called(self, r):\n        p = r.pubsub()\n        p.subscribe(\"foo\")\n        assert p.subscribed is True\n\n        # Ensure p has the event attribute your wait_for_message would call:\n        ev = getattr(p, \"subscribed_event\", None)\n\n        assert ev is not None, (\n            \"PubSub event attribute not found (check redis-py version)\"\n        )\n\n        with patch.object(ev, \"wait\") as mock:\n            assert wait_for_message(p) == make_message(\"subscribe\", \"foo\", 1)\n            assert mock.called is False\n\n\nclass TestPubSubWorkerThread:\n    @pytest.mark.skipif(\n        platform.python_implementation() == \"PyPy\", reason=\"Pypy threading issue\"\n    )\n    def test_pubsub_worker_thread_exception_handler(self, r):\n        event = threading.Event()\n\n        def exception_handler(ex, pubsub, thread):\n            thread.stop()\n            event.set()\n\n        p = r.pubsub()\n        p.subscribe(**{\"foo\": lambda m: m})\n        with mock.patch.object(p, \"get_message\", side_effect=Exception(\"error\")):\n            pubsub_thread = p.run_in_thread(\n                daemon=True, exception_handler=exception_handler\n            )\n\n        assert event.wait(timeout=1.0)\n        pubsub_thread.join(timeout=1.0)\n        assert not pubsub_thread.is_alive()\n\n\nclass TestPubSubDeadlock:\n    @pytest.mark.timeout(30, method=\"thread\")\n    def test_pubsub_deadlock(self, master_host):\n        pool = redis.ConnectionPool(host=master_host[0], port=master_host[1])\n        r = redis.Redis(connection_pool=pool)\n\n        for i in range(60):\n            p = r.pubsub()\n            p.subscribe(\"my-channel-1\", \"my-channel-2\")\n            pool.reset()\n\n\n@pytest.mark.timeout(5, method=\"thread\")\n@pytest.mark.parametrize(\"method\", [\"get_message\", \"listen\"])\n@pytest.mark.onlynoncluster\nclass TestPubSubAutoReconnect:\n    def mysetup(self, r, method):\n        self.messages = queue.Queue()\n        self.pubsub = r.pubsub()\n        self.state = 0\n        self.cond = threading.Condition()\n        if method == \"get_message\":\n            self.get_message = self.loop_step_get_message\n        else:\n            self.get_message = self.loop_step_listen\n\n        self.thread = threading.Thread(target=self.loop)\n        self.thread.daemon = True\n        self.thread.start()\n        # get the initial connect message\n        message = self.messages.get(timeout=1)\n        assert message == {\n            \"channel\": b\"foo\",\n            \"data\": 1,\n            \"pattern\": None,\n            \"type\": \"subscribe\",\n        }\n\n    def wait_for_reconnect(self):\n        self.cond.wait_for(lambda: self.pubsub.connection._sock is not None, timeout=2)\n        assert self.pubsub.connection._sock is not None  # we didn't time out\n        assert self.state == 3\n\n        message = self.messages.get(timeout=1)\n        assert message == {\n            \"channel\": b\"foo\",\n            \"data\": 1,\n            \"pattern\": None,\n            \"type\": \"subscribe\",\n        }\n\n    def mycleanup(self):\n        # kill thread\n        with self.cond:\n            self.state = 4  # quit\n            self.cond.notify()\n        self.thread.join()\n\n    def test_reconnect_socket_error(self, r: redis.Redis, method):\n        \"\"\"\n        Test that a socket error will cause reconnect\n        \"\"\"\n        self.mysetup(r, method)\n        try:\n            # now, disconnect the connection, and wait for it to be re-established\n            with self.cond:\n                self.state = 1\n                with mock.patch.object(self.pubsub.connection, \"_parser\") as mockobj:\n                    mockobj.read_response.side_effect = socket.error\n                    mockobj.can_read.side_effect = socket.error\n                    # wait until thread notices the disconnect until we undo the patch\n                    self.cond.wait_for(lambda: self.state >= 2)\n                    assert (\n                        self.pubsub.connection._sock is None\n                    )  # it is in a disconnected state\n                self.wait_for_reconnect()\n\n        finally:\n            self.mycleanup()\n\n    def test_reconnect_disconnect(self, r: redis.Redis, method):\n        \"\"\"\n        Test that a manual disconnect() will cause reconnect\n        \"\"\"\n        self.mysetup(r, method)\n        try:\n            # now, disconnect the connection, and wait for it to be re-established\n            with self.cond:\n                self.state = 1\n                self.pubsub.connection.disconnect()\n                assert self.pubsub.connection._sock is None\n                # wait for reconnect\n                self.wait_for_reconnect()\n        finally:\n            self.mycleanup()\n\n    def loop(self):\n        # reader loop, performing state transitions as it\n        # discovers disconnects and reconnects\n        self.pubsub.subscribe(\"foo\")\n        while True:\n            time.sleep(0.01)  # give main thread chance to get lock\n            with self.cond:\n                old_state = self.state\n                try:\n                    if self.state == 4:\n                        break\n                    # print ('state, %s, sock %s' % (state, pubsub.connection._sock))\n                    got_msg = self.get_message()\n                    assert got_msg\n                    if self.state in (1, 2):\n                        self.state = 3  # successful reconnect\n                except redis.ConnectionError:\n                    assert self.state in (1, 2)\n                    self.state = 2\n                finally:\n                    self.cond.notify()\n                # assert that we noticed a connect error, or automatically\n                # reconnected without error\n                if old_state == 1:\n                    assert self.state in (2, 3)\n\n    def loop_step_get_message(self):\n        # get a single message via listen()\n        message = self.pubsub.get_message(timeout=0.1)\n        if message is not None:\n            self.messages.put(message)\n            return True\n        return False\n\n    def loop_step_listen(self):\n        # get a single message via listen()\n        for message in self.pubsub.listen():\n            self.messages.put(message)\n            return True\n\n\n@pytest.mark.onlynoncluster\nclass TestBaseException:\n    def test_base_exception(self, r: redis.Redis):\n        \"\"\"\n        Manually trigger a BaseException inside the parser's .read_response method\n        and verify that it isn't caught\n        \"\"\"\n        pubsub = r.pubsub()\n        pubsub.subscribe(\"foo\")\n\n        def is_connected():\n            return pubsub.connection._sock is not None\n\n        assert is_connected()\n\n        def get_msg():\n            # blocking method to return messages\n            while True:\n                response = pubsub.parse_response(block=True)\n                message = pubsub.handle_message(\n                    response, ignore_subscribe_messages=False\n                )\n                if message is not None:\n                    return message\n\n        # get subscribe message\n        msg = get_msg()\n        assert msg is not None\n        # timeout waiting for another message which never arrives\n        assert is_connected()\n        with (\n            patch(\"redis._parsers._RESP2Parser.read_response\") as mock1,\n            patch(\"redis._parsers._HiredisParser.read_response\") as mock2,\n            patch(\"redis._parsers._RESP3Parser.read_response\") as mock3,\n        ):\n            mock1.side_effect = BaseException(\"boom\")\n            mock2.side_effect = BaseException(\"boom\")\n            mock3.side_effect = BaseException(\"boom\")\n\n            with pytest.raises(BaseException):\n                get_msg()\n\n        # the timeout on the read should not cause disconnect\n        assert is_connected()\n\n\nclass TestPubSubMetricsRecording:\n    \"\"\"\n    Unit tests that verify metrics are properly recorded from PubSub operations\n    through the direct record_* function calls.\n\n    These tests use fully mocked connection and connection pool - no real Redis\n    or OTel integration is used.\n    \"\"\"\n\n    @pytest.fixture\n    def mock_connection(self):\n        \"\"\"Create a mock connection with required attributes.\"\"\"\n        conn = mock.MagicMock()\n        conn.host = \"localhost\"\n        conn.port = 6379\n        conn.db = 0\n        conn.should_reconnect.return_value = False\n\n        # Mock retry to just execute the function directly\n        def mock_call_with_retry(do, fail, is_retryable=None, with_failure_count=False):\n            return do()\n\n        conn.retry.call_with_retry = mock_call_with_retry\n        conn.retry.get_retries.return_value = 0\n\n        return conn\n\n    @pytest.fixture\n    def mock_connection_pool(self, mock_connection):\n        \"\"\"Create a mock connection pool.\"\"\"\n        pool = mock.MagicMock()\n        pool.get_connection.return_value = mock_connection\n        pool.get_encoder.return_value = mock.MagicMock()\n        return pool\n\n    @pytest.fixture\n    def mock_meter(self):\n        \"\"\"Create a mock Meter that tracks all instrument calls.\"\"\"\n        meter = mock.MagicMock()\n\n        # Create mock histogram for operation duration\n        self.operation_duration = mock.MagicMock()\n        # Create mock counter for client errors\n        self.client_errors = mock.MagicMock()\n\n        def create_histogram_side_effect(name, **kwargs):\n            if name == \"db.client.operation.duration\":\n                return self.operation_duration\n            return mock.MagicMock()\n\n        def create_counter_side_effect(name, **kwargs):\n            if name == \"redis.client.errors\":\n                return self.client_errors\n            return mock.MagicMock()\n\n        meter.create_counter.side_effect = create_counter_side_effect\n        meter.create_up_down_counter.return_value = mock.MagicMock()\n        meter.create_histogram.side_effect = create_histogram_side_effect\n\n        return meter\n\n    @pytest.fixture\n    def setup_pubsub_with_otel(self, mock_connection_pool, mock_connection, mock_meter):\n        \"\"\"\n        Setup a PubSub with mocked connection and OTel collector.\n        Returns tuple of (pubsub, operation_duration_mock).\n        \"\"\"\n        from redis.client import PubSub\n        from redis.event import EventDispatcher\n        from redis.observability import recorder\n        from redis.observability.config import OTelConfig, MetricGroup\n        from redis.observability.metrics import RedisMetricsCollector\n\n        # Reset any existing collector state\n        recorder.reset_collector()\n\n        # Create config with COMMAND group enabled\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND])\n\n        # Create collector with mocked meter\n        with mock.patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        # Patch the recorder to use our collector\n        with mock.patch.object(\n            recorder, \"_get_or_create_collector\", return_value=collector\n        ):\n            # Create event dispatcher (real one, to test the full chain)\n            event_dispatcher = EventDispatcher()\n\n            # Create PubSub with mocked connection pool\n            pubsub = PubSub(\n                connection_pool=mock_connection_pool,\n                event_dispatcher=event_dispatcher,\n            )\n\n            # Set the connection directly to avoid subscribe flow\n            pubsub.connection = mock_connection\n\n            yield pubsub, self.operation_duration\n\n        # Cleanup\n        recorder.reset_collector()\n\n    def test_pubsub_execute_records_metric(self, setup_pubsub_with_otel):\n        \"\"\"\n        Test that executing a PubSub command records operation duration metric\n        which is delivered to the Meter's histogram.record() method.\n        \"\"\"\n\n        pubsub, operation_duration_mock = setup_pubsub_with_otel\n\n        # Mock the command to return successfully\n        mock_command = mock.MagicMock(return_value=True)\n\n        # Execute a command through _execute\n        pubsub._execute(pubsub.connection, mock_command, \"SUBSCRIBE\", \"foo\")\n\n        # Verify the Meter's histogram.record() was called\n        operation_duration_mock.record.assert_called_once()\n\n        # Get the call arguments\n        call_args = operation_duration_mock.record.call_args\n\n        # Verify duration was recorded (first positional arg)\n        duration = call_args[0][0]\n        assert isinstance(duration, float)\n        assert duration >= 0\n\n        # Verify attributes\n        attrs = call_args[1][\"attributes\"]\n        assert attrs[\"db.operation.name\"] == \"SUBSCRIBE\"\n        assert attrs[\"server.address\"] == \"localhost\"\n        assert attrs[\"server.port\"] == 6379\n        assert attrs[\"db.namespace\"] == \"0\"\n\n    def test_pubsub_error_records_error_count(\n        self, mock_connection_pool, mock_connection, mock_meter\n    ):\n        \"\"\"\n        Test that when a PubSub command raises an exception,\n        error count is recorded via record_error_count.\n\n        Note: record_operation_duration is NOT called for final errors -\n        only record_error_count is called. record_operation_duration is\n        only called during retries (in _close_connection) and on success.\n        \"\"\"\n\n        recorder.reset_collector()\n        # Enable RESILIENCY metric group for error counting\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND, MetricGroup.RESILIENCY])\n\n        with mock.patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        with mock.patch.object(\n            recorder, \"_get_or_create_collector\", return_value=collector\n        ):\n            event_dispatcher = EventDispatcher()\n\n            pubsub = PubSub(\n                connection_pool=mock_connection_pool,\n                event_dispatcher=event_dispatcher,\n            )\n            pubsub.connection = mock_connection\n\n            # Make command raise an exception\n            test_error = redis.ConnectionError(\"Connection failed\")\n            mock_command = mock.MagicMock(side_effect=test_error)\n\n            # Execute should raise the error\n            with pytest.raises(redis.ConnectionError):\n                pubsub._execute(pubsub.connection, mock_command, \"SUBSCRIBE\", \"foo\")\n\n            # Verify record_error_count was called (via client_errors counter)\n            self.client_errors.add.assert_called_once()\n\n            # Verify error type is recorded in attributes\n            call_args = self.client_errors.add.call_args\n            attrs = call_args[1][\"attributes\"]\n            assert \"error.type\" in attrs\n\n            # Verify operation_duration was NOT called (no retries, direct failure)\n            self.operation_duration.record.assert_not_called()\n\n        recorder.reset_collector()\n\n    def test_pubsub_server_attributes_recorded(self, setup_pubsub_with_otel):\n        \"\"\"\n        Test that server address, port, and db namespace are correctly recorded.\n        \"\"\"\n        pubsub, operation_duration_mock = setup_pubsub_with_otel\n\n        mock_command = mock.MagicMock(return_value=True)\n        pubsub._execute(pubsub.connection, mock_command, \"PING\")\n\n        call_args = operation_duration_mock.record.call_args\n        attrs = call_args[1][\"attributes\"]\n\n        # Verify server attributes match mock connection\n        assert attrs[\"server.address\"] == \"localhost\"\n        assert attrs[\"server.port\"] == 6379\n        assert attrs[\"db.namespace\"] == \"0\"\n\n    def test_pubsub_retry_records_metric_on_each_attempt(\n        self, mock_connection_pool, mock_meter\n    ):\n        \"\"\"\n        Test that when a PubSub command is retried, operation duration metric\n        is recorded for each retry attempt with retry_attempts attribute.\n        \"\"\"\n\n        # Create connection with retry behavior\n        mock_connection = mock.MagicMock()\n        mock_connection.host = \"localhost\"\n        mock_connection.port = 6379\n        mock_connection.db = 0\n        mock_connection.should_reconnect.return_value = False\n\n        max_retries = 2\n\n        def call_with_retry_impl(\n            func, error_handler, is_retryable=None, with_failure_count=False\n        ):\n            \"\"\"Simulate retry behavior - fail twice, then succeed.\"\"\"\n            for attempt in range(max_retries + 1):\n                try:\n                    return func()\n                except redis.ConnectionError as e:\n                    if attempt < max_retries:\n                        if with_failure_count:\n                            error_handler(e, attempt + 1)\n                        else:\n                            error_handler(e)\n                    else:\n                        raise\n\n        mock_connection.retry.call_with_retry = call_with_retry_impl\n        mock_connection.retry.get_retries.return_value = max_retries\n\n        mock_connection_pool.get_connection.return_value = mock_connection\n\n        recorder.reset_collector()\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND])\n\n        with mock.patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        with mock.patch.object(\n            recorder, \"_get_or_create_collector\", return_value=collector\n        ):\n            event_dispatcher = EventDispatcher()\n\n            pubsub = PubSub(\n                connection_pool=mock_connection_pool,\n                event_dispatcher=event_dispatcher,\n            )\n            pubsub.connection = mock_connection\n\n            # Make command fail twice then succeed\n            call_count = [0]\n\n            def command_impl(*args, **kwargs):\n                call_count[0] += 1\n                if call_count[0] <= 2:\n                    raise redis.ConnectionError(\"Connection failed\")\n                return True\n\n            mock_command = mock.MagicMock(side_effect=command_impl)\n\n            # Execute command - should retry twice then succeed\n            pubsub._execute(pubsub.connection, mock_command, \"SUBSCRIBE\", \"foo\")\n\n            # Verify histogram.record() was called 3 times:\n            # 2 retry attempts + 1 final success\n            assert self.operation_duration.record.call_count == 3\n\n            calls = self.operation_duration.record.call_args_list\n\n            # First two calls should have error.type (retry attempts)\n            assert \"error.type\" in calls[0][1][\"attributes\"]\n            assert \"error.type\" in calls[1][1][\"attributes\"]\n\n            # Last call should be success (no error.type)\n            assert \"error.type\" not in calls[2][1][\"attributes\"]\n\n        recorder.reset_collector()\n\n    def test_pubsub_retry_exhausted_records_final_error_metric(\n        self, mock_connection_pool, mock_meter\n    ):\n        \"\"\"\n        Test that when all retries are exhausted, operation duration metrics\n        are recorded for each retry attempt, and error count is recorded for\n        the final error.\n\n        Note: record_operation_duration is called during retries (in _close_connection),\n        but record_error_count is called for the final error (not record_operation_duration).\n        \"\"\"\n\n        mock_connection = mock.MagicMock()\n        mock_connection.host = \"localhost\"\n        mock_connection.port = 6379\n        mock_connection.db = 0\n        mock_connection.should_reconnect.return_value = False\n\n        max_retries = 2\n\n        def call_with_retry_impl(\n            func, error_handler, is_retryable=None, with_failure_count=False\n        ):\n            \"\"\"Simulate retry behavior - always fail.\"\"\"\n            for attempt in range(max_retries + 1):\n                try:\n                    return func()\n                except redis.ConnectionError as e:\n                    if attempt < max_retries:\n                        if with_failure_count:\n                            error_handler(e, attempt + 1)\n                        else:\n                            error_handler(e)\n                    else:\n                        raise\n\n        mock_connection.retry.call_with_retry = call_with_retry_impl\n        mock_connection.retry.get_retries.return_value = max_retries\n\n        mock_connection_pool.get_connection.return_value = mock_connection\n\n        recorder.reset_collector()\n        # Enable both COMMAND and RESILIENCY metric groups\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND, MetricGroup.RESILIENCY])\n\n        with mock.patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        with mock.patch.object(\n            recorder, \"_get_or_create_collector\", return_value=collector\n        ):\n            event_dispatcher = EventDispatcher()\n\n            pubsub = PubSub(\n                connection_pool=mock_connection_pool,\n                event_dispatcher=event_dispatcher,\n            )\n            pubsub.connection = mock_connection\n\n            # Make command always fail\n            mock_command = mock.MagicMock(\n                side_effect=redis.ConnectionError(\"Connection failed\")\n            )\n\n            # Execute command - should fail after all retries\n            with pytest.raises(redis.ConnectionError):\n                pubsub._execute(pubsub.connection, mock_command, \"SUBSCRIBE\", \"foo\")\n\n            # Verify histogram.record() was called 2 times (for retry attempts only)\n            # The final error uses record_error_count, not record_operation_duration\n            assert self.operation_duration.record.call_count == 2\n\n            calls = self.operation_duration.record.call_args_list\n\n            # All retry calls should have error.type\n            for call in calls:\n                assert \"error.type\" in call[1][\"attributes\"]\n                assert call[1][\"attributes\"][\"db.operation.name\"] == \"SUBSCRIBE\"\n\n            # Verify record_error_count was called once for the final error\n            self.client_errors.add.assert_called_once()\n\n            # Verify error type is recorded in the final error\n            error_call_args = self.client_errors.add.call_args\n            error_attrs = error_call_args[1][\"attributes\"]\n            assert \"error.type\" in error_attrs\n\n        recorder.reset_collector()\n\n    def test_pubsub_no_metric_when_no_command_name(self, setup_pubsub_with_otel):\n        \"\"\"\n        Test that no metric is recorded when command_name is None.\n        \"\"\"\n        pubsub, operation_duration_mock = setup_pubsub_with_otel\n\n        mock_command = mock.MagicMock(return_value=True)\n\n        # Execute without command name (no args)\n        pubsub._execute(pubsub.connection, mock_command)\n\n        # Verify no event was emitted\n        operation_duration_mock.record.assert_not_called()\n\n    def test_pubsub_different_commands_record_correct_names(\n        self, mock_connection_pool, mock_connection, mock_meter\n    ):\n        \"\"\"\n        Test that different PubSub commands record metrics with correct command names.\n        \"\"\"\n\n        recorder.reset_collector()\n        config = OTelConfig(metric_groups=[MetricGroup.COMMAND])\n\n        with mock.patch(\"redis.observability.metrics.OTEL_AVAILABLE\", True):\n            collector = RedisMetricsCollector(mock_meter, config)\n\n        with mock.patch.object(\n            recorder, \"_get_or_create_collector\", return_value=collector\n        ):\n            event_dispatcher = EventDispatcher()\n\n            pubsub = PubSub(\n                connection_pool=mock_connection_pool,\n                event_dispatcher=event_dispatcher,\n            )\n            pubsub.connection = mock_connection\n\n            mock_command = mock.MagicMock(return_value=True)\n\n            commands = [\n                \"SUBSCRIBE\",\n                \"UNSUBSCRIBE\",\n                \"PSUBSCRIBE\",\n                \"PUNSUBSCRIBE\",\n                \"PING\",\n            ]\n\n            for cmd in commands:\n                pubsub._execute(pubsub.connection, mock_command, cmd, \"channel\")\n\n            # Verify all commands were recorded\n            assert self.operation_duration.record.call_count == len(commands)\n\n            calls = self.operation_duration.record.call_args_list\n            recorded_commands = [\n                call[1][\"attributes\"][\"db.operation.name\"] for call in calls\n            ]\n\n            assert recorded_commands == commands\n\n        recorder.reset_collector()\n\n\nclass TestPubSubTimeoutPropagation:\n    \"\"\"\n    Tests for timeout propagation through the entire pubsub read chain.\n    Ensures that timeouts are properly passed from get_message() through\n    parse_response() to the parser and socket buffer layers.\n    \"\"\"\n\n    def test_get_message_timeout_is_respected(self, r):\n        \"\"\"\n        Test that get_message() with timeout parameter respects the timeout\n        and returns None when no message arrives within the timeout period.\n        \"\"\"\n        p = r.pubsub()\n        p.subscribe(\"foo\")\n        # Read subscription message\n        msg = wait_for_message(p, timeout=1.0)\n        assert msg is not None\n        assert msg[\"type\"] == \"subscribe\"\n\n        # Call get_message with a short timeout - should return None\n        # since no message is published\n        start = time.monotonic()\n        msg = p.get_message(timeout=0.1)\n        elapsed = time.monotonic() - start\n        assert msg is None\n        # Verify timeout was actually respected (within reasonable bounds)\n        assert elapsed < 0.5\n\n    def test_get_message_timeout_with_published_message(self, r):\n        \"\"\"\n        Test that get_message() with timeout returns a message if one\n        arrives before the timeout expires.\n        \"\"\"\n        p = r.pubsub()\n        p.subscribe(\"foo\")\n        # Read subscription message\n        msg = wait_for_message(p, timeout=1.0)\n        assert msg is not None\n\n        # Publish a message\n        r.publish(\"foo\", \"hello\")\n\n        # get_message with timeout should return the message\n        msg = p.get_message(timeout=1.0)\n        assert msg is not None\n        assert msg[\"type\"] == \"message\"\n        assert msg[\"data\"] == b\"hello\"\n\n    def test_parse_response_timeout_propagation(self, r):\n        \"\"\"\n        Test that parse_response() properly propagates timeout to read_response().\n        \"\"\"\n        p = r.pubsub()\n        p.subscribe(\"foo\")\n        # Read subscription message\n        msg = wait_for_message(p, timeout=1.0)\n        assert msg is not None\n\n        # Call parse_response with timeout - should respect it\n        start = time.monotonic()\n        response = p.parse_response(block=False, timeout=0.1)\n        elapsed = time.monotonic() - start\n        assert response is None\n        assert elapsed < 0.5\n\n    def test_get_message_timeout_zero_returns_immediately(self, r):\n        \"\"\"\n        Test that get_message(timeout=0) returns immediately without blocking.\n        \"\"\"\n        p = r.pubsub()\n        p.subscribe(\"foo\")\n        # Read subscription message\n        msg = wait_for_message(p, timeout=1.0)\n        assert msg is not None\n\n        # get_message with timeout=0 should return immediately\n        start = time.monotonic()\n        msg = p.get_message(timeout=0)\n        elapsed = time.monotonic() - start\n        assert msg is None\n        assert elapsed < 0.1\n\n    def test_get_message_timeout_none_blocks(self, r):\n        \"\"\"\n        Test that get_message(timeout=None) blocks indefinitely.\n        We test this by using a thread to publish a message after a delay.\n        \"\"\"\n        p = r.pubsub()\n        p.subscribe(\"foo\")\n        # Read subscription message\n        msg = wait_for_message(p, timeout=1.0)\n        assert msg is not None\n\n        # Publish a message after a short delay in a thread\n        def publish_after_delay():\n            time.sleep(0.2)\n            r.publish(\"foo\", \"delayed_message\")\n\n        thread = threading.Thread(target=publish_after_delay, daemon=True)\n        thread.start()\n\n        # get_message with timeout=None should block until message arrives\n        start = time.monotonic()\n        msg = p.get_message(timeout=None)\n        elapsed = time.monotonic() - start\n        assert msg is not None\n        assert msg[\"type\"] == \"message\"\n        assert msg[\"data\"] == b\"delayed_message\"\n        # Should have waited at least 0.2 seconds\n        assert elapsed >= 0.15\n        thread.join(timeout=1.0)\n\n    def test_multiple_messages_with_timeout(self, r):\n        \"\"\"\n        Test that timeout is properly handled when reading multiple messages.\n        \"\"\"\n        p = r.pubsub()\n        p.subscribe(\"foo\")\n        # Read subscription message\n        msg = wait_for_message(p, timeout=1.0)\n        assert msg is not None\n\n        # Publish multiple messages\n        r.publish(\"foo\", \"msg1\")\n        r.publish(\"foo\", \"msg2\")\n        r.publish(\"foo\", \"msg3\")\n\n        # Read all messages with timeout\n        messages = []\n        for _ in range(3):\n            msg = wait_for_message(p, timeout=1.0, func=p.get_message)\n            if msg:\n                messages.append(msg)\n\n        assert len(messages) == 3\n        assert messages[0][\"data\"] == b\"msg1\"\n        assert messages[1][\"data\"] == b\"msg2\"\n        assert messages[2][\"data\"] == b\"msg3\"\n\n    def test_timeout_with_pattern_subscribe(self, r):\n        \"\"\"\n        Test that timeout works correctly with pattern subscriptions.\n        \"\"\"\n        p = r.pubsub()\n        p.psubscribe(\"foo*\")\n        # Read subscription message\n        msg = wait_for_message(p, timeout=1.0)\n        assert msg is not None\n        assert msg[\"type\"] == \"psubscribe\"\n\n        # Publish a message matching the pattern\n        r.publish(\"foobar\", \"hello\")\n\n        # get_message with timeout should return the message\n        msg = p.get_message(timeout=1.0)\n        assert msg is not None\n        assert msg[\"type\"] == \"pmessage\"\n        assert msg[\"data\"] == b\"hello\"\n\n    def test_timeout_with_no_subscription(self, r):\n        \"\"\"\n        Test that get_message with timeout returns None when subscribed but no messages.\n        \"\"\"\n        p = r.pubsub()\n        p.subscribe(\"foo\")\n        # Read subscription message\n        msg = wait_for_message(p, timeout=1.0)\n        assert msg is not None\n\n        # get_message with timeout should return None when no messages\n        msg = p.get_message(timeout=0.1)\n        assert msg is None\n\n\nclass TestClusterPubSubTimeoutPropagation:\n    \"\"\"\n    Tests for timeout propagation in ClusterPubSub for sharded pubsub.\n    \"\"\"\n\n    @pytest.mark.onlycluster\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_get_sharded_message_timeout_is_respected(self, r):\n        \"\"\"\n        Test that get_sharded_message() with timeout parameter respects the timeout\n        and returns None when no message arrives within the timeout period.\n        \"\"\"\n        pubsub = r.pubsub()\n        channel = \"test-channel:{0}\"\n        pubsub.ssubscribe(channel)\n        # Read subscription message\n        msg = wait_for_message(pubsub, timeout=1.0, func=pubsub.get_sharded_message)\n        assert msg is not None\n        assert msg[\"type\"] == \"ssubscribe\"\n\n        # Call get_sharded_message with a short timeout - should return None\n        start = time.monotonic()\n        msg = pubsub.get_sharded_message(timeout=0.1)\n        elapsed = time.monotonic() - start\n        assert msg is None\n        # Verify timeout was actually respected\n        assert elapsed < 0.5\n        pubsub.close()\n\n    @pytest.mark.onlycluster\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_get_sharded_message_timeout_with_published_message(self, r):\n        \"\"\"\n        Test that get_sharded_message() with timeout returns a message if one\n        arrives before the timeout expires.\n        \"\"\"\n        pubsub = r.pubsub()\n        channel = \"test-channel:{0}\"\n        pubsub.ssubscribe(channel)\n        # Read subscription message\n        msg = wait_for_message(pubsub, timeout=1.0, func=pubsub.get_sharded_message)\n        assert msg is not None\n\n        # Publish a message\n        r.spublish(channel, \"hello\")\n\n        # get_sharded_message with timeout should return the message\n        msg = pubsub.get_sharded_message(timeout=1.0)\n        assert msg is not None\n        assert msg[\"type\"] == \"smessage\"\n        assert msg[\"data\"] == b\"hello\"\n        pubsub.close()\n\n    @pytest.mark.onlycluster\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_get_sharded_message_timeout_zero_returns_immediately(self, r):\n        \"\"\"\n        Test that get_sharded_message(timeout=0) returns immediately without blocking.\n        \"\"\"\n        pubsub = r.pubsub()\n        channel = \"test-channel:{0}\"\n        pubsub.ssubscribe(channel)\n        # Read subscription message\n        msg = wait_for_message(pubsub, timeout=1.0, func=pubsub.get_sharded_message)\n        assert msg is not None\n\n        # get_sharded_message with timeout=0 should return immediately\n        start = time.monotonic()\n        msg = pubsub.get_sharded_message(timeout=0)\n        elapsed = time.monotonic() - start\n        assert msg is None\n        assert elapsed < 0.1\n        pubsub.close()\n\n    @pytest.mark.onlycluster\n    @skip_if_server_version_lt(\"7.0.0\")\n    def test_get_sharded_message_multiple_channels_with_timeout(self, r):\n        \"\"\"\n        Test that timeout is properly handled when reading from multiple sharded channels.\n        \"\"\"\n        pubsub = r.pubsub()\n        channel1 = \"test-channel:{0}\"\n        channel2 = \"test-channel:{6}\"\n        pubsub.ssubscribe(channel1, channel2)\n        # Read subscription messages\n        for _ in range(2):\n            msg = wait_for_message(pubsub, timeout=1.0, func=pubsub.get_sharded_message)\n            assert msg is not None\n            assert msg[\"type\"] == \"ssubscribe\"\n\n        # Publish messages to both channels\n        r.spublish(channel1, \"msg1\")\n        r.spublish(channel2, \"msg2\")\n\n        # Read messages with timeout\n        messages = []\n        for _ in range(2):\n            msg = wait_for_message(pubsub, timeout=1.0, func=pubsub.get_sharded_message)\n            if msg and msg[\"type\"] == \"smessage\":\n                messages.append(msg)\n\n        assert len(messages) == 2\n        pubsub.close()\n"
  },
  {
    "path": "tests/test_retry.py",
    "content": "from unittest.mock import patch\n\nimport pytest\nfrom redis.asyncio.retry import Retry as AsyncRetry\nfrom redis.backoff import (\n    AbstractBackoff,\n    ConstantBackoff,\n    DecorrelatedJitterBackoff,\n    EqualJitterBackoff,\n    ExponentialBackoff,\n    ExponentialWithJitterBackoff,\n    FullJitterBackoff,\n    NoBackoff,\n)\nfrom redis.client import Redis\nfrom redis.connection import Connection, UnixDomainSocketConnection\nfrom redis.exceptions import (\n    BusyLoadingError,\n    ConnectionError,\n    ReadOnlyError,\n    TimeoutError,\n)\nfrom redis.retry import Retry\n\nfrom .conftest import _get_client\n\n\nclass BackoffMock(AbstractBackoff):\n    def __init__(self):\n        self.reset_calls = 0\n        self.calls = 0\n\n    def reset(self):\n        self.reset_calls += 1\n\n    def compute(self, failures):\n        self.calls += 1\n        return 0\n\n\nclass TestConnectionConstructorWithRetry:\n    \"Test that the Connection constructors properly handles Retry objects\"\n\n    @pytest.mark.parametrize(\"retry_on_timeout\", [False, True])\n    @pytest.mark.parametrize(\"Class\", [Connection, UnixDomainSocketConnection])\n    def test_retry_on_timeout_boolean(self, Class, retry_on_timeout):\n        c = Class(retry_on_timeout=retry_on_timeout)\n        assert c.retry_on_timeout == retry_on_timeout\n        assert isinstance(c.retry, Retry)\n        assert c.retry._retries == (1 if retry_on_timeout else 0)\n\n    @pytest.mark.parametrize(\"retries\", range(10))\n    @pytest.mark.parametrize(\"Class\", [Connection, UnixDomainSocketConnection])\n    def test_retry_on_timeout_retry(self, Class, retries):\n        retry_on_timeout = retries > 0\n        c = Class(retry_on_timeout=retry_on_timeout, retry=Retry(NoBackoff(), retries))\n        assert c.retry_on_timeout == retry_on_timeout\n        assert isinstance(c.retry, Retry)\n        assert c.retry._retries == retries\n\n    @pytest.mark.parametrize(\"Class\", [Connection, UnixDomainSocketConnection])\n    def test_retry_on_error(self, Class):\n        c = Class(retry_on_error=[ReadOnlyError])\n        assert c.retry_on_error == [ReadOnlyError]\n        assert isinstance(c.retry, Retry)\n        assert c.retry._retries == 1\n\n    @pytest.mark.parametrize(\"Class\", [Connection, UnixDomainSocketConnection])\n    def test_retry_on_error_empty_value(self, Class):\n        c = Class(retry_on_error=[])\n        assert c.retry_on_error == []\n        assert isinstance(c.retry, Retry)\n        assert c.retry._retries == 0\n\n    @pytest.mark.parametrize(\"Class\", [Connection, UnixDomainSocketConnection])\n    def test_retry_on_error_and_timeout(self, Class):\n        c = Class(\n            retry_on_error=[ReadOnlyError, BusyLoadingError], retry_on_timeout=True\n        )\n        assert c.retry_on_error == [ReadOnlyError, BusyLoadingError, TimeoutError]\n        assert isinstance(c.retry, Retry)\n        assert c.retry._retries == 1\n\n    @pytest.mark.parametrize(\"retries\", range(10))\n    @pytest.mark.parametrize(\"Class\", [Connection, UnixDomainSocketConnection])\n    def test_retry_on_error_retry(self, Class, retries):\n        c = Class(retry_on_error=[ReadOnlyError], retry=Retry(NoBackoff(), retries))\n        assert c.retry_on_error == [ReadOnlyError]\n        assert isinstance(c.retry, Retry)\n        assert c.retry._retries == retries\n\n\n@pytest.mark.parametrize(\"retry_class\", [Retry, AsyncRetry])\n@pytest.mark.parametrize(\n    \"args\",\n    [\n        (ConstantBackoff(0), 0),\n        (ConstantBackoff(10), 5),\n        (NoBackoff(), 0),\n    ]\n    + [\n        backoff\n        for Backoff in (\n            DecorrelatedJitterBackoff,\n            EqualJitterBackoff,\n            ExponentialBackoff,\n            ExponentialWithJitterBackoff,\n            FullJitterBackoff,\n        )\n        for backoff in ((Backoff(), 2), (Backoff(25), 5), (Backoff(25, 5), 5))\n    ],\n)\ndef test_retry_eq_and_hashable(retry_class, args):\n    assert retry_class(*args) == retry_class(*args)\n\n    # create another retry object with different parameters\n    copy = list(args)\n    if isinstance(copy[0], ConstantBackoff):\n        copy[1] = 9000\n    else:\n        copy[0] = ConstantBackoff(9000)\n\n    assert retry_class(*args) != retry_class(*copy)\n    assert retry_class(*copy) != retry_class(*args)\n    assert (\n        len(\n            {\n                retry_class(*args),\n                retry_class(*args),\n                retry_class(*copy),\n                retry_class(*copy),\n            }\n        )\n        == 2\n    )\n\n\nclass TestRetry:\n    \"Test that Retry calls backoff and retries the expected number of times\"\n\n    def setup_method(self, test_method):\n        self.actual_attempts = 0\n        self.actual_failures = 0\n\n    def _do(self):\n        self.actual_attempts += 1\n        raise ConnectionError()\n\n    def _fail(self, error):\n        self.actual_failures += 1\n\n    def _fail_inf(self, error):\n        self.actual_failures += 1\n        if self.actual_failures == 5:\n            raise ConnectionError()\n\n    @pytest.mark.parametrize(\"retries\", range(10))\n    def test_retry(self, retries):\n        backoff = BackoffMock()\n        retry = Retry(backoff, retries)\n        with pytest.raises(ConnectionError):\n            retry.call_with_retry(self._do, self._fail)\n\n        assert self.actual_attempts == 1 + retries\n        assert self.actual_failures == 1 + retries\n        assert backoff.reset_calls == 1\n        assert backoff.calls == retries\n\n    def test_infinite_retry(self):\n        backoff = BackoffMock()\n        # specify infinite retries, but give up after 5\n        retry = Retry(backoff, -1)\n        with pytest.raises(ConnectionError):\n            retry.call_with_retry(self._do, self._fail_inf)\n\n        assert self.actual_attempts == 5\n        assert self.actual_failures == 5\n\n\n@pytest.mark.onlynoncluster\nclass TestRedisClientRetry:\n    \"Test the standalone Redis client behavior with retries\"\n\n    def test_client_retry_on_error_with_success(self, request):\n        with patch.object(Redis, \"parse_response\") as parse_response:\n\n            def mock_parse_response(connection, *args, **options):\n                def ok_response(connection, *args, **options):\n                    return \"MOCK_OK\"\n\n                parse_response.side_effect = ok_response\n                raise ReadOnlyError()\n\n            parse_response.side_effect = mock_parse_response\n            r = _get_client(Redis, request, retry_on_error=[ReadOnlyError])\n            assert r.get(\"foo\") == \"MOCK_OK\"\n            assert parse_response.call_count == 2\n\n    def test_client_retry_on_error_raise(self, request):\n        with patch.object(Redis, \"parse_response\") as parse_response:\n            parse_response.side_effect = BusyLoadingError()\n            retries = 3\n            r = _get_client(\n                Redis,\n                request,\n                retry_on_error=[ReadOnlyError, BusyLoadingError],\n                retry=Retry(NoBackoff(), retries),\n            )\n            with pytest.raises(BusyLoadingError):\n                try:\n                    r.get(\"foo\")\n                finally:\n                    assert parse_response.call_count == retries + 1\n\n    def test_client_retry_on_error_different_error_raised(self, request):\n        with patch.object(Redis, \"parse_response\") as parse_response:\n            parse_response.side_effect = OSError()\n            retries = 3\n            r = _get_client(\n                Redis,\n                request,\n                retry_on_error=[ReadOnlyError],\n                retry=Retry(NoBackoff(), retries),\n            )\n            with pytest.raises(OSError):\n                try:\n                    r.get(\"foo\")\n                finally:\n                    assert parse_response.call_count == 1\n\n    def test_client_retry_on_error_and_timeout(self, request):\n        with patch.object(Redis, \"parse_response\") as parse_response:\n            parse_response.side_effect = TimeoutError()\n            retries = 3\n            r = _get_client(\n                Redis,\n                request,\n                retry_on_error=[ReadOnlyError],\n                retry_on_timeout=True,\n                retry=Retry(NoBackoff(), retries),\n            )\n            with pytest.raises(TimeoutError):\n                try:\n                    r.get(\"foo\")\n                finally:\n                    assert parse_response.call_count == retries + 1\n\n    def test_client_retry_on_timeout(self, request):\n        with patch.object(Redis, \"parse_response\") as parse_response:\n            parse_response.side_effect = TimeoutError()\n            retries = 3\n            r = _get_client(\n                Redis, request, retry_on_timeout=True, retry=Retry(NoBackoff(), retries)\n            )\n            with pytest.raises(TimeoutError):\n                try:\n                    r.get(\"foo\")\n                finally:\n                    assert parse_response.call_count == retries + 1\n\n    @pytest.mark.onlycluster\n    def test_get_set_retry_object_for_cluster_client(self, request):\n        retry = Retry(NoBackoff(), 2)\n        r = _get_client(Redis, request, retry_on_timeout=True, retry=retry)\n        exist_conn = r.connection_pool.get_connection()\n        assert r.retry._retries == retry._retries\n        assert isinstance(r.retry._backoff, NoBackoff)\n        new_retry_policy = Retry(ExponentialBackoff(), 3)\n        r.set_retry(new_retry_policy)\n        assert r.retry._retries == new_retry_policy._retries\n        assert isinstance(r.retry._backoff, ExponentialBackoff)\n        assert exist_conn.retry._retries == new_retry_policy._retries\n        new_conn = r.connection_pool.get_connection()\n        assert new_conn.retry._retries == new_retry_policy._retries\n\n    @pytest.mark.onlynoncluster\n    def test_get_set_retry_object(self, request):\n        retry = Retry(NoBackoff(), 2)\n        r = _get_client(Redis, request, retry_on_timeout=True, retry=retry)\n        exist_conn = r.connection_pool.get_connection()\n        assert r.get_retry()._retries == retry._retries\n        assert isinstance(r.get_retry()._backoff, NoBackoff)\n        new_retry_policy = Retry(ExponentialBackoff(), 3)\n        r.set_retry(new_retry_policy)\n        assert r.get_retry()._retries == new_retry_policy._retries\n        assert isinstance(r.get_retry()._backoff, ExponentialBackoff)\n        assert exist_conn.retry._retries == new_retry_policy._retries\n        new_conn = r.connection_pool.get_connection()\n        assert new_conn.retry._retries == new_retry_policy._retries\n"
  },
  {
    "path": "tests/test_scenario/__init__.py",
    "content": ""
  },
  {
    "path": "tests/test_scenario/conftest.py",
    "content": "import json\nimport logging\nimport os\nimport re\nfrom typing import Optional\nfrom urllib.parse import urlparse\n\nimport pytest\nfrom redis import RedisCluster\n\nfrom redis.backoff import NoBackoff, ExponentialBackoff\nfrom redis.event import EventDispatcher, EventListenerInterface\nfrom redis.multidb.client import MultiDBClient\nfrom redis.multidb.config import (\n    DatabaseConfig,\n    MultiDbConfig,\n    DEFAULT_HEALTH_CHECK_INTERVAL,\n)\nfrom redis.multidb.event import ActiveDatabaseChanged\nfrom redis.multidb.failure_detector import DEFAULT_MIN_NUM_FAILURES\nfrom redis.asyncio.multidb.healthcheck import DEFAULT_HEALTH_CHECK_DELAY\nfrom redis.backoff import ExponentialWithJitterBackoff\nfrom redis.client import Redis\nfrom redis.maint_notifications import EndpointType, MaintNotificationsConfig\nfrom redis.retry import Retry\nfrom tests.test_scenario.fault_injector_client import (\n    ProxyServerFaultInjector,\n    REFaultInjector,\n)\n\nRELAXED_TIMEOUT = 30\nCLIENT_TIMEOUT = 5\n\nDEFAULT_ENDPOINT_NAME = \"m-standard\"\nDEFAULT_OSS_API_ENDPOINT_NAME = \"maint-notifications-oss-api\"\n\n\nclass CheckActiveDatabaseChangedListener(EventListenerInterface):\n    def __init__(self):\n        self.is_changed_flag = False\n\n    def listen(self, event: ActiveDatabaseChanged):\n        self.is_changed_flag = True\n\n\ndef use_mock_proxy():\n    return os.getenv(\"REDIS_ENTERPRISE_TESTS\", \"true\").lower() == \"false\"\n\n\n# Module-level singleton for fault injector client used in parametrize\n# This ensures we create only ONE instance that's shared between parametrize and fixture\n_FAULT_INJECTOR_CLIENT_OSS_API = (\n    ProxyServerFaultInjector(oss_cluster=True)\n    if use_mock_proxy()\n    else REFaultInjector(os.getenv(\"FAULT_INJECTION_API_URL\", \"http://127.0.0.1:20324\"))\n)\n\n\n@pytest.fixture()\ndef endpoint_name(request):\n    return request.config.getoption(\"--endpoint-name\") or os.getenv(\n        \"REDIS_ENDPOINT_NAME\", DEFAULT_ENDPOINT_NAME\n    )\n\n\n@pytest.fixture()\ndef cluster_endpoint_name(request):\n    return request.config.getoption(\"--cluster-endpoint-name\") or os.getenv(\n        \"REDIS_CLUSTER_ENDPOINT_NAME\", DEFAULT_OSS_API_ENDPOINT_NAME\n    )\n\n\ndef get_endpoints_config(endpoint_name: str):\n    endpoints_config = os.getenv(\"REDIS_ENDPOINTS_CONFIG_PATH\", None)\n\n    if not (endpoints_config and os.path.exists(endpoints_config)):\n        raise FileNotFoundError(f\"Endpoints config file not found: {endpoints_config}\")\n\n    try:\n        with open(endpoints_config, \"r\") as f:\n            data = json.load(f)\n            db = data[endpoint_name]\n            return db\n    except Exception as e:\n        raise ValueError(\n            f\"Failed to load endpoints config file: {endpoints_config}\"\n        ) from e\n\n\ndef get_bdbs_config(endpoint_name: str):\n    bdbs_config = os.getenv(\"REDIS_BDBS_CONFIG_PATH\", None)\n\n    if not (bdbs_config and os.path.exists(bdbs_config)):\n        raise FileNotFoundError(f\"BDBs config file not found: {bdbs_config}\")\n\n    try:\n        with open(bdbs_config, \"r\") as f:\n            data = json.load(f)\n            dbs = data[\"databases\"]\n            for db in dbs:\n                if db[\"name\"] == endpoint_name:\n                    return db\n            pytest.fail(f\"Failed to find bdb config for {endpoint_name}\")\n    except Exception as e:\n        raise ValueError(f\"Failed to load bdbs config file: {bdbs_config}\") from e\n\n\n@pytest.fixture()\ndef endpoints_config(endpoint_name: str):\n    return get_endpoints_config(endpoint_name)\n\n\n@pytest.fixture()\ndef maint_notifications_cluster_bdb_config(cluster_endpoint_name: str):\n    \"\"\"\n    Get the bdb config for the cluster used in the maint notifications tests.\n    This will be used to create the test database for each test.\n    The bdb config is the same for all tests, but the database is created with a random name.\n    \"\"\"\n    return get_bdbs_config(cluster_endpoint_name)\n\n\n@pytest.fixture()\ndef fault_injector_client():\n    if use_mock_proxy():\n        return ProxyServerFaultInjector(oss_cluster=False)\n    else:\n        url = os.getenv(\"FAULT_INJECTION_API_URL\", \"http://127.0.0.1:20324\")\n        return REFaultInjector(url)\n\n\n@pytest.fixture()\ndef fault_injector_client_oss_api():\n    \"\"\"Return the singleton instance to ensure parametrize and tests use the same client.\"\"\"\n    return _FAULT_INJECTOR_CLIENT_OSS_API\n\n\n@pytest.fixture()\ndef r_multi_db(\n    request,\n) -> tuple[MultiDBClient, CheckActiveDatabaseChangedListener, dict]:\n    client_class = request.param.get(\"client_class\", Redis)\n\n    if client_class == Redis:\n        endpoint_config = get_endpoints_config(\"re-active-active\")\n    else:\n        endpoint_config = get_endpoints_config(\"re-active-active-oss-cluster\")\n\n    username = endpoint_config.get(\"username\", None)\n    password = endpoint_config.get(\"password\", None)\n    min_num_failures = request.param.get(\"min_num_failures\", DEFAULT_MIN_NUM_FAILURES)\n    command_retry = request.param.get(\n        \"command_retry\", Retry(ExponentialBackoff(cap=0.1, base=0.01), retries=10)\n    )\n\n    # Retry configuration different for health checks as initial health check require more time in case\n    # if infrastructure wasn't restored from the previous test.\n    health_check_interval = request.param.get(\n        \"health_check_interval\", DEFAULT_HEALTH_CHECK_INTERVAL\n    )\n    health_check_delay = request.param.get(\n        \"health_check_delay\", DEFAULT_HEALTH_CHECK_DELAY\n    )\n    event_dispatcher = EventDispatcher()\n    listener = CheckActiveDatabaseChangedListener()\n    event_dispatcher.register_listeners(\n        {\n            ActiveDatabaseChanged: [listener],\n        }\n    )\n    db_configs = []\n\n    db_config = DatabaseConfig(\n        weight=1.0,\n        from_url=endpoint_config[\"endpoints\"][0],\n        client_kwargs={\n            \"username\": username,\n            \"password\": password,\n            \"decode_responses\": True,\n        },\n        health_check_url=extract_cluster_fqdn(endpoint_config[\"endpoints\"][0]),\n    )\n    db_configs.append(db_config)\n\n    db_config1 = DatabaseConfig(\n        weight=0.9,\n        from_url=endpoint_config[\"endpoints\"][1],\n        client_kwargs={\n            \"username\": username,\n            \"password\": password,\n            \"decode_responses\": True,\n        },\n        health_check_url=extract_cluster_fqdn(endpoint_config[\"endpoints\"][1]),\n    )\n    db_configs.append(db_config1)\n\n    config = MultiDbConfig(\n        client_class=client_class,\n        databases_config=db_configs,\n        command_retry=command_retry,\n        min_num_failures=min_num_failures,\n        health_check_probes=3,\n        health_check_interval=health_check_interval,\n        event_dispatcher=event_dispatcher,\n        health_check_delay=health_check_delay,\n    )\n\n    return MultiDBClient(config), listener, endpoint_config\n\n\ndef extract_cluster_fqdn(url):\n    \"\"\"\n    Extract Cluster FQDN from Redis URL\n    \"\"\"\n    # Parse the URL\n    parsed = urlparse(url)\n\n    # Extract hostname and port\n    hostname = parsed.hostname\n\n    # Remove the 'redis-XXXX.' prefix using regex\n    # This pattern matches 'redis-' followed by digits and a dot\n    cleaned_hostname = re.sub(r\"^redis-\\d+\\.\", \"\", hostname)\n\n    # Reconstruct the URL\n    return f\"https://{cleaned_hostname}\"\n\n\ndef _prepare_ssl_certificates(cert_chain: bool) -> dict:\n    \"\"\"\n    Prepare SSL certificates for Redis cluster connection.\n\n    Args:\n        cert_chain: PEM-encoded certificate chain containing client cert + intermediate + CA cert.\n                   This is the full certificate chain that will be used to validate the server.\n\n    Returns:\n        dict: SSL configuration kwargs for RedisCluster\n    \"\"\"\n    certs_config_path = os.environ.get(\"MTLS_CONFIG_PATH\", None)\n\n    if not cert_chain:\n        return {\n            \"ssl_cert_reqs\": \"none\",\n            \"ssl_check_hostname\": False,\n        }\n\n    if not certs_config_path:\n        raise ValueError(\n            \"MTLS enabled test is triggered but MTLS_CONFIG_PATH environment variable not set\"\n        )\n\n    # The cert_chain contains the full chain (client cert + intermediate + root CA)\n    # Use it as CA data for validating the server's certificate\n    return {\n        \"ssl_cert_reqs\": \"none\",\n        \"ssl_keyfile\": os.path.join(certs_config_path, \"client.key\"),\n        \"ssl_certfile\": os.path.join(certs_config_path, \"client.crt\"),\n    }\n\n\n@pytest.fixture()\ndef client_maint_notifications(endpoints_config):\n    return _get_client_maint_notifications(endpoints_config)\n\n\ndef _get_client_maint_notifications(\n    endpoints_config,\n    protocol: int = 3,\n    enable_maintenance_notifications: bool = True,\n    endpoint_type: Optional[EndpointType] = None,\n    enable_relaxed_timeout: bool = True,\n    enable_proactive_reconnect: bool = True,\n    disable_retries: bool = False,\n    socket_timeout: Optional[float] = None,\n    host_config: Optional[str] = None,\n):\n    \"\"\"Create Redis client with maintenance notifications enabled.\"\"\"\n\n    # Get credentials from the configuration\n    username = endpoints_config.get(\"username\")\n    password = endpoints_config.get(\"password\")\n\n    # Parse host and port from endpoints URL\n    endpoints = endpoints_config.get(\"endpoints\", [])\n    if not endpoints:\n        raise ValueError(\"No endpoints found in configuration\")\n\n    parsed = urlparse(endpoints[0])\n    host = parsed.hostname if host_config is None else host_config\n    port = parsed.port\n\n    if not host:\n        raise ValueError(f\"Could not parse host from endpoint URL: {endpoints[0]}\")\n\n    logging.info(f\"Connecting to Redis Enterprise: {host}:{port} with user: {username}\")\n\n    # Configure maintenance notifications\n    maintenance_config = MaintNotificationsConfig(\n        enabled=enable_maintenance_notifications,\n        proactive_reconnect=enable_proactive_reconnect,\n        relaxed_timeout=RELAXED_TIMEOUT if enable_relaxed_timeout else -1,\n        endpoint_type=endpoint_type,\n    )\n\n    if disable_retries:\n        retry = Retry(NoBackoff(), 0)\n    else:\n        retry = Retry(backoff=ExponentialWithJitterBackoff(base=1, cap=10), retries=3)\n\n    tls_enabled = True if parsed.scheme == \"rediss\" else False\n    logging.info(f\"TLS enabled: {tls_enabled}\")\n\n    # Create Redis client with maintenance notifications config\n    # This will automatically create the MaintNotificationsPoolHandler\n    client = Redis(\n        host=host,\n        port=port,\n        socket_timeout=CLIENT_TIMEOUT if socket_timeout is None else socket_timeout,\n        username=username,\n        password=password,\n        ssl=tls_enabled,\n        ssl_cert_reqs=\"none\",\n        ssl_check_hostname=False,\n        protocol=protocol,  # RESP3 required for push notifications\n        maint_notifications_config=maintenance_config,\n        retry=retry,\n    )\n    logging.info(\"Redis client created with maintenance notifications enabled\")\n    logging.info(f\"Client uses Protocol: {client.connection_pool.get_protocol()}\")\n\n    return client\n\n\ndef get_cluster_client_maint_notifications(\n    endpoints_config,\n    protocol: int = 3,\n    enable_maintenance_notifications: bool = True,\n    endpoint_type: Optional[EndpointType] = None,\n    enable_relaxed_timeout: bool = True,\n    enable_proactive_reconnect: bool = True,\n    disable_retries: bool = False,\n    auth_ssl_client_certs: bool = False,\n    socket_timeout: Optional[float] = None,\n):\n    \"\"\"Create Redis cluster client with maintenance notifications enabled.\"\"\"\n    # Get credentials from the configuration\n    username = endpoints_config.get(\"username\")\n    password = endpoints_config.get(\"password\")\n\n    # Parse host and port from endpoints URL\n    endpoints = endpoints_config.get(\"endpoints\", [])\n    if not endpoints:\n        raise ValueError(\"No endpoints found in configuration\")\n\n    parsed = urlparse(endpoints[0])\n    host = parsed.hostname\n    port = parsed.port\n\n    if not host:\n        raise ValueError(f\"Could not parse host from endpoint URL: {endpoints[0]}\")\n\n    logging.info(f\"Connecting to Redis Enterprise: {host}:{port} with user: {username}\")\n\n    if disable_retries:\n        retry = Retry(NoBackoff(), 0)\n    else:\n        retry = Retry(backoff=ExponentialWithJitterBackoff(base=1, cap=10), retries=3)\n\n    tls_enabled = True if parsed.scheme == \"rediss\" else False\n    logging.info(f\"TLS enabled: {tls_enabled}\")\n\n    tls_kwargs = {\"ssl\": tls_enabled}\n\n    if tls_enabled:\n        # Prepare SSL certificate configuration\n        ssl_config = _prepare_ssl_certificates(auth_ssl_client_certs)\n        tls_kwargs.update(ssl_config)\n\n    # Configure maintenance notifications\n    maintenance_config = MaintNotificationsConfig(\n        enabled=enable_maintenance_notifications,\n        proactive_reconnect=enable_proactive_reconnect,\n        relaxed_timeout=RELAXED_TIMEOUT if enable_relaxed_timeout else -1,\n        endpoint_type=endpoint_type,\n    )\n\n    # Create Redis cluster client with maintenance notifications config\n    client = RedisCluster(\n        host=host,\n        port=port,\n        socket_timeout=CLIENT_TIMEOUT if socket_timeout is None else socket_timeout,\n        username=username,\n        password=password,\n        protocol=protocol,  # RESP3 required for push notifications\n        maint_notifications_config=maintenance_config,\n        retry=retry,\n        **tls_kwargs,\n    )\n    logging.info(\"Redis cluster client created with maintenance notifications enabled\")\n    logging.info(\n        f\"Cluster working with the following nodes: {[(node.name, node.server_type) for node in client.get_nodes()]}\"\n    )\n\n    return client\n"
  },
  {
    "path": "tests/test_scenario/fault_injector_client.py",
    "content": "from abc import ABC, abstractmethod\nfrom dataclasses import dataclass\nimport json\nimport logging\nimport time\nimport urllib.request\nimport urllib.error\nfrom typing import Dict, Any, Optional, Tuple, Union\nfrom enum import Enum\n\nimport pytest\n\nfrom redis.cluster import ClusterNode\nfrom tests.maint_notifications.proxy_server_helpers import (\n    ProxyInterceptorHelper,\n    RespTranslator,\n    SlotsRange,\n)\n\nDEFAULT_BDB_ID = 1\n\n\nclass TaskStatuses:\n    \"\"\"Class to hold completed statuses constants.\"\"\"\n\n    FAILED = \"failed\"\n    FINISHED = \"finished\"\n    SUCCESS = \"success\"\n    RUNNING = \"running\"\n\n    COMPLETED_STATUSES = [FAILED, FINISHED, SUCCESS]\n\n\nclass ActionType(str, Enum):\n    DMC_RESTART = \"dmc_restart\"\n    FAILOVER = \"failover\"\n    RESHARD = \"reshard\"\n    CREATE_DATABASE = \"create_database\"\n    DELETE_DATABASE = \"delete_database\"\n    SEQUENCE_OF_ACTIONS = \"sequence_of_actions\"\n    NETWORK_FAILURE = \"network_failure\"\n    EXECUTE_RLUTIL_COMMAND = \"execute_rlutil_command\"\n    EXECUTE_RLADMIN_COMMAND = \"execute_rladmin_command\"\n    SLOT_MIGRATE = \"slot_migrate\"\n\n\nclass SlotMigrateEffects(str, Enum):\n    REMOVE_ADD = \"remove-add\"\n    REMOVE = \"remove\"\n    ADD = \"add\"\n    SLOT_SHUFFLE = \"slot-shuffle\"\n\n\nclass RestartDmcParams:\n    def __init__(self, bdb_id: str):\n        self.bdb_id = bdb_id\n\n    def to_dict(self) -> Dict[str, str]:\n        return {\"bdb_id\": self.bdb_id}\n\n\nclass ActionRequest:\n    def __init__(\n        self,\n        action_type: ActionType,\n        parameters: Union[Dict[str, Any], RestartDmcParams],\n    ):\n        self.type = action_type\n        self.parameters = parameters\n\n    def to_dict(self) -> Dict[str, Any]:\n        return {\n            \"type\": self.type.value,  # Use the string value of the enum\n            \"parameters\": self.parameters.to_dict()\n            if isinstance(self.parameters, RestartDmcParams)\n            else self.parameters,\n        }\n\n\n@dataclass\nclass NodeInfo:\n    node_id: str\n    role: str\n    internal_address: str\n    external_address: str\n    hostname: str\n    port: int\n\n\nclass FaultInjectorClient(ABC):\n    @abstractmethod\n    def get_operation_result(\n        self,\n        action_id: str,\n        timeout: int = 60,\n    ) -> Dict[str, Any]:\n        pass\n\n    @abstractmethod\n    def create_database(\n        self,\n        bdb_config: Dict[str, Any],\n    ) -> Dict[str, Any]:\n        pass\n\n    @abstractmethod\n    def delete_database(\n        self,\n        bdb_id: int,\n    ) -> Dict[str, Any]:\n        pass\n\n    @abstractmethod\n    def find_database_id_by_name(\n        self,\n        database_name: str,\n        force_cluster_info_refresh: bool = True,\n    ) -> Optional[int]:\n        pass\n\n    @abstractmethod\n    def find_target_node_and_empty_node(\n        self,\n        endpoint_config: Dict[str, Any],\n        force_cluster_info_refresh: bool = True,\n    ) -> Tuple[NodeInfo, NodeInfo]:\n        pass\n\n    @abstractmethod\n    def find_endpoint_for_bind(\n        self,\n        endpoint_name: str,\n        force_cluster_info_refresh: bool = True,\n    ) -> str:\n        pass\n\n    @abstractmethod\n    def execute_failover(\n        self,\n        endpoint_config: Dict[str, Any],\n        timeout: int = 60,\n    ) -> Dict[str, Any]:\n        pass\n\n    @abstractmethod\n    def execute_migrate(\n        self,\n        endpoint_config: Dict[str, Any],\n        target_node: str,\n        empty_node: str,\n        skip_end_notification: bool = False,\n    ) -> str:\n        pass\n\n    @abstractmethod\n    def execute_rebind(\n        self,\n        endpoint_config: Dict[str, Any],\n        endpoint_id: str,\n    ) -> str:\n        pass\n\n    @abstractmethod\n    def get_moving_ttl(self) -> int:\n        pass\n\n    @abstractmethod\n    def get_slot_migrate_triggers(\n        self,\n        effect_name: SlotMigrateEffects,\n    ) -> Dict[str, Any]:\n        pass\n\n    @abstractmethod\n    def trigger_effect(\n        self,\n        endpoint_config: Dict[str, Any],\n        effect_name: SlotMigrateEffects,\n        trigger_name: Optional[str] = None,\n        source_node: Optional[str] = None,\n        target_node: Optional[str] = None,\n        skip_end_notification: bool = False,\n    ) -> str:\n        pass\n\n\nclass REFaultInjector(FaultInjectorClient):\n    \"\"\"Fault injector client for Redis Enterprise cluster setup.\"\"\"\n\n    MOVING_TTL = 15\n\n    def __init__(self, base_url: str):\n        self.base_url = base_url.rstrip(\"/\")\n        self._cluster_nodes_info = None\n        self._current_db_id = None\n\n    def _make_request(\n        self, method: str, path: str, data: Optional[Dict] = None\n    ) -> Dict[str, Any]:\n        url = f\"{self.base_url}{path}\"\n        headers = {\"Content-Type\": \"application/json\"} if data else {}\n\n        request_data = json.dumps(data).encode(\"utf-8\") if data else None\n\n        request = urllib.request.Request(\n            url, method=method, data=request_data, headers=headers\n        )\n\n        try:\n            with urllib.request.urlopen(request) as response:\n                return json.loads(response.read().decode(\"utf-8\"))\n        except urllib.error.HTTPError as e:\n            if e.code == 422:\n                error_body = json.loads(e.read().decode(\"utf-8\"))\n                raise ValueError(f\"Validation Error: {error_body}\")\n            raise\n\n    def list_actions(self) -> Dict[str, Any]:\n        \"\"\"List all available actions\"\"\"\n        return self._make_request(\"GET\", \"/action\")\n\n    def trigger_action(self, action_request: ActionRequest) -> Dict[str, Any]:\n        \"\"\"Trigger a new action\"\"\"\n        request_data = action_request.to_dict()\n        return self._make_request(\"POST\", \"/action\", request_data)\n\n    def get_action_status(self, action_id: str) -> Dict[str, Any]:\n        \"\"\"Get the status of a specific action\"\"\"\n        return self._make_request(\"GET\", f\"/action/{action_id}\")\n\n    def execute_rladmin_command(\n        self, command: str, bdb_id: Optional[str] = None\n    ) -> Dict[str, Any]:\n        \"\"\"Execute rladmin command directly as string\"\"\"\n        url = f\"{self.base_url}/rladmin\"\n\n        # The fault injector expects the raw command string\n        command_string = f\"rladmin {command}\"\n        if bdb_id:\n            command_string = f\"rladmin -b {bdb_id} {command}\"\n\n        headers = {\"Content-Type\": \"text/plain\"}\n\n        request = urllib.request.Request(\n            url, method=\"POST\", data=command_string.encode(\"utf-8\"), headers=headers\n        )\n\n        try:\n            with urllib.request.urlopen(request) as response:\n                return json.loads(response.read().decode(\"utf-8\"))\n        except urllib.error.HTTPError as e:\n            if e.code == 422:\n                error_body = json.loads(e.read().decode(\"utf-8\"))\n                raise ValueError(f\"Validation Error: {error_body}\")\n            raise\n\n    def get_operation_result(\n        self,\n        action_id: str,\n        timeout: int = 60,\n    ) -> Dict[str, Any]:\n        \"\"\"Get the result of a specific action\"\"\"\n        start_time = time.time()  # returns the time in seconds\n        check_interval = 0.3\n\n        while time.time() - start_time < timeout:\n            try:\n                status_result = self.get_action_status(action_id)\n                operation_status = status_result.get(\"status\", \"unknown\")\n\n                if operation_status in TaskStatuses.COMPLETED_STATUSES:\n                    logging.debug(\n                        f\"Operation {action_id} completed with status: \"\n                        f\"{operation_status}\"\n                    )\n                    if operation_status != TaskStatuses.SUCCESS:\n                        pytest.fail(f\"Operation {action_id} failed: {status_result}\")\n                    return status_result\n\n                time.sleep(check_interval)\n            except Exception as e:\n                logging.warning(f\"Error checking operation status: {e}\")\n                time.sleep(check_interval)\n        else:\n            pytest.fail(\n                f\"Timeout waiting for operation {action_id}. Start time: {start_time}, current time: {time.time()}\"\n            )\n\n    def create_database(\n        self,\n        bdb_config: Dict[str, Any],\n    ) -> Dict[str, Any]:\n        \"\"\"Create a new database.\"\"\"\n        # Please provide the config just for the db that will be created\n        logging.debug(f\"Creating database with config: {bdb_config}\")\n        params = {\"database_config\": bdb_config}\n        create_db_action = ActionRequest(\n            action_type=ActionType.CREATE_DATABASE,\n            parameters=params,\n        )\n        result = self.trigger_action(create_db_action)\n        action_id = result.get(\"action_id\")\n        if not action_id:\n            raise Exception(f\"Failed to trigger create database action: {result}\")\n\n        action_status_check_response = self.get_operation_result(action_id)\n        logging.debug(f\"Create database action result: {action_status_check_response}\")\n\n        if action_status_check_response.get(\"status\") != TaskStatuses.SUCCESS:\n            raise Exception(\n                f\"Create database action failed: {action_status_check_response}\"\n            )\n\n        self._current_db_id = action_status_check_response[\"output\"][\"bdb_id\"]\n        return action_status_check_response[\"output\"]\n\n    def delete_database(\n        self,\n        bdb_id: int,\n    ) -> Dict[str, Any]:\n        logging.debug(f\"Deleting database with id: {bdb_id}\")\n        params = {\"bdb_id\": bdb_id}\n        delete_db_action = ActionRequest(\n            action_type=ActionType.DELETE_DATABASE,\n            parameters=params,\n        )\n        result = self.trigger_action(delete_db_action)\n        action_id = result.get(\"action_id\")\n        if not action_id:\n            raise Exception(f\"Failed to trigger delete database action: {result}\")\n\n        action_status_check_response = self.get_operation_result(action_id)\n\n        self._current_db_id = None\n\n        if action_status_check_response.get(\"status\") != TaskStatuses.SUCCESS:\n            raise Exception(\n                f\"Delete database action failed: {action_status_check_response}\"\n            )\n        logging.debug(f\"Delete database action result: {action_status_check_response}\")\n        return action_status_check_response\n\n    def get_cluster_nodes_info(self) -> None:\n        \"\"\"Get cluster nodes information from Redis Enterprise.\"\"\"\n        try:\n            # Use rladmin status to get node information\n            get_status_action = ActionRequest(\n                action_type=ActionType.EXECUTE_RLADMIN_COMMAND,\n                parameters={\n                    \"rladmin_command\": \"status\",\n                    \"bdb_id\": DEFAULT_BDB_ID\n                    if self._current_db_id is None\n                    else self._current_db_id,  # Any database id will do - it just only needs to exist\n                },\n            )\n            trigger_action_result = self.trigger_action(get_status_action)\n            action_id = trigger_action_result.get(\"action_id\")\n            if not action_id:\n                raise ValueError(\n                    f\"Failed to trigger get cluster status action: {trigger_action_result}\"\n                )\n\n            action_status_check_response = self.get_operation_result(action_id)\n\n            if action_status_check_response.get(\"status\") != TaskStatuses.SUCCESS:\n                raise Exception(\n                    f\"Get cluster status action failed: {action_status_check_response}\"\n                )\n            self._cluster_nodes_info = action_status_check_response.get(\n                \"output\", {}\n            ).get(\"output\", \"\")\n\n        except Exception as e:\n            pytest.fail(f\"Failed to get cluster nodes info: {e}\")\n\n    def find_database_id_by_name(\n        self,\n        database_name: str,\n        force_cluster_info_refresh: bool = True,\n    ) -> Optional[int]:\n        \"\"\"Find the database ID by name.\"\"\"\n        if self._cluster_nodes_info is None or force_cluster_info_refresh:\n            self.get_cluster_nodes_info()\n\n        if not self._cluster_nodes_info:\n            raise ValueError(\"No cluster status info found\")\n\n        # Parse the DATABASES section to find the database ID\n        lines = self._cluster_nodes_info.split(\"\\n\")\n        databases_section_started = False\n\n        for line in lines:\n            line = line.strip()\n\n            # Start of DATABASES section\n            if line.startswith(\"DATABASES:\"):\n                databases_section_started = True\n                continue\n            elif databases_section_started and line and not line.startswith(\"DB:ID\"):\n                # Parse database line: db:3 m-standard redis:5 node:3 master 8192-16383 1.79MB OK\n\n                parts = line.split()\n                if len(parts) >= 2 and parts[1] == database_name:\n                    return int(parts[0].replace(\"db:\", \"\"))\n\n        raise ValueError(f\"Database {database_name} not found\")\n\n    def find_target_node_and_empty_node(\n        self,\n        endpoint_config: Dict[str, Any],\n        force_cluster_info_refresh: bool = True,\n    ) -> Tuple[NodeInfo, NodeInfo]:\n        \"\"\"Find the node with master shards and the node with no shards.\n\n        Returns:\n            tuple: (target_node, empty_node) where target_node has master shards\n                and empty_node has no shards\n        \"\"\"\n        db_port = int(endpoint_config.get(\"port\", 0))\n\n        if self._cluster_nodes_info is None or force_cluster_info_refresh:\n            self.get_cluster_nodes_info()\n\n        if not self._cluster_nodes_info:\n            raise ValueError(\"No cluster status output found\")\n\n        # Parse the sections to find nodes with master shards and nodes with no shards\n        lines = self._cluster_nodes_info.split(\"\\n\")\n        shards_section_started = False\n        nodes_section_started = False\n\n        # Get all node IDs from CLUSTER NODES section\n        all_nodes = set()\n        all_nodes_details = {}\n        nodes_with_any_shards = set()  # Nodes with shards from ANY database\n        nodes_with_target_db_shards = set()  # Nodes with shards from target database\n        master_nodes = set()  # Master nodes for target database only\n\n        for line in lines:\n            line = line.strip()\n\n            # Start of CLUSTER NODES section\n            if line.startswith(\"CLUSTER NODES:\"):\n                nodes_section_started = True\n                continue\n            elif line.startswith(\"DATABASES:\"):\n                nodes_section_started = False\n                continue\n            elif nodes_section_started and line and not line.startswith(\"NODE:ID\"):\n                # Parse node line: node:1  master 10.0.101.206 ... (ignore the role)\n                parts = line.split()\n                if len(parts) >= 1:\n                    node_id = parts[0].replace(\"*\", \"\")  # Remove * prefix if present\n                    node_role = parts[1]\n                    node_internal_address = parts[2]\n                    node_external_address = parts[3]\n                    node_hostname = parts[4]\n\n                    node = NodeInfo(\n                        node_id.split(\":\")[1],\n                        node_role,\n                        node_internal_address,\n                        node_external_address,\n                        node_hostname,\n                        db_port,\n                    )\n                    all_nodes.add(node_id)\n                    all_nodes_details[node_id.split(\":\")[1]] = node\n\n            # Start of SHARDS section - only care about shard roles here\n            if line.startswith(\"SHARDS:\"):\n                shards_section_started = True\n                continue\n            elif shards_section_started and line.startswith(\"DB:ID\"):\n                continue\n            elif shards_section_started and line and not line.startswith(\"ENDPOINTS:\"):\n                # Parse shard line: db:1  m-standard  redis:1  node:2  master  0-8191  1.4MB  OK\n                parts = line.split()\n                if len(parts) >= 5:\n                    db_id = parts[0]  # db:1, db:2, etc.\n                    node_id = parts[3]  # node:2\n                    shard_role = parts[4]  # master/slave - this is what matters\n\n                    # Track ALL nodes with shards (for finding truly empty nodes)\n                    nodes_with_any_shards.add(node_id)\n\n                    # Only track master nodes for the specific database we're testing\n                    bdb_id = endpoint_config.get(\"bdb_id\")\n                    if db_id == f\"db:{bdb_id}\":\n                        nodes_with_target_db_shards.add(node_id)\n                        if shard_role == \"master\":\n                            master_nodes.add(node_id)\n            elif line.startswith(\"ENDPOINTS:\") or not line:\n                shards_section_started = False\n\n        # Find empty node (node with no shards from ANY database)\n        nodes_with_no_shards_target_bdb = all_nodes - nodes_with_target_db_shards\n\n        logging.debug(f\"All nodes: {all_nodes}\")\n        logging.debug(f\"Nodes with shards from any database: {nodes_with_any_shards}\")\n        logging.debug(\n            f\"Nodes with target database shards: {nodes_with_target_db_shards}\"\n        )\n        logging.debug(f\"Master nodes (target database only): {master_nodes}\")\n        logging.debug(\n            f\"Nodes with no shards from target database: {nodes_with_no_shards_target_bdb}\"\n        )\n\n        if not nodes_with_no_shards_target_bdb:\n            raise ValueError(\"All nodes have shards from target database\")\n\n        if not master_nodes:\n            raise ValueError(\"No nodes with master shards from target database found\")\n\n        # Return the first available empty node and master node (numeric part only)\n        empty_node = next(iter(nodes_with_no_shards_target_bdb)).split(\":\")[\n            1\n        ]  # node:1 -> 1\n        target_node = next(iter(master_nodes)).split(\":\")[1]  # node:2 -> 2\n\n        return all_nodes_details[target_node], all_nodes_details[empty_node]\n\n    def find_endpoint_for_bind(\n        self,\n        endpoint_name: str,\n        force_cluster_info_refresh: bool = True,\n    ) -> str:\n        \"\"\"Find the endpoint ID from cluster status.\n\n        Returns:\n            str: The endpoint ID (e.g., \"1:1\")\n        \"\"\"\n        if self._cluster_nodes_info is None or force_cluster_info_refresh:\n            self.get_cluster_nodes_info()\n\n        if not self._cluster_nodes_info:\n            raise ValueError(\"No cluster status output found\")\n\n        if not self._cluster_nodes_info:\n            raise ValueError(\"No cluster status output found\")\n\n        # Parse the ENDPOINTS section to find endpoint ID\n        lines = self._cluster_nodes_info.split(\"\\n\")\n        endpoints_section_started = False\n\n        for line in lines:\n            line = line.strip()\n\n            # Start of ENDPOINTS section\n            if line.startswith(\"ENDPOINTS:\"):\n                endpoints_section_started = True\n                continue\n            elif line.startswith(\"SHARDS:\"):\n                break\n            elif endpoints_section_started and line and not line.startswith(\"DB:ID\"):\n                # Parse endpoint line: db:1  m-standard  endpoint:1:1  node:2  single  No\n                parts = line.split()\n                if len(parts) >= 3 and parts[1] == endpoint_name:\n                    endpoint_full = parts[2]  # endpoint:1:1\n                    if endpoint_full.startswith(\"endpoint:\"):\n                        endpoint_id = endpoint_full.replace(\"endpoint:\", \"\")  # 1:1\n                        return endpoint_id\n\n        raise ValueError(f\"No endpoint ID for {endpoint_name} found in cluster status\")\n\n    def execute_failover(\n        self,\n        endpoint_config: Dict[str, Any],\n        timeout: int = 60,\n    ) -> Dict[str, Any]:\n        \"\"\"Execute failover command and wait for completion.\"\"\"\n\n        try:\n            # Refresh cluster info before getting the shard - we want to be sure\n            # that we have the current state\n            shard = self._get_first_master_shard(\n                endpoint_config,\n                force_cluster_info_refresh=True,\n            )\n            bdb_id = endpoint_config.get(\"bdb_id\")\n            command = f\"failover db db:{bdb_id} shard {shard}\"\n\n            parameters = {\n                \"bdb_id\": bdb_id,\n                \"rladmin_command\": command,  # Just the command without \"rladmin\" prefix\n            }\n            logging.debug(f\"Executing rladmin_command with parameter: {parameters}\")\n\n            failover_action = ActionRequest(\n                action_type=ActionType.EXECUTE_RLADMIN_COMMAND,\n                parameters=parameters,\n            )\n            result = self.trigger_action(failover_action)\n\n            logging.debug(f\"Failover command action result: {result}\")\n\n            action_id = result.get(\"action_id\")\n            if not action_id:\n                raise Exception(f\"Failed to trigger failover action: {result}\")\n\n            action_status_check_response = self.get_operation_result(\n                action_id, timeout=timeout\n            )\n            logging.info(\n                f\"Completed failover execution: {action_status_check_response}\"\n            )\n            return action_status_check_response\n\n        except Exception as e:\n            pytest.fail(f\"Failed to execute failover: {e}\")\n\n    def execute_migrate(\n        self,\n        endpoint_config: Dict[str, Any],\n        target_node: str,\n        empty_node: str,\n        skip_end_notification: bool = False,\n    ) -> str:\n        \"\"\"Execute rladmin migrate command and wait for completion.\"\"\"\n        command = f\"migrate node {target_node} all_shards target_node {empty_node}\"\n\n        # Get bdb_id from endpoint configuration\n        bdb_id = endpoint_config.get(\"bdb_id\")\n\n        try:\n            # Correct parameter format for fault injector\n            parameters = {\n                \"bdb_id\": bdb_id,\n                \"rladmin_command\": command,  # Just the command without \"rladmin\" prefix\n            }\n\n            logging.debug(f\"Executing rladmin_command with parameter: {parameters}\")\n\n            action = ActionRequest(\n                action_type=ActionType.EXECUTE_RLADMIN_COMMAND, parameters=parameters\n            )\n            result = self.trigger_action(action)\n\n            logging.debug(f\"Migrate command action result: {result}\")\n\n            action_id = result.get(\"action_id\")\n\n            if not action_id:\n                raise Exception(f\"Failed to trigger migrate action: {result}\")\n            return action_id\n        except Exception as e:\n            raise Exception(f\"Failed to execute rladmin migrate: {e}\")\n\n    def execute_rebind(\n        self,\n        endpoint_config: Dict[str, Any],\n        endpoint_id: str,\n    ) -> str:\n        \"\"\"Execute rladmin bind endpoint command and wait for completion.\"\"\"\n\n        endpoint_policy = endpoint_config[\"raw_endpoints\"][0][\"proxy_policy\"]\n        logging.info(\n            f\"Executing rladmin bind endpoint {endpoint_id} policy {endpoint_policy}\"\n        )\n        command = f\"bind endpoint {endpoint_id} policy {endpoint_policy}\"\n\n        bdb_id = endpoint_config.get(\"bdb_id\")\n\n        try:\n            parameters = {\n                \"rladmin_command\": command,  # Just the command without \"rladmin\" prefix\n                \"bdb_id\": bdb_id,\n            }\n\n            logging.info(f\"Executing rladmin_command with parameter: {parameters}\")\n            action = ActionRequest(\n                action_type=ActionType.EXECUTE_RLADMIN_COMMAND, parameters=parameters\n            )\n            result = self.trigger_action(action)\n            logging.info(\n                f\"Migrate command {command} with parameters {parameters} trigger result: {result}\"\n            )\n\n            action_id = result.get(\"action_id\")\n\n            if not action_id:\n                raise Exception(f\"Failed to trigger bind endpoint action: {result}\")\n            return action_id\n        except Exception as e:\n            raise Exception(f\"Failed to execute rladmin bind endpoint: {e}\")\n\n    def get_slot_migrate_triggers(\n        self,\n        effect_name: SlotMigrateEffects,\n    ) -> Dict[str, Any]:\n        \"\"\"Get available triggers(trigger name + db example config) for a slot migration effect.\"\"\"\n        return self._make_request(\n            \"GET\", f\"/slot-migrate?effect={effect_name.value}&cluster_index=0\"\n        )\n\n    def trigger_effect(\n        self,\n        endpoint_config: Dict[str, Any],\n        effect_name: SlotMigrateEffects,\n        trigger_name: str,\n        source_node: Optional[str] = None,\n        target_node: Optional[str] = None,\n        skip_end_notification: bool = False,\n    ) -> str:\n        \"\"\"Execute FI action that will trigger the desired effect.\"\"\"\n\n        # Get bdb_id from endpoint configuration\n        bdb_id = endpoint_config.get(\"bdb_id\")\n        cluster_index = 0\n\n        try:\n            # Correct parameter format for fault injector\n            parameters = {\n                \"bdb_id\": bdb_id,\n                \"cluster_index\": cluster_index,\n                \"effect\": effect_name,\n                \"trigger\": trigger_name,\n            }\n            if source_node:\n                parameters[\"source_node\"] = source_node\n            if target_node:\n                parameters[\"target_node\"] = target_node\n\n            logging.debug(f\"Executing slot migrate with parameters: {parameters}\")\n\n            action = ActionRequest(\n                action_type=ActionType.SLOT_MIGRATE, parameters=parameters\n            )\n            result = self.trigger_action(action)\n\n            logging.debug(f\"Trigger effect action result: {result}\")\n\n            action_id = result.get(\"action_id\")\n\n            if not action_id:\n                raise Exception(f\"Failed to trigger slot migrate action: {result}\")\n            return action_id\n        except Exception as e:\n            raise Exception(f\"Failed to execute slot migrate: {e}\")\n\n    def get_moving_ttl(self) -> int:\n        return self.MOVING_TTL\n\n    def _get_first_master_shard(\n        self,\n        endpoint_config: Dict[str, Any],\n        force_cluster_info_refresh: bool = True,\n    ) -> str:\n        \"\"\"Get the first master shard from the endpoint configuration.\"\"\"\n        bdb_id = endpoint_config.get(\"bdb_id\")\n\n        if self._cluster_nodes_info is None or force_cluster_info_refresh:\n            self.get_cluster_nodes_info()\n\n        if not self._cluster_nodes_info:\n            raise ValueError(\"No cluster status output found\")\n\n        # Parse the SHARDS section to find the shard id covering slot 0\n        lines = self._cluster_nodes_info.split(\"\\n\")\n        shards_section_started = False\n\n        for line in lines:\n            line = line.strip()\n\n            # Start of SHARDS section\n            if line.startswith(\"SHARDS:\"):\n                shards_section_started = True\n                continue\n            elif shards_section_started and line and not line.startswith(\"DB:ID\"):\n                # Parse shard line: db:3 m-standard redis:3 node:3 master 0-8191 1.79MB OK\n                parts = line.split()\n                if (\n                    len(parts) >= 8\n                    and parts[0] == f\"db:{bdb_id}\"\n                    and parts[4] == \"master\"\n                    and parts[5].startswith(\"0-\")\n                ):\n                    return parts[2].replace(\"redis:\", \"\")  # redis:3 --> 3\n\n        raise ValueError(\"No master shard found\")\n\n\nclass ProxyServerFaultInjector(FaultInjectorClient):\n    \"\"\"Fault injector client for proxy server setup.\"\"\"\n\n    NODE_PORT_1 = 15379\n    NODE_PORT_2 = 15380\n    NODE_PORT_3 = 15381\n\n    # Initial cluster node configuration for proxy-based tests\n    PROXY_CLUSTER_NODES = [\n        ClusterNode(\"127.0.0.1\", NODE_PORT_1),\n        ClusterNode(\"127.0.0.1\", NODE_PORT_2),\n    ]\n\n    DEFAULT_CLUSTER_SLOTS = [\n        SlotsRange(\"127.0.0.1\", NODE_PORT_1, 0, 8191),\n        SlotsRange(\"127.0.0.1\", NODE_PORT_2, 8192, 16383),\n    ]\n\n    CLUSTER_SLOTS_INTERCEPTOR_NAME = \"test_topology\"\n\n    SLEEP_TIME_BETWEEN_START_END_NOTIFICATIONS = 2\n    MOVING_TTL = 4\n\n    def __init__(self, oss_cluster: bool = False):\n        self.oss_cluster = oss_cluster\n        self.proxy_helper = ProxyInterceptorHelper()\n\n        # set the initial state of the proxy server\n        logging.info(\n            f\"Setting up initial cluster slots -> {self.DEFAULT_CLUSTER_SLOTS}\"\n        )\n        self.proxy_helper.set_cluster_slots(\n            self.CLUSTER_SLOTS_INTERCEPTOR_NAME, self.DEFAULT_CLUSTER_SLOTS\n        )\n\n        self.seq_id = 0\n\n    def _get_seq_id(self):\n        self.seq_id += 1\n        return self.seq_id\n\n    def get_operation_result(\n        self,\n        action_id: str,\n        timeout: int = 60,\n    ) -> Dict[str, Any]:\n        return {\"status\": \"done\"}\n\n    def create_database(\n        self,\n        bdb_config: Dict[str, Any],\n    ) -> Dict[str, Any]:\n        return {\n            \"bdb_id\": 1,\n            \"username\": \"default\",\n            \"password\": \"\",\n            \"tls\": False,\n            \"raw_endpoints\": [\n                {\n                    \"addr\": [\"127.0.0.1\"],\n                    \"addr_type\": \"external\",\n                    \"dns_name\": \"localhost\",\n                    \"oss_cluster_api_preferred_endpoint_type\": \"ip\",\n                    \"oss_cluster_api_preferred_ip_type\": \"internal\",\n                    \"port\": 15379,\n                    \"proxy_policy\": \"all-master-shards\",\n                    \"uid\": \"1:1\",\n                }\n            ],\n            \"endpoints\": [\"redis://127.0.0.1:15379\"],\n        }\n\n    def delete_database(\n        self,\n        endpoint_config: Dict[str, Any],\n    ) -> Dict[str, Any]:\n        return {}\n\n    def find_database_id_by_name(\n        self,\n        database_name: str,\n        force_cluster_info_refresh: bool = True,\n    ) -> Optional[int]:\n        return 1\n\n    def find_target_node_and_empty_node(\n        self,\n        endpoint_config: Dict[str, Any],\n        force_cluster_info_refresh: bool = True,\n    ) -> Tuple[NodeInfo, NodeInfo]:\n        target_node = NodeInfo(\n            \"1\", \"master\", \"0.0.0.0\", \"127.0.0.1\", \"localhost\", self.NODE_PORT_1\n        )\n        empty_node = NodeInfo(\n            \"3\", \"master\", \"0.0.0.0\", \"127.0.0.1\", \"localhost\", self.NODE_PORT_3\n        )\n        return target_node, empty_node\n\n    def find_endpoint_for_bind(\n        self,\n        endpoint_name: str,\n        force_cluster_info_refresh: bool = True,\n    ) -> str:\n        return \"1:1\"\n\n    def execute_failover(\n        self, endpoint_config: Dict[str, Any], timeout: int = 60\n    ) -> Dict[str, Any]:\n        \"\"\"\n        Simulates a failover operation and waits for completion.\n        This method does not create or manage threads; if asynchronous execution is required,\n        it should be called from a separate thread by the caller.\n        This will always run for the same nodes - node 1 to node 3!\n        Assumes that the initial state is the DEFAULT_CLUSTER_SLOTS - shard 1 on node 1 and shard 2 on node 2.\n        In a real RE cluster, a replica would exist on another node, which is simulated here with node 3.\n        \"\"\"\n\n        # send smigrating\n        if self.oss_cluster:\n            start_maint_notif = RespTranslator.oss_maint_notification_to_resp(\n                f\"SMIGRATING {self._get_seq_id()} 0-8191\"\n            )\n        else:\n            # send failing over\n            start_maint_notif = RespTranslator.re_cluster_maint_notification_to_resp(\n                f\"FAILING_OVER {self._get_seq_id()} 2 [1]\"\n            )\n\n        self.proxy_helper.send_notification(start_maint_notif)\n\n        # sleep to allow the client to receive the notification\n        time.sleep(self.SLEEP_TIME_BETWEEN_START_END_NOTIFICATIONS)\n\n        if self.oss_cluster:\n            # intercept cluster slots\n            self.proxy_helper.set_cluster_slots(\n                self.CLUSTER_SLOTS_INTERCEPTOR_NAME,\n                [\n                    SlotsRange(\"127.0.0.1\", self.NODE_PORT_3, 0, 8191),\n                    SlotsRange(\"127.0.0.1\", self.NODE_PORT_2, 8192, 16383),\n                ],\n            )\n            # send smigrated\n            end_maint_notif = RespTranslator.oss_maint_notification_to_resp(\n                f\"SMIGRATED {self._get_seq_id()} 127.0.0.1:{self.NODE_PORT_3} 0-8191\"\n            )\n        else:\n            # send failed over\n            end_maint_notif = RespTranslator.re_cluster_maint_notification_to_resp(\n                f\"FAILED_OVER {self._get_seq_id()} [1]\"\n            )\n        self.proxy_helper.send_notification(end_maint_notif)\n\n        return {\"status\": \"done\"}\n\n    def execute_migrate(\n        self,\n        endpoint_config: Dict[str, Any],\n        target_node: str,\n        empty_node: str,\n        skip_end_notification: bool = False,\n    ) -> str:\n        \"\"\"\n        Simulate migrate command execution.\n        This method does not create or manage threads; it simulates the migration process synchronously.\n        If asynchronous execution is desired, the caller should run this method in a separate thread.\n        This will run always for the same nodes - node 1 to node 2!\n        Assuming that the initial state is the DEFAULT_CLUSTER_SLOTS - shard 1 on node 1 and shard 2 on node 2.\n\n        \"\"\"\n\n        if self.oss_cluster:\n            # send smigrating\n            start_maint_notif = RespTranslator.oss_maint_notification_to_resp(\n                f\"SMIGRATING {self._get_seq_id()} 0-200\"\n            )\n        else:\n            # send migrating\n            start_maint_notif = RespTranslator.re_cluster_maint_notification_to_resp(\n                f\"MIGRATING {self._get_seq_id()} 2 [1]\"\n            )\n\n        self.proxy_helper.send_notification(start_maint_notif)\n\n        # sleep to allow the client to receive the notification\n        time.sleep(self.SLEEP_TIME_BETWEEN_START_END_NOTIFICATIONS)\n\n        if self.oss_cluster:\n            if not skip_end_notification:\n                # intercept cluster slots\n                self.proxy_helper.set_cluster_slots(\n                    self.CLUSTER_SLOTS_INTERCEPTOR_NAME,\n                    [\n                        SlotsRange(\"127.0.0.1\", self.NODE_PORT_2, 0, 200),\n                        SlotsRange(\"127.0.0.1\", self.NODE_PORT_1, 201, 8191),\n                        SlotsRange(\"127.0.0.1\", self.NODE_PORT_2, 8192, 16383),\n                    ],\n                )\n                # send smigrated\n                end_maint_notif = RespTranslator.oss_maint_notification_to_resp(\n                    f\"SMIGRATED {self._get_seq_id()} 127.0.0.1:{self.NODE_PORT_2} 0-200\"\n                )\n                self.proxy_helper.send_notification(end_maint_notif)\n        else:\n            # send migrated\n            end_maint_notif = RespTranslator.re_cluster_maint_notification_to_resp(\n                f\"MIGRATED {self._get_seq_id()} [1]\"\n            )\n            self.proxy_helper.send_notification(end_maint_notif)\n\n        return \"done\"\n\n    def execute_rebind(self, endpoint_config: Dict[str, Any], endpoint_id: str) -> str:\n        \"\"\"\n        Execute rladmin bind endpoint command and wait for completion.\n        This method simulates the actual bind process. It does not create or manage threads;\n        if you wish to run it in a separate thread, you must do so from the caller.\n        This will run always for the same nodes - node 1 to node 3!\n        Assuming that the initial state is the DEFAULT_CLUSTER_SLOTS - shard 1 on node 1\n        and shard 2 on node 2.\n\n        \"\"\"\n        sleep_time = self.SLEEP_TIME_BETWEEN_START_END_NOTIFICATIONS\n        if self.oss_cluster:\n            # smigrating should be sent as part of the migrate flow\n            pass\n        else:\n            # send moving\n            sleep_time = self.MOVING_TTL\n            maint_start_notif = RespTranslator.re_cluster_maint_notification_to_resp(\n                f\"MOVING {self._get_seq_id()} {sleep_time} 127.0.0.1:{self.NODE_PORT_3}\"\n            )\n            self.proxy_helper.send_notification(maint_start_notif)\n\n        # sleep to allow the client to receive the notification\n        time.sleep(sleep_time)\n\n        if self.oss_cluster:\n            # intercept cluster slots\n            self.proxy_helper.set_cluster_slots(\n                self.CLUSTER_SLOTS_INTERCEPTOR_NAME,\n                [\n                    SlotsRange(\"127.0.0.1\", self.NODE_PORT_3, 0, 8191),\n                    SlotsRange(\"127.0.0.1\", self.NODE_PORT_2, 8192, 16383),\n                ],\n            )\n            # send smigrated\n            smigrated_node_1 = RespTranslator.oss_maint_notification_to_resp(\n                f\"SMIGRATED {self._get_seq_id()} 127.0.0.1:{self.NODE_PORT_3} 0-8191\"\n            )\n            self.proxy_helper.send_notification(smigrated_node_1)\n        else:\n            # TODO drop connections to node 1 to simulate that the node is removed\n            pass\n\n        return \"done\"\n\n    def get_moving_ttl(self) -> int:\n        return self.MOVING_TTL\n\n    def get_slot_migrate_triggers(\n        self,\n        effect_name: SlotMigrateEffects,\n    ) -> Dict[str, Any]:\n        raise NotImplementedError(\"Not implemented for proxy server\")\n\n    def trigger_effect(\n        self,\n        endpoint_config: Dict[str, Any],\n        effect_name: SlotMigrateEffects,\n        trigger_name: Optional[str] = None,\n        source_node: Optional[str] = None,\n        target_node: Optional[str] = None,\n        skip_end_notification: bool = False,\n    ) -> str:\n        \"\"\"\n        Trigger the desired effect. For the proxy server,\n        this will need to be implemented in next iterations.\n        \"\"\"\n        raise NotImplementedError(\"Not implemented for proxy server\")\n"
  },
  {
    "path": "tests/test_scenario/maint_notifications_helpers.py",
    "content": "import binascii\nimport logging\nimport time\nfrom typing import Any, Dict, Optional, Tuple, Union\nimport pytest\nfrom redis import RedisCluster\n\nfrom redis.client import Redis\nfrom redis.connection import Connection\nfrom tests.test_scenario.fault_injector_client import (\n    FaultInjectorClient,\n    NodeInfo,\n    SlotMigrateEffects,\n)\n\n\nclass ClientValidations:\n    @staticmethod\n    def get_default_connection(redis_client: Union[Redis, RedisCluster]) -> Connection:\n        \"\"\"Get a random connection from the pool.\"\"\"\n        if isinstance(redis_client, RedisCluster):\n            return redis_client.get_default_node().redis_connection.connection_pool.get_connection()\n        if isinstance(redis_client, Redis):\n            return redis_client.connection_pool.get_connection()\n        raise ValueError(f\"Unsupported redis client type: {type(redis_client)}\")\n\n    @staticmethod\n    def release_connection(\n        redis_client: Union[Redis, RedisCluster], connection: Connection\n    ):\n        \"\"\"Release a connection back to the pool.\"\"\"\n        if isinstance(redis_client, RedisCluster):\n            node_address = connection.host + \":\" + str(connection.port)\n            node = redis_client.get_node(node_address)\n            if node is None:\n                raise ValueError(\n                    f\"Node not found in cluster for address: {node_address}\"\n                )\n            node.redis_connection.connection_pool.release(connection)\n        elif isinstance(redis_client, Redis):\n            redis_client.connection_pool.release(connection)\n        else:\n            raise ValueError(f\"Unsupported redis client type: {type(redis_client)}\")\n\n    @staticmethod\n    def wait_push_notification(\n        redis_client: Union[Redis, RedisCluster],\n        timeout: float = 120,\n        fail_on_timeout: bool = True,\n        connection: Optional[Connection] = None,\n    ):\n        \"\"\"Wait for a push notification to be received.\"\"\"\n        start_time = time.time()  # returns the time in seconds\n        check_interval = 0.2  # Check more frequently during operations\n        test_conn = (\n            connection\n            if connection\n            else ClientValidations.get_default_connection(redis_client)\n        )\n        logging.info(\n            f\"Waiting for push notification on connection: {test_conn}, \"\n            f\"local socket port: {test_conn._sock.getsockname()[1] if test_conn._sock else None}\"\n        )\n\n        try:\n            while time.time() - start_time < timeout:\n                try:\n                    if test_conn.can_read(timeout=0.2):\n                        # reading is important, it triggers the push notification\n                        push_response = test_conn.read_response(push_request=True)\n                        logging.debug(\n                            f\"Push notification has been received. Response: {push_response}\"\n                        )\n                        if test_conn.should_reconnect():\n                            logging.debug(\"Connection is marked for reconnect\")\n                        return\n                except Exception as e:\n                    logging.error(f\"Error reading push notification: {e}\")\n                    break\n                time.sleep(check_interval)\n            if fail_on_timeout:\n                pytest.fail(\n                    f\"Timeout waiting for push notification: waiting > {time.time() - start_time} seconds.\"\n                )\n        finally:\n            # Release the connection back to the pool\n            try:\n                if not connection:\n                    ClientValidations.release_connection(redis_client, test_conn)\n            except Exception as e:\n                logging.error(f\"Error releasing connection: {e}\")\n\n\nclass ClusterOperations:\n    @staticmethod\n    def find_database_id_by_name(\n        fault_injector: FaultInjectorClient,\n        database_name: str,\n        force_cluster_info_refresh: bool = True,\n    ) -> Optional[int]:\n        \"\"\"Find the database ID by name.\"\"\"\n        return fault_injector.find_database_id_by_name(\n            database_name, force_cluster_info_refresh\n        )\n\n    @staticmethod\n    def find_target_node_and_empty_node(\n        fault_injector: FaultInjectorClient,\n        endpoint_config: Dict[str, Any],\n        force_cluster_info_refresh: bool = True,\n    ) -> Tuple[NodeInfo, NodeInfo]:\n        \"\"\"Find the node with master shards and the node with no shards.\n\n        Returns:\n            tuple: (target_node, empty_node) where target_node has master shards\n                   and empty_node has no shards\n        \"\"\"\n        return fault_injector.find_target_node_and_empty_node(\n            endpoint_config, force_cluster_info_refresh\n        )\n\n    @staticmethod\n    def find_endpoint_for_bind(\n        fault_injector: FaultInjectorClient,\n        endpoint_name: str,\n        force_cluster_info_refresh: bool = True,\n    ) -> str:\n        \"\"\"Find the endpoint ID from cluster status.\n\n        Returns:\n            str: The endpoint ID (e.g., \"1:1\")\n        \"\"\"\n        return fault_injector.find_endpoint_for_bind(\n            endpoint_name, force_cluster_info_refresh\n        )\n\n    @staticmethod\n    def execute_failover(\n        fault_injector: FaultInjectorClient,\n        endpoint_config: Dict[str, Any],\n        timeout: int = 60,\n    ) -> Dict[str, Any]:\n        \"\"\"Execute failover command and wait for completion.\"\"\"\n        return fault_injector.execute_failover(endpoint_config, timeout)\n\n    @staticmethod\n    def execute_migrate(\n        fault_injector: FaultInjectorClient,\n        endpoint_config: Dict[str, Any],\n        target_node: str,\n        empty_node: str,\n        skip_end_notification: bool = False,\n    ) -> str:\n        \"\"\"Execute rladmin migrate command and wait for completion.\"\"\"\n        return fault_injector.execute_migrate(\n            endpoint_config, target_node, empty_node, skip_end_notification\n        )\n\n    @staticmethod\n    def execute_rebind(\n        fault_injector: FaultInjectorClient,\n        endpoint_config: Dict[str, Any],\n        endpoint_id: str,\n    ) -> str:\n        \"\"\"Execute rladmin bind endpoint command and wait for completion.\"\"\"\n        return fault_injector.execute_rebind(endpoint_config, endpoint_id)\n\n    @staticmethod\n    def get_slot_migrate_triggers(\n        fault_injector: FaultInjectorClient,\n        effect_name: SlotMigrateEffects,\n    ) -> Dict[str, Any]:\n        \"\"\"Get available triggers(trigger name + db example config) for a slot migration effect.\"\"\"\n        return fault_injector.get_slot_migrate_triggers(effect_name)\n\n    @staticmethod\n    def trigger_effect(\n        fault_injector: FaultInjectorClient,\n        endpoint_config: Dict[str, Any],\n        effect_name: SlotMigrateEffects,\n        trigger_name: Optional[str] = None,\n        source_node: Optional[str] = None,\n        target_node: Optional[str] = None,\n        skip_end_notification: bool = False,\n    ) -> str:\n        \"\"\"Execute fault injector action that will trigger the desired effect.\n\n        Args:\n            fault_injector: The fault injector client to use\n            endpoint_config: Endpoint configuration dictionary\n            effect_name: The effect to trigger (e.g., SlotMigrateEffects enum value)\n            trigger_name: Optional trigger/variant name\n            source_node: Optional source node ID\n            target_node: Optional target node ID\n            skip_end_notification: Whether to skip end notification\n\n        Returns:\n            str: Action ID for tracking the operation\n        \"\"\"\n        return fault_injector.trigger_effect(\n            endpoint_config=endpoint_config,\n            effect_name=effect_name,\n            trigger_name=trigger_name,\n            source_node=source_node,\n            target_node=target_node,\n            skip_end_notification=skip_end_notification,\n        )\n\n\nclass KeyGenerationHelpers:\n    TOTAL_SLOTS = 16384\n\n    @staticmethod\n    def redis_crc16(data: bytes) -> int:\n        \"\"\"\n        Redis-compatible CRC16 (CRC-CCITT)\n        \"\"\"\n        return binascii.crc_hqx(data, 0)\n\n    @staticmethod\n    def redis_slot(key: str) -> int:\n        \"\"\"\n        Compute Redis Cluster hash slot for a key\n        \"\"\"\n        start = key.find(\"{\")\n        if start != -1:\n            end = key.find(\"}\", start + 1)\n            if end != -1 and end > start + 1:\n                key = key[start + 1 : end]\n\n        return (\n            KeyGenerationHelpers.redis_crc16(key.encode(\"utf-8\"))\n            % KeyGenerationHelpers.TOTAL_SLOTS\n        )\n\n    @staticmethod\n    def generate_key(slot_number: int, prefix: str = \"key\") -> str:\n        \"\"\"\n        Generate a Redis key that hashes to the given slot\n        \"\"\"\n        if not (0 <= slot_number < KeyGenerationHelpers.TOTAL_SLOTS):\n            raise ValueError(\"slot_number must be between 0 and 16383\")\n\n        i = 0\n        while True:\n            hashtag = f\"{slot_number}-{i}\"\n            candidate = f\"{prefix}:{{{hashtag}}}\"\n            if KeyGenerationHelpers.redis_slot(candidate) == slot_number:\n                return candidate\n            i += 1\n\n    @staticmethod\n    def generate_keys_for_all_shards(\n        shards_count: int, prefix: str = \"key\", keys_per_shard: int = 1\n    ) -> list:\n        \"\"\"\n        Generate keys for all shards based on slot ranges.\n\n        Divides the total slots (16384) evenly across shards and generates\n        keys for each shard range.\n\n        Args:\n            shards_count: Number of shards in the cluster\n            prefix: Prefix for generated keys\n            keys_per_shard: Number of keys to generate per shard\n\n        Returns:\n            List of generated keys distributed across all shards\n        \"\"\"\n        keys = []\n        slots_per_shard = KeyGenerationHelpers.TOTAL_SLOTS // shards_count\n\n        for shard_index in range(shards_count):\n            # Calculate slot range for this shard\n            start_slot = shard_index * slots_per_shard\n\n            # Last shard gets any remaining slots\n            if shard_index == shards_count - 1:\n                end_slot = KeyGenerationHelpers.TOTAL_SLOTS - 1\n            else:\n                end_slot = start_slot + slots_per_shard - 1\n\n            # Generate keys for this shard's slot range\n            for i in range(keys_per_shard):\n                # Pick a slot within this shard's range\n                slot_number = start_slot + (i % (end_slot - start_slot + 1))\n                keys.append(KeyGenerationHelpers.generate_key(slot_number, prefix))\n\n        return keys\n"
  },
  {
    "path": "tests/test_scenario/test_active_active.py",
    "content": "import json\nimport logging\nimport os\nimport threading\nfrom time import sleep\nfrom typing import Optional\n\nimport pytest\n\nfrom redis import Redis, RedisCluster\nfrom redis.backoff import ConstantBackoff\nfrom redis.client import Pipeline\nfrom redis.multidb.exception import TemporaryUnavailableException\nfrom redis.multidb.failover import DEFAULT_FAILOVER_ATTEMPTS, DEFAULT_FAILOVER_DELAY\nfrom redis.asyncio.multidb.healthcheck import LagAwareHealthCheck\nfrom redis.retry import Retry\nfrom redis.utils import dummy_fail\nfrom tests.test_scenario.fault_injector_client import ActionRequest, ActionType\n\nlogger = logging.getLogger(__name__)\n\n\ndef trigger_network_failure_action(\n    fault_injector_client, config, event: Optional[threading.Event] = None\n):\n    action_request = ActionRequest(\n        action_type=ActionType.NETWORK_FAILURE,\n        parameters={\"bdb_id\": config[\"bdb_id\"], \"delay\": 3, \"cluster_index\": 0},\n    )\n\n    result = fault_injector_client.trigger_action(action_request)\n    status_result = fault_injector_client.get_action_status(result[\"action_id\"])\n\n    while status_result[\"status\"] != \"success\":\n        sleep(0.1)\n        status_result = fault_injector_client.get_action_status(result[\"action_id\"])\n        logger.info(\n            f\"Waiting for action to complete. Status: {status_result['status']}\"\n        )\n\n    if event:\n        event.set()\n\n    logger.info(f\"Action completed. Status: {status_result['status']}\")\n\n\n@pytest.mark.skip(reason=\"Temporarily disabled\")\nclass TestActiveActive:\n    def teardown_method(self, method):\n        # Timeout so the cluster could recover from network failure.\n        sleep(10)\n\n    @pytest.mark.parametrize(\n        \"r_multi_db\",\n        [\n            {\"client_class\": Redis, \"min_num_failures\": 2},\n            {\"client_class\": RedisCluster, \"min_num_failures\": 2},\n        ],\n        ids=[\"standalone\", \"cluster\"],\n        indirect=True,\n    )\n    @pytest.mark.timeout(100)\n    def test_multi_db_client_failover_to_another_db(\n        self, r_multi_db, fault_injector_client\n    ):\n        r_multi_db, listener, config = r_multi_db\n\n        # Handle unavailable databases from previous test.\n        retry = Retry(\n            supported_errors=(TemporaryUnavailableException,),\n            retries=DEFAULT_FAILOVER_ATTEMPTS,\n            backoff=ConstantBackoff(backoff=DEFAULT_FAILOVER_DELAY),\n        )\n\n        event = threading.Event()\n        thread = threading.Thread(\n            target=trigger_network_failure_action,\n            daemon=True,\n            args=(fault_injector_client, config, event),\n        )\n\n        # Client initialized on the first command.\n        retry.call_with_retry(\n            lambda: r_multi_db.set(\"key\", \"value\"), lambda _: dummy_fail()\n        )\n        thread.start()\n\n        # Execute commands before network failure\n        while not event.is_set():\n            assert (\n                retry.call_with_retry(\n                    lambda: r_multi_db.get(\"key\"), lambda _: dummy_fail()\n                )\n                == \"value\"\n            )\n            sleep(0.5)\n\n        # Execute commands until database failover\n        while not listener.is_changed_flag:\n            assert (\n                retry.call_with_retry(\n                    lambda: r_multi_db.get(\"key\"), lambda _: dummy_fail()\n                )\n                == \"value\"\n            )\n            sleep(0.5)\n\n    @pytest.mark.parametrize(\n        \"r_multi_db\",\n        [\n            {\"client_class\": Redis, \"min_num_failures\": 2, \"health_check_interval\": 20},\n            {\n                \"client_class\": RedisCluster,\n                \"min_num_failures\": 2,\n                \"health_check_interval\": 20,\n            },\n        ],\n        ids=[\"standalone\", \"cluster\"],\n        indirect=True,\n    )\n    @pytest.mark.timeout(100)\n    def test_multi_db_client_uses_lag_aware_health_check(\n        self, r_multi_db, fault_injector_client\n    ):\n        r_multi_db, listener, config = r_multi_db\n        retry = Retry(\n            supported_errors=(TemporaryUnavailableException,),\n            retries=DEFAULT_FAILOVER_ATTEMPTS,\n            backoff=ConstantBackoff(backoff=DEFAULT_FAILOVER_DELAY),\n        )\n\n        event = threading.Event()\n        thread = threading.Thread(\n            target=trigger_network_failure_action,\n            daemon=True,\n            args=(fault_injector_client, config, event),\n        )\n\n        env0_username = os.getenv(\"ENV0_USERNAME\")\n        env0_password = os.getenv(\"ENV0_PASSWORD\")\n\n        # Adding additional health check to the client.\n        r_multi_db.add_health_check(\n            LagAwareHealthCheck(\n                verify_tls=False,\n                auth_basic=(env0_username, env0_password),\n                lag_aware_tolerance=10000,\n            )\n        )\n\n        # Client initialized on the first command.\n        retry.call_with_retry(\n            lambda: r_multi_db.set(\"key\", \"value\"), lambda _: dummy_fail()\n        )\n        thread.start()\n\n        # Execute commands before network failure\n        while not event.is_set():\n            assert (\n                retry.call_with_retry(\n                    lambda: r_multi_db.get(\"key\"), lambda _: dummy_fail()\n                )\n                == \"value\"\n            )\n            sleep(0.5)\n\n        # Execute commands after network failure\n        while not listener.is_changed_flag:\n            assert (\n                retry.call_with_retry(\n                    lambda: r_multi_db.get(\"key\"), lambda _: dummy_fail()\n                )\n                == \"value\"\n            )\n            sleep(0.5)\n\n    @pytest.mark.parametrize(\n        \"r_multi_db\",\n        [\n            {\"client_class\": Redis, \"min_num_failures\": 2},\n            {\"client_class\": RedisCluster, \"min_num_failures\": 2},\n        ],\n        ids=[\"standalone\", \"cluster\"],\n        indirect=True,\n    )\n    @pytest.mark.timeout(100)\n    def test_context_manager_pipeline_failover_to_another_db(\n        self, r_multi_db, fault_injector_client\n    ):\n        r_multi_db, listener, config = r_multi_db\n        retry = Retry(\n            supported_errors=(TemporaryUnavailableException,),\n            retries=DEFAULT_FAILOVER_ATTEMPTS,\n            backoff=ConstantBackoff(backoff=DEFAULT_FAILOVER_DELAY),\n        )\n\n        event = threading.Event()\n        thread = threading.Thread(\n            target=trigger_network_failure_action,\n            daemon=True,\n            args=(fault_injector_client, config, event),\n        )\n\n        def callback():\n            with r_multi_db.pipeline() as pipe:\n                pipe.set(\"{hash}key1\", \"value1\")\n                pipe.set(\"{hash}key2\", \"value2\")\n                pipe.set(\"{hash}key3\", \"value3\")\n                pipe.get(\"{hash}key1\")\n                pipe.get(\"{hash}key2\")\n                pipe.get(\"{hash}key3\")\n                assert pipe.execute() == [\n                    True,\n                    True,\n                    True,\n                    \"value1\",\n                    \"value2\",\n                    \"value3\",\n                ]\n\n        # Client initialized on first pipe execution.\n        retry.call_with_retry(lambda: callback(), lambda _: dummy_fail())\n        thread.start()\n\n        # Execute pipeline before network failure\n        while not event.is_set():\n            retry.call_with_retry(lambda: callback(), lambda _: dummy_fail())\n            sleep(0.5)\n\n        # Execute pipeline until database failover\n        for _ in range(5):\n            retry.call_with_retry(lambda: callback(), lambda _: dummy_fail())\n            sleep(0.5)\n\n    @pytest.mark.parametrize(\n        \"r_multi_db\",\n        [\n            {\"client_class\": Redis, \"min_num_failures\": 2},\n            {\"client_class\": RedisCluster, \"min_num_failures\": 2},\n        ],\n        ids=[\"standalone\", \"cluster\"],\n        indirect=True,\n    )\n    @pytest.mark.timeout(100)\n    def test_chaining_pipeline_failover_to_another_db(\n        self, r_multi_db, fault_injector_client\n    ):\n        r_multi_db, listener, config = r_multi_db\n        retry = Retry(\n            supported_errors=(TemporaryUnavailableException,),\n            retries=DEFAULT_FAILOVER_ATTEMPTS,\n            backoff=ConstantBackoff(backoff=DEFAULT_FAILOVER_DELAY),\n        )\n\n        event = threading.Event()\n        thread = threading.Thread(\n            target=trigger_network_failure_action,\n            daemon=True,\n            args=(fault_injector_client, config, event),\n        )\n\n        def callback():\n            pipe = r_multi_db.pipeline()\n            pipe.set(\"{hash}key1\", \"value1\")\n            pipe.set(\"{hash}key2\", \"value2\")\n            pipe.set(\"{hash}key3\", \"value3\")\n            pipe.get(\"{hash}key1\")\n            pipe.get(\"{hash}key2\")\n            pipe.get(\"{hash}key3\")\n            assert pipe.execute() == [True, True, True, \"value1\", \"value2\", \"value3\"]\n\n        # Client initialized on first pipe execution.\n        retry.call_with_retry(lambda: callback(), lambda _: dummy_fail())\n\n        thread.start()\n\n        # Execute pipeline before network failure\n        while not event.is_set():\n            retry.call_with_retry(lambda: callback(), lambda _: dummy_fail())\n        sleep(0.5)\n\n        # Execute pipeline until database failover\n        for _ in range(5):\n            retry.call_with_retry(lambda: callback(), lambda _: dummy_fail())\n        sleep(0.5)\n\n    @pytest.mark.parametrize(\n        \"r_multi_db\",\n        [\n            {\"client_class\": Redis, \"min_num_failures\": 2},\n            {\"client_class\": RedisCluster, \"min_num_failures\": 2},\n        ],\n        ids=[\"standalone\", \"cluster\"],\n        indirect=True,\n    )\n    @pytest.mark.timeout(100)\n    def test_transaction_failover_to_another_db(\n        self, r_multi_db, fault_injector_client\n    ):\n        r_multi_db, listener, config = r_multi_db\n        retry = Retry(\n            supported_errors=(TemporaryUnavailableException,),\n            retries=DEFAULT_FAILOVER_ATTEMPTS,\n            backoff=ConstantBackoff(backoff=DEFAULT_FAILOVER_DELAY),\n        )\n\n        event = threading.Event()\n        thread = threading.Thread(\n            target=trigger_network_failure_action,\n            daemon=True,\n            args=(fault_injector_client, config, event),\n        )\n\n        def callback(pipe: Pipeline):\n            pipe.set(\"{hash}key1\", \"value1\")\n            pipe.set(\"{hash}key2\", \"value2\")\n            pipe.set(\"{hash}key3\", \"value3\")\n            pipe.get(\"{hash}key1\")\n            pipe.get(\"{hash}key2\")\n            pipe.get(\"{hash}key3\")\n\n        # Client initialized on first transaction execution.\n        retry.call_with_retry(\n            lambda: r_multi_db.transaction(callback), lambda _: dummy_fail()\n        )\n        thread.start()\n\n        # Execute transaction before network failure\n        while not event.is_set():\n            retry.call_with_retry(\n                lambda: r_multi_db.transaction(callback), lambda _: dummy_fail()\n            )\n            sleep(0.5)\n\n        # Execute transaction until database failover\n        while not listener.is_changed_flag:\n            retry.call_with_retry(\n                lambda: r_multi_db.transaction(callback), lambda _: dummy_fail()\n            )\n            sleep(0.5)\n\n    @pytest.mark.parametrize(\n        \"r_multi_db\",\n        [\n            {\"client_class\": Redis, \"min_num_failures\": 2},\n            {\"client_class\": RedisCluster, \"min_num_failures\": 2},\n        ],\n        ids=[\"standalone\", \"cluster\"],\n        indirect=True,\n    )\n    @pytest.mark.timeout(100)\n    def test_pubsub_failover_to_another_db(self, r_multi_db, fault_injector_client):\n        r_multi_db, listener, config = r_multi_db\n        retry = Retry(\n            supported_errors=(TemporaryUnavailableException,),\n            retries=DEFAULT_FAILOVER_ATTEMPTS,\n            backoff=ConstantBackoff(backoff=DEFAULT_FAILOVER_DELAY),\n        )\n\n        event = threading.Event()\n        thread = threading.Thread(\n            target=trigger_network_failure_action,\n            daemon=True,\n            args=(fault_injector_client, config, event),\n        )\n        data = json.dumps({\"message\": \"test\"})\n        messages_count = 0\n\n        def handler(message):\n            nonlocal messages_count\n            messages_count += 1\n\n        pubsub = r_multi_db.pubsub()\n\n        # Assign a handler and run in a separate thread.\n        retry.call_with_retry(\n            lambda: pubsub.subscribe(**{\"test-channel\": handler}),\n            lambda _: dummy_fail(),\n        )\n        pubsub_thread = pubsub.run_in_thread(sleep_time=0.1, daemon=True)\n        thread.start()\n\n        # Execute publish before network failure\n        while not event.is_set():\n            retry.call_with_retry(\n                lambda: r_multi_db.publish(\"test-channel\", data), lambda _: dummy_fail()\n            )\n            sleep(0.5)\n\n        # Execute publish until database failover\n        while not listener.is_changed_flag:\n            retry.call_with_retry(\n                lambda: r_multi_db.publish(\"test-channel\", data), lambda _: dummy_fail()\n            )\n            sleep(0.5)\n\n        pubsub_thread.stop()\n        assert messages_count > 2\n\n    @pytest.mark.parametrize(\n        \"r_multi_db\",\n        [\n            {\"client_class\": Redis, \"min_num_failures\": 2},\n            {\"client_class\": RedisCluster, \"min_num_failures\": 2},\n        ],\n        ids=[\"standalone\", \"cluster\"],\n        indirect=True,\n    )\n    @pytest.mark.timeout(100)\n    def test_sharded_pubsub_failover_to_another_db(\n        self, r_multi_db, fault_injector_client\n    ):\n        r_multi_db, listener, config = r_multi_db\n        retry = Retry(\n            supported_errors=(TemporaryUnavailableException,),\n            retries=DEFAULT_FAILOVER_ATTEMPTS,\n            backoff=ConstantBackoff(backoff=DEFAULT_FAILOVER_DELAY),\n        )\n\n        event = threading.Event()\n        thread = threading.Thread(\n            target=trigger_network_failure_action,\n            daemon=True,\n            args=(fault_injector_client, config, event),\n        )\n        data = json.dumps({\"message\": \"test\"})\n        messages_count = 0\n\n        def handler(message):\n            nonlocal messages_count\n            messages_count += 1\n\n        pubsub = r_multi_db.pubsub()\n\n        # Assign a handler and run in a separate thread.\n        retry.call_with_retry(\n            lambda: pubsub.ssubscribe(**{\"test-channel\": handler}),\n            lambda _: dummy_fail(),\n        )\n        pubsub_thread = pubsub.run_in_thread(\n            sleep_time=0.1, daemon=True, sharded_pubsub=True\n        )\n        thread.start()\n\n        # Execute publish before network failure\n        while not event.is_set():\n            retry.call_with_retry(\n                lambda: r_multi_db.spublish(\"test-channel\", data),\n                lambda _: dummy_fail(),\n            )\n            sleep(0.5)\n\n        # Execute publish until database failover\n        while not listener.is_changed_flag:\n            retry.call_with_retry(\n                lambda: r_multi_db.spublish(\"test-channel\", data),\n                lambda _: dummy_fail(),\n            )\n            sleep(0.5)\n\n        pubsub_thread.stop()\n        assert messages_count > 2\n"
  },
  {
    "path": "tests/test_scenario/test_maint_notifications.py",
    "content": "\"\"\"Tests for Redis Enterprise moving push notifications with real cluster operations.\"\"\"\n\nfrom concurrent.futures import ThreadPoolExecutor\nimport json\nimport logging\nimport random\nfrom queue import Queue\nfrom threading import Thread\nimport threading\nimport time\nfrom typing import Any, Dict, List, Literal, Optional, Union\n\nimport pytest\n\nfrom redis import Redis\nfrom redis.connection import ConnectionInterface\nfrom redis.maint_notifications import (\n    EndpointType,\n    MaintNotificationsConfig,\n    MaintenanceState,\n)\nfrom tests.test_scenario.conftest import (\n    CLIENT_TIMEOUT,\n    RELAXED_TIMEOUT,\n    _FAULT_INJECTOR_CLIENT_OSS_API,\n    _get_client_maint_notifications,\n    get_cluster_client_maint_notifications,\n    use_mock_proxy,\n)\nfrom tests.test_scenario.fault_injector_client import (\n    FaultInjectorClient,\n    NodeInfo,\n    ProxyServerFaultInjector,\n    SlotMigrateEffects,\n)\nfrom tests.test_scenario.maint_notifications_helpers import (\n    ClientValidations,\n    ClusterOperations,\n    KeyGenerationHelpers,\n)\n\nlogging.basicConfig(\n    level=logging.INFO,\n    format=\"%(asctime)s %(levelname)s %(message)s\",\n    datefmt=\"%Y-%m-%d %H:%M:%S:%f\",\n)\n\n# Set DEBUG level for specific redis-py loggers\nlogging.getLogger(\"redis.maint_notifications\").setLevel(logging.DEBUG)\nlogging.getLogger(\"redis.cluster\").setLevel(logging.DEBUG)\n\nBIND_TIMEOUT = 60\nMIGRATE_TIMEOUT = 60\nFAILOVER_TIMEOUT = 15\nSMIGRATING_TIMEOUT = 20\nSMIGRATED_TIMEOUT = 40\n\nSLOT_SHUFFLE_TIMEOUT = 120\n\nDEFAULT_BIND_TTL = 15\nDEFAULT_STANDALONE_CLIENT_SOCKET_TIMEOUT = 1\nDEFAULT_OSS_API_CLIENT_SOCKET_TIMEOUT = 1\n\n\nclass TestPushNotificationsBase:\n    \"\"\"\n    Test Redis Enterprise maintenance push notifications with real cluster\n    operations.\n    \"\"\"\n\n    def _trigger_effect(\n        self,\n        fault_injector_client: FaultInjectorClient,\n        endpoints_config: Dict[str, Any],\n        effect_name: SlotMigrateEffects,\n        trigger_name: Optional[str] = None,\n        target_node: Optional[str] = None,\n        empty_node: Optional[str] = None,\n        skip_end_notification: bool = False,\n        timeout: int = SLOT_SHUFFLE_TIMEOUT,\n    ):\n        trigger_effect_action_id = ClusterOperations.trigger_effect(\n            fault_injector=fault_injector_client,\n            endpoint_config=endpoints_config,\n            effect_name=effect_name,\n            trigger_name=trigger_name,\n            source_node=target_node,\n            target_node=empty_node,\n            skip_end_notification=skip_end_notification,\n        )\n\n        trigger_effect_result = fault_injector_client.get_operation_result(\n            trigger_effect_action_id,\n            timeout=timeout,\n        )\n        logging.debug(f\"Action execution result: {trigger_effect_result}\")\n\n    def _execute_failover(\n        self,\n        fault_injector_client: FaultInjectorClient,\n        endpoints_config: Dict[str, Any],\n    ):\n        failover_result = ClusterOperations.execute_failover(\n            fault_injector_client, endpoints_config\n        )\n\n        logging.debug(\"Marking failover as executed\")\n        self._failover_executed = True\n\n        logging.debug(f\"Failover result: {failover_result}\")\n\n    def _execute_migration(\n        self,\n        fault_injector_client: FaultInjectorClient,\n        endpoints_config: Dict[str, Any],\n        target_node: str,\n        empty_node: str,\n        skip_end_notification: bool = False,\n    ):\n        migrate_action_id = ClusterOperations.execute_migrate(\n            fault_injector=fault_injector_client,\n            endpoint_config=endpoints_config,\n            target_node=target_node,\n            empty_node=empty_node,\n            skip_end_notification=skip_end_notification,\n        )\n\n        migrate_result = fault_injector_client.get_operation_result(\n            migrate_action_id, timeout=MIGRATE_TIMEOUT\n        )\n        logging.debug(f\"Migration result: {migrate_result}\")\n\n    def _execute_bind(\n        self,\n        fault_injector_client: FaultInjectorClient,\n        endpoints_config: Dict[str, Any],\n        endpoint_id: str,\n    ):\n        bind_action_id = ClusterOperations.execute_rebind(\n            fault_injector_client, endpoints_config, endpoint_id\n        )\n\n        bind_result = fault_injector_client.get_operation_result(\n            bind_action_id, timeout=BIND_TIMEOUT\n        )\n        logging.debug(f\"Bind result: {bind_result}\")\n\n    def _execute_migrate_bind_flow(\n        self,\n        fault_injector_client: FaultInjectorClient,\n        endpoints_config: Dict[str, Any],\n        target_node: str,\n        empty_node: str,\n        endpoint_id: str,\n    ):\n        self._execute_migration(\n            fault_injector_client=fault_injector_client,\n            endpoints_config=endpoints_config,\n            target_node=target_node,\n            empty_node=empty_node,\n            skip_end_notification=True,\n        )\n        self._execute_bind(\n            fault_injector_client=fault_injector_client,\n            endpoints_config=endpoints_config,\n            endpoint_id=endpoint_id,\n        )\n\n    def _get_all_connections_in_pool(self, client: Redis) -> List[ConnectionInterface]:\n        connections = []\n        with client.connection_pool._lock:\n            for conn in client.connection_pool._get_free_connections():\n                connections.append(conn)\n            for conn in client.connection_pool._get_in_use_connections():\n                connections.append(conn)\n        return connections\n\n    def _validate_maintenance_state(\n        self, client: Redis, expected_matching_conns_count: int\n    ):\n        \"\"\"Validate the client connections are in the expected state after migration.\"\"\"\n        matching_conns_count = 0\n        connections = self._get_all_connections_in_pool(client)\n\n        for conn in connections:\n            if (\n                conn._sock is not None\n                and conn._sock.gettimeout() == RELAXED_TIMEOUT\n                and conn.maintenance_state == MaintenanceState.MAINTENANCE\n            ):\n                matching_conns_count += 1\n        assert matching_conns_count == expected_matching_conns_count\n\n    def _validate_moving_state(\n        self,\n        client: Redis,\n        configured_endpoint_type: EndpointType,\n        expected_matching_connected_conns_count: int,\n        expected_matching_disconnected_conns_count: int,\n        fault_injector_client: FaultInjectorClient,\n    ):\n        \"\"\"Validate the client connections are in the expected state after migration.\"\"\"\n        matching_connected_conns_count = 0\n        matching_disconnected_conns_count = 0\n        with client.connection_pool._lock:\n            connections = self._get_all_connections_in_pool(client)\n            for conn in connections:\n                endpoint_configured_correctly = bool(\n                    (\n                        configured_endpoint_type == EndpointType.NONE\n                        and conn.host == conn.orig_host_address\n                    )\n                    or (\n                        configured_endpoint_type != EndpointType.NONE\n                        and conn.host != conn.orig_host_address\n                        and (\n                            configured_endpoint_type\n                            == MaintNotificationsConfig().get_endpoint_type(\n                                conn.host, conn\n                            )\n                        )\n                    )\n                    or isinstance(\n                        fault_injector_client, ProxyServerFaultInjector\n                    )  # we should not validate the endpoint type when using proxy server\n                )\n\n                if (\n                    conn._sock is not None\n                    and conn._sock.gettimeout() == RELAXED_TIMEOUT\n                    and conn.maintenance_state == MaintenanceState.MOVING\n                    and endpoint_configured_correctly\n                ):\n                    matching_connected_conns_count += 1\n                elif (\n                    conn._sock is None\n                    and conn.maintenance_state == MaintenanceState.MOVING\n                    and conn.socket_timeout == RELAXED_TIMEOUT\n                    and endpoint_configured_correctly\n                ):\n                    matching_disconnected_conns_count += 1\n                else:\n                    pass\n        assert matching_connected_conns_count == expected_matching_connected_conns_count\n        assert (\n            matching_disconnected_conns_count\n            == expected_matching_disconnected_conns_count\n        )\n\n    def _validate_default_state(\n        self,\n        client: Redis,\n        expected_matching_conns_count: Union[int, Literal[\"all\"]],\n        configured_timeout: float = CLIENT_TIMEOUT,\n    ):\n        \"\"\"Validate the client connections are in the expected state after migration.\"\"\"\n        matching_conns_count = 0\n        connections = self._get_all_connections_in_pool(client)\n        logging.info(f\"Validating {len(connections)} connections\")\n        logging.info(f\"Expected matching conns count: {expected_matching_conns_count}\")\n\n        for conn in connections:\n            if conn._sock is None:\n                if (\n                    conn.maintenance_state == MaintenanceState.NONE\n                    and conn.socket_timeout == configured_timeout\n                    and conn.host == conn.orig_host_address\n                ):\n                    matching_conns_count += 1\n                else:\n                    logging.debug(\n                        f\"Connection not matching default state: \"\n                        f\"maintenance_state={conn.maintenance_state}, \"\n                        f\"socket_timeout={conn.socket_timeout}, \"\n                        f\"host={conn.host}, \"\n                        f\"orig_host_address={conn.orig_host_address}\"\n                    )\n            elif (\n                conn._sock.gettimeout() == configured_timeout\n                and conn.maintenance_state == MaintenanceState.NONE\n                and conn.host == conn.orig_host_address\n            ):\n                matching_conns_count += 1\n            else:\n                logging.debug(\n                    f\"Connection not matching default state: \"\n                    f\"maintenance_state={conn.maintenance_state}, \"\n                    f\"socket_timeout={conn.socket_timeout}, \"\n                    f\"host={conn.host}, \"\n                    f\"orig_host_address={conn.orig_host_address}\"\n                )\n\n        # Get client configuration details for error message\n        conn_kwargs = client.connection_pool.connection_kwargs\n        client_host = conn_kwargs.get(\"host\", \"unknown\")\n        client_port = conn_kwargs.get(\"port\", \"unknown\")\n\n        if expected_matching_conns_count == \"all\":\n            expected_matching_conns_count = len(connections)\n\n        assert matching_conns_count == expected_matching_conns_count, (\n            f\"Default state validation failed. \"\n            f\"Client: host={client_host}, port={client_port}, \"\n            f\"configured_timeout={configured_timeout}. \"\n            f\"Expected {expected_matching_conns_count} matching connections, \"\n            f\"but found {matching_conns_count} out of {len(connections)} total connections.\"\n        )\n\n    def _validate_default_notif_disabled_state(\n        self, client: Redis, expected_matching_conns_count: int\n    ):\n        \"\"\"Validate the client connections are in the expected state after migration.\"\"\"\n        matching_conns_count = 0\n        connections = self._get_all_connections_in_pool(client)\n\n        for conn in connections:\n            if conn._sock is None:\n                if (\n                    conn.maintenance_state == MaintenanceState.NONE\n                    and conn.socket_timeout == CLIENT_TIMEOUT\n                    and not hasattr(conn, \"orig_host_address\")\n                ):\n                    matching_conns_count += 1\n            elif (\n                conn._sock.gettimeout() == CLIENT_TIMEOUT\n                and conn.maintenance_state == MaintenanceState.NONE\n                and not hasattr(conn, \"orig_host_address\")\n            ):\n                matching_conns_count += 1\n        assert matching_conns_count == expected_matching_conns_count\n\n\nclass TestStandaloneClientPushNotifications(TestPushNotificationsBase):\n    @pytest.fixture(autouse=True)\n    def setup_and_cleanup(\n        self,\n        client_maint_notifications: Redis,\n        fault_injector_client: FaultInjectorClient,\n        endpoints_config: Dict[str, Any],\n        endpoint_name: str,\n    ):\n        # Initialize cleanup flags first to ensure they exist even if setup fails\n        self._failover_executed = False\n        self.endpoint_id = None\n\n        try:\n            target_node, empty_node = ClusterOperations.find_target_node_and_empty_node(\n                fault_injector_client, endpoints_config\n            )\n            logging.info(f\"Using target_node: {target_node}, empty_node: {empty_node}\")\n        except Exception as e:\n            pytest.fail(f\"Failed to find target and empty nodes: {e}\")\n\n        try:\n            self.endpoint_id = ClusterOperations.find_endpoint_for_bind(\n                fault_injector_client,\n                endpoint_name,\n                force_cluster_info_refresh=False,\n            )\n            logging.info(f\"Using endpoint: {self.endpoint_id}\")\n        except Exception as e:\n            pytest.fail(f\"Failed to find endpoint for bind operation: {e}\")\n\n        # Ensure setup completed successfully\n        if not target_node or not empty_node:\n            pytest.fail(\"Setup failed: target_node or empty_node not available\")\n        if not self.endpoint_id:\n            pytest.fail(\"Setup failed: endpoint_id not available\")\n\n        self.target_node: NodeInfo = target_node\n        self.empty_node: NodeInfo = empty_node\n\n        # Yield control to the test\n        yield\n\n        # Cleanup code - this will run even if the test fails\n        logging.info(\"Starting cleanup...\")\n        try:\n            client_maint_notifications.close()\n        except Exception as e:\n            logging.error(f\"Failed to close client: {e}\")\n\n        # Only attempt cleanup if we have the necessary attributes and they were executed\n        if (\n            not isinstance(fault_injector_client, ProxyServerFaultInjector)\n            and self._failover_executed\n        ):\n            try:\n                self._execute_failover(fault_injector_client, endpoints_config)\n                logging.info(\"Failover cleanup completed\")\n            except Exception as e:\n                logging.error(f\"Failed to revert failover: {e}\")\n\n        logging.info(\"Cleanup finished\")\n\n    @pytest.mark.timeout(300)  # 5 minutes timeout for this test\n    def test_receive_failing_over_and_failed_over_push_notification(\n        self,\n        client_maint_notifications: Redis,\n        fault_injector_client: FaultInjectorClient,\n        endpoints_config: Dict[str, Any],\n    ):\n        \"\"\"\n        Test the push notifications are received when executing cluster operations.\n\n        \"\"\"\n        logging.info(\"Creating one connection in the pool.\")\n        conn = client_maint_notifications.connection_pool.get_connection()\n\n        logging.info(\"Executing failover command...\")\n        failover_thread = Thread(\n            target=self._execute_failover,\n            name=\"failover_thread\",\n            args=(fault_injector_client, endpoints_config),\n        )\n        failover_thread.start()\n\n        logging.info(\"Waiting for FAILING_OVER push notifications...\")\n        ClientValidations.wait_push_notification(\n            client_maint_notifications, timeout=FAILOVER_TIMEOUT, connection=conn\n        )\n\n        logging.info(\"Validating connection maintenance state...\")\n        assert conn.maintenance_state == MaintenanceState.MAINTENANCE\n        assert conn._sock.gettimeout() == RELAXED_TIMEOUT\n\n        logging.info(\"Waiting for FAILED_OVER push notifications...\")\n        ClientValidations.wait_push_notification(\n            client_maint_notifications, timeout=FAILOVER_TIMEOUT, connection=conn\n        )\n\n        logging.info(\"Validating connection default states is restored...\")\n        assert conn.maintenance_state == MaintenanceState.NONE\n        assert conn._sock.gettimeout() == CLIENT_TIMEOUT\n\n        logging.info(\"Releasing connection back to the pool...\")\n        client_maint_notifications.connection_pool.release(conn)\n\n        failover_thread.join()\n\n    @pytest.mark.timeout(300)  # 5 minutes timeout for this test\n    def test_receive_migrating_and_moving_push_notification(\n        self,\n        client_maint_notifications: Redis,\n        fault_injector_client: FaultInjectorClient,\n        endpoints_config: Dict[str, Any],\n    ):\n        \"\"\"\n        Test the push notifications are received when executing cluster operations.\n\n        \"\"\"\n        # create one connection and release it back to the pool\n        conn = client_maint_notifications.connection_pool.get_connection()\n        client_maint_notifications.connection_pool.release(conn)\n\n        logging.info(\"Executing rladmin migrate command...\")\n        migrate_thread = Thread(\n            target=self._execute_migration,\n            name=\"migrate_thread\",\n            args=(\n                fault_injector_client,\n                endpoints_config,\n                self.target_node.node_id,\n                self.empty_node.node_id,\n            ),\n        )\n        migrate_thread.start()\n\n        logging.info(\"Waiting for MIGRATING push notifications...\")\n        ClientValidations.wait_push_notification(\n            client_maint_notifications, timeout=MIGRATE_TIMEOUT\n        )\n\n        logging.info(\"Validating connection migrating state...\")\n        conn = client_maint_notifications.connection_pool.get_connection()\n        assert conn.maintenance_state == MaintenanceState.MAINTENANCE\n        assert conn._sock.gettimeout() == RELAXED_TIMEOUT\n        client_maint_notifications.connection_pool.release(conn)\n\n        logging.info(\"Waiting for MIGRATED push notifications...\")\n        ClientValidations.wait_push_notification(\n            client_maint_notifications, timeout=MIGRATE_TIMEOUT\n        )\n\n        logging.info(\"Validating connection states...\")\n        conn = client_maint_notifications.connection_pool.get_connection()\n        assert conn.maintenance_state == MaintenanceState.NONE\n        assert conn._sock.gettimeout() == CLIENT_TIMEOUT\n        client_maint_notifications.connection_pool.release(conn)\n\n        migrate_thread.join()\n\n        logging.info(\"Executing rladmin bind endpoint command...\")\n\n        bind_thread = Thread(\n            target=self._execute_bind,\n            name=\"bind_thread\",\n            args=(fault_injector_client, endpoints_config, self.endpoint_id),\n        )\n        bind_thread.start()\n\n        logging.info(\"Waiting for MOVING push notifications...\")\n        ClientValidations.wait_push_notification(\n            client_maint_notifications, timeout=BIND_TIMEOUT\n        )\n\n        logging.info(\"Validating connection states...\")\n        conn = client_maint_notifications.connection_pool.get_connection()\n        assert conn.maintenance_state == MaintenanceState.MOVING\n        assert conn._sock.gettimeout() == RELAXED_TIMEOUT\n\n        logging.info(\"Waiting for moving ttl to expire\")\n        time.sleep(BIND_TIMEOUT)\n\n        logging.info(\"Validating connection states...\")\n        assert conn.maintenance_state == MaintenanceState.NONE\n        assert conn.socket_timeout == CLIENT_TIMEOUT\n        assert conn._sock.gettimeout() == CLIENT_TIMEOUT\n        client_maint_notifications.connection_pool.release(conn)\n        bind_thread.join()\n\n    @pytest.mark.timeout(300)  # 5 minutes timeout\n    @pytest.mark.parametrize(\n        \"endpoint_type\",\n        [\n            EndpointType.EXTERNAL_FQDN,\n            EndpointType.EXTERNAL_IP,\n            EndpointType.NONE,\n        ],\n    )\n    def test_timeout_handling_during_migrating_and_moving(\n        self,\n        endpoint_type: EndpointType,\n        fault_injector_client: FaultInjectorClient,\n        endpoints_config: Dict[str, Any],\n    ):\n        \"\"\"\n        Test the push notifications are received when executing cluster operations.\n\n        \"\"\"\n        logging.info(f\"Testing timeout handling for endpoint type: {endpoint_type}\")\n        client = _get_client_maint_notifications(\n            endpoints_config=endpoints_config, endpoint_type=endpoint_type\n        )\n\n        # Create three connections in the pool\n        logging.info(\"Creating three connections in the pool.\")\n        conns = []\n        for _ in range(3):\n            conns.append(client.connection_pool.get_connection())\n        # Release the connections\n        for conn in conns:\n            client.connection_pool.release(conn)\n\n        logging.info(\"Executing rladmin migrate command...\")\n        migrate_thread = Thread(\n            target=self._execute_migration,\n            name=\"migrate_thread\",\n            args=(\n                fault_injector_client,\n                endpoints_config,\n                self.target_node.node_id,\n                self.empty_node.node_id,\n            ),\n        )\n        migrate_thread.start()\n\n        logging.info(\"Waiting for MIGRATING push notifications...\")\n        # this will consume the notification in one of the connections\n        ClientValidations.wait_push_notification(client, timeout=MIGRATE_TIMEOUT)\n\n        self._validate_maintenance_state(client, expected_matching_conns_count=1)\n        self._validate_default_state(client, expected_matching_conns_count=2)\n\n        logging.info(\"Waiting for MIGRATED push notifications...\")\n        ClientValidations.wait_push_notification(client, timeout=MIGRATE_TIMEOUT)\n\n        logging.info(\"Validating connection states after MIGRATED ...\")\n        self._validate_default_state(client, expected_matching_conns_count=3)\n\n        migrate_thread.join()\n\n        logging.info(\"Executing rladmin bind endpoint command...\")\n\n        bind_thread = Thread(\n            target=self._execute_bind,\n            name=\"bind_thread\",\n            args=(fault_injector_client, endpoints_config, self.endpoint_id),\n        )\n        bind_thread.start()\n\n        logging.info(\"Waiting for MOVING push notifications...\")\n        # this will consume the notification in one of the connections\n        # and will handle the states of the rest\n        # the consumed connection will be disconnected during\n        # releasing it back to the pool and as a result we will have\n        # 3 disconnected connections in the pool\n        ClientValidations.wait_push_notification(client, timeout=BIND_TIMEOUT)\n\n        if endpoint_type == EndpointType.NONE:\n            logging.info(\n                \"Waiting for moving ttl/2 to expire to validate proactive reconnection\"\n            )\n            time.sleep(fault_injector_client.get_moving_ttl() / 2)\n\n        logging.info(\"Validating connections states...\")\n        self._validate_moving_state(\n            client,\n            endpoint_type,\n            expected_matching_connected_conns_count=0,\n            expected_matching_disconnected_conns_count=3,\n            fault_injector_client=fault_injector_client,\n        )\n        # during get_connection() the connection will be reconnected\n        # either to the address provided in the moving notification or to the original address\n        # depending on the configured endpoint type\n        # with this call we test if we are able to connect to the new address\n        conn = client.connection_pool.get_connection()\n        self._validate_moving_state(\n            client,\n            endpoint_type,\n            expected_matching_connected_conns_count=1,\n            expected_matching_disconnected_conns_count=2,\n            fault_injector_client=fault_injector_client,\n        )\n        client.connection_pool.release(conn)\n\n        logging.info(\"Waiting for moving ttl to expire\")\n        time.sleep(BIND_TIMEOUT)\n\n        logging.info(\"Validating connection states...\")\n        self._validate_default_state(client, expected_matching_conns_count=3)\n        bind_thread.join()\n\n    @pytest.mark.timeout(300)  # 5 minutes timeout\n    @pytest.mark.parametrize(\n        \"endpoint_type\",\n        [\n            EndpointType.EXTERNAL_FQDN,\n            EndpointType.EXTERNAL_IP,\n            EndpointType.NONE,\n        ],\n    )\n    def test_connection_handling_during_moving(\n        self,\n        endpoint_type: EndpointType,\n        fault_injector_client: FaultInjectorClient,\n        endpoints_config: Dict[str, Any],\n    ):\n        logging.info(f\"Testing timeout handling for endpoint type: {endpoint_type}\")\n        client = _get_client_maint_notifications(\n            endpoints_config=endpoints_config, endpoint_type=endpoint_type\n        )\n\n        logging.info(\"Creating one connection in the pool.\")\n        first_conn = client.connection_pool.get_connection()\n\n        logging.info(\"Executing rladmin migrate command...\")\n        migrate_thread = Thread(\n            target=self._execute_migration,\n            name=\"migrate_thread\",\n            args=(\n                fault_injector_client,\n                endpoints_config,\n                self.target_node.node_id,\n                self.empty_node.node_id,\n            ),\n        )\n        migrate_thread.start()\n\n        logging.info(\"Waiting for MIGRATING push notifications...\")\n        # this will consume the notification in the provided connection\n        ClientValidations.wait_push_notification(\n            client, timeout=MIGRATE_TIMEOUT, connection=first_conn\n        )\n\n        self._validate_maintenance_state(client, expected_matching_conns_count=1)\n\n        logging.info(\"Waiting for MIGRATED push notification ...\")\n        ClientValidations.wait_push_notification(\n            client, timeout=MIGRATE_TIMEOUT, connection=first_conn\n        )\n\n        client.connection_pool.release(first_conn)\n\n        migrate_thread.join()\n\n        logging.info(\"Executing rladmin bind endpoint command...\")\n\n        bind_thread = Thread(\n            target=self._execute_bind,\n            name=\"bind_thread\",\n            args=(fault_injector_client, endpoints_config, self.endpoint_id),\n        )\n        bind_thread.start()\n\n        logging.info(\"Waiting for MOVING push notifications on random connection ...\")\n        # this will consume the notification in one of the connections\n        # and will handle the states of the rest\n        # the consumed connection will be disconnected during\n        # releasing it back to the pool and as a result we will have\n        # 3 disconnected connections in the pool\n        ClientValidations.wait_push_notification(client, timeout=BIND_TIMEOUT)\n\n        if endpoint_type == EndpointType.NONE:\n            logging.info(\n                \"Waiting for moving ttl/2 to expire to validate proactive reconnection\"\n            )\n            time.sleep(fault_injector_client.get_moving_ttl() / 2)\n\n        # validate that new connections will also receive the moving notification\n        connections = []\n        for _ in range(3):\n            connections.append(client.connection_pool.get_connection())\n        for conn in connections:\n            logging.debug(f\"Releasing connection {conn}. {conn.maintenance_state}\")\n            client.connection_pool.release(conn)\n\n        logging.info(\"Validating connections states during MOVING ...\")\n        # during get_connection() the existing connection will be reconnected\n        # either to the address provided in the moving notification or to the original address\n        # depending on the configured endpoint type\n        # with this call we test if we are able to connect to the new address\n        # new connection should also be marked as moving\n        self._validate_moving_state(\n            client,\n            endpoint_type,\n            expected_matching_connected_conns_count=3,\n            expected_matching_disconnected_conns_count=0,\n            fault_injector_client=fault_injector_client,\n        )\n\n        logging.info(\"Waiting for moving ttl to expire\")\n        time.sleep(fault_injector_client.get_moving_ttl())\n\n        logging.info(\"Validating connection states after MOVING has expired ...\")\n        self._validate_default_state(client, expected_matching_conns_count=3)\n        bind_thread.join()\n\n    @pytest.mark.timeout(300)  # 5 minutes timeout\n    def test_old_connection_shutdown_during_moving(\n        self,\n        fault_injector_client: FaultInjectorClient,\n        endpoints_config: Dict[str, Any],\n    ):\n        # it is better to use ip for this test - enables validation that\n        # the connection is disconnected from the original address\n        # and connected to the new address\n        endpoint_type = EndpointType.EXTERNAL_IP\n        logging.info(\"Testing old connection shutdown during MOVING\")\n        client = _get_client_maint_notifications(\n            endpoints_config=endpoints_config, endpoint_type=endpoint_type\n        )\n\n        # create one connection and release it back to the pool\n        conn = client.connection_pool.get_connection()\n        client.connection_pool.release(conn)\n\n        logging.info(\"Starting migration ...\")\n        migrate_thread = Thread(\n            target=self._execute_migration,\n            name=\"migrate_thread\",\n            args=(\n                fault_injector_client,\n                endpoints_config,\n                self.target_node.node_id,\n                self.empty_node.node_id,\n            ),\n        )\n        migrate_thread.start()\n\n        logging.info(\"Waiting for MIGRATING push notifications...\")\n        ClientValidations.wait_push_notification(client, timeout=MIGRATE_TIMEOUT)\n        self._validate_maintenance_state(client, expected_matching_conns_count=1)\n\n        logging.info(\"Waiting for MIGRATED push notification ...\")\n        ClientValidations.wait_push_notification(client, timeout=MIGRATE_TIMEOUT)\n        self._validate_default_state(client, expected_matching_conns_count=1)\n        migrate_thread.join()\n\n        moving_event = threading.Event()\n\n        def execute_commands(moving_event: threading.Event, errors: Queue):\n            while not moving_event.is_set():\n                try:\n                    client.set(\"key\", \"value\")\n                    client.get(\"key\")\n                except Exception as e:\n                    errors.put(\n                        f\"Command failed in thread {threading.current_thread().name}: {e}\"\n                    )\n\n        # get the connection here because in case of proxy server\n        # new connections will not receive the notification and there is a chance\n        # that the existing connections in the pool that are used in the multiple\n        # threads might have already consumed the notification\n        # even with re clusters we might end up with an existing connection that has been\n        # freed up in the pool that will not receive the notification while we are waiting\n        # for it because it has already received and processed it\n        conn_to_check_moving = client.connection_pool.get_connection()\n\n        logging.info(\"Starting rebind...\")\n        bind_thread = Thread(\n            target=self._execute_bind,\n            name=\"bind_thread\",\n            args=(fault_injector_client, endpoints_config, self.endpoint_id),\n        )\n        bind_thread.start()\n\n        errors = Queue()\n        threads_count = 10\n        futures = []\n\n        logging.info(f\"Starting {threads_count} command execution threads...\")\n        # Start the worker pool and submit N identical worker tasks\n        with ThreadPoolExecutor(\n            max_workers=threads_count, thread_name_prefix=\"command_execution_thread\"\n        ) as executor:\n            futures = [\n                executor.submit(execute_commands, moving_event, errors)\n                for _ in range(threads_count)\n            ]\n\n            logging.info(\"Waiting for MOVING push notification ...\")\n            # this will consume the notification in one of the connections\n            # and will handle the states of the rest\n            ClientValidations.wait_push_notification(\n                client, timeout=BIND_TIMEOUT, connection=conn_to_check_moving\n            )\n            # set the event to stop the command execution threads\n            logging.info(\"Setting moving event...\")\n            moving_event.set()\n            # release the connection back to the pool so that it can be disconnected\n            # as part of the flow\n            client.connection_pool.release(conn_to_check_moving)\n\n            # Wait for all workers to finish and propagate any exceptions\n            for f in futures:\n                f.result()\n\n        logging.info(\n            \"All command execution threads finished. Validating connections states...\"\n        )\n        # validate that all connections are either disconnected\n        # or connected to the new address\n        connections = self._get_all_connections_in_pool(client)\n        for conn in connections:\n            if conn._sock is not None:\n                assert conn.get_resolved_ip() == conn.host\n                assert conn.maintenance_state == MaintenanceState.MOVING\n                assert conn._sock.gettimeout() == RELAXED_TIMEOUT\n                if not isinstance(fault_injector_client, ProxyServerFaultInjector):\n                    assert conn.host != conn.orig_host_address\n                assert not conn.should_reconnect()\n            else:\n                assert conn.maintenance_state == MaintenanceState.MOVING\n                assert conn.socket_timeout == RELAXED_TIMEOUT\n                if not isinstance(fault_injector_client, ProxyServerFaultInjector):\n                    assert conn.host != conn.orig_host_address\n                assert not conn.should_reconnect()\n\n        # validate no errors were raised in the command execution threads\n        assert errors.empty(), f\"Errors occurred in threads: {errors.queue}\"\n\n        logging.info(\"Waiting for moving ttl to expire\")\n        bind_thread.join()\n\n    @pytest.mark.timeout(300)  # 5 minutes timeout\n    @pytest.mark.skipif(\n        use_mock_proxy(),\n        reason=\"Mock proxy doesn't support sending notifications to new connections.\",\n    )\n    def test_new_connections_receive_moving(\n        self,\n        client_maint_notifications: Redis,\n        fault_injector_client: FaultInjectorClient,\n        endpoints_config: Dict[str, Any],\n    ):\n        logging.info(\"Creating one connection in the pool.\")\n        first_conn = client_maint_notifications.connection_pool.get_connection()\n\n        logging.info(\"Executing rladmin migrate command...\")\n        migrate_thread = Thread(\n            target=self._execute_migration,\n            name=\"migrate_thread\",\n            args=(\n                fault_injector_client,\n                endpoints_config,\n                self.target_node.node_id,\n                self.empty_node.node_id,\n            ),\n        )\n        migrate_thread.start()\n\n        logging.info(\"Waiting for MIGRATING push notifications...\")\n        # this will consume the notification in the provided connection\n        ClientValidations.wait_push_notification(\n            client_maint_notifications, timeout=MIGRATE_TIMEOUT, connection=first_conn\n        )\n\n        self._validate_maintenance_state(\n            client_maint_notifications, expected_matching_conns_count=1\n        )\n\n        logging.info(\"Waiting for MIGRATED push notifications on both connections ...\")\n        ClientValidations.wait_push_notification(\n            client_maint_notifications, timeout=MIGRATE_TIMEOUT, connection=first_conn\n        )\n\n        migrate_thread.join()\n\n        logging.info(\"Executing rladmin bind endpoint command...\")\n\n        bind_thread = Thread(\n            target=self._execute_bind,\n            name=\"bind_thread\",\n            args=(fault_injector_client, endpoints_config, self.endpoint_id),\n        )\n        bind_thread.start()\n\n        logging.info(\"Waiting for MOVING push notifications on random connection ...\")\n        ClientValidations.wait_push_notification(\n            client_maint_notifications, timeout=BIND_TIMEOUT, connection=first_conn\n        )\n\n        old_address = first_conn._sock.getpeername()[0]\n        logging.info(f\"The node address before bind: {old_address}\")\n        logging.info(\n            \"Creating new client to connect to the same node - new connections to this node should receive the moving notification...\"\n        )\n\n        endpoint_type = EndpointType.EXTERNAL_IP\n        # create new client with new pool that should also receive the moving notification\n        new_client = _get_client_maint_notifications(\n            endpoints_config=endpoints_config,\n            endpoint_type=endpoint_type,\n            host_config=old_address,\n        )\n\n        # the moving notification will be consumed as\n        # part of the client connection setup, so we don't need\n        # to wait for it explicitly with wait_push_notification\n        logging.info(\n            \"Creating one connection in the new pool that should receive the moving notification.\"\n        )\n        new_client_conn = new_client.connection_pool.get_connection()\n\n        logging.info(\"Validating connections states during MOVING ...\")\n        self._validate_moving_state(\n            new_client,\n            endpoint_type,\n            expected_matching_connected_conns_count=1,\n            expected_matching_disconnected_conns_count=0,\n            fault_injector_client=fault_injector_client,\n        )\n\n        logging.info(\"Waiting for moving thread to be completed ...\")\n        bind_thread.join()\n\n        new_client.connection_pool.release(new_client_conn)\n        new_client.close()\n\n        client_maint_notifications.connection_pool.release(first_conn)\n\n    @pytest.mark.timeout(300)  # 5 minutes timeout\n    @pytest.mark.skipif(\n        use_mock_proxy(),\n        reason=\"Mock proxy doesn't support sending notifications to new connections.\",\n    )\n    def test_new_connections_receive_migrating(\n        self,\n        client_maint_notifications: Redis,\n        fault_injector_client: FaultInjectorClient,\n        endpoints_config: Dict[str, Any],\n    ):\n        logging.info(\"Creating one connection in the pool.\")\n        first_conn = client_maint_notifications.connection_pool.get_connection()\n\n        logging.info(\"Executing rladmin migrate command...\")\n        migrate_thread = Thread(\n            target=self._execute_migration,\n            name=\"migrate_thread\",\n            args=(\n                fault_injector_client,\n                endpoints_config,\n                self.target_node.node_id,\n                self.empty_node.node_id,\n            ),\n        )\n        migrate_thread.start()\n\n        logging.info(\"Waiting for MIGRATING push notifications...\")\n        # this will consume the notification in the provided connection\n        ClientValidations.wait_push_notification(\n            client_maint_notifications, timeout=MIGRATE_TIMEOUT, connection=first_conn\n        )\n\n        self._validate_maintenance_state(\n            client_maint_notifications, expected_matching_conns_count=1\n        )\n\n        # validate that new connections will also receive the migrating notification\n        # it should be received as part of the client connection setup flow\n        logging.info(\n            \"Creating second connection that should receive the migrating notification as well.\"\n        )\n        second_connection = client_maint_notifications.connection_pool.get_connection()\n        self._validate_maintenance_state(\n            client_maint_notifications, expected_matching_conns_count=2\n        )\n\n        logging.info(\"Waiting for MIGRATED push notifications on both connections ...\")\n        ClientValidations.wait_push_notification(\n            client_maint_notifications, timeout=MIGRATE_TIMEOUT, connection=first_conn\n        )\n        ClientValidations.wait_push_notification(\n            client_maint_notifications,\n            timeout=MIGRATE_TIMEOUT,\n            connection=second_connection,\n        )\n\n        migrate_thread.join()\n        logging.info(\"Executing rladmin bind endpoint command for cleanup...\")\n\n        bind_thread = Thread(\n            target=self._execute_bind,\n            name=\"bind_thread\",\n            args=(fault_injector_client, endpoints_config, self.endpoint_id),\n        )\n        bind_thread.start()\n        bind_thread.join()\n        client_maint_notifications.connection_pool.release(first_conn)\n        client_maint_notifications.connection_pool.release(second_connection)\n\n    @pytest.mark.timeout(300)\n    def test_disabled_handling_during_migrating_and_moving(\n        self,\n        fault_injector_client: FaultInjectorClient,\n        endpoints_config: Dict[str, Any],\n    ):\n        logging.info(\"Creating client with disabled notifications.\")\n        client = _get_client_maint_notifications(\n            endpoints_config=endpoints_config,\n            enable_maintenance_notifications=False,\n        )\n\n        logging.info(\"Creating one connection in the pool.\")\n        first_conn = client.connection_pool.get_connection()\n\n        logging.info(\"Executing rladmin migrate command...\")\n        migrate_thread = Thread(\n            target=self._execute_migration,\n            name=\"migrate_thread\",\n            args=(\n                fault_injector_client,\n                endpoints_config,\n                self.target_node.node_id,\n                self.empty_node.node_id,\n            ),\n        )\n        migrate_thread.start()\n\n        logging.info(\"Waiting for MIGRATING push notifications...\")\n        # this will consume the notification in the provided connection if it arrives\n        ClientValidations.wait_push_notification(\n            client, timeout=5, fail_on_timeout=False, connection=first_conn\n        )\n\n        self._validate_default_notif_disabled_state(\n            client, expected_matching_conns_count=1\n        )\n\n        # validate that new connections will also receive the moving notification\n        logging.info(\n            \"Creating second connection in the pool\"\n            \" and expect it not to receive the migrating as well.\"\n        )\n\n        second_connection = client.connection_pool.get_connection()\n        ClientValidations.wait_push_notification(\n            client, timeout=5, fail_on_timeout=False, connection=second_connection\n        )\n\n        logging.info(\n            \"Validating connection states after MIGRATING for both connections ...\"\n        )\n        self._validate_default_notif_disabled_state(\n            client, expected_matching_conns_count=2\n        )\n\n        logging.info(\"Waiting for MIGRATED push notifications on both connections ...\")\n        ClientValidations.wait_push_notification(\n            client, timeout=5, fail_on_timeout=False, connection=first_conn\n        )\n        ClientValidations.wait_push_notification(\n            client, timeout=5, fail_on_timeout=False, connection=second_connection\n        )\n\n        client.connection_pool.release(first_conn)\n        client.connection_pool.release(second_connection)\n\n        migrate_thread.join()\n\n        logging.info(\"Executing rladmin bind endpoint command...\")\n\n        bind_thread = Thread(\n            target=self._execute_bind,\n            name=\"bind_thread\",\n            args=(fault_injector_client, endpoints_config, self.endpoint_id),\n        )\n        bind_thread.start()\n\n        logging.info(\"Waiting for MOVING push notifications on random connection ...\")\n        # this will consume the notification if it arrives in one of the connections\n        # and will handle the states of the rest\n        # the consumed connection will be disconnected during\n        # releasing it back to the pool and as a result we will have\n        # 3 disconnected connections in the pool\n        ClientValidations.wait_push_notification(\n            client,\n            timeout=10,\n            fail_on_timeout=False,\n        )\n\n        # validate that new connections will also receive the moving notification\n        connections = []\n        for _ in range(3):\n            connections.append(client.connection_pool.get_connection())\n        for conn in connections:\n            client.connection_pool.release(conn)\n\n        logging.info(\"Validating connections states during MOVING ...\")\n        self._validate_default_notif_disabled_state(\n            client, expected_matching_conns_count=3\n        )\n\n        logging.info(\"Waiting for moving ttl to expire\")\n        time.sleep(DEFAULT_BIND_TTL)\n\n        logging.info(\"Validating connection states after MOVING has expired ...\")\n        self._validate_default_notif_disabled_state(\n            client, expected_matching_conns_count=3\n        )\n        bind_thread.join()\n\n    @pytest.mark.timeout(300)\n    @pytest.mark.parametrize(\n        \"endpoint_type\",\n        [\n            EndpointType.EXTERNAL_FQDN,\n            EndpointType.EXTERNAL_IP,\n            EndpointType.NONE,\n        ],\n    )\n    def test_command_execution_during_migrating_and_moving(\n        self,\n        fault_injector_client: FaultInjectorClient,\n        endpoints_config: Dict[str, Any],\n        endpoint_type: EndpointType,\n    ):\n        \"\"\"\n        Test command execution during migrating and moving notifications.\n\n        This test validates that:\n        1. Commands can be executed during MIGRATING and MOVING notifications\n        2. Commands are not blocked by the notifications\n        3. Commands are executed successfully\n        \"\"\"\n        errors = Queue()\n        if isinstance(fault_injector_client, ProxyServerFaultInjector):\n            execution_duration = 20\n        else:\n            execution_duration = 180\n\n        socket_timeout = DEFAULT_STANDALONE_CLIENT_SOCKET_TIMEOUT\n\n        client = _get_client_maint_notifications(\n            endpoints_config=endpoints_config,\n            endpoint_type=endpoint_type,\n            disable_retries=True,\n            socket_timeout=socket_timeout,\n            enable_maintenance_notifications=True,\n        )\n\n        def execute_commands(duration: int, errors: Queue):\n            start = time.time()\n            while time.time() - start < duration:\n                try:\n                    client.set(\"key\", \"value\")\n                    client.get(\"key\")\n                except Exception as e:\n                    logging.error(\n                        f\"Error in thread {threading.current_thread().name}: {e}\"\n                    )\n                    errors.put(\n                        f\"Command failed in thread {threading.current_thread().name}: {e}\"\n                    )\n            logging.debug(f\"{threading.current_thread().name}: Thread ended\")\n\n        threads = []\n        for i in range(10):\n            thread = Thread(\n                target=execute_commands,\n                name=f\"command_execution_thread_{i}\",\n                args=(\n                    execution_duration,\n                    errors,\n                ),\n            )\n            thread.start()\n            threads.append(thread)\n\n        logging.info(\"Waiting for threads to start and have a few cycles executed ...\")\n        time.sleep(3)\n\n        migrate_and_bind_thread = Thread(\n            target=self._execute_migrate_bind_flow,\n            name=\"migrate_and_bind_thread\",\n            args=(\n                fault_injector_client,\n                endpoints_config,\n                self.target_node.node_id,\n                self.empty_node.node_id,\n                self.endpoint_id,\n            ),\n        )\n        migrate_and_bind_thread.start()\n\n        for thread in threads:\n            thread.join()\n\n        migrate_and_bind_thread.join()\n\n        # validate connections settings\n        self._validate_default_state(\n            client, expected_matching_conns_count=10, configured_timeout=socket_timeout\n        )\n\n        assert errors.empty(), f\"Errors occurred in threads: {errors.queue}\"\n\n\ndef generate_params(\n    fault_injector_client: FaultInjectorClient,\n    effect_names: list[SlotMigrateEffects],\n    skip_combinations: list[tuple[SlotMigrateEffects, str]] = [],\n):\n    # params should produce list of tuples: (effect_name, trigger_name, bdb_config, bdb_name)\n    params = []\n    try:\n        logging.info(f\"Extracting params for test with effect_names: {effect_names}\")\n        for effect_name in effect_names:\n            triggers_data = ClusterOperations.get_slot_migrate_triggers(\n                fault_injector_client, effect_name\n            )\n\n            for trigger_info in triggers_data[\"triggers\"]:\n                trigger = trigger_info[\"name\"]\n                if (effect_name, trigger) in skip_combinations:\n                    continue\n                if trigger == \"maintenance_mode\":\n                    continue\n                trigger_requirements = trigger_info[\"requirements\"]\n                for requirement in trigger_requirements:\n                    dbconfig = requirement[\"dbconfig\"]\n                    ip_type = requirement[\"oss_cluster_api\"][\"ip_type\"]\n                    if ip_type == \"internal\":\n                        continue\n                    db_name_pattern = dbconfig.get(\"name\").rsplit(\"-\", 1)[0]\n                    dbconfig[\"name\"] = (\n                        db_name_pattern  # this will ensure dbs will be deleted\n                    )\n\n                    params.append((effect_name, trigger, dbconfig, db_name_pattern))\n    except Exception as e:\n        logging.error(f\"Failed to extract params for test: {e}\")\n\n    return params\n\n\nclass TestClusterClientPushNotificationsWithEffectTriggerBase(\n    TestPushNotificationsBase\n):\n    def delete_prev_db(\n        self,\n        fault_injector_client_oss_api: FaultInjectorClient,\n        db_name: str,\n    ):\n        try:\n            logging.info(f\"Deleting database if exists: {db_name}\")\n            existing_db_id = None\n            existing_db_id = ClusterOperations.find_database_id_by_name(\n                fault_injector_client_oss_api, db_name\n            )\n\n            if existing_db_id:\n                fault_injector_client_oss_api.delete_database(existing_db_id)\n                logging.info(f\"Deleted database: {db_name}\")\n            else:\n                logging.info(f\"Database {db_name} does not exist.\")\n        except Exception as e:\n            logging.error(f\"Failed to delete database {db_name}: {e}\")\n\n    def create_db(\n        self,\n        fault_injector_client_oss_api: FaultInjectorClient,\n        bdb_config: Dict[str, Any],\n    ):\n        try:\n            logging.info(f\"Creating database: \\n{json.dumps(bdb_config, indent=2)}\")\n            cluster_endpoint_config = fault_injector_client_oss_api.create_database(\n                bdb_config\n            )\n            return cluster_endpoint_config\n        except Exception as e:\n            pytest.fail(f\"Failed to create database: {e}\")\n\n    def setup_env(\n        self,\n        fault_injector_client_oss_api: FaultInjectorClient,\n        db_config: Dict[str, Any],\n    ):\n        self.delete_prev_db(fault_injector_client_oss_api, db_config[\"name\"])\n\n        cluster_endpoint_config = self.create_db(\n            fault_injector_client_oss_api, db_config\n        )\n\n        self._bdb_name = db_config[\"name\"]\n        socket_timeout = DEFAULT_OSS_API_CLIENT_SOCKET_TIMEOUT\n\n        auth_ssl_client_certs_config_info = db_config.get(\n            \"authentication_ssl_client_certs\", None\n        )\n\n        auth_ssl_client_certs = (\n            True\n            if auth_ssl_client_certs_config_info\n            and auth_ssl_client_certs_config_info[0][\"client_cert\"] is not None\n            else False\n        )\n\n        cluster_client_maint_notifications = get_cluster_client_maint_notifications(\n            endpoints_config=cluster_endpoint_config,\n            disable_retries=True,\n            socket_timeout=socket_timeout,\n            enable_maintenance_notifications=True,\n            auth_ssl_client_certs=auth_ssl_client_certs,\n        )\n        return cluster_client_maint_notifications, cluster_endpoint_config\n\n    @pytest.fixture(autouse=True)\n    def setup_and_cleanup(\n        self,\n    ):\n        self.maintenance_ops_threads = []\n        self._bdb_name = None\n\n        # Yield control to the test\n        yield\n\n        # Cleanup code - this will run even if the test fails\n        logging.info(\"Starting cleanup...\")\n        if self._bdb_name:\n            self.delete_prev_db(_FAULT_INJECTOR_CLIENT_OSS_API, self._bdb_name)\n\n        logging.info(\"Waiting for maintenance operations threads to finish...\")\n        for thread in self.maintenance_ops_threads:\n            thread.join()\n\n        logging.info(\"Cleanup finished\")\n\n\nclass TestClusterClientPushNotificationsHandlingWithEffectTrigger(\n    TestClusterClientPushNotificationsWithEffectTriggerBase\n):\n    @pytest.mark.timeout(300)  # 5 minutes timeout for this test\n    @pytest.mark.parametrize(\n        \"effect_name, trigger, db_config, db_name\",\n        generate_params(\n            _FAULT_INJECTOR_CLIENT_OSS_API, [SlotMigrateEffects.SLOT_SHUFFLE]\n        ),\n    )\n    def test_notification_handling_during_node_shuffle_no_node_replacement(\n        self,\n        fault_injector_client_oss_api: FaultInjectorClient,\n        effect_name: SlotMigrateEffects,\n        trigger: str,\n        db_config: dict[str, Any],\n        db_name: str,\n    ):\n        \"\"\"\n        Test the push notifications are received when executing re cluster operations.\n        The test validates the behavior when during the operations the slots are moved\n        between the nodes, but no new nodes are appearing and no nodes are disappearing\n\n        \"\"\"\n        logging.info(f\"DB name: {db_name}\")\n\n        cluster_client_maint_notifications, cluster_endpoint_config = self.setup_env(\n            fault_injector_client_oss_api, db_config\n        )\n\n        logging.info(\"Creating one connection in each node's pool.\")\n        initial_cluster_nodes = (\n            cluster_client_maint_notifications.nodes_manager.nodes_cache.copy()\n        )\n        in_use_connections = {}\n        for node in initial_cluster_nodes.values():\n            in_use_connections[node] = (\n                node.redis_connection.connection_pool.get_connection()\n            )\n\n        logging.info(\"Executing FI command that triggers the desired effect...\")\n        trigger_effect_thread = Thread(\n            target=self._trigger_effect,\n            name=\"trigger_effect_thread\",\n            args=(\n                fault_injector_client_oss_api,\n                cluster_endpoint_config,\n                effect_name,\n                trigger,\n            ),\n        )\n        self.maintenance_ops_threads.append(trigger_effect_thread)\n        trigger_effect_thread.start()\n\n        logging.info(\"Waiting for SMIGRATING push notifications in all connections...\")\n        for conn in in_use_connections.values():\n            ClientValidations.wait_push_notification(\n                cluster_client_maint_notifications,\n                timeout=int(SLOT_SHUFFLE_TIMEOUT / 2),\n                connection=conn,\n            )\n\n        logging.info(\"Validating connection maintenance state...\")\n        for conn in in_use_connections.values():\n            assert conn.maintenance_state == MaintenanceState.MAINTENANCE\n            assert conn._sock.gettimeout() == RELAXED_TIMEOUT\n            assert conn.should_reconnect() is False\n\n        assert len(initial_cluster_nodes) == len(\n            cluster_client_maint_notifications.nodes_manager.nodes_cache\n        )\n\n        for node_key in initial_cluster_nodes.keys():\n            assert (\n                node_key in cluster_client_maint_notifications.nodes_manager.nodes_cache\n            )\n\n        logging.info(\"Waiting for SMIGRATED push notifications...\")\n        con_to_read_smigrated = random.choice(list(in_use_connections.values()))\n        ClientValidations.wait_push_notification(\n            cluster_client_maint_notifications,\n            timeout=SMIGRATED_TIMEOUT,\n            connection=con_to_read_smigrated,\n        )\n\n        logging.info(\"Validating connection state after SMIGRATED ...\")\n\n        updated_cluster_nodes = (\n            cluster_client_maint_notifications.nodes_manager.nodes_cache.copy()\n        )\n\n        removed_nodes = set(initial_cluster_nodes.values()) - set(\n            updated_cluster_nodes.values()\n        )\n        assert len(removed_nodes) == 0\n        assert len(initial_cluster_nodes) == len(updated_cluster_nodes)\n\n        marked_conns_for_reconnect = 0\n        for conn in in_use_connections.values():\n            if conn.should_reconnect():\n                marked_conns_for_reconnect += 1\n        # only one connection should be marked for reconnect\n        # onle the one that belongs to the node that was from\n        # the src address of the maintenance\n        assert marked_conns_for_reconnect == 1\n\n        logging.info(\"Releasing connections back to the pool...\")\n        for node, conn in in_use_connections.items():\n            if node.redis_connection is None:\n                continue\n            node.redis_connection.connection_pool.release(conn)\n\n        trigger_effect_thread.join()\n        self.maintenance_ops_threads.remove(trigger_effect_thread)\n\n    @pytest.mark.timeout(300)  # 5 minutes timeout for this test\n    @pytest.mark.parametrize(\n        \"effect_name, trigger, db_config, db_name\",\n        generate_params(\n            _FAULT_INJECTOR_CLIENT_OSS_API,\n            [\n                SlotMigrateEffects.REMOVE_ADD,\n            ],\n        ),\n    )\n    def test_notification_handling_with_node_replace(\n        self,\n        fault_injector_client_oss_api: FaultInjectorClient,\n        effect_name: SlotMigrateEffects,\n        trigger: str,\n        db_config: dict[str, Any],\n        db_name: str,\n    ):\n        \"\"\"\n        Test the push notifications are received when executing re cluster operations.\n        The test validates the behavior when during the operations the slots are moved\n        between the nodes, and as a result a node is removed and a new node is added to the cluster\n\n        \"\"\"\n        logging.info(f\"DB name: {db_name}\")\n\n        cluster_client_maint_notifications, cluster_endpoint_config = self.setup_env(\n            fault_injector_client_oss_api, db_config\n        )\n\n        logging.info(\"Creating one connection in each node's pool.\")\n\n        initial_cluster_nodes = (\n            cluster_client_maint_notifications.nodes_manager.nodes_cache.copy()\n        )\n        in_use_connections = {}\n        for node in initial_cluster_nodes.values():\n            in_use_connections[node] = (\n                node.redis_connection.connection_pool.get_connection()\n            )\n\n        logging.info(\"Executing FI command that triggers the desired effect...\")\n        trigger_effect_thread = Thread(\n            target=self._trigger_effect,\n            name=\"trigger_effect_thread\",\n            args=(\n                fault_injector_client_oss_api,\n                cluster_endpoint_config,\n                effect_name,\n                trigger,\n            ),\n        )\n        self.maintenance_ops_threads.append(trigger_effect_thread)\n        trigger_effect_thread.start()\n\n        logging.info(\"Waiting for SMIGRATING push notifications in all connections...\")\n        for conn in in_use_connections.values():\n            ClientValidations.wait_push_notification(\n                cluster_client_maint_notifications,\n                timeout=SMIGRATING_TIMEOUT,\n                connection=conn,\n            )\n\n        logging.info(\"Validating connection maintenance state...\")\n        for conn in in_use_connections.values():\n            assert conn.maintenance_state == MaintenanceState.MAINTENANCE\n            assert conn._sock.gettimeout() == RELAXED_TIMEOUT\n            assert conn.should_reconnect() is False\n\n        assert len(initial_cluster_nodes) == len(\n            cluster_client_maint_notifications.nodes_manager.nodes_cache\n        )\n\n        for node_key in initial_cluster_nodes.keys():\n            assert (\n                node_key in cluster_client_maint_notifications.nodes_manager.nodes_cache\n            )\n\n        logging.info(\"Waiting for SMIGRATED push notifications...\")\n        con_to_read_smigrated = random.choice(list(in_use_connections.values()))\n        ClientValidations.wait_push_notification(\n            cluster_client_maint_notifications,\n            timeout=SMIGRATED_TIMEOUT,\n            connection=con_to_read_smigrated,\n        )\n\n        logging.info(\"Validating connection state after SMIGRATED ...\")\n\n        updated_cluster_nodes = (\n            cluster_client_maint_notifications.nodes_manager.nodes_cache.copy()\n        )\n\n        removed_nodes = set(initial_cluster_nodes.values()) - set(\n            updated_cluster_nodes.values()\n        )\n        assert len(removed_nodes) == 1\n        removed_node = removed_nodes.pop()\n        assert removed_node is not None\n\n        added_nodes = set(updated_cluster_nodes.values()) - set(\n            initial_cluster_nodes.values()\n        )\n        assert len(added_nodes) == 1\n\n        conn = in_use_connections.get(removed_node)\n        # connection will be dropped, but it is marked\n        # to be disconnected before released to the pool\n        # we don't waste time to update the timeouts and state\n        # so it is pointless to check those configs\n        assert conn is not None\n        assert conn.should_reconnect() is True\n\n        logging.info(\"Releasing connections back to the pool...\")\n        for node, conn in in_use_connections.items():\n            if node.redis_connection is None:\n                continue\n            node.redis_connection.connection_pool.release(conn)\n\n        trigger_effect_thread.join()\n        self.maintenance_ops_threads.remove(trigger_effect_thread)\n\n    @pytest.mark.timeout(300)  # 5 minutes timeout for this test\n    @pytest.mark.parametrize(\n        \"effect_name, trigger, db_config, db_name\",\n        generate_params(\n            _FAULT_INJECTOR_CLIENT_OSS_API,\n            [\n                SlotMigrateEffects.REMOVE,\n            ],\n        ),\n    )\n    def test_notification_handling_with_node_remove(\n        self,\n        fault_injector_client_oss_api: FaultInjectorClient,\n        effect_name: SlotMigrateEffects,\n        trigger: str,\n        db_config: dict[str, Any],\n        db_name: str,\n    ):\n        \"\"\"\n        Test the push notifications are received when executing re cluster operations.\n        The test validates the behavior when during the operations the slots are moved\n        between the nodes, and as a result a node is removed.\n\n        \"\"\"\n        logging.info(f\"DB name: {db_name}\")\n\n        cluster_client_maint_notifications, cluster_endpoint_config = self.setup_env(\n            fault_injector_client_oss_api, db_config\n        )\n\n        logging.info(\"Creating one connection in each node's pool.\")\n\n        initial_cluster_nodes = (\n            cluster_client_maint_notifications.nodes_manager.nodes_cache.copy()\n        )\n        in_use_connections = {}\n        for node in initial_cluster_nodes.values():\n            in_use_connections[node] = (\n                node.redis_connection.connection_pool.get_connection()\n            )\n\n        logging.info(\"Executing FI command that triggers the desired effect...\")\n        trigger_effect_thread = Thread(\n            target=self._trigger_effect,\n            name=\"trigger_effect_thread\",\n            args=(\n                fault_injector_client_oss_api,\n                cluster_endpoint_config,\n                effect_name,\n                trigger,\n            ),\n        )\n        self.maintenance_ops_threads.append(trigger_effect_thread)\n        trigger_effect_thread.start()\n\n        logging.info(\"Waiting for SMIGRATING push notifications in all connections...\")\n        for conn in in_use_connections.values():\n            ClientValidations.wait_push_notification(\n                cluster_client_maint_notifications,\n                timeout=int(SLOT_SHUFFLE_TIMEOUT / 2),\n                connection=conn,\n            )\n\n        logging.info(\"Validating connection maintenance state...\")\n        for conn in in_use_connections.values():\n            assert conn.maintenance_state == MaintenanceState.MAINTENANCE\n            assert conn._sock.gettimeout() == RELAXED_TIMEOUT\n            assert conn.should_reconnect() is False\n\n        assert len(initial_cluster_nodes) == len(\n            cluster_client_maint_notifications.nodes_manager.nodes_cache\n        )\n\n        for node_key in initial_cluster_nodes.keys():\n            assert (\n                node_key in cluster_client_maint_notifications.nodes_manager.nodes_cache\n            )\n\n        logging.info(\"Waiting for SMIGRATED push notifications...\")\n        con_to_read_smigrated = random.choice(list(in_use_connections.values()))\n        ClientValidations.wait_push_notification(\n            cluster_client_maint_notifications,\n            timeout=SMIGRATED_TIMEOUT,\n            connection=con_to_read_smigrated,\n        )\n\n        logging.info(\"Validating connection state after SMIGRATED ...\")\n\n        updated_cluster_nodes = (\n            cluster_client_maint_notifications.nodes_manager.nodes_cache.copy()\n        )\n\n        removed_nodes = set(initial_cluster_nodes.values()) - set(\n            updated_cluster_nodes.values()\n        )\n        assert len(removed_nodes) == 1\n        removed_node = removed_nodes.pop()\n        assert removed_node is not None\n\n        assert len(initial_cluster_nodes) == len(updated_cluster_nodes) + 1\n\n        conn = in_use_connections.get(removed_node)\n        # connection will be dropped, but it is marked\n        # to be disconnected before released to the pool\n        # we don't waste time to update the timeouts and state\n        # so it is pointless to check those configs\n        assert conn is not None\n        assert conn.should_reconnect() is True\n\n        # validate no other connections are marked for reconnect\n        marked_conns_for_reconnect = 0\n        for conn in in_use_connections.values():\n            if conn.should_reconnect():\n                marked_conns_for_reconnect += 1\n        # only one connection should be marked for reconnect\n        # onle the one that belongs to the node that was from\n        # the src address of the maintenance\n        assert marked_conns_for_reconnect == 1\n\n        logging.info(\"Releasing connections back to the pool...\")\n        for node, conn in in_use_connections.items():\n            if node.redis_connection is None:\n                continue\n            node.redis_connection.connection_pool.release(conn)\n\n        trigger_effect_thread.join()\n        self.maintenance_ops_threads.remove(trigger_effect_thread)\n\n    @pytest.mark.timeout(300)  # 5 minutes timeout for this test\n    @pytest.mark.skipif(\n        use_mock_proxy(),\n        reason=\"Mock proxy doesn't support sending notifications to new connections.\",\n    )\n    @pytest.mark.parametrize(\n        \"effect_name, trigger, db_config, db_name\",\n        generate_params(\n            _FAULT_INJECTOR_CLIENT_OSS_API,\n            [\n                SlotMigrateEffects.SLOT_SHUFFLE,\n                SlotMigrateEffects.REMOVE_ADD,\n                SlotMigrateEffects.REMOVE,\n                SlotMigrateEffects.ADD,\n            ],\n            skip_combinations=[\n                (SlotMigrateEffects.SLOT_SHUFFLE, \"failover\"),\n            ],  # maintenance ends too fast for the test to be reliable\n        ),\n    )\n    def test_new_connections_receive_last_smigrating_smigrated_notification(\n        self,\n        fault_injector_client_oss_api: FaultInjectorClient,\n        effect_name: SlotMigrateEffects,\n        trigger: str,\n        db_config: dict[str, Any],\n        db_name: str,\n    ):\n        \"\"\"\n        Test the push notifications are sent to the newly created connections.\n\n        \"\"\"\n        logging.info(f\"DB name: {db_name}\")\n\n        cluster_client_maint_notifications, cluster_endpoint_config = self.setup_env(\n            fault_injector_client_oss_api, db_config\n        )\n\n        logging.info(\"Creating one connection in each node's pool.\")\n        initial_cluster_nodes = (\n            cluster_client_maint_notifications.nodes_manager.nodes_cache.copy()\n        )\n        in_use_connections = {}\n        for node in initial_cluster_nodes.values():\n            in_use_connections[node] = [\n                node.redis_connection.connection_pool.get_connection()\n            ]\n\n        logging.info(\"Executing FI command that triggers the desired effect...\")\n        trigger_effect_thread = Thread(\n            target=self._trigger_effect,\n            name=\"trigger_effect_thread\",\n            args=(\n                fault_injector_client_oss_api,\n                cluster_endpoint_config,\n                effect_name,\n                trigger,\n            ),\n        )\n\n        self.maintenance_ops_threads.append(trigger_effect_thread)\n        trigger_effect_thread.start()\n\n        logging.info(\"Waiting for SMIGRATING push notifications in all connections...\")\n        for conns_per_node in in_use_connections.values():\n            for conn in conns_per_node:\n                ClientValidations.wait_push_notification(\n                    cluster_client_maint_notifications,\n                    timeout=int(SLOT_SHUFFLE_TIMEOUT / 2),\n                    connection=conn,\n                )\n                logging.info(\n                    f\"Validating connection MAINTENANCE state and RELAXED timeout for conn: {conn}...\"\n                )\n                assert conn.maintenance_state == MaintenanceState.MAINTENANCE\n                assert conn._sock.gettimeout() == RELAXED_TIMEOUT\n                assert conn.should_reconnect() is False\n\n        logging.info(\n            \"Validating newly created connections will receive the SMIGRATING notification...\"\n        )\n        for node in initial_cluster_nodes.values():\n            conn = node.redis_connection.connection_pool.get_connection()\n            in_use_connections[node].append(conn)\n            ClientValidations.wait_push_notification(\n                cluster_client_maint_notifications,\n                timeout=1,\n                connection=conn,\n                fail_on_timeout=False,  # it might get read during handshake\n            )\n            logging.info(\n                f\"Validating new connection MAINTENANCE state and RELAXED timeout for conn: {conn}...\"\n            )\n            assert conn.maintenance_state == MaintenanceState.MAINTENANCE\n            assert conn._sock.gettimeout() == RELAXED_TIMEOUT\n            assert conn.should_reconnect() is False\n\n        logging.info(\n            \"Waiting for SMIGRATED push notifications in ALL EXISTING connections...\"\n        )\n        marked_conns_for_reconnect = 0\n        for conns_per_node in in_use_connections.values():\n            for conn in conns_per_node:\n                ClientValidations.wait_push_notification(\n                    cluster_client_maint_notifications,\n                    timeout=SMIGRATED_TIMEOUT,\n                    connection=conn,\n                )\n                logging.info(\n                    f\"Validating connection state after SMIGRATED for conn: {conn}, \"\n                    f\"local socket port: {conn._sock.getsockname()[1] if conn._sock else None}...\"\n                )\n                if conn.should_reconnect():\n                    logging.info(f\"Connection marked for reconnect: {conn}\")\n                    marked_conns_for_reconnect += 1\n                assert conn.maintenance_state == MaintenanceState.NONE\n                assert conn.socket_timeout == DEFAULT_OSS_API_CLIENT_SOCKET_TIMEOUT\n                assert (\n                    conn.socket_connect_timeout == DEFAULT_OSS_API_CLIENT_SOCKET_TIMEOUT\n                )\n        assert (\n            marked_conns_for_reconnect >= 1\n        )  # at least one should be marked for reconnect\n\n        logging.info(\"Releasing connections back to the pool...\")\n        for node, conns in in_use_connections.items():\n            if node.redis_connection is None:\n                continue\n            for conn in conns:\n                node.redis_connection.connection_pool.release(conn)\n\n        trigger_effect_thread.join()\n        self.maintenance_ops_threads.remove(trigger_effect_thread)\n\n\nclass TestClusterClientCommandsExecutionWithPushNotificationsWithEffectTrigger(\n    TestClusterClientPushNotificationsWithEffectTriggerBase\n):\n    @pytest.mark.timeout(300)  # 5 minutes timeout for this test\n    @pytest.mark.parametrize(\n        \"effect_name, trigger, db_config, db_name\",\n        generate_params(\n            _FAULT_INJECTOR_CLIENT_OSS_API,\n            [\n                SlotMigrateEffects.SLOT_SHUFFLE,\n                SlotMigrateEffects.REMOVE,\n                SlotMigrateEffects.ADD,\n                SlotMigrateEffects.SLOT_SHUFFLE,\n            ],\n        ),\n    )\n    def test_command_execution_during_slot_shuffle_no_node_replacement(\n        self,\n        fault_injector_client_oss_api: FaultInjectorClient,\n        effect_name: SlotMigrateEffects,\n        trigger: str,\n        db_config: dict[str, Any],\n        db_name: str,\n    ):\n        \"\"\"\n        Test the push notifications are received when executing re cluster operations.\n        \"\"\"\n        logging.info(f\"DB name: {db_name}\")\n\n        cluster_client_maint_notifications, cluster_endpoint_config = self.setup_env(\n            fault_injector_client_oss_api, db_config\n        )\n\n        shards_count = db_config[\"shards_count\"]\n        logging.info(f\"Shards count: {shards_count}\")\n\n        errors = Queue()\n        if isinstance(fault_injector_client_oss_api, ProxyServerFaultInjector):\n            execution_duration = 20\n        else:\n            execution_duration = 40\n\n        def execute_commands(duration: int, errors: Queue):\n            start = time.time()\n            executed_commands_count = 0\n            keys_for_all_shards = KeyGenerationHelpers.generate_keys_for_all_shards(\n                shards_count,\n                prefix=f\"{threading.current_thread().name}_{effect_name}_{trigger}_key\",\n            )\n\n            logging.info(\"Starting commands execution...\")\n            while time.time() - start < duration:\n                for key in keys_for_all_shards:\n                    try:\n                        # the slot is covered by the first shard - this one will have slots migrated\n                        cluster_client_maint_notifications.set(key, \"value\")\n                        cluster_client_maint_notifications.get(key)\n                        executed_commands_count += 2\n                    except Exception as e:\n                        logging.error(\n                            f\"Error in thread {threading.current_thread().name}: {e}\"\n                        )\n                        errors.put(\n                            f\"Command failed in thread {threading.current_thread().name}: {e}\"\n                        )\n                if executed_commands_count % 500 == 0:\n                    logging.debug(\n                        f\"Executed {executed_commands_count} commands in {threading.current_thread().name}\"\n                    )\n            logging.debug(f\"{threading.current_thread().name}: Thread ended\")\n\n        threads = []\n        for i in range(10):\n            thread = Thread(\n                target=execute_commands,\n                name=f\"cmd_execution_{i}\",\n                args=(\n                    execution_duration,\n                    errors,\n                ),\n            )\n            thread.start()\n            threads.append(thread)\n\n        logging.info(\"Waiting for threads to start and have a few cycles executed ...\")\n        time.sleep(3)\n\n        logging.info(\"Executing FI command that triggers the desired effect...\")\n        trigger_effect_thread = Thread(\n            target=self._trigger_effect,\n            name=\"trigger_effect_thread\",\n            args=(\n                fault_injector_client_oss_api,\n                cluster_endpoint_config,\n                effect_name,\n                trigger,\n            ),\n        )\n        self.maintenance_ops_threads.append(trigger_effect_thread)\n        trigger_effect_thread.start()\n\n        for thread in threads:\n            thread.join()\n\n        trigger_effect_thread.join()\n        self.maintenance_ops_threads.remove(trigger_effect_thread)\n\n        # go through all nodes and all their connections and consume the buffers - to validate no\n        # notifications were left unconsumed\n        logging.info(\n            \"Consuming all buffers to validate no notifications were left unconsumed...\"\n        )\n        for (\n            node\n        ) in cluster_client_maint_notifications.nodes_manager.nodes_cache.values():\n            if node.redis_connection is None:\n                continue\n            for conn in self._get_all_connections_in_pool(node.redis_connection):\n                if conn._sock:\n                    while conn.can_read(timeout=0.2):\n                        conn.read_response(push_request=True)\n            logging.info(f\"Consumed all buffers for node: {node.name}\")\n        logging.info(\"All buffers consumed.\")\n\n        for (\n            node\n        ) in cluster_client_maint_notifications.nodes_manager.nodes_cache.values():\n            # validate connections settings\n            self._validate_default_state(\n                node.redis_connection,\n                expected_matching_conns_count=\"all\",\n                configured_timeout=DEFAULT_OSS_API_CLIENT_SOCKET_TIMEOUT,\n            )\n            logging.info(\n                f\"Node successfully validated: {node.name}, \"\n                f\"connections: {len(self._get_all_connections_in_pool(node.redis_connection))}\"\n            )\n\n        # validate no errors were raised in the command execution threads\n        assert errors.empty(), f\"Errors occurred in threads: {errors.queue}\"\n"
  },
  {
    "path": "tests/test_scripting.py",
    "content": "import pytest\nimport redis\nfrom redis import exceptions\nfrom redis.commands.core import Script\nfrom tests.conftest import skip_if_redis_enterprise, skip_if_server_version_lt\n\nmultiply_script = \"\"\"\nlocal value = redis.call('GET', KEYS[1])\nvalue = tonumber(value)\nreturn value * ARGV[1]\"\"\"\n\nmsgpack_hello_script = \"\"\"\nlocal message = cmsgpack.unpack(ARGV[1])\nlocal name = message['name']\nreturn \"hello \" .. name\n\"\"\"\nmsgpack_hello_script_broken = \"\"\"\nlocal message = cmsgpack.unpack(ARGV[1])\nlocal names = message['name']\nreturn \"hello \" .. name\n\"\"\"\n\n\nclass TestScript:\n    \"\"\"\n    We have a few tests to directly test the Script class.\n\n    However, most of the behavioral tests are covered by `TestScripting`.\n    \"\"\"\n\n    @pytest.fixture()\n    def script_str(self):\n        return \"fake-script\"\n\n    @pytest.fixture()\n    def script_bytes(self):\n        return b\"\\xcf\\x84o\\xcf\\x81\\xce\\xbdo\\xcf\\x82\"\n\n    def test_script_text(self, r, script_str, script_bytes):\n        assert Script(r, script_str).script == \"fake-script\"\n        assert Script(r, script_bytes).script == b\"\\xcf\\x84o\\xcf\\x81\\xce\\xbdo\\xcf\\x82\"\n\n    def test_string_script_sha(self, r, script_str):\n        script = Script(r, script_str)\n        assert script.sha == \"505e4245f0866b60552741b3cce9a0c3d3b66a87\"\n\n    def test_bytes_script_sha(self, r, script_bytes):\n        script = Script(r, script_bytes)\n        assert script.sha == \"1329344e6bf995a35a8dc57ab1a6af8b2d54a763\"\n\n    def test_encoder(self, r, script_bytes):\n        encoder = Script(r, script_bytes).get_encoder()\n        assert encoder is not None\n        assert encoder.encode(\"fake-script\") == b\"fake-script\"\n\n\nclass TestScripting:\n    @pytest.fixture(autouse=True)\n    def reset_scripts(self, r):\n        r.script_flush()\n\n    def test_eval_multiply(self, r):\n        r.set(\"a\", 2)\n        # 2 * 3 == 6\n        assert r.eval(multiply_script, 1, \"a\", 3) == 6\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    @skip_if_redis_enterprise()\n    def test_eval_ro(self, r):\n        r.set(\"a\", \"b\")\n        assert r.eval_ro(\"return redis.call('GET', KEYS[1])\", 1, \"a\") == b\"b\"\n        with pytest.raises(redis.ResponseError):\n            r.eval_ro(\"return redis.call('DEL', KEYS[1])\", 1, \"a\")\n\n    def test_eval_msgpack(self, r):\n        msgpack_message_dumped = b\"\\x81\\xa4name\\xa3Joe\"\n        # this is msgpack.dumps({\"name\": \"joe\"})\n        assert r.eval(msgpack_hello_script, 0, msgpack_message_dumped) == b\"hello Joe\"\n\n    def test_eval_same_slot(self, r):\n        \"\"\"\n        In a clustered redis, the script keys must be in the same slot.\n\n        This test isn't very interesting for standalone redis, but it doesn't\n        hurt anything.\n        \"\"\"\n        r.set(\"A{foo}\", 2)\n        r.set(\"B{foo}\", 4)\n        # 2 * 4 == 8\n\n        script = \"\"\"\n        local value = redis.call('GET', KEYS[1])\n        local value2 = redis.call('GET', KEYS[2])\n        return value * value2\n        \"\"\"\n        result = r.eval(script, 2, \"A{foo}\", \"B{foo}\")\n        assert result == 8\n\n    @pytest.mark.onlycluster\n    def test_eval_crossslot(self, r):\n        \"\"\"\n        In a clustered redis, the script keys must be in the same slot.\n\n        This test should fail, because the two keys we send are in different\n        slots. This test assumes that {foo} and {bar} will not go to the same\n        server when used. In a setup with 3 primaries and 3 secondaries, this\n        assumption holds.\n        \"\"\"\n        r.set(\"A{foo}\", 2)\n        r.set(\"B{bar}\", 4)\n        # 2 * 4 == 8\n\n        script = \"\"\"\n        local value = redis.call('GET', KEYS[1])\n        local value2 = redis.call('GET', KEYS[2])\n        return value * value2\n        \"\"\"\n        with pytest.raises(exceptions.RedisClusterException):\n            r.eval(script, 2, \"A{foo}\", \"B{bar}\")\n\n    @skip_if_server_version_lt(\"6.2.0\")\n    def test_script_flush_620(self, r):\n        r.set(\"a\", 2)\n        r.script_load(multiply_script)\n        r.script_flush(\"ASYNC\")\n\n        r.set(\"a\", 2)\n        r.script_load(multiply_script)\n        r.script_flush(\"SYNC\")\n\n        r.set(\"a\", 2)\n        r.script_load(multiply_script)\n        r.script_flush()\n\n        with pytest.raises(exceptions.DataError):\n            r.set(\"a\", 2)\n            r.script_load(multiply_script)\n            r.script_flush(\"NOTREAL\")\n\n    def test_script_flush(self, r):\n        r.set(\"a\", 2)\n        r.script_load(multiply_script)\n        r.script_flush(None)\n\n        with pytest.raises(exceptions.DataError):\n            r.set(\"a\", 2)\n            r.script_load(multiply_script)\n            r.script_flush(\"NOTREAL\")\n\n    def test_evalsha(self, r):\n        r.set(\"a\", 2)\n        sha = r.script_load(multiply_script)\n        # 2 * 3 == 6\n        assert r.evalsha(sha, 1, \"a\", 3) == 6\n\n    @skip_if_server_version_lt(\"7.0.0\")\n    @skip_if_redis_enterprise()\n    def test_evalsha_ro(self, r):\n        r.set(\"a\", \"b\")\n        get_sha = r.script_load(\"return redis.call('GET', KEYS[1])\")\n        del_sha = r.script_load(\"return redis.call('DEL', KEYS[1])\")\n        assert r.evalsha_ro(get_sha, 1, \"a\") == b\"b\"\n        with pytest.raises(redis.ResponseError):\n            r.evalsha_ro(del_sha, 1, \"a\")\n\n    def test_evalsha_script_not_loaded(self, r):\n        r.set(\"a\", 2)\n        sha = r.script_load(multiply_script)\n        # remove the script from Redis's cache\n        r.script_flush()\n        with pytest.raises(exceptions.NoScriptError):\n            r.evalsha(sha, 1, \"a\", 3)\n\n    def test_script_loading(self, r):\n        # get the sha, then clear the cache\n        sha = r.script_load(multiply_script)\n        r.script_flush()\n        assert r.script_exists(sha) == [False]\n        r.script_load(multiply_script)\n        assert r.script_exists(sha) == [True]\n\n    def test_flush_response(self, r):\n        r.script_load(multiply_script)\n        flush_response = r.script_flush()\n        assert flush_response is True\n\n    def test_script_object(self, r):\n        r.set(\"a\", 2)\n        multiply = r.register_script(multiply_script)\n        precalculated_sha = multiply.sha\n        assert precalculated_sha\n        assert r.script_exists(multiply.sha) == [False]\n        # Test second evalsha block (after NoScriptError)\n        assert multiply(keys=[\"a\"], args=[3]) == 6\n        # At this point, the script should be loaded\n        assert r.script_exists(multiply.sha) == [True]\n        # Test that the precalculated sha matches the one from redis\n        assert multiply.sha == precalculated_sha\n        # Test first evalsha block\n        assert multiply(keys=[\"a\"], args=[3]) == 6\n\n    # Scripting is not supported in cluster pipelines\n    @pytest.mark.onlynoncluster\n    def test_script_object_in_pipeline(self, r):\n        multiply = r.register_script(multiply_script)\n        precalculated_sha = multiply.sha\n        assert precalculated_sha\n        pipe = r.pipeline()\n        pipe.set(\"a\", 2)\n        pipe.get(\"a\")\n        multiply(keys=[\"a\"], args=[3], client=pipe)\n        assert r.script_exists(multiply.sha) == [False]\n        # [SET worked, GET 'a', result of multiple script]\n        assert pipe.execute() == [True, b\"2\", 6]\n        # The script should have been loaded by pipe.execute()\n        assert r.script_exists(multiply.sha) == [True]\n        # The precalculated sha should have been the correct one\n        assert multiply.sha == precalculated_sha\n\n        # purge the script from redis's cache and re-run the pipeline\n        # the multiply script should be reloaded by pipe.execute()\n        r.script_flush()\n        pipe = r.pipeline()\n        pipe.set(\"a\", 2)\n        pipe.get(\"a\")\n        multiply(keys=[\"a\"], args=[3], client=pipe)\n        assert r.script_exists(multiply.sha) == [False]\n        # [SET worked, GET 'a', result of multiple script]\n        assert pipe.execute() == [True, b\"2\", 6]\n        assert r.script_exists(multiply.sha) == [True]\n\n    # Scripting is not supported in cluster pipelines\n    @pytest.mark.onlynoncluster\n    def test_eval_msgpack_pipeline_error_in_lua(self, r):\n        msgpack_hello = r.register_script(msgpack_hello_script)\n        assert msgpack_hello.sha\n\n        pipe = r.pipeline()\n\n        # avoiding a dependency to msgpack, this is the output of\n        # msgpack.dumps({\"name\": \"joe\"})\n        msgpack_message_1 = b\"\\x81\\xa4name\\xa3Joe\"\n\n        msgpack_hello(args=[msgpack_message_1], client=pipe)\n\n        assert r.script_exists(msgpack_hello.sha) == [False]\n        assert pipe.execute()[0] == b\"hello Joe\"\n        assert r.script_exists(msgpack_hello.sha) == [True]\n\n        msgpack_hello_broken = r.register_script(msgpack_hello_script_broken)\n\n        msgpack_hello_broken(args=[msgpack_message_1], client=pipe)\n        with pytest.raises(exceptions.ResponseError) as excinfo:\n            pipe.execute()\n        assert excinfo.type == exceptions.ResponseError\n"
  },
  {
    "path": "tests/test_search.py",
    "content": "import bz2\nimport csv\nimport os\nimport random\nimport time\nfrom io import TextIOWrapper\n\nimport numpy as np\nimport pytest\nfrom redis import ResponseError\nimport redis\n\nimport redis.commands.search.aggregation as aggregations\nfrom redis.commands.search.hybrid_query import (\n    CombinationMethods,\n    CombineResultsMethod,\n    HybridCursorQuery,\n    HybridFilter,\n    HybridPostProcessingConfig,\n    HybridQuery,\n    HybridSearchQuery,\n    HybridVsimQuery,\n    VectorSearchMethods,\n)\nfrom redis.commands.search.hybrid_result import HybridCursorResult\nimport redis.commands.search.reducers as reducers\nfrom redis.commands.json.path import Path\nfrom redis.commands.search import Search\nfrom redis.commands.search.field import (\n    GeoField,\n    GeoShapeField,\n    NumericField,\n    TagField,\n    TextField,\n    VectorField,\n)\nfrom redis.commands.search.index_definition import IndexDefinition, IndexType\nfrom redis.commands.search.query import (\n    GeoFilter,\n    NumericFilter,\n    Query,\n    SortbyField,\n)\nfrom redis.commands.search.result import Result\nfrom redis.commands.search.suggestion import Suggestion\nfrom redis.utils import safe_str\n\nfrom .conftest import (\n    _get_client,\n    is_resp2_connection,\n    skip_if_redis_enterprise,\n    skip_if_resp_version,\n    skip_if_server_version_gte,\n    skip_if_server_version_lt,\n    skip_ifmodversion_lt,\n)\n\nWILL_PLAY_TEXT = os.path.abspath(\n    os.path.join(os.path.dirname(__file__), \"testdata\", \"will_play_text.csv.bz2\")\n)\n\nTITLES_CSV = os.path.abspath(\n    os.path.join(os.path.dirname(__file__), \"testdata\", \"titles.csv\")\n)\n\n\ndef _assert_search_result(client, result, expected_doc_ids):\n    \"\"\"\n    Make sure the result of a geo search is as expected, taking into account the RESP\n    version being used.\n    \"\"\"\n    if is_resp2_connection(client):\n        assert set([doc.id for doc in result.docs]) == set(expected_doc_ids)\n    else:\n        assert set([doc[\"id\"] for doc in result[\"results\"]]) == set(expected_doc_ids)\n\n\nclass SearchTestsBase:\n    @staticmethod\n    def waitForIndex(env, idx, timeout=None):\n        delay = 0.1\n        while True:\n            try:\n                res = env.execute_command(\"FT.INFO\", idx)\n                if int(res[res.index(\"indexing\") + 1]) == 0:\n                    break\n            except ValueError:\n                break\n            except AttributeError:\n                try:\n                    if int(res[\"indexing\"]) == 0:\n                        break\n                except ValueError:\n                    break\n            except ResponseError:\n                # index doesn't exist yet\n                # continue to sleep and try again\n                pass\n\n            time.sleep(delay)\n            if timeout is not None:\n                timeout -= delay\n                if timeout <= 0:\n                    break\n\n    @staticmethod\n    def getClient(client):\n        \"\"\"\n        Gets a client client attached to an index name which is ready to be\n        created\n        \"\"\"\n        return client\n\n    @staticmethod\n    def createIndex(client, num_docs=100, definition=None):\n        try:\n            client.create_index(\n                (\n                    TextField(\"play\", weight=5.0),\n                    TextField(\"txt\"),\n                    NumericField(\"chapter\"),\n                ),\n                definition=definition,\n            )\n        except redis.ResponseError:\n            client.dropindex(delete_documents=True)\n            return SearchTestsBase.createIndex(\n                client, num_docs=num_docs, definition=definition\n            )\n\n        chapters = {}\n        bzfp = TextIOWrapper(bz2.BZ2File(WILL_PLAY_TEXT), encoding=\"utf8\")\n\n        r = csv.reader(bzfp, delimiter=\";\")\n        for n, line in enumerate(r):\n            play, chapter, _, text = line[1], line[2], line[4], line[5]\n\n            key = f\"{play}:{chapter}\".lower()\n            d = chapters.setdefault(key, {})\n            d[\"play\"] = play\n            d[\"txt\"] = d.get(\"txt\", \"\") + \" \" + text\n            d[\"chapter\"] = int(chapter or 0)\n            if len(chapters) == num_docs:\n                break\n\n        indexer = client.batch_indexer(chunk_size=50)\n        assert isinstance(indexer, Search.BatchIndexer)\n        assert 50 == indexer.chunk_size\n\n        for key, doc in chapters.items():\n            indexer.client.client.hset(key, mapping=doc)\n        indexer.commit()\n\n    @pytest.fixture\n    def client(self, request, stack_url):\n        r = _get_client(redis.Redis, request, decode_responses=True, from_url=stack_url)\n        r.flushdb()\n        return r\n\n\nclass TestBaseSearchFunctionality(SearchTestsBase):\n    @pytest.mark.redismod\n    def test_client(self, client):\n        num_docs = 500\n        self.createIndex(client.ft(), num_docs=num_docs)\n        self.waitForIndex(client, getattr(client.ft(), \"index_name\", \"idx\"))\n        # verify info\n        info = client.ft().info()\n        for k in [\n            \"index_name\",\n            \"index_options\",\n            \"attributes\",\n            \"num_docs\",\n            \"max_doc_id\",\n            \"num_terms\",\n            \"num_records\",\n            \"inverted_sz_mb\",\n            \"offset_vectors_sz_mb\",\n            \"doc_table_size_mb\",\n            \"key_table_size_mb\",\n            \"records_per_doc_avg\",\n            \"bytes_per_record_avg\",\n            \"offsets_per_term_avg\",\n            \"offset_bits_per_record_avg\",\n        ]:\n            assert k in info\n\n        assert client.ft().index_name == info[\"index_name\"]\n        assert num_docs == int(info[\"num_docs\"])\n\n        res = client.ft().search(\"henry iv\")\n        if is_resp2_connection(client):\n            assert isinstance(res, Result)\n            assert 225 == res.total\n            assert 10 == len(res.docs)\n            assert res.duration > 0\n\n            for doc in res.docs:\n                assert doc.id\n                assert doc[\"id\"]\n                assert doc.play == \"Henry IV\"\n                assert doc[\"play\"] == \"Henry IV\"\n                assert len(doc.txt) > 0\n\n            # test no content\n            res = client.ft().search(Query(\"king\").no_content())\n            assert 194 == res.total\n            assert 10 == len(res.docs)\n            for doc in res.docs:\n                assert \"txt\" not in doc.__dict__\n                assert \"play\" not in doc.__dict__\n\n            # test verbatim vs no verbatim\n            total = client.ft().search(Query(\"kings\").no_content()).total\n            vtotal = client.ft().search(Query(\"kings\").no_content().verbatim()).total\n            assert total > vtotal\n\n            # test in fields\n            txt_total = (\n                client.ft()\n                .search(Query(\"henry\").no_content().limit_fields(\"txt\"))\n                .total\n            )\n            play_total = (\n                client.ft()\n                .search(Query(\"henry\").no_content().limit_fields(\"play\"))\n                .total\n            )\n            both_total = (\n                client.ft()\n                .search(Query(\"henry\").no_content().limit_fields(\"play\", \"txt\"))\n                .total\n            )\n            assert 129 == txt_total\n            assert 494 == play_total\n            assert 494 == both_total\n\n            # test load_document\n            doc = client.ft().load_document(\"henry vi part 3:62\")\n            assert doc is not None\n            assert \"henry vi part 3:62\" == doc.id\n            assert doc.play == \"Henry VI Part 3\"\n            assert len(doc.txt) > 0\n\n            # test in-keys\n            ids = [x.id for x in client.ft().search(Query(\"henry\")).docs]\n            assert 10 == len(ids)\n            subset = ids[:5]\n            docs = client.ft().search(Query(\"henry\").limit_ids(*subset))\n            assert len(subset) == docs.total\n            ids = [x.id for x in docs.docs]\n            assert set(ids) == set(subset)\n\n            # test slop and in order\n            assert 193 == client.ft().search(Query(\"henry king\")).total\n            assert 3 == client.ft().search(Query(\"henry king\").slop(0).in_order()).total\n            assert (\n                52 == client.ft().search(Query(\"king henry\").slop(0).in_order()).total\n            )\n            assert 53 == client.ft().search(Query(\"henry king\").slop(0)).total\n            assert 167 == client.ft().search(Query(\"henry king\").slop(100)).total\n\n            # test delete document\n            client.hset(\"doc-5ghs2\", mapping={\"play\": \"Death of a Salesman\"})\n            res = client.ft().search(Query(\"death of a salesman\"))\n            assert 1 == res.total\n\n            assert 1 == client.ft().delete_document(\"doc-5ghs2\")\n            res = client.ft().search(Query(\"death of a salesman\"))\n            assert 0 == res.total\n            assert 0 == client.ft().delete_document(\"doc-5ghs2\")\n\n            client.hset(\"doc-5ghs2\", mapping={\"play\": \"Death of a Salesman\"})\n            res = client.ft().search(Query(\"death of a salesman\"))\n            assert 1 == res.total\n            client.ft().delete_document(\"doc-5ghs2\")\n        else:\n            assert isinstance(res, dict)\n            assert 225 == res[\"total_results\"]\n            assert 10 == len(res[\"results\"])\n\n            for doc in res[\"results\"]:\n                assert doc[\"id\"]\n                assert doc[\"extra_attributes\"][\"play\"] == \"Henry IV\"\n                assert len(doc[\"extra_attributes\"][\"txt\"]) > 0\n\n            # test no content\n            res = client.ft().search(Query(\"king\").no_content())\n            assert 194 == res[\"total_results\"]\n            assert 10 == len(res[\"results\"])\n            for doc in res[\"results\"]:\n                assert \"extra_attributes\" not in doc.keys()\n\n            # test verbatim vs no verbatim\n            total = client.ft().search(Query(\"kings\").no_content())[\"total_results\"]\n            vtotal = client.ft().search(Query(\"kings\").no_content().verbatim())[\n                \"total_results\"\n            ]\n            assert total > vtotal\n\n            # test in fields\n            txt_total = client.ft().search(\n                Query(\"henry\").no_content().limit_fields(\"txt\")\n            )[\"total_results\"]\n            play_total = client.ft().search(\n                Query(\"henry\").no_content().limit_fields(\"play\")\n            )[\"total_results\"]\n            both_total = client.ft().search(\n                Query(\"henry\").no_content().limit_fields(\"play\", \"txt\")\n            )[\"total_results\"]\n            assert 129 == txt_total\n            assert 494 == play_total\n            assert 494 == both_total\n\n            # test load_document\n            doc = client.ft().load_document(\"henry vi part 3:62\")\n            assert doc is not None\n            assert \"henry vi part 3:62\" == doc.id\n            assert doc.play == \"Henry VI Part 3\"\n            assert len(doc.txt) > 0\n\n            # test in-keys\n            ids = [x[\"id\"] for x in client.ft().search(Query(\"henry\"))[\"results\"]]\n            assert 10 == len(ids)\n            subset = ids[:5]\n            docs = client.ft().search(Query(\"henry\").limit_ids(*subset))\n            assert len(subset) == docs[\"total_results\"]\n            ids = [x[\"id\"] for x in docs[\"results\"]]\n            assert set(ids) == set(subset)\n\n            # test slop and in order\n            assert 193 == client.ft().search(Query(\"henry king\"))[\"total_results\"]\n            assert (\n                3\n                == client.ft().search(Query(\"henry king\").slop(0).in_order())[\n                    \"total_results\"\n                ]\n            )\n            assert (\n                52\n                == client.ft().search(Query(\"king henry\").slop(0).in_order())[\n                    \"total_results\"\n                ]\n            )\n            assert (\n                53 == client.ft().search(Query(\"henry king\").slop(0))[\"total_results\"]\n            )\n            assert (\n                167\n                == client.ft().search(Query(\"henry king\").slop(100))[\"total_results\"]\n            )\n\n            # test delete document\n            client.hset(\"doc-5ghs2\", mapping={\"play\": \"Death of a Salesman\"})\n            res = client.ft().search(Query(\"death of a salesman\"))\n            assert 1 == res[\"total_results\"]\n\n            assert 1 == client.ft().delete_document(\"doc-5ghs2\")\n            res = client.ft().search(Query(\"death of a salesman\"))\n            assert 0 == res[\"total_results\"]\n            assert 0 == client.ft().delete_document(\"doc-5ghs2\")\n\n            client.hset(\"doc-5ghs2\", mapping={\"play\": \"Death of a Salesman\"})\n            res = client.ft().search(Query(\"death of a salesman\"))\n            assert 1 == res[\"total_results\"]\n            client.ft().delete_document(\"doc-5ghs2\")\n\n    @pytest.mark.redismod\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_gte(\"7.9.0\")\n    def test_scores(self, client):\n        client.ft().create_index((TextField(\"txt\"),))\n\n        client.hset(\"doc1\", mapping={\"txt\": \"foo baz\"})\n        client.hset(\"doc2\", mapping={\"txt\": \"foo bar\"})\n\n        q = Query(\"foo ~bar\").with_scores()\n        res = client.ft().search(q)\n        if is_resp2_connection(client):\n            assert 2 == res.total\n            assert \"doc2\" == res.docs[0].id\n            assert 3.0 == res.docs[0].score\n            assert \"doc1\" == res.docs[1].id\n        else:\n            assert 2 == res[\"total_results\"]\n            assert \"doc2\" == res[\"results\"][0][\"id\"]\n            assert 3.0 == res[\"results\"][0][\"score\"]\n            assert \"doc1\" == res[\"results\"][1][\"id\"]\n\n    @pytest.mark.redismod\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"7.9.0\")\n    def test_scores_with_new_default_scorer(self, client):\n        client.ft().create_index((TextField(\"txt\"),))\n\n        client.hset(\"doc1\", mapping={\"txt\": \"foo baz\"})\n        client.hset(\"doc2\", mapping={\"txt\": \"foo bar\"})\n\n        q = Query(\"foo ~bar\").with_scores()\n        res = client.ft().search(q)\n        if is_resp2_connection(client):\n            assert 2 == res.total\n            assert \"doc2\" == res.docs[0].id\n            assert 0.87 == pytest.approx(res.docs[0].score, 0.01)\n            assert \"doc1\" == res.docs[1].id\n        else:\n            assert 2 == res[\"total_results\"]\n            assert \"doc2\" == res[\"results\"][0][\"id\"]\n            assert 0.87 == pytest.approx(res[\"results\"][0][\"score\"], 0.01)\n            assert \"doc1\" == res[\"results\"][1][\"id\"]\n\n    @pytest.mark.redismod\n    def test_stopwords(self, client):\n        client.ft().create_index((TextField(\"txt\"),), stopwords=[\"foo\", \"bar\", \"baz\"])\n        client.hset(\"doc1\", mapping={\"txt\": \"foo bar\"})\n        client.hset(\"doc2\", mapping={\"txt\": \"hello world\"})\n        self.waitForIndex(client, getattr(client.ft(), \"index_name\", \"idx\"))\n\n        q1 = Query(\"foo bar\").no_content()\n        q2 = Query(\"foo bar hello world\").no_content()\n        res1, res2 = client.ft().search(q1), client.ft().search(q2)\n        if is_resp2_connection(client):\n            assert 0 == res1.total\n            assert 1 == res2.total\n        else:\n            assert 0 == res1[\"total_results\"]\n            assert 1 == res2[\"total_results\"]\n\n    @pytest.mark.redismod\n    def test_filters(self, client):\n        client.ft().create_index(\n            (TextField(\"txt\"), NumericField(\"num\"), GeoField(\"loc\"))\n        )\n        client.hset(\n            \"doc1\", mapping={\"txt\": \"foo bar\", \"num\": 3.141, \"loc\": \"-0.441,51.458\"}\n        )\n        client.hset(\"doc2\", mapping={\"txt\": \"foo baz\", \"num\": 2, \"loc\": \"-0.1,51.2\"})\n\n        self.waitForIndex(client, getattr(client.ft(), \"index_name\", \"idx\"))\n        # Test numerical filter\n        q1 = Query(\"foo\").add_filter(NumericFilter(\"num\", 0, 2)).no_content()\n        q2 = (\n            Query(\"foo\")\n            .add_filter(NumericFilter(\"num\", 2, NumericFilter.INF, minExclusive=True))\n            .no_content()\n        )\n        res1, res2 = client.ft().search(q1), client.ft().search(q2)\n        if is_resp2_connection(client):\n            assert 1 == res1.total\n            assert 1 == res2.total\n            assert \"doc2\" == res1.docs[0].id\n            assert \"doc1\" == res2.docs[0].id\n        else:\n            assert 1 == res1[\"total_results\"]\n            assert 1 == res2[\"total_results\"]\n            assert \"doc2\" == res1[\"results\"][0][\"id\"]\n            assert \"doc1\" == res2[\"results\"][0][\"id\"]\n\n        # Test geo filter\n        q1 = Query(\"foo\").add_filter(GeoFilter(\"loc\", -0.44, 51.45, 10)).no_content()\n        q2 = Query(\"foo\").add_filter(GeoFilter(\"loc\", -0.44, 51.45, 100)).no_content()\n        res1, res2 = client.ft().search(q1), client.ft().search(q2)\n\n        if is_resp2_connection(client):\n            assert 1 == res1.total\n            assert 2 == res2.total\n            assert \"doc1\" == res1.docs[0].id\n\n            # Sort results, after RDB reload order may change\n            res = [res2.docs[0].id, res2.docs[1].id]\n            res.sort()\n            assert [\"doc1\", \"doc2\"] == res\n        else:\n            assert 1 == res1[\"total_results\"]\n            assert 2 == res2[\"total_results\"]\n            assert \"doc1\" == res1[\"results\"][0][\"id\"]\n\n            # Sort results, after RDB reload order may change\n            res = [res2[\"results\"][0][\"id\"], res2[\"results\"][1][\"id\"]]\n            res.sort()\n            assert [\"doc1\", \"doc2\"] == res\n\n    @pytest.mark.redismod\n    def test_sort_by(self, client):\n        client.ft().create_index((TextField(\"txt\"), NumericField(\"num\", sortable=True)))\n        client.hset(\"doc1\", mapping={\"txt\": \"foo bar\", \"num\": 1})\n        client.hset(\"doc2\", mapping={\"txt\": \"foo baz\", \"num\": 2})\n        client.hset(\"doc3\", mapping={\"txt\": \"foo qux\", \"num\": 3})\n\n        # Test sort\n        q1 = Query(\"foo\").sort_by(\"num\", asc=True).no_content()\n        q2 = Query(\"foo\").sort_by(\"num\", asc=False).no_content()\n        res1, res2 = client.ft().search(q1), client.ft().search(q2)\n\n        if is_resp2_connection(client):\n            assert 3 == res1.total\n            assert \"doc1\" == res1.docs[0].id\n            assert \"doc2\" == res1.docs[1].id\n            assert \"doc3\" == res1.docs[2].id\n            assert 3 == res2.total\n            assert \"doc1\" == res2.docs[2].id\n            assert \"doc2\" == res2.docs[1].id\n            assert \"doc3\" == res2.docs[0].id\n        else:\n            assert 3 == res1[\"total_results\"]\n            assert \"doc1\" == res1[\"results\"][0][\"id\"]\n            assert \"doc2\" == res1[\"results\"][1][\"id\"]\n            assert \"doc3\" == res1[\"results\"][2][\"id\"]\n            assert 3 == res2[\"total_results\"]\n            assert \"doc1\" == res2[\"results\"][2][\"id\"]\n            assert \"doc2\" == res2[\"results\"][1][\"id\"]\n            assert \"doc3\" == res2[\"results\"][0][\"id\"]\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.0.0\", \"search\")\n    def test_drop_index(self, client):\n        \"\"\"\n        Ensure the index gets dropped by data remains by default\n        \"\"\"\n        for x in range(20):\n            for keep_docs in [[True, {}], [False, {\"name\": \"haveit\"}]]:\n                idx = \"HaveIt\"\n                index = self.getClient(client)\n                index.hset(\"index:haveit\", mapping={\"name\": \"haveit\"})\n                idef = IndexDefinition(prefix=[\"index:\"])\n                index.ft(idx).create_index((TextField(\"name\"),), definition=idef)\n                self.waitForIndex(index, idx)\n                index.ft(idx).dropindex(delete_documents=keep_docs[0])\n                i = index.hgetall(\"index:haveit\")\n                assert i == keep_docs[1]\n\n    @pytest.mark.redismod\n    def test_example(self, client):\n        # Creating the index definition and schema\n        client.ft().create_index((TextField(\"title\", weight=5.0), TextField(\"body\")))\n\n        # Indexing a document\n        client.hset(\n            \"doc1\",\n            mapping={\n                \"title\": \"RediSearch\",\n                \"body\": \"Redisearch impements a search engine on top of redis\",\n            },\n        )\n\n        # Searching with complex parameters:\n        q = Query(\"search engine\").verbatim().no_content().paging(0, 5)\n\n        res = client.ft().search(q)\n        assert res is not None\n\n    @pytest.mark.redismod\n    @skip_if_redis_enterprise()\n    def test_auto_complete(self, client):\n        n = 0\n        with open(TITLES_CSV) as f:\n            cr = csv.reader(f)\n\n            for row in cr:\n                n += 1\n                term, score = row[0], float(row[1])\n                assert n == client.ft().sugadd(\"ac\", Suggestion(term, score=score))\n\n        assert n == client.ft().suglen(\"ac\")\n        ret = client.ft().sugget(\"ac\", \"bad\", with_scores=True)\n        assert 2 == len(ret)\n        assert \"badger\" == ret[0].string\n        assert isinstance(ret[0].score, float)\n        assert 1.0 != ret[0].score\n        assert \"badalte rishtey\" == ret[1].string\n        assert isinstance(ret[1].score, float)\n        assert 1.0 != ret[1].score\n\n        ret = client.ft().sugget(\"ac\", \"bad\", fuzzy=True, num=10)\n        assert 10 == len(ret)\n        assert 1.0 == ret[0].score\n        strs = {x.string for x in ret}\n\n        for sug in strs:\n            assert 1 == client.ft().sugdel(\"ac\", sug)\n        # make sure a second delete returns 0\n        for sug in strs:\n            assert 0 == client.ft().sugdel(\"ac\", sug)\n\n        # make sure they were actually deleted\n        ret2 = client.ft().sugget(\"ac\", \"bad\", fuzzy=True, num=10)\n        for sug in ret2:\n            assert sug.string not in strs\n\n        # Test with payload\n        client.ft().sugadd(\"ac\", Suggestion(\"pay1\", payload=\"pl1\"))\n        client.ft().sugadd(\"ac\", Suggestion(\"pay2\", payload=\"pl2\"))\n        client.ft().sugadd(\"ac\", Suggestion(\"pay3\", payload=\"pl3\"))\n\n        sugs = client.ft().sugget(\"ac\", \"pay\", with_payloads=True, with_scores=True)\n        assert 3 == len(sugs)\n        for sug in sugs:\n            assert sug.payload\n            assert sug.payload.startswith(\"pl\")\n\n    @pytest.mark.redismod\n    def test_no_index(self, client):\n        client.ft().create_index(\n            (\n                TextField(\"field\"),\n                TextField(\"text\", no_index=True, sortable=True),\n                NumericField(\"numeric\", no_index=True, sortable=True),\n                GeoField(\"geo\", no_index=True, sortable=True),\n                TagField(\"tag\", no_index=True, sortable=True),\n            )\n        )\n\n        client.hset(\n            \"doc1\",\n            mapping={\n                \"field\": \"aaa\",\n                \"text\": \"1\",\n                \"numeric\": \"1\",\n                \"geo\": \"1,1\",\n                \"tag\": \"1\",\n            },\n        )\n        client.hset(\n            \"doc2\",\n            mapping={\n                \"field\": \"aab\",\n                \"text\": \"2\",\n                \"numeric\": \"2\",\n                \"geo\": \"2,2\",\n                \"tag\": \"2\",\n            },\n        )\n        self.waitForIndex(client, getattr(client.ft(), \"index_name\", \"idx\"))\n\n        if is_resp2_connection(client):\n            res = client.ft().search(Query(\"@text:aa*\"))\n            assert 0 == res.total\n\n            res = client.ft().search(Query(\"@field:aa*\"))\n            assert 2 == res.total\n\n            res = client.ft().search(Query(\"*\").sort_by(\"text\", asc=False))\n            assert 2 == res.total\n            assert \"doc2\" == res.docs[0].id\n\n            res = client.ft().search(Query(\"*\").sort_by(\"text\", asc=True))\n            assert \"doc1\" == res.docs[0].id\n\n            res = client.ft().search(Query(\"*\").sort_by(\"numeric\", asc=True))\n            assert \"doc1\" == res.docs[0].id\n\n            res = client.ft().search(Query(\"*\").sort_by(\"geo\", asc=True))\n            assert \"doc1\" == res.docs[0].id\n\n            res = client.ft().search(Query(\"*\").sort_by(\"tag\", asc=True))\n            assert \"doc1\" == res.docs[0].id\n        else:\n            res = client.ft().search(Query(\"@text:aa*\"))\n            assert 0 == res[\"total_results\"]\n\n            res = client.ft().search(Query(\"@field:aa*\"))\n            assert 2 == res[\"total_results\"]\n\n            res = client.ft().search(Query(\"*\").sort_by(\"text\", asc=False))\n            assert 2 == res[\"total_results\"]\n            assert \"doc2\" == res[\"results\"][0][\"id\"]\n\n            res = client.ft().search(Query(\"*\").sort_by(\"text\", asc=True))\n            assert \"doc1\" == res[\"results\"][0][\"id\"]\n\n            res = client.ft().search(Query(\"*\").sort_by(\"numeric\", asc=True))\n            assert \"doc1\" == res[\"results\"][0][\"id\"]\n\n            res = client.ft().search(Query(\"*\").sort_by(\"geo\", asc=True))\n            assert \"doc1\" == res[\"results\"][0][\"id\"]\n\n            res = client.ft().search(Query(\"*\").sort_by(\"tag\", asc=True))\n            assert \"doc1\" == res[\"results\"][0][\"id\"]\n\n        # Ensure exception is raised for non-indexable, non-sortable fields\n        with pytest.raises(Exception):\n            TextField(\"name\", no_index=True, sortable=False)\n        with pytest.raises(Exception):\n            NumericField(\"name\", no_index=True, sortable=False)\n        with pytest.raises(Exception):\n            GeoField(\"name\", no_index=True, sortable=False)\n        with pytest.raises(Exception):\n            TagField(\"name\", no_index=True, sortable=False)\n\n    @pytest.mark.redismod\n    def test_explain(self, client):\n        client.ft().create_index((TextField(\"f1\"), TextField(\"f2\"), TextField(\"f3\")))\n        res = client.ft().explain(\"@f3:f3_val @f2:f2_val @f1:f1_val\")\n        assert res\n\n    @pytest.mark.redismod\n    def test_explaincli(self, client):\n        with pytest.raises(NotImplementedError):\n            client.ft().explain_cli(\"foo\")\n\n    @pytest.mark.redismod\n    def test_summarize(self, client):\n        self.createIndex(client.ft())\n        self.waitForIndex(client, getattr(client.ft(), \"index_name\", \"idx\"))\n\n        q = Query('\"king henry\"').paging(0, 1)\n        q.highlight(fields=(\"play\", \"txt\"), tags=(\"<b>\", \"</b>\"))\n        q.summarize(\"txt\")\n\n        if is_resp2_connection(client):\n            doc = sorted(client.ft().search(q).docs)[0]\n            assert \"<b>Henry</b> IV\" == doc.play\n            assert (\n                \"ACT I SCENE I. London. The palace. Enter <b>KING</b> <b>HENRY</b>, LORD JOHN OF LANCASTER, the EARL of WESTMORELAND, SIR... \"  # noqa\n                == doc.txt\n            )\n\n            q = Query('\"king henry\"').paging(0, 1).summarize().highlight()\n\n            doc = sorted(client.ft().search(q).docs)[0]\n            assert \"<b>Henry</b> ... \" == doc.play\n            assert (\n                \"ACT I SCENE I. London. The palace. Enter <b>KING</b> <b>HENRY</b>, LORD JOHN OF LANCASTER, the EARL of WESTMORELAND, SIR... \"  # noqa\n                == doc.txt\n            )\n        else:\n            doc = sorted(client.ft().search(q)[\"results\"])[0]\n            assert \"<b>Henry</b> IV\" == doc[\"extra_attributes\"][\"play\"]\n            assert (\n                \"ACT I SCENE I. London. The palace. Enter <b>KING</b> <b>HENRY</b>, LORD JOHN OF LANCASTER, the EARL of WESTMORELAND, SIR... \"  # noqa\n                == doc[\"extra_attributes\"][\"txt\"]\n            )\n\n            q = Query('\"king henry\"').paging(0, 1).summarize().highlight()\n\n            doc = sorted(client.ft().search(q)[\"results\"])[0]\n            assert \"<b>Henry</b> ... \" == doc[\"extra_attributes\"][\"play\"]\n            assert (\n                \"ACT I SCENE I. London. The palace. Enter <b>KING</b> <b>HENRY</b>, LORD JOHN OF LANCASTER, the EARL of WESTMORELAND, SIR... \"  # noqa\n                == doc[\"extra_attributes\"][\"txt\"]\n            )\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.0.0\", \"search\")\n    def test_alias(self, client):\n        index1 = self.getClient(client)\n        index2 = self.getClient(client)\n\n        def1 = IndexDefinition(prefix=[\"index1:\"])\n        def2 = IndexDefinition(prefix=[\"index2:\"])\n\n        ftindex1 = index1.ft(\"testAlias\")\n        ftindex2 = index2.ft(\"testAlias2\")\n        ftindex1.create_index((TextField(\"name\"),), definition=def1)\n        ftindex2.create_index((TextField(\"name\"),), definition=def2)\n\n        index1.hset(\"index1:lonestar\", mapping={\"name\": \"lonestar\"})\n        index2.hset(\"index2:yogurt\", mapping={\"name\": \"yogurt\"})\n\n        if is_resp2_connection(client):\n            res = ftindex1.search(\"*\").docs[0]\n            assert \"index1:lonestar\" == res.id\n\n            # create alias and check for results\n            ftindex1.aliasadd(\"spaceballs\")\n            alias_client = self.getClient(client).ft(\"spaceballs\")\n            res = alias_client.search(\"*\").docs[0]\n            assert \"index1:lonestar\" == res.id\n\n            # Throw an exception when trying to add an alias that already exists\n            with pytest.raises(Exception):\n                ftindex2.aliasadd(\"spaceballs\")\n\n            # update alias and ensure new results\n            ftindex2.aliasupdate(\"spaceballs\")\n            alias_client2 = self.getClient(client).ft(\"spaceballs\")\n\n            res = alias_client2.search(\"*\").docs[0]\n            assert \"index2:yogurt\" == res.id\n        else:\n            res = ftindex1.search(\"*\")[\"results\"][0]\n            assert \"index1:lonestar\" == res[\"id\"]\n\n            # create alias and check for results\n            ftindex1.aliasadd(\"spaceballs\")\n            alias_client = self.getClient(client).ft(\"spaceballs\")\n            res = alias_client.search(\"*\")[\"results\"][0]\n            assert \"index1:lonestar\" == res[\"id\"]\n\n            # Throw an exception when trying to add an alias that already exists\n            with pytest.raises(Exception):\n                ftindex2.aliasadd(\"spaceballs\")\n\n            # update alias and ensure new results\n            ftindex2.aliasupdate(\"spaceballs\")\n            alias_client2 = self.getClient(client).ft(\"spaceballs\")\n\n            res = alias_client2.search(\"*\")[\"results\"][0]\n            assert \"index2:yogurt\" == res[\"id\"]\n\n        ftindex2.aliasdel(\"spaceballs\")\n        with pytest.raises(Exception):\n            alias_client2.search(\"*\").docs[0]\n\n    @pytest.mark.redismod\n    @pytest.mark.xfail(strict=False)\n    def test_alias_basic(self, client):\n        # Creating a client with one index\n        index1 = self.getClient(client).ft(\"testAlias\")\n\n        index1.create_index((TextField(\"txt\"),))\n        index1.client.hset(\"doc1\", mapping={\"txt\": \"text goes here\"})\n\n        index2 = self.getClient(client).ft(\"testAlias2\")\n        index2.create_index((TextField(\"txt\"),))\n        index2.client.hset(\"doc2\", mapping={\"txt\": \"text goes here\"})\n\n        # add the actual alias and check\n        index1.aliasadd(\"myalias\")\n        alias_client = self.getClient(client).ft(\"myalias\")\n        if is_resp2_connection(client):\n            res = sorted(alias_client.search(\"*\").docs, key=lambda x: x.id)\n            assert \"doc1\" == res[0].id\n\n            # Throw an exception when trying to add an alias that already exists\n            with pytest.raises(Exception):\n                index2.aliasadd(\"myalias\")\n\n            # update the alias and ensure we get doc2\n            index2.aliasupdate(\"myalias\")\n            alias_client2 = self.getClient(client).ft(\"myalias\")\n            res = sorted(alias_client2.search(\"*\").docs, key=lambda x: x.id)\n            assert \"doc1\" == res[0].id\n        else:\n            res = sorted(alias_client.search(\"*\")[\"results\"], key=lambda x: x[\"id\"])\n            assert \"doc1\" == res[0][\"id\"]\n\n            # Throw an exception when trying to add an alias that already exists\n            with pytest.raises(Exception):\n                index2.aliasadd(\"myalias\")\n\n            # update the alias and ensure we get doc2\n            index2.aliasupdate(\"myalias\")\n            alias_client2 = self.getClient(client).ft(\"myalias\")\n            res = sorted(alias_client2.search(\"*\")[\"results\"], key=lambda x: x[\"id\"])\n            assert \"doc1\" == res[0][\"id\"]\n\n        # delete the alias and expect an error if we try to query again\n        index2.aliasdel(\"myalias\")\n        with pytest.raises(Exception):\n            _ = alias_client2.search(\"*\").docs[0]\n\n    @pytest.mark.redismod\n    def test_textfield_sortable_nostem(self, client):\n        # Creating the index definition with sortable and no_stem\n        client.ft().create_index((TextField(\"txt\", sortable=True, no_stem=True),))\n\n        # Now get the index info to confirm its contents\n        response = client.ft().info()\n        if is_resp2_connection(client):\n            assert \"SORTABLE\" in response[\"attributes\"][0]\n            assert \"NOSTEM\" in response[\"attributes\"][0]\n        else:\n            assert \"SORTABLE\" in response[\"attributes\"][0][\"flags\"]\n            assert \"NOSTEM\" in response[\"attributes\"][0][\"flags\"]\n\n    @pytest.mark.redismod\n    def test_alter_schema_add(self, client):\n        # Creating the index definition and schema\n        client.ft().create_index(TextField(\"title\"))\n\n        # Using alter to add a field\n        client.ft().alter_schema_add(TextField(\"body\"))\n\n        # Indexing a document\n        client.hset(\n            \"doc1\",\n            mapping={\"title\": \"MyTitle\", \"body\": \"Some content only in the body\"},\n        )\n\n        # Searching with parameter only in the body (the added field)\n        q = Query(\"only in the body\")\n\n        # Ensure we find the result searching on the added body field\n        res = client.ft().search(q)\n        if is_resp2_connection(client):\n            assert 1 == res.total\n        else:\n            assert 1 == res[\"total_results\"]\n\n    @pytest.mark.redismod\n    def test_spell_check(self, client):\n        client.ft().create_index((TextField(\"f1\"), TextField(\"f2\")))\n\n        client.hset(\n            \"doc1\", mapping={\"f1\": \"some valid content\", \"f2\": \"this is sample text\"}\n        )\n        client.hset(\"doc2\", mapping={\"f1\": \"very important\", \"f2\": \"lorem ipsum\"})\n        self.waitForIndex(client, getattr(client.ft(), \"index_name\", \"idx\"))\n\n        if is_resp2_connection(client):\n            # test spellcheck\n            res = client.ft().spellcheck(\"impornant\")\n            assert \"important\" == res[\"impornant\"][0][\"suggestion\"]\n\n            res = client.ft().spellcheck(\"contnt\")\n            assert \"content\" == res[\"contnt\"][0][\"suggestion\"]\n\n            # test spellcheck with Levenshtein distance\n            res = client.ft().spellcheck(\"vlis\")\n            assert res == {}\n            res = client.ft().spellcheck(\"vlis\", distance=2)\n            assert \"valid\" == res[\"vlis\"][0][\"suggestion\"]\n\n            # test spellcheck include\n            client.ft().dict_add(\"dict\", \"lore\", \"lorem\", \"lorm\")\n            res = client.ft().spellcheck(\"lorm\", include=\"dict\")\n            assert len(res[\"lorm\"]) == 3\n            assert (\n                res[\"lorm\"][0][\"suggestion\"],\n                res[\"lorm\"][1][\"suggestion\"],\n                res[\"lorm\"][2][\"suggestion\"],\n            ) == (\"lorem\", \"lore\", \"lorm\")\n            assert (res[\"lorm\"][0][\"score\"], res[\"lorm\"][1][\"score\"]) == (\"0.5\", \"0\")\n\n            # test spellcheck exclude\n            res = client.ft().spellcheck(\"lorm\", exclude=\"dict\")\n            assert res == {}\n        else:\n            # test spellcheck\n            res = client.ft().spellcheck(\"impornant\")\n            assert \"important\" in res[\"results\"][\"impornant\"][0].keys()\n\n            res = client.ft().spellcheck(\"contnt\")\n            assert \"content\" in res[\"results\"][\"contnt\"][0].keys()\n\n            # test spellcheck with Levenshtein distance\n            res = client.ft().spellcheck(\"vlis\")\n            assert res == {\"results\": {\"vlis\": []}}\n            res = client.ft().spellcheck(\"vlis\", distance=2)\n            assert \"valid\" in res[\"results\"][\"vlis\"][0].keys()\n\n            # test spellcheck include\n            client.ft().dict_add(\"dict\", \"lore\", \"lorem\", \"lorm\")\n            res = client.ft().spellcheck(\"lorm\", include=\"dict\")\n            assert len(res[\"results\"][\"lorm\"]) == 3\n            assert \"lorem\" in res[\"results\"][\"lorm\"][0].keys()\n            assert \"lore\" in res[\"results\"][\"lorm\"][1].keys()\n            assert \"lorm\" in res[\"results\"][\"lorm\"][2].keys()\n            assert (\n                res[\"results\"][\"lorm\"][0][\"lorem\"],\n                res[\"results\"][\"lorm\"][1][\"lore\"],\n            ) == (0.5, 0)\n\n            # test spellcheck exclude\n            res = client.ft().spellcheck(\"lorm\", exclude=\"dict\")\n            assert res == {\"results\": {}}\n\n    @pytest.mark.redismod\n    def test_dict_operations(self, client):\n        client.ft().create_index((TextField(\"f1\"), TextField(\"f2\")))\n        # Add three items\n        res = client.ft().dict_add(\"custom_dict\", \"item1\", \"item2\", \"item3\")\n        assert 3 == res\n\n        # Remove one item\n        res = client.ft().dict_del(\"custom_dict\", \"item2\")\n        assert 1 == res\n\n        # Dump dict and inspect content\n        res = client.ft().dict_dump(\"custom_dict\")\n        assert res == [\"item1\", \"item3\"]\n\n        # Remove rest of the items before reload\n        client.ft().dict_del(\"custom_dict\", *res)\n\n    @pytest.mark.redismod\n    def test_phonetic_matcher(self, client):\n        client.ft().create_index((TextField(\"name\"),))\n        client.hset(\"doc1\", mapping={\"name\": \"Jon\"})\n        client.hset(\"doc2\", mapping={\"name\": \"John\"})\n\n        res = client.ft().search(Query(\"Jon\"))\n        if is_resp2_connection(client):\n            assert 1 == len(res.docs)\n            assert \"Jon\" == res.docs[0].name\n        else:\n            assert 1 == res[\"total_results\"]\n            assert \"Jon\" == res[\"results\"][0][\"extra_attributes\"][\"name\"]\n\n        # Drop and create index with phonetic matcher\n        client.flushdb()\n\n        client.ft().create_index((TextField(\"name\", phonetic_matcher=\"dm:en\"),))\n        client.hset(\"doc1\", mapping={\"name\": \"Jon\"})\n        client.hset(\"doc2\", mapping={\"name\": \"John\"})\n\n        res = client.ft().search(Query(\"Jon\"))\n        if is_resp2_connection(client):\n            assert 2 == len(res.docs)\n            assert [\"John\", \"Jon\"] == sorted(d.name for d in res.docs)\n        else:\n            assert 2 == res[\"total_results\"]\n            assert [\"John\", \"Jon\"] == sorted(\n                d[\"extra_attributes\"][\"name\"] for d in res[\"results\"]\n            )\n\n    @pytest.mark.redismod\n    def test_get(self, client):\n        client.ft().create_index((TextField(\"f1\"), TextField(\"f2\")))\n\n        assert [None] == client.ft().get(\"doc1\")\n        assert [None, None] == client.ft().get(\"doc2\", \"doc1\")\n\n        client.hset(\n            \"doc1\",\n            mapping={\"f1\": \"some valid content dd1\", \"f2\": \"this is sample text f1\"},\n        )\n        client.hset(\n            \"doc2\",\n            mapping={\"f1\": \"some valid content dd2\", \"f2\": \"this is sample text f2\"},\n        )\n\n        assert [\n            [\"f1\", \"some valid content dd2\", \"f2\", \"this is sample text f2\"]\n        ] == client.ft().get(\"doc2\")\n        assert [\n            [\"f1\", \"some valid content dd1\", \"f2\", \"this is sample text f1\"],\n            [\"f1\", \"some valid content dd2\", \"f2\", \"this is sample text f2\"],\n        ] == client.ft().get(\"doc1\", \"doc2\")\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.0.0\", \"search\")\n    def test_index_definition(self, client):\n        \"\"\"\n        Create definition and test its args\n        \"\"\"\n        with pytest.raises(RuntimeError):\n            IndexDefinition(prefix=[\"hset:\", \"henry\"], index_type=\"json\")\n\n        definition = IndexDefinition(\n            prefix=[\"hset:\", \"henry\"],\n            filter=\"@f1==32\",\n            language=\"English\",\n            language_field=\"play\",\n            score_field=\"chapter\",\n            score=0.5,\n            payload_field=\"txt\",\n            index_type=IndexType.JSON,\n        )\n\n        assert [\n            \"ON\",\n            \"JSON\",\n            \"PREFIX\",\n            2,\n            \"hset:\",\n            \"henry\",\n            \"FILTER\",\n            \"@f1==32\",\n            \"LANGUAGE_FIELD\",\n            \"play\",\n            \"LANGUAGE\",\n            \"English\",\n            \"SCORE_FIELD\",\n            \"chapter\",\n            \"SCORE\",\n            0.5,\n            \"PAYLOAD_FIELD\",\n            \"txt\",\n        ] == definition.args\n\n        self.createIndex(client.ft(), num_docs=500, definition=definition)\n\n    @pytest.mark.redismod\n    @pytest.mark.onlynoncluster\n    @skip_if_redis_enterprise()\n    @skip_if_server_version_gte(\"7.9.0\")\n    def test_expire(self, client):\n        client.ft().create_index((TextField(\"txt\", sortable=True),), temporary=4)\n        ttl = client.execute_command(\"ft.debug\", \"TTL\", \"idx\")\n        assert ttl > 2\n\n        while ttl > 2:\n            ttl = client.execute_command(\"ft.debug\", \"TTL\", \"idx\")\n            time.sleep(0.01)\n\n    @pytest.mark.redismod\n    def test_skip_initial_scan(self, client):\n        client.hset(\"doc1\", \"foo\", \"bar\")\n        q = Query(\"@foo:bar\")\n\n        client.ft().create_index((TextField(\"foo\"),), skip_initial_scan=True)\n        res = client.ft().search(q)\n        if is_resp2_connection(client):\n            assert res.total == 0\n        else:\n            assert res[\"total_results\"] == 0\n\n    @pytest.mark.redismod\n    def test_summarize_disabled_nooffset(self, client):\n        client.ft().create_index((TextField(\"txt\"),), no_term_offsets=True)\n        client.hset(\"doc1\", mapping={\"txt\": \"foo bar\"})\n        with pytest.raises(Exception):\n            client.ft().search(Query(\"foo\").summarize(fields=[\"txt\"]))\n\n    @pytest.mark.redismod\n    def test_summarize_disabled_nohl(self, client):\n        client.ft().create_index((TextField(\"txt\"),), no_highlight=True)\n        client.hset(\"doc1\", mapping={\"txt\": \"foo bar\"})\n        with pytest.raises(Exception):\n            client.ft().search(Query(\"foo\").summarize(fields=[\"txt\"]))\n\n    @pytest.mark.redismod\n    def test_max_text_fields(self, client):\n        # Creating the index definition\n        client.ft().create_index((TextField(\"f0\"),))\n        for x in range(1, 32):\n            client.ft().alter_schema_add((TextField(f\"f{x}\"),))\n\n        # Should be too many indexes\n        with pytest.raises(redis.ResponseError):\n            client.ft().alter_schema_add((TextField(f\"f{x}\"),))\n\n        client.ft().dropindex()\n        # Creating the index definition\n        client.ft().create_index((TextField(\"f0\"),), max_text_fields=True)\n        # Fill the index with fields\n        for x in range(1, 50):\n            client.ft().alter_schema_add((TextField(f\"f{x}\"),))\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.0.0\", \"search\")\n    def test_create_client_definition(self, client):\n        \"\"\"\n        Create definition with no index type provided,\n        and use hset to test the client definition (the default is HASH).\n        \"\"\"\n        definition = IndexDefinition(prefix=[\"hset:\", \"henry\"])\n        self.createIndex(client.ft(), num_docs=500, definition=definition)\n\n        info = client.ft().info()\n        assert 494 == int(info[\"num_docs\"])\n\n        client.ft().client.hset(\"hset:1\", \"f1\", \"v1\")\n        info = client.ft().info()\n        assert 495 == int(info[\"num_docs\"])\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.0.0\", \"search\")\n    def test_create_client_definition_hash(self, client):\n        \"\"\"\n        Create definition with IndexType.HASH as index type (ON HASH),\n        and use hset to test the client definition.\n        \"\"\"\n        definition = IndexDefinition(\n            prefix=[\"hset:\", \"henry\"], index_type=IndexType.HASH\n        )\n        self.createIndex(client.ft(), num_docs=500, definition=definition)\n\n        info = client.ft().info()\n        assert 494 == int(info[\"num_docs\"])\n\n        client.ft().client.hset(\"hset:1\", \"f1\", \"v1\")\n        info = client.ft().info()\n        assert 495 == int(info[\"num_docs\"])\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.2.0\", \"search\")\n    def test_create_client_definition_json(self, client):\n        \"\"\"\n        Create definition with IndexType.JSON as index type (ON JSON),\n        and use json client to test it.\n        \"\"\"\n        definition = IndexDefinition(prefix=[\"king:\"], index_type=IndexType.JSON)\n        client.ft().create_index((TextField(\"$.name\"),), definition=definition)\n\n        client.json().set(\"king:1\", Path.root_path(), {\"name\": \"henry\"})\n        client.json().set(\"king:2\", Path.root_path(), {\"name\": \"james\"})\n\n        res = client.ft().search(\"henry\")\n        if is_resp2_connection(client):\n            assert res.docs[0].id == \"king:1\"\n            assert res.docs[0].payload is None\n            assert res.docs[0].json == '{\"name\":\"henry\"}'\n            assert res.total == 1\n        else:\n            assert res[\"results\"][0][\"id\"] == \"king:1\"\n            assert res[\"results\"][0][\"extra_attributes\"][\"$\"] == '{\"name\":\"henry\"}'\n            assert res[\"total_results\"] == 1\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.2.0\", \"search\")\n    def test_fields_as_name(self, client):\n        # create index\n        SCHEMA = (\n            TextField(\"$.name\", sortable=True, as_name=\"name\"),\n            NumericField(\"$.age\", as_name=\"just_a_number\"),\n        )\n        definition = IndexDefinition(index_type=IndexType.JSON)\n        client.ft().create_index(SCHEMA, definition=definition)\n\n        # insert json data\n        res = client.json().set(\"doc:1\", Path.root_path(), {\"name\": \"Jon\", \"age\": 25})\n        assert res\n\n        res = client.ft().search(Query(\"Jon\").return_fields(\"name\", \"just_a_number\"))\n        if is_resp2_connection(client):\n            assert 1 == len(res.docs)\n            assert \"doc:1\" == res.docs[0].id\n            assert \"Jon\" == res.docs[0].name\n            assert \"25\" == res.docs[0].just_a_number\n        else:\n            assert 1 == len(res[\"results\"])\n            assert \"doc:1\" == res[\"results\"][0][\"id\"]\n            assert \"Jon\" == res[\"results\"][0][\"extra_attributes\"][\"name\"]\n            assert \"25\" == res[\"results\"][0][\"extra_attributes\"][\"just_a_number\"]\n\n    @pytest.mark.redismod\n    def test_casesensitive(self, client):\n        # create index\n        SCHEMA = (TagField(\"t\", case_sensitive=False),)\n        client.ft().create_index(SCHEMA)\n        client.ft().client.hset(\"1\", \"t\", \"HELLO\")\n        client.ft().client.hset(\"2\", \"t\", \"hello\")\n\n        res = client.ft().search(\"@t:{HELLO}\")\n\n        if is_resp2_connection(client):\n            assert 2 == len(res.docs)\n            assert \"1\" == res.docs[0].id\n            assert \"2\" == res.docs[1].id\n        else:\n            assert 2 == len(res[\"results\"])\n            assert \"1\" == res[\"results\"][0][\"id\"]\n            assert \"2\" == res[\"results\"][1][\"id\"]\n\n        # create casesensitive index\n        client.ft().dropindex()\n        SCHEMA = (TagField(\"t\", case_sensitive=True),)\n        client.ft().create_index(SCHEMA)\n        self.waitForIndex(client, getattr(client.ft(), \"index_name\", \"idx\"))\n\n        res = client.ft().search(\"@t:{HELLO}\")\n        if is_resp2_connection(client):\n            assert 1 == len(res.docs)\n            assert \"1\" == res.docs[0].id\n        else:\n            assert 1 == len(res[\"results\"])\n            assert \"1\" == res[\"results\"][0][\"id\"]\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.2.0\", \"search\")\n    def test_search_return_fields(self, client):\n        res = client.json().set(\n            \"doc:1\",\n            Path.root_path(),\n            {\"t\": \"riceratops\", \"t2\": \"telmatosaurus\", \"n\": 9072, \"flt\": 97.2},\n        )\n        assert res\n\n        # create index on\n        definition = IndexDefinition(index_type=IndexType.JSON)\n        SCHEMA = (TextField(\"$.t\"), NumericField(\"$.flt\"))\n        client.ft().create_index(SCHEMA, definition=definition)\n        self.waitForIndex(client, getattr(client.ft(), \"index_name\", \"idx\"))\n\n        if is_resp2_connection(client):\n            total = (\n                client.ft().search(Query(\"*\").return_field(\"$.t\", as_field=\"txt\")).docs\n            )\n            assert 1 == len(total)\n            assert \"doc:1\" == total[0].id\n            assert \"riceratops\" == total[0].txt\n\n            total = (\n                client.ft().search(Query(\"*\").return_field(\"$.t2\", as_field=\"txt\")).docs\n            )\n            assert 1 == len(total)\n            assert \"doc:1\" == total[0].id\n            assert \"telmatosaurus\" == total[0].txt\n        else:\n            total = client.ft().search(Query(\"*\").return_field(\"$.t\", as_field=\"txt\"))\n            assert 1 == len(total[\"results\"])\n            assert \"doc:1\" == total[\"results\"][0][\"id\"]\n            assert \"riceratops\" == total[\"results\"][0][\"extra_attributes\"][\"txt\"]\n\n            total = client.ft().search(Query(\"*\").return_field(\"$.t2\", as_field=\"txt\"))\n            assert 1 == len(total[\"results\"])\n            assert \"doc:1\" == total[\"results\"][0][\"id\"]\n            assert \"telmatosaurus\" == total[\"results\"][0][\"extra_attributes\"][\"txt\"]\n\n    @pytest.mark.redismod\n    @skip_if_resp_version(3)\n    def test_binary_and_text_fields(self, client):\n        fake_vec = np.array([0.1, 0.2, 0.3, 0.4], dtype=np.float32)\n\n        index_name = \"mixed_index\"\n        mixed_data = {\"first_name\": \"🐍python\", \"vector_emb\": fake_vec.tobytes()}\n        client.hset(f\"{index_name}:1\", mapping=mixed_data)\n\n        schema = (\n            TagField(\"first_name\"),\n            VectorField(\n                \"embeddings_bio\",\n                algorithm=\"HNSW\",\n                attributes={\n                    \"TYPE\": \"FLOAT32\",\n                    \"DIM\": 4,\n                    \"DISTANCE_METRIC\": \"COSINE\",\n                },\n            ),\n        )\n\n        client.ft(index_name).create_index(\n            fields=schema,\n            definition=IndexDefinition(\n                prefix=[f\"{index_name}:\"], index_type=IndexType.HASH\n            ),\n        )\n\n        self.waitForIndex(client, index_name)\n\n        query = (\n            Query(\"*\")\n            .return_field(\"vector_emb\", decode_field=False)\n            .return_field(\"first_name\")\n        )\n        docs = client.ft(index_name).search(query=query, query_params={}).docs\n        decoded_vec_from_search_results = np.frombuffer(\n            docs[0][\"vector_emb\"], dtype=np.float32\n        )\n\n        assert np.array_equal(decoded_vec_from_search_results, fake_vec), (\n            \"The vectors are not equal\"\n        )\n\n        assert docs[0][\"first_name\"] == mixed_data[\"first_name\"], (\n            \"The text field is not decoded correctly\"\n        )\n\n    @pytest.mark.redismod\n    def test_synupdate(self, client):\n        definition = IndexDefinition(index_type=IndexType.HASH)\n        client.ft().create_index(\n            (TextField(\"title\"), TextField(\"body\")), definition=definition\n        )\n\n        client.ft().synupdate(\"id1\", True, \"boy\", \"child\", \"offspring\")\n        client.hset(\"doc1\", mapping={\"title\": \"he is a baby\", \"body\": \"this is a test\"})\n\n        client.ft().synupdate(\"id1\", True, \"baby\")\n        client.hset(\n            \"doc2\", mapping={\"title\": \"he is another baby\", \"body\": \"another test\"}\n        )\n\n        res = client.ft().search(Query(\"child\").expander(\"SYNONYM\"))\n        if is_resp2_connection(client):\n            assert res.docs[0].id == \"doc2\"\n            assert res.docs[0].title == \"he is another baby\"\n            assert res.docs[0].body == \"another test\"\n        else:\n            assert res[\"results\"][0][\"id\"] == \"doc2\"\n            assert (\n                res[\"results\"][0][\"extra_attributes\"][\"title\"] == \"he is another baby\"\n            )\n            assert res[\"results\"][0][\"extra_attributes\"][\"body\"] == \"another test\"\n\n    @pytest.mark.redismod\n    def test_syndump(self, client):\n        definition = IndexDefinition(index_type=IndexType.HASH)\n        client.ft().create_index(\n            (TextField(\"title\"), TextField(\"body\")), definition=definition\n        )\n\n        client.ft().synupdate(\"id1\", False, \"boy\", \"child\", \"offspring\")\n        client.ft().synupdate(\"id2\", False, \"baby\", \"child\")\n        client.ft().synupdate(\"id3\", False, \"tree\", \"wood\")\n        res = client.ft().syndump()\n        assert res == {\n            \"boy\": [\"id1\"],\n            \"tree\": [\"id3\"],\n            \"wood\": [\"id3\"],\n            \"child\": [\"id1\", \"id2\"],\n            \"baby\": [\"id2\"],\n            \"offspring\": [\"id1\"],\n        }\n\n    @pytest.mark.redismod\n    def test_expire_while_search(self, client: redis.Redis):\n        client.ft().create_index((TextField(\"txt\"),))\n        client.hset(\"hset:1\", \"txt\", \"a\")\n        client.hset(\"hset:2\", \"txt\", \"b\")\n        client.hset(\"hset:3\", \"txt\", \"c\")\n        if is_resp2_connection(client):\n            assert 3 == client.ft().search(Query(\"*\")).total\n            client.pexpire(\"hset:2\", 300)\n            for _ in range(500):\n                client.ft().search(Query(\"*\")).docs[1]\n            time.sleep(1)\n            assert 2 == client.ft().search(Query(\"*\")).total\n        else:\n            assert 3 == client.ft().search(Query(\"*\"))[\"total_results\"]\n            client.pexpire(\"hset:2\", 300)\n            for _ in range(500):\n                client.ft().search(Query(\"*\"))[\"results\"][1]\n            time.sleep(1)\n            assert 2 == client.ft().search(Query(\"*\"))[\"total_results\"]\n\n    @pytest.mark.redismod\n    @pytest.mark.experimental\n    def test_withsuffixtrie(self, client: redis.Redis):\n        # create index\n        assert client.ft().create_index((TextField(\"txt\"),))\n        self.waitForIndex(client, getattr(client.ft(), \"index_name\", \"idx\"))\n        if is_resp2_connection(client):\n            info = client.ft().info()\n            assert \"WITHSUFFIXTRIE\" not in info[\"attributes\"][0]\n            assert client.ft().dropindex()\n\n            # create withsuffixtrie index (text fields)\n            assert client.ft().create_index(TextField(\"t\", withsuffixtrie=True))\n            self.waitForIndex(client, getattr(client.ft(), \"index_name\", \"idx\"))\n            info = client.ft().info()\n            assert \"WITHSUFFIXTRIE\" in info[\"attributes\"][0]\n            assert client.ft().dropindex()\n\n            # create withsuffixtrie index (tag field)\n            assert client.ft().create_index(TagField(\"t\", withsuffixtrie=True))\n            self.waitForIndex(client, getattr(client.ft(), \"index_name\", \"idx\"))\n            info = client.ft().info()\n            assert \"WITHSUFFIXTRIE\" in info[\"attributes\"][0]\n        else:\n            info = client.ft().info()\n            assert \"WITHSUFFIXTRIE\" not in info[\"attributes\"][0][\"flags\"]\n            assert client.ft().dropindex()\n\n            # create withsuffixtrie index (text fields)\n            assert client.ft().create_index(TextField(\"t\", withsuffixtrie=True))\n            self.waitForIndex(client, getattr(client.ft(), \"index_name\", \"idx\"))\n            info = client.ft().info()\n            assert \"WITHSUFFIXTRIE\" in info[\"attributes\"][0][\"flags\"]\n            assert client.ft().dropindex()\n\n            # create withsuffixtrie index (tag field)\n            assert client.ft().create_index(TagField(\"t\", withsuffixtrie=True))\n            self.waitForIndex(client, getattr(client.ft(), \"index_name\", \"idx\"))\n            info = client.ft().info()\n            assert \"WITHSUFFIXTRIE\" in info[\"attributes\"][0][\"flags\"]\n\n    @pytest.mark.redismod\n    def test_query_timeout(self, r: redis.Redis):\n        q1 = Query(\"foo\").timeout(5000)\n        assert q1.get_args() == [\"foo\", \"TIMEOUT\", 5000, \"DIALECT\", 2, \"LIMIT\", 0, 10]\n        q1 = Query(\"foo\").timeout(0)\n        assert q1.get_args() == [\"foo\", \"TIMEOUT\", 0, \"DIALECT\", 2, \"LIMIT\", 0, 10]\n        q2 = Query(\"foo\").timeout(\"not_a_number\")\n        with pytest.raises(redis.ResponseError):\n            r.ft().search(q2)\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"7.2.0\")\n    @skip_ifmodversion_lt(\"2.8.4\", \"search\")\n    def test_geoshape(self, client: redis.Redis):\n        client.ft().create_index(GeoShapeField(\"geom\", GeoShapeField.FLAT))\n        self.waitForIndex(client, getattr(client.ft(), \"index_name\", \"idx\"))\n        client.hset(\"small\", \"geom\", \"POLYGON((1 1, 1 100, 100 100, 100 1, 1 1))\")\n        client.hset(\"large\", \"geom\", \"POLYGON((1 1, 1 200, 200 200, 200 1, 1 1))\")\n        q1 = Query(\"@geom:[WITHIN $poly]\").dialect(3)\n        qp1 = {\"poly\": \"POLYGON((0 0, 0 150, 150 150, 150 0, 0 0))\"}\n        q2 = Query(\"@geom:[CONTAINS $poly]\").dialect(3)\n        qp2 = {\"poly\": \"POLYGON((2 2, 2 50, 50 50, 50 2, 2 2))\"}\n        result = client.ft().search(q1, query_params=qp1)\n        _assert_search_result(client, result, [\"small\"])\n        result = client.ft().search(q2, query_params=qp2)\n        _assert_search_result(client, result, [\"small\", \"large\"])\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"7.4.0\")\n    @skip_ifmodversion_lt(\"2.10.0\", \"search\")\n    def test_search_missing_fields(self, client):\n        definition = IndexDefinition(prefix=[\"property:\"], index_type=IndexType.HASH)\n\n        fields = [\n            TextField(\"title\", sortable=True),\n            TagField(\"features\", index_missing=True),\n            TextField(\"description\", index_missing=True),\n        ]\n\n        client.ft().create_index(fields, definition=definition)\n\n        # All fields present\n        client.hset(\n            \"property:1\",\n            mapping={\n                \"title\": \"Luxury Villa in Malibu\",\n                \"features\": \"pool,sea view,modern\",\n                \"description\": \"A stunning modern villa overlooking the Pacific Ocean.\",\n            },\n        )\n\n        # Missing features\n        client.hset(\n            \"property:2\",\n            mapping={\n                \"title\": \"Downtown Flat\",\n                \"description\": \"Modern flat in central Paris with easy access to metro.\",\n            },\n        )\n\n        # Missing description\n        client.hset(\n            \"property:3\",\n            mapping={\n                \"title\": \"Beachfront Bungalow\",\n                \"features\": \"beachfront,sun deck\",\n            },\n        )\n\n        with pytest.raises(redis.exceptions.ResponseError):\n            client.ft().search(\n                Query(\"ismissing(@title)\").return_field(\"id\").no_content()\n            )\n\n        res = client.ft().search(\n            Query(\"ismissing(@features)\").return_field(\"id\").no_content()\n        )\n        _assert_search_result(client, res, [\"property:2\"])\n\n        res = client.ft().search(\n            Query(\"-ismissing(@features)\").return_field(\"id\").no_content()\n        )\n        _assert_search_result(client, res, [\"property:1\", \"property:3\"])\n\n        res = client.ft().search(\n            Query(\"ismissing(@description)\").return_field(\"id\").no_content()\n        )\n        _assert_search_result(client, res, [\"property:3\"])\n\n        res = client.ft().search(\n            Query(\"-ismissing(@description)\").return_field(\"id\").no_content()\n        )\n        _assert_search_result(client, res, [\"property:1\", \"property:2\"])\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"7.4.0\")\n    @skip_ifmodversion_lt(\"2.10.0\", \"search\")\n    def test_create_index_empty_or_missing_fields_with_sortable(self, client):\n        definition = IndexDefinition(prefix=[\"property:\"], index_type=IndexType.HASH)\n\n        fields = [\n            TextField(\"title\", sortable=True, index_empty=True),\n            TagField(\"features\", index_missing=True, sortable=True),\n            TextField(\"description\", no_index=True, sortable=True),\n        ]\n\n        client.ft().create_index(fields, definition=definition)\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"7.4.0\")\n    @skip_ifmodversion_lt(\"2.10.0\", \"search\")\n    def test_search_empty_fields(self, client):\n        definition = IndexDefinition(prefix=[\"property:\"], index_type=IndexType.HASH)\n\n        fields = [\n            TextField(\"title\", sortable=True),\n            TagField(\"features\", index_empty=True),\n            TextField(\"description\", index_empty=True),\n        ]\n\n        client.ft().create_index(fields, definition=definition)\n\n        # All fields present\n        client.hset(\n            \"property:1\",\n            mapping={\n                \"title\": \"Luxury Villa in Malibu\",\n                \"features\": \"pool,sea view,modern\",\n                \"description\": \"A stunning modern villa overlooking the Pacific Ocean.\",\n            },\n        )\n\n        # Empty features\n        client.hset(\n            \"property:2\",\n            mapping={\n                \"title\": \"Downtown Flat\",\n                \"features\": \"\",\n                \"description\": \"Modern flat in central Paris with easy access to metro.\",\n            },\n        )\n\n        # Empty description\n        client.hset(\n            \"property:3\",\n            mapping={\n                \"title\": \"Beachfront Bungalow\",\n                \"features\": \"beachfront,sun deck\",\n                \"description\": \"\",\n            },\n        )\n\n        with pytest.raises(redis.exceptions.ResponseError) as e:\n            client.ft().search(Query(\"@title:''\").return_field(\"id\").no_content())\n        assert \"Use `INDEXEMPTY` in field creation\" in e.value.args[0]\n\n        res = client.ft().search(\n            Query(\"@features:{$empty}\").return_field(\"id\").no_content(),\n            query_params={\"empty\": \"\"},\n        )\n        _assert_search_result(client, res, [\"property:2\"])\n\n        res = client.ft().search(\n            Query(\"-@features:{$empty}\").return_field(\"id\").no_content(),\n            query_params={\"empty\": \"\"},\n        )\n        _assert_search_result(client, res, [\"property:1\", \"property:3\"])\n\n        res = client.ft().search(\n            Query(\"@description:''\").return_field(\"id\").no_content()\n        )\n        _assert_search_result(client, res, [\"property:3\"])\n\n        res = client.ft().search(\n            Query(\"-@description:''\").return_field(\"id\").no_content()\n        )\n        _assert_search_result(client, res, [\"property:1\", \"property:2\"])\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"7.4.0\")\n    @skip_ifmodversion_lt(\"2.10.0\", \"search\")\n    def test_special_characters_in_fields(self, client):\n        definition = IndexDefinition(prefix=[\"resource:\"], index_type=IndexType.HASH)\n\n        fields = [\n            TagField(\"uuid\"),\n            TagField(\"tags\", separator=\"|\"),\n            TextField(\"description\"),\n            NumericField(\"rating\"),\n        ]\n\n        client.ft().create_index(fields, definition=definition)\n\n        client.hset(\n            \"resource:1\",\n            mapping={\n                \"uuid\": \"123e4567-e89b-12d3-a456-426614174000\",\n                \"tags\": \"finance|crypto|$btc|blockchain\",\n                \"description\": \"Analysis of blockchain technologies & Bitcoin's potential.\",\n                \"rating\": 5,\n            },\n        )\n\n        client.hset(\n            \"resource:2\",\n            mapping={\n                \"uuid\": \"987e6543-e21c-12d3-a456-426614174999\",\n                \"tags\": \"health|well-being|fitness|new-year's-resolutions\",\n                \"description\": \"Health trends for the new year, including fitness regimes.\",\n                \"rating\": 4,\n            },\n        )\n\n        # no need to escape - when using params\n        res = client.ft().search(\n            Query(\"@uuid:{$uuid}\"),\n            query_params={\"uuid\": \"123e4567-e89b-12d3-a456-426614174000\"},\n        )\n        _assert_search_result(client, res, [\"resource:1\"])\n\n        # with double quotes exact match no need to escape the - even without params\n        res = client.ft().search(\n            Query('@uuid:{\"123e4567-e89b-12d3-a456-426614174000\"}')\n        )\n        _assert_search_result(client, res, [\"resource:1\"])\n\n        res = client.ft().search(Query('@tags:{\"new-year\\'s-resolutions\"}'))\n        _assert_search_result(client, res, [\"resource:2\"])\n\n        # possible to search numeric fields by single value\n        res = client.ft().search(Query(\"@rating:[4]\"))\n        _assert_search_result(client, res, [\"resource:2\"])\n\n        # some chars still need escaping\n        res = client.ft().search(Query(r\"@tags:{\\$btc}\"))\n        _assert_search_result(client, res, [\"resource:1\"])\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.4.3\", \"search\")\n    def test_vector_search_with_default_dialect(self, client):\n        client.ft().create_index(\n            (\n                VectorField(\n                    \"v\", \"HNSW\", {\"TYPE\": \"FLOAT32\", \"DIM\": 2, \"DISTANCE_METRIC\": \"L2\"}\n                ),\n            )\n        )\n\n        client.hset(\"a\", \"v\", \"aaaaaaaa\")\n        client.hset(\"b\", \"v\", \"aaaabaaa\")\n        client.hset(\"c\", \"v\", \"aaaaabaa\")\n\n        query = \"*=>[KNN 2 @v $vec]\"\n        q = Query(query)\n\n        assert \"DIALECT\" in q.get_args()\n        assert 2 in q.get_args()\n\n        res = client.ft().search(q, query_params={\"vec\": \"aaaaaaaa\"})\n        if is_resp2_connection(client):\n            assert res.total == 2\n        else:\n            assert res[\"total_results\"] == 2\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.4.3\", \"search\")\n    def test_search_query_with_different_dialects(self, client):\n        client.ft().create_index(\n            (TextField(\"name\"), TextField(\"lastname\")),\n            definition=IndexDefinition(prefix=[\"test:\"]),\n        )\n\n        client.hset(\"test:1\", \"name\", \"James\")\n        client.hset(\"test:1\", \"lastname\", \"Brown\")\n\n        # Query with default DIALECT 2\n        query = \"@name: James Brown\"\n        q = Query(query)\n        res = client.ft().search(q)\n        if is_resp2_connection(client):\n            assert res.total == 1\n        else:\n            assert res[\"total_results\"] == 1\n\n        # Query with explicit DIALECT 1\n        query = \"@name: James Brown\"\n        q = Query(query).dialect(1)\n        res = client.ft().search(q)\n        if is_resp2_connection(client):\n            assert res.total == 0\n        else:\n            assert res[\"total_results\"] == 0\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"7.9.0\")\n    def test_info_exposes_search_info(self, client):\n        assert len(client.info(\"search\")) > 0\n\n\nclass TestScorers(SearchTestsBase):\n    @pytest.mark.redismod\n    @pytest.mark.onlynoncluster\n    # NOTE(imalinovskyi): This test contains hardcoded scores valid only for RediSearch 2.8+\n    @skip_ifmodversion_lt(\"2.8.0\", \"search\")\n    @skip_if_server_version_gte(\"7.9.0\")\n    def test_scorer(self, client):\n        client.ft().create_index((TextField(\"description\"),))\n\n        client.hset(\n            \"doc1\",\n            mapping={\"description\": \"The quick brown fox jumps over the lazy dog\"},\n        )\n        client.hset(\n            \"doc2\",\n            mapping={\n                \"description\": \"Quick alice was beginning to get very tired of sitting by her quick sister on the bank, and of having nothing to do.\"  # noqa\n            },\n        )\n\n        # default scorer is TFIDF\n        if is_resp2_connection(client):\n            res = client.ft().search(Query(\"quick\").with_scores())\n            assert 1.0 == res.docs[0].score\n            res = client.ft().search(Query(\"quick\").scorer(\"TFIDF\").with_scores())\n            assert 1.0 == res.docs[0].score\n            res = client.ft().search(\n                Query(\"quick\").scorer(\"TFIDF.DOCNORM\").with_scores()\n            )\n            assert 0.14285714285714285 == res.docs[0].score\n            res = client.ft().search(Query(\"quick\").scorer(\"BM25\").with_scores())\n            assert 0.22471909420069797 == res.docs[0].score\n            res = client.ft().search(Query(\"quick\").scorer(\"DISMAX\").with_scores())\n            assert 2.0 == res.docs[0].score\n            res = client.ft().search(Query(\"quick\").scorer(\"DOCSCORE\").with_scores())\n            assert 1.0 == res.docs[0].score\n            res = client.ft().search(Query(\"quick\").scorer(\"HAMMING\").with_scores())\n            assert 0.0 == res.docs[0].score\n        else:\n            res = client.ft().search(Query(\"quick\").with_scores())\n            assert 1.0 == res[\"results\"][0][\"score\"]\n            res = client.ft().search(Query(\"quick\").scorer(\"TFIDF\").with_scores())\n            assert 1.0 == res[\"results\"][0][\"score\"]\n            res = client.ft().search(\n                Query(\"quick\").scorer(\"TFIDF.DOCNORM\").with_scores()\n            )\n            assert 0.14285714285714285 == res[\"results\"][0][\"score\"]\n            res = client.ft().search(Query(\"quick\").scorer(\"BM25\").with_scores())\n            assert 0.22471909420069797 == res[\"results\"][0][\"score\"]\n            res = client.ft().search(Query(\"quick\").scorer(\"DISMAX\").with_scores())\n            assert 2.0 == res[\"results\"][0][\"score\"]\n            res = client.ft().search(Query(\"quick\").scorer(\"DOCSCORE\").with_scores())\n            assert 1.0 == res[\"results\"][0][\"score\"]\n            res = client.ft().search(Query(\"quick\").scorer(\"HAMMING\").with_scores())\n            assert 0.0 == res[\"results\"][0][\"score\"]\n\n    @pytest.mark.redismod\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"7.9.0\")\n    def test_scorer_with_new_default_scorer(self, client):\n        client.ft().create_index((TextField(\"description\"),))\n\n        client.hset(\n            \"doc1\",\n            mapping={\"description\": \"The quick brown fox jumps over the lazy dog\"},\n        )\n        client.hset(\n            \"doc2\",\n            mapping={\n                \"description\": \"Quick alice was beginning to get very tired of sitting by her quick sister on the bank, and of having nothing to do.\"  # noqa\n            },\n        )\n\n        # default scorer is BM25STD\n        if is_resp2_connection(client):\n            res = client.ft().search(Query(\"quick\").with_scores())\n            assert 0.23 == pytest.approx(res.docs[0].score, 0.05)\n            res = client.ft().search(Query(\"quick\").scorer(\"TFIDF\").with_scores())\n            assert 1.0 == res.docs[0].score\n            res = client.ft().search(\n                Query(\"quick\").scorer(\"TFIDF.DOCNORM\").with_scores()\n            )\n            assert 0.14285714285714285 == res.docs[0].score\n            res = client.ft().search(Query(\"quick\").scorer(\"BM25\").with_scores())\n            assert 0.22471909420069797 == res.docs[0].score\n            res = client.ft().search(Query(\"quick\").scorer(\"DISMAX\").with_scores())\n            assert 2.0 == res.docs[0].score\n            res = client.ft().search(Query(\"quick\").scorer(\"DOCSCORE\").with_scores())\n            assert 1.0 == res.docs[0].score\n            res = client.ft().search(Query(\"quick\").scorer(\"HAMMING\").with_scores())\n            assert 0.0 == res.docs[0].score\n        else:\n            res = client.ft().search(Query(\"quick\").with_scores())\n            assert 0.23 == pytest.approx(res[\"results\"][0][\"score\"], 0.05)\n            res = client.ft().search(Query(\"quick\").scorer(\"TFIDF\").with_scores())\n            assert 1.0 == res[\"results\"][0][\"score\"]\n            res = client.ft().search(\n                Query(\"quick\").scorer(\"TFIDF.DOCNORM\").with_scores()\n            )\n            assert 0.14285714285714285 == res[\"results\"][0][\"score\"]\n            res = client.ft().search(Query(\"quick\").scorer(\"BM25\").with_scores())\n            assert 0.22471909420069797 == res[\"results\"][0][\"score\"]\n            res = client.ft().search(Query(\"quick\").scorer(\"DISMAX\").with_scores())\n            assert 2.0 == res[\"results\"][0][\"score\"]\n            res = client.ft().search(Query(\"quick\").scorer(\"DOCSCORE\").with_scores())\n            assert 1.0 == res[\"results\"][0][\"score\"]\n            res = client.ft().search(Query(\"quick\").scorer(\"HAMMING\").with_scores())\n            assert 0.0 == res[\"results\"][0][\"score\"]\n\n\nclass TestConfig(SearchTestsBase):\n    @pytest.mark.redismod\n    @pytest.mark.onlynoncluster\n    @skip_ifmodversion_lt(\"2.2.0\", \"search\")\n    @skip_if_server_version_gte(\"7.9.0\")\n    def test_config(self, client):\n        assert client.ft().config_set(\"TIMEOUT\", \"100\")\n        with pytest.raises(redis.ResponseError):\n            client.ft().config_set(\"TIMEOUT\", \"null\")\n        res = client.ft().config_get(\"*\")\n        assert \"100\" == res[\"TIMEOUT\"]\n        res = client.ft().config_get(\"TIMEOUT\")\n        assert \"100\" == res[\"TIMEOUT\"]\n\n    @pytest.mark.redismod\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_lt(\"7.9.0\")\n    def test_config_with_removed_ftconfig(self, client):\n        assert client.config_set(\"timeout\", \"100\")\n        with pytest.raises(redis.ResponseError):\n            client.config_set(\"timeout\", \"null\")\n        res = client.config_get(\"*\")\n        assert \"100\" == res[\"timeout\"]\n        res = client.config_get(\"timeout\")\n        assert \"100\" == res[\"timeout\"]\n\n    @pytest.mark.redismod\n    @pytest.mark.onlynoncluster\n    @skip_ifmodversion_lt(\"2.4.3\", \"search\")\n    def test_dialect_config(self, client):\n        assert client.ft().config_get(\"DEFAULT_DIALECT\")\n        client.ft().config_set(\"DEFAULT_DIALECT\", 2)\n        assert client.ft().config_get(\"DEFAULT_DIALECT\") == {\"DEFAULT_DIALECT\": \"2\"}\n        with pytest.raises(redis.ResponseError):\n            client.ft().config_set(\"DEFAULT_DIALECT\", 0)\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.4.3\", \"search\")\n    def test_dialect(self, client):\n        client.ft().create_index(\n            (\n                TagField(\"title\"),\n                TextField(\"t1\"),\n                TextField(\"t2\"),\n                NumericField(\"num\"),\n                VectorField(\n                    \"v\",\n                    \"HNSW\",\n                    {\"TYPE\": \"FLOAT32\", \"DIM\": 1, \"DISTANCE_METRIC\": \"COSINE\"},\n                ),\n            )\n        )\n        client.hset(\"h\", \"t1\", \"hello\")\n        with pytest.raises(redis.ResponseError) as err:\n            client.ft().explain(Query(\"(*)\").dialect(1))\n        assert \"Syntax error\" in str(err.value)\n        assert \"WILDCARD\" in client.ft().explain(Query(\"(*)\"))\n\n        with pytest.raises(redis.ResponseError) as err:\n            client.ft().explain(Query(\"$hello\").dialect(1))\n        assert \"Syntax error\" in str(err.value)\n        q = Query(\"$hello\")\n        expected = \"UNION {\\n  hello\\n  +hello(expanded)\\n}\\n\"\n        assert expected in client.ft().explain(q, query_params={\"hello\": \"hello\"})\n\n        expected = \"NUMERIC {0.000000 <= @num <= 10.000000}\\n\"\n        assert expected in client.ft().explain(Query(\"@title:(@num:[0 10])\").dialect(1))\n        with pytest.raises(redis.ResponseError) as err:\n            client.ft().explain(Query(\"@title:(@num:[0 10])\"))\n        assert \"Syntax error\" in str(err.value)\n\n\nclass TestAggregations(SearchTestsBase):\n    @pytest.mark.redismod\n    @pytest.mark.onlynoncluster\n    def test_aggregations_groupby(self, client):\n        # Creating the index definition and schema\n        client.ft().create_index(\n            (\n                NumericField(\"random_num\"),\n                TextField(\"title\"),\n                TextField(\"body\"),\n                TextField(\"parent\"),\n            )\n        )\n\n        # Indexing a document\n        client.hset(\n            \"search\",\n            mapping={\n                \"title\": \"RediSearch\",\n                \"body\": \"Redisearch impements a search engine on top of redis\",\n                \"parent\": \"redis\",\n                \"random_num\": 10,\n            },\n        )\n        client.hset(\n            \"ai\",\n            mapping={\n                \"title\": \"RedisAI\",\n                \"body\": \"RedisAI executes Deep Learning/Machine Learning models and managing their data.\",  # noqa\n                \"parent\": \"redis\",\n                \"random_num\": 3,\n            },\n        )\n        client.hset(\n            \"json\",\n            mapping={\n                \"title\": \"RedisJson\",\n                \"body\": \"RedisJSON implements ECMA-404 The JSON Data Interchange Standard as a native data type.\",  # noqa\n                \"parent\": \"redis\",\n                \"random_num\": 8,\n            },\n        )\n\n        if is_resp2_connection(client):\n            req = aggregations.AggregateRequest(\"redis\").group_by(\n                \"@parent\", reducers.count()\n            )\n\n            res = client.ft().aggregate(req).rows[0]\n            assert res[1] == \"redis\"\n            assert res[3] == \"3\"\n\n            req = aggregations.AggregateRequest(\"redis\").group_by(\n                \"@parent\", reducers.count_distinct(\"@title\")\n            )\n\n            res = client.ft().aggregate(req).rows[0]\n            assert res[1] == \"redis\"\n            assert res[3] == \"3\"\n\n            req = aggregations.AggregateRequest(\"redis\").group_by(\n                \"@parent\", reducers.count_distinctish(\"@title\")\n            )\n\n            res = client.ft().aggregate(req).rows[0]\n            assert res[1] == \"redis\"\n            assert res[3] == \"3\"\n\n            req = aggregations.AggregateRequest(\"redis\").group_by(\n                \"@parent\", reducers.sum(\"@random_num\")\n            )\n\n            res = client.ft().aggregate(req).rows[0]\n            assert res[1] == \"redis\"\n            assert res[3] == \"21\"  # 10+8+3\n\n            req = aggregations.AggregateRequest(\"redis\").group_by(\n                \"@parent\", reducers.min(\"@random_num\")\n            )\n\n            res = client.ft().aggregate(req).rows[0]\n            assert res[1] == \"redis\"\n            assert res[3] == \"3\"  # min(10,8,3)\n\n            req = aggregations.AggregateRequest(\"redis\").group_by(\n                \"@parent\", reducers.max(\"@random_num\")\n            )\n\n            res = client.ft().aggregate(req).rows[0]\n            assert res[1] == \"redis\"\n            assert res[3] == \"10\"  # max(10,8,3)\n\n            req = aggregations.AggregateRequest(\"redis\").group_by(\n                \"@parent\", reducers.avg(\"@random_num\")\n            )\n\n            res = client.ft().aggregate(req).rows[0]\n            assert res[1] == \"redis\"\n            index = res.index(\"__generated_aliasavgrandom_num\")\n            assert res[index + 1] == \"7\"  # (10+3+8)/3\n\n            req = aggregations.AggregateRequest(\"redis\").group_by(\n                \"@parent\", reducers.stddev(\"random_num\")\n            )\n\n            res = client.ft().aggregate(req).rows[0]\n            assert res[1] == \"redis\"\n            assert res[3] == \"3.60555127546\"\n\n            req = aggregations.AggregateRequest(\"redis\").group_by(\n                \"@parent\", reducers.quantile(\"@random_num\", 0.5)\n            )\n\n            res = client.ft().aggregate(req).rows[0]\n            assert res[1] == \"redis\"\n            assert res[3] == \"8\"  # median of 3,8,10\n\n            req = aggregations.AggregateRequest(\"redis\").group_by(\n                \"@parent\", reducers.tolist(\"@title\")\n            )\n\n            res = client.ft().aggregate(req).rows[0]\n            assert res[1] == \"redis\"\n            assert set(res[3]) == {\"RediSearch\", \"RedisAI\", \"RedisJson\"}\n\n            req = aggregations.AggregateRequest(\"redis\").group_by(\n                \"@parent\", reducers.first_value(\"@title\").alias(\"first\")\n            )\n\n            res = client.ft().aggregate(req).rows[0]\n            assert res == [\"parent\", \"redis\", \"first\", \"RediSearch\"]\n\n            req = aggregations.AggregateRequest(\"redis\").group_by(\n                \"@parent\", reducers.random_sample(\"@title\", 2).alias(\"random\")\n            )\n\n            res = client.ft().aggregate(req).rows[0]\n            assert res[1] == \"redis\"\n            assert res[2] == \"random\"\n            assert len(res[3]) == 2\n            assert res[3][0] in [\"RediSearch\", \"RedisAI\", \"RedisJson\"]\n        else:\n            req = aggregations.AggregateRequest(\"redis\").group_by(\n                \"@parent\", reducers.count()\n            )\n\n            res = client.ft().aggregate(req)[\"results\"][0]\n            assert res[\"extra_attributes\"][\"parent\"] == \"redis\"\n            assert res[\"extra_attributes\"][\"__generated_aliascount\"] == \"3\"\n\n            req = aggregations.AggregateRequest(\"redis\").group_by(\n                \"@parent\", reducers.count_distinct(\"@title\")\n            )\n\n            res = client.ft().aggregate(req)[\"results\"][0]\n            assert res[\"extra_attributes\"][\"parent\"] == \"redis\"\n            assert (\n                res[\"extra_attributes\"][\"__generated_aliascount_distincttitle\"] == \"3\"\n            )\n\n            req = aggregations.AggregateRequest(\"redis\").group_by(\n                \"@parent\", reducers.count_distinctish(\"@title\")\n            )\n\n            res = client.ft().aggregate(req)[\"results\"][0]\n            assert res[\"extra_attributes\"][\"parent\"] == \"redis\"\n            assert (\n                res[\"extra_attributes\"][\"__generated_aliascount_distinctishtitle\"]\n                == \"3\"\n            )\n\n            req = aggregations.AggregateRequest(\"redis\").group_by(\n                \"@parent\", reducers.sum(\"@random_num\")\n            )\n\n            res = client.ft().aggregate(req)[\"results\"][0]\n            assert res[\"extra_attributes\"][\"parent\"] == \"redis\"\n            assert res[\"extra_attributes\"][\"__generated_aliassumrandom_num\"] == \"21\"\n\n            req = aggregations.AggregateRequest(\"redis\").group_by(\n                \"@parent\", reducers.min(\"@random_num\")\n            )\n\n            res = client.ft().aggregate(req)[\"results\"][0]\n            assert res[\"extra_attributes\"][\"parent\"] == \"redis\"\n            assert res[\"extra_attributes\"][\"__generated_aliasminrandom_num\"] == \"3\"\n\n            req = aggregations.AggregateRequest(\"redis\").group_by(\n                \"@parent\", reducers.max(\"@random_num\")\n            )\n\n            res = client.ft().aggregate(req)[\"results\"][0]\n            assert res[\"extra_attributes\"][\"parent\"] == \"redis\"\n            assert res[\"extra_attributes\"][\"__generated_aliasmaxrandom_num\"] == \"10\"\n\n            req = aggregations.AggregateRequest(\"redis\").group_by(\n                \"@parent\", reducers.avg(\"@random_num\")\n            )\n\n            res = client.ft().aggregate(req)[\"results\"][0]\n            assert res[\"extra_attributes\"][\"parent\"] == \"redis\"\n            assert res[\"extra_attributes\"][\"__generated_aliasavgrandom_num\"] == \"7\"\n\n            req = aggregations.AggregateRequest(\"redis\").group_by(\n                \"@parent\", reducers.stddev(\"random_num\")\n            )\n\n            res = client.ft().aggregate(req)[\"results\"][0]\n            assert res[\"extra_attributes\"][\"parent\"] == \"redis\"\n            assert (\n                res[\"extra_attributes\"][\"__generated_aliasstddevrandom_num\"]\n                == \"3.60555127546\"\n            )\n\n            req = aggregations.AggregateRequest(\"redis\").group_by(\n                \"@parent\", reducers.quantile(\"@random_num\", 0.5)\n            )\n\n            res = client.ft().aggregate(req)[\"results\"][0]\n            assert res[\"extra_attributes\"][\"parent\"] == \"redis\"\n            assert (\n                res[\"extra_attributes\"][\"__generated_aliasquantilerandom_num,0.5\"]\n                == \"8\"\n            )\n\n            req = aggregations.AggregateRequest(\"redis\").group_by(\n                \"@parent\", reducers.tolist(\"@title\")\n            )\n\n            res = client.ft().aggregate(req)[\"results\"][0]\n            assert res[\"extra_attributes\"][\"parent\"] == \"redis\"\n            assert set(res[\"extra_attributes\"][\"__generated_aliastolisttitle\"]) == {\n                \"RediSearch\",\n                \"RedisAI\",\n                \"RedisJson\",\n            }\n\n            req = aggregations.AggregateRequest(\"redis\").group_by(\n                \"@parent\", reducers.first_value(\"@title\").alias(\"first\")\n            )\n\n            res = client.ft().aggregate(req)[\"results\"][0]\n            assert res[\"extra_attributes\"] == {\"parent\": \"redis\", \"first\": \"RediSearch\"}\n\n            req = aggregations.AggregateRequest(\"redis\").group_by(\n                \"@parent\", reducers.random_sample(\"@title\", 2).alias(\"random\")\n            )\n\n            res = client.ft().aggregate(req)[\"results\"][0]\n            assert res[\"extra_attributes\"][\"parent\"] == \"redis\"\n            assert \"random\" in res[\"extra_attributes\"].keys()\n            assert len(res[\"extra_attributes\"][\"random\"]) == 2\n            assert res[\"extra_attributes\"][\"random\"][0] in [\n                \"RediSearch\",\n                \"RedisAI\",\n                \"RedisJson\",\n            ]\n\n    @pytest.mark.redismod\n    def test_aggregations_sort_by_and_limit(self, client):\n        client.ft().create_index((TextField(\"t1\"), TextField(\"t2\")))\n\n        client.ft().client.hset(\"doc1\", mapping={\"t1\": \"a\", \"t2\": \"b\"})\n        client.ft().client.hset(\"doc2\", mapping={\"t1\": \"b\", \"t2\": \"a\"})\n\n        if is_resp2_connection(client):\n            # test sort_by using SortDirection\n            req = aggregations.AggregateRequest(\"*\").sort_by(\n                aggregations.Asc(\"@t2\"), aggregations.Desc(\"@t1\")\n            )\n            res = client.ft().aggregate(req)\n            assert res.rows[0] == [\"t2\", \"a\", \"t1\", \"b\"]\n            assert res.rows[1] == [\"t2\", \"b\", \"t1\", \"a\"]\n\n            # test sort_by without SortDirection\n            req = aggregations.AggregateRequest(\"*\").sort_by(\"@t1\")\n            res = client.ft().aggregate(req)\n            assert res.rows[0] == [\"t1\", \"a\"]\n            assert res.rows[1] == [\"t1\", \"b\"]\n\n            # test sort_by with max\n            req = aggregations.AggregateRequest(\"*\").sort_by(\"@t1\", max=1)\n            res = client.ft().aggregate(req)\n            assert len(res.rows) == 1\n\n            # test limit\n            req = aggregations.AggregateRequest(\"*\").sort_by(\"@t1\").limit(1, 1)\n            res = client.ft().aggregate(req)\n            assert len(res.rows) == 1\n            assert res.rows[0] == [\"t1\", \"b\"]\n        else:\n            # test sort_by using SortDirection\n            req = aggregations.AggregateRequest(\"*\").sort_by(\n                aggregations.Asc(\"@t2\"), aggregations.Desc(\"@t1\")\n            )\n            res = client.ft().aggregate(req)[\"results\"]\n            assert res[0][\"extra_attributes\"] == {\"t2\": \"a\", \"t1\": \"b\"}\n            assert res[1][\"extra_attributes\"] == {\"t2\": \"b\", \"t1\": \"a\"}\n\n            # test sort_by without SortDirection\n            req = aggregations.AggregateRequest(\"*\").sort_by(\"@t1\")\n            res = client.ft().aggregate(req)[\"results\"]\n            assert res[0][\"extra_attributes\"] == {\"t1\": \"a\"}\n            assert res[1][\"extra_attributes\"] == {\"t1\": \"b\"}\n\n            # test sort_by with max\n            req = aggregations.AggregateRequest(\"*\").sort_by(\"@t1\", max=1)\n            res = client.ft().aggregate(req)\n            assert len(res[\"results\"]) == 1\n\n            # test limit\n            req = aggregations.AggregateRequest(\"*\").sort_by(\"@t1\").limit(1, 1)\n            res = client.ft().aggregate(req)\n            assert len(res[\"results\"]) == 1\n            assert res[\"results\"][0][\"extra_attributes\"] == {\"t1\": \"b\"}\n\n    @pytest.mark.redismod\n    def test_aggregations_load(self, client):\n        client.ft().create_index((TextField(\"t1\"), TextField(\"t2\")))\n\n        client.ft().client.hset(\"doc1\", mapping={\"t1\": \"hello\", \"t2\": \"world\"})\n\n        if is_resp2_connection(client):\n            # load t1\n            req = aggregations.AggregateRequest(\"*\").load(\"t1\")\n            res = client.ft().aggregate(req)\n            assert res.rows[0] == [\"t1\", \"hello\"]\n\n            # load t2\n            req = aggregations.AggregateRequest(\"*\").load(\"t2\")\n            res = client.ft().aggregate(req)\n            assert res.rows[0] == [\"t2\", \"world\"]\n\n            # load all\n            req = aggregations.AggregateRequest(\"*\").load()\n            res = client.ft().aggregate(req)\n            assert res.rows[0] == [\"t1\", \"hello\", \"t2\", \"world\"]\n        else:\n            # load t1\n            req = aggregations.AggregateRequest(\"*\").load(\"t1\")\n            res = client.ft().aggregate(req)\n            assert res[\"results\"][0][\"extra_attributes\"] == {\"t1\": \"hello\"}\n\n            # load t2\n            req = aggregations.AggregateRequest(\"*\").load(\"t2\")\n            res = client.ft().aggregate(req)\n            assert res[\"results\"][0][\"extra_attributes\"] == {\"t2\": \"world\"}\n\n            # load all\n            req = aggregations.AggregateRequest(\"*\").load()\n            res = client.ft().aggregate(req)\n            assert res[\"results\"][0][\"extra_attributes\"] == {\n                \"t1\": \"hello\",\n                \"t2\": \"world\",\n            }\n\n    @pytest.mark.redismod\n    def test_aggregations_apply(self, client):\n        client.ft().create_index(\n            (\n                TextField(\"PrimaryKey\", sortable=True),\n                NumericField(\"CreatedDateTimeUTC\", sortable=True),\n            )\n        )\n\n        client.ft().client.hset(\n            \"doc1\",\n            mapping={\n                \"PrimaryKey\": \"9::362330\",\n                \"CreatedDateTimeUTC\": \"637387878524969984\",\n            },\n        )\n        client.ft().client.hset(\n            \"doc2\",\n            mapping={\n                \"PrimaryKey\": \"9::362329\",\n                \"CreatedDateTimeUTC\": \"637387875859270016\",\n            },\n        )\n\n        req = aggregations.AggregateRequest(\"*\").apply(\n            CreatedDateTimeUTC=\"@CreatedDateTimeUTC * 10\"\n        )\n        res = client.ft().aggregate(req)\n        if is_resp2_connection(client):\n            res_set = {res.rows[0][1], res.rows[1][1]}\n            assert res_set == {\"6373878785249699840\", \"6373878758592700416\"}\n        else:\n            res_set = {\n                res[\"results\"][0][\"extra_attributes\"][\"CreatedDateTimeUTC\"],\n                res[\"results\"][1][\"extra_attributes\"][\"CreatedDateTimeUTC\"],\n            }\n            assert res_set == {\"6373878785249699840\", \"6373878758592700416\"}\n\n    @pytest.mark.redismod\n    def test_aggregations_filter(self, client):\n        client.ft().create_index(\n            (TextField(\"name\", sortable=True), NumericField(\"age\", sortable=True))\n        )\n\n        client.ft().client.hset(\"doc1\", mapping={\"name\": \"bar\", \"age\": \"25\"})\n        client.ft().client.hset(\"doc2\", mapping={\"name\": \"foo\", \"age\": \"19\"})\n\n        for dialect in [1, 2]:\n            req = (\n                aggregations.AggregateRequest(\"*\")\n                .filter(\"@name=='foo' && @age < 20\")\n                .dialect(dialect)\n            )\n            res = client.ft().aggregate(req)\n            if is_resp2_connection(client):\n                assert len(res.rows) == 1\n                assert res.rows[0] == [\"name\", \"foo\", \"age\", \"19\"]\n\n                req = (\n                    aggregations.AggregateRequest(\"*\")\n                    .filter(\"@age > 15\")\n                    .sort_by(\"@age\")\n                    .dialect(dialect)\n                )\n                res = client.ft().aggregate(req)\n                assert len(res.rows) == 2\n                assert res.rows[0] == [\"age\", \"19\"]\n                assert res.rows[1] == [\"age\", \"25\"]\n            else:\n                assert len(res[\"results\"]) == 1\n                assert res[\"results\"][0][\"extra_attributes\"] == {\n                    \"name\": \"foo\",\n                    \"age\": \"19\",\n                }\n\n                req = (\n                    aggregations.AggregateRequest(\"*\")\n                    .filter(\"@age > 15\")\n                    .sort_by(\"@age\")\n                    .dialect(dialect)\n                )\n                res = client.ft().aggregate(req)\n                assert len(res[\"results\"]) == 2\n                assert res[\"results\"][0][\"extra_attributes\"] == {\"age\": \"19\"}\n                assert res[\"results\"][1][\"extra_attributes\"] == {\"age\": \"25\"}\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.10.05\", \"search\")\n    def test_aggregations_add_scores(self, client):\n        client.ft().create_index(\n            (\n                TextField(\"name\", sortable=True, weight=5.0),\n                NumericField(\"age\", sortable=True),\n            )\n        )\n\n        client.hset(\"doc1\", mapping={\"name\": \"bar\", \"age\": \"25\"})\n        client.hset(\"doc2\", mapping={\"name\": \"foo\", \"age\": \"19\"})\n\n        req = aggregations.AggregateRequest(\"*\").add_scores()\n        res = client.ft().aggregate(req)\n\n        if isinstance(res, dict):\n            assert len(res[\"results\"]) == 2\n            assert res[\"results\"][0][\"extra_attributes\"] == {\"__score\": \"0.2\"}\n            assert res[\"results\"][1][\"extra_attributes\"] == {\"__score\": \"0.2\"}\n        else:\n            assert len(res.rows) == 2\n            assert res.rows[0] == [\"__score\", \"0.2\"]\n            assert res.rows[1] == [\"__score\", \"0.2\"]\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.10.05\", \"search\")\n    async def test_aggregations_hybrid_scoring(self, client):\n        client.ft().create_index(\n            (\n                TextField(\"name\", sortable=True, weight=5.0),\n                TextField(\"description\", sortable=True, weight=5.0),\n                VectorField(\n                    \"vector\",\n                    \"HNSW\",\n                    {\"TYPE\": \"FLOAT32\", \"DIM\": 2, \"DISTANCE_METRIC\": \"COSINE\"},\n                ),\n            )\n        )\n\n        client.hset(\n            \"doc1\",\n            mapping={\n                \"name\": \"cat book\",\n                \"description\": \"an animal book about cats\",\n                \"vector\": np.array([0.1, 0.2]).astype(np.float32).tobytes(),\n            },\n        )\n        client.hset(\n            \"doc2\",\n            mapping={\n                \"name\": \"dog book\",\n                \"description\": \"an animal book about dogs\",\n                \"vector\": np.array([0.2, 0.1]).astype(np.float32).tobytes(),\n            },\n        )\n\n        query_string = \"(@description:animal)=>[KNN 3 @vector $vec_param AS dist]\"\n        req = (\n            aggregations.AggregateRequest(query_string)\n            .scorer(\"BM25\")\n            .add_scores()\n            .apply(hybrid_score=\"@__score + @dist\")\n            .load(\"*\")\n            .dialect(4)\n        )\n\n        res = client.ft().aggregate(\n            req,\n            query_params={\n                \"vec_param\": np.array([0.11, 0.21]).astype(np.float32).tobytes()\n            },\n        )\n\n        if isinstance(res, dict):\n            assert len(res[\"results\"]) == 2\n        else:\n            assert len(res.rows) == 2\n            for row in res.rows:\n                len(row) == 6\n\n\nclass TestSearchWithJsonIndex(SearchTestsBase):\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.2.0\", \"search\")\n    def test_create_json_with_alias(self, client):\n        \"\"\"\n        Create definition with IndexType.JSON as index type (ON JSON) with two\n        fields with aliases, and use json client to test it.\n        \"\"\"\n        definition = IndexDefinition(prefix=[\"king:\"], index_type=IndexType.JSON)\n        client.ft().create_index(\n            (TextField(\"$.name\", as_name=\"name\"), NumericField(\"$.num\", as_name=\"num\")),\n            definition=definition,\n        )\n\n        client.json().set(\"king:1\", Path.root_path(), {\"name\": \"henry\", \"num\": 42})\n        client.json().set(\"king:2\", Path.root_path(), {\"name\": \"james\", \"num\": 3.14})\n\n        if is_resp2_connection(client):\n            res = client.ft().search(\"@name:henry\")\n            assert res.docs[0].id == \"king:1\"\n            assert res.docs[0].json == '{\"name\":\"henry\",\"num\":42}'\n            assert res.total == 1\n\n            res = client.ft().search(\"@num:[0 10]\")\n            assert res.docs[0].id == \"king:2\"\n            assert res.docs[0].json == '{\"name\":\"james\",\"num\":3.14}'\n            assert res.total == 1\n        else:\n            res = client.ft().search(\"@name:henry\")\n            assert res[\"results\"][0][\"id\"] == \"king:1\"\n            assert (\n                res[\"results\"][0][\"extra_attributes\"][\"$\"]\n                == '{\"name\":\"henry\",\"num\":42}'\n            )\n            assert res[\"total_results\"] == 1\n\n            res = client.ft().search(\"@num:[0 10]\")\n            assert res[\"results\"][0][\"id\"] == \"king:2\"\n            assert (\n                res[\"results\"][0][\"extra_attributes\"][\"$\"]\n                == '{\"name\":\"james\",\"num\":3.14}'\n            )\n            assert res[\"total_results\"] == 1\n\n        # Tests returns an error if path contain special characters (user should\n        # use an alias)\n        with pytest.raises(Exception):\n            client.ft().search(\"@$.name:henry\")\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.2.0\", \"search\")\n    def test_json_with_multipath(self, client):\n        \"\"\"\n        Create definition with IndexType.JSON as index type (ON JSON),\n        and use json client to test it.\n        \"\"\"\n        definition = IndexDefinition(prefix=[\"king:\"], index_type=IndexType.JSON)\n        client.ft().create_index(\n            (TagField(\"$..name\", as_name=\"name\")), definition=definition\n        )\n\n        client.json().set(\n            \"king:1\",\n            Path.root_path(),\n            {\"name\": \"henry\", \"country\": {\"name\": \"england\"}},\n        )\n\n        if is_resp2_connection(client):\n            res = client.ft().search(\"@name:{henry}\")\n            assert res.docs[0].id == \"king:1\"\n            assert res.docs[0].json == '{\"name\":\"henry\",\"country\":{\"name\":\"england\"}}'\n            assert res.total == 1\n\n            res = client.ft().search(\"@name:{england}\")\n            assert res.docs[0].id == \"king:1\"\n            assert res.docs[0].json == '{\"name\":\"henry\",\"country\":{\"name\":\"england\"}}'\n            assert res.total == 1\n        else:\n            res = client.ft().search(\"@name:{henry}\")\n            assert res[\"results\"][0][\"id\"] == \"king:1\"\n            assert (\n                res[\"results\"][0][\"extra_attributes\"][\"$\"]\n                == '{\"name\":\"henry\",\"country\":{\"name\":\"england\"}}'\n            )\n            assert res[\"total_results\"] == 1\n\n            res = client.ft().search(\"@name:{england}\")\n            assert res[\"results\"][0][\"id\"] == \"king:1\"\n            assert (\n                res[\"results\"][0][\"extra_attributes\"][\"$\"]\n                == '{\"name\":\"henry\",\"country\":{\"name\":\"england\"}}'\n            )\n            assert res[\"total_results\"] == 1\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.2.0\", \"search\")\n    def test_json_with_jsonpath(self, client):\n        definition = IndexDefinition(index_type=IndexType.JSON)\n        client.ft().create_index(\n            (\n                TextField('$[\"prod:name\"]', as_name=\"name\"),\n                TextField(\"$.prod:name\", as_name=\"name_unsupported\"),\n            ),\n            definition=definition,\n        )\n\n        client.json().set(\"doc:1\", Path.root_path(), {\"prod:name\": \"RediSearch\"})\n\n        if is_resp2_connection(client):\n            # query for a supported field succeeds\n            res = client.ft().search(Query(\"@name:RediSearch\"))\n            assert res.total == 1\n            assert res.docs[0].id == \"doc:1\"\n            assert res.docs[0].json == '{\"prod:name\":\"RediSearch\"}'\n\n            # query for an unsupported field\n            res = client.ft().search(\"@name_unsupported:RediSearch\")\n            assert res.total == 1\n\n            # return of a supported field succeeds\n            res = client.ft().search(Query(\"@name:RediSearch\").return_field(\"name\"))\n            assert res.total == 1\n            assert res.docs[0].id == \"doc:1\"\n            assert res.docs[0].name == \"RediSearch\"\n        else:\n            # query for a supported field succeeds\n            res = client.ft().search(Query(\"@name:RediSearch\"))\n            assert res[\"total_results\"] == 1\n            assert res[\"results\"][0][\"id\"] == \"doc:1\"\n            assert (\n                res[\"results\"][0][\"extra_attributes\"][\"$\"]\n                == '{\"prod:name\":\"RediSearch\"}'\n            )\n\n            # query for an unsupported field\n            res = client.ft().search(\"@name_unsupported:RediSearch\")\n            assert res[\"total_results\"] == 1\n\n            # return of a supported field succeeds\n            res = client.ft().search(Query(\"@name:RediSearch\").return_field(\"name\"))\n            assert res[\"total_results\"] == 1\n            assert res[\"results\"][0][\"id\"] == \"doc:1\"\n            assert res[\"results\"][0][\"extra_attributes\"][\"name\"] == \"RediSearch\"\n\n\nclass TestProfile(SearchTestsBase):\n    @pytest.mark.redismod\n    @pytest.mark.onlynoncluster\n    @skip_if_redis_enterprise()\n    @skip_if_server_version_gte(\"7.9.0\")\n    @skip_if_server_version_lt(\"6.3.0\")\n    def test_profile(self, client):\n        client.ft().create_index((TextField(\"t\"),))\n        client.ft().client.hset(\"1\", \"t\", \"hello\")\n        client.ft().client.hset(\"2\", \"t\", \"world\")\n\n        # check using Query\n        q = Query(\"hello|world\").no_content()\n        if is_resp2_connection(client):\n            res, det = client.ft().profile(q)\n            det = det.info\n\n            assert isinstance(det, list)\n            assert len(res.docs) == 2  # check also the search result\n\n            # check using AggregateRequest\n            req = (\n                aggregations.AggregateRequest(\"*\")\n                .load(\"t\")\n                .apply(prefix=\"startswith(@t, 'hel')\")\n            )\n            res, det = client.ft().profile(req)\n            det = det.info\n            assert isinstance(det, list)\n            assert len(res.rows) == 2  # check also the search result\n        else:\n            res = client.ft().profile(q)\n            res = res.info\n\n            assert isinstance(res, dict)\n            assert len(res[\"results\"]) == 2  # check also the search result\n\n            # check using AggregateRequest\n            req = (\n                aggregations.AggregateRequest(\"*\")\n                .load(\"t\")\n                .apply(prefix=\"startswith(@t, 'hel')\")\n            )\n            res = client.ft().profile(req)\n            res = res.info\n\n            assert isinstance(res, dict)\n            assert len(res[\"results\"]) == 2  # check also the search result\n\n    @pytest.mark.redismod\n    @pytest.mark.onlynoncluster\n    @skip_if_redis_enterprise()\n    @skip_if_server_version_lt(\"7.9.0\")\n    def test_profile_with_coordinator(self, client):\n        client.ft().create_index((TextField(\"t\"),))\n        client.ft().client.hset(\"1\", \"t\", \"hello\")\n        client.ft().client.hset(\"2\", \"t\", \"world\")\n\n        # check using Query\n        q = Query(\"hello|world\").no_content()\n        if is_resp2_connection(client):\n            res, det = client.ft().profile(q)\n            det = det.info\n\n            assert isinstance(det, list)\n            assert len(res.docs) == 2  # check also the search result\n\n            # check using AggregateRequest\n            req = (\n                aggregations.AggregateRequest(\"*\")\n                .load(\"t\")\n                .apply(prefix=\"startswith(@t, 'hel')\")\n            )\n            res, det = client.ft().profile(req)\n            det = det.info\n\n            assert isinstance(det, list)\n            assert det[0] == \"Shards\"\n            assert det[2] == \"Coordinator\"\n            assert len(res.rows) == 2  # check also the search result\n        else:\n            res = client.ft().profile(q)\n            res = res.info\n\n            assert isinstance(res, dict)\n            assert len(res[\"Results\"][\"results\"]) == 2  # check also the search result\n\n            # check using AggregateRequest\n            req = (\n                aggregations.AggregateRequest(\"*\")\n                .load(\"t\")\n                .apply(prefix=\"startswith(@t, 'hel')\")\n            )\n            res = client.ft().profile(req)\n            res = res.info\n\n            assert isinstance(res, dict)\n            assert len(res[\"Results\"][\"results\"]) == 2  # check also the search result\n\n    @pytest.mark.redismod\n    @pytest.mark.onlynoncluster\n    @skip_if_server_version_gte(\"7.9.0\")\n    @skip_if_server_version_lt(\"6.3.0\")\n    def test_profile_limited(self, client):\n        client.ft().create_index((TextField(\"t\"),))\n        client.ft().client.hset(\"1\", \"t\", \"hello\")\n        client.ft().client.hset(\"2\", \"t\", \"hell\")\n        client.ft().client.hset(\"3\", \"t\", \"help\")\n        client.ft().client.hset(\"4\", \"t\", \"helowa\")\n\n        q = Query(\"%hell% hel*\")\n        if is_resp2_connection(client):\n            res, det = client.ft().profile(q, limited=True)\n            det = det.info\n            assert det[4][1][7][9] == \"The number of iterators in the union is 3\"\n            assert det[4][1][8][9] == \"The number of iterators in the union is 4\"\n            assert det[4][1][1] == \"INTERSECT\"\n            assert len(res.docs) == 3  # check also the search result\n        else:\n            res = client.ft().profile(q, limited=True)\n            res = res.info\n            iterators_profile = res[\"profile\"][\"Iterators profile\"]\n            assert (\n                iterators_profile[0][\"Child iterators\"][0][\"Child iterators\"]\n                == \"The number of iterators in the union is 3\"\n            )\n            assert (\n                iterators_profile[0][\"Child iterators\"][1][\"Child iterators\"]\n                == \"The number of iterators in the union is 4\"\n            )\n            assert iterators_profile[0][\"Type\"] == \"INTERSECT\"\n            assert len(res[\"results\"]) == 3  # check also the search result\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.4.3\", \"search\")\n    @skip_if_server_version_gte(\"7.9.0\")\n    @skip_if_server_version_lt(\"6.3.0\")\n    def test_profile_query_params(self, client):\n        client.ft().create_index(\n            (\n                VectorField(\n                    \"v\", \"HNSW\", {\"TYPE\": \"FLOAT32\", \"DIM\": 2, \"DISTANCE_METRIC\": \"L2\"}\n                ),\n            )\n        )\n        client.hset(\"a\", \"v\", \"aaaaaaaa\")\n        client.hset(\"b\", \"v\", \"aaaabaaa\")\n        client.hset(\"c\", \"v\", \"aaaaabaa\")\n        query = \"*=>[KNN 2 @v $vec]\"\n        q = Query(query).return_field(\"__v_score\").sort_by(\"__v_score\", True)\n        if is_resp2_connection(client):\n            res, det = client.ft().profile(q, query_params={\"vec\": \"aaaaaaaa\"})\n            det = det.info\n            assert det[4][1][5] == 2.0\n            assert det[4][1][1] == \"VECTOR\"\n            assert res.total == 2\n            assert \"a\" == res.docs[0].id\n            assert \"0\" == res.docs[0].__getattribute__(\"__v_score\")\n        else:\n            res = client.ft().profile(q, query_params={\"vec\": \"aaaaaaaa\"})\n            res = res.info\n            assert res[\"profile\"][\"Iterators profile\"][0][\"Counter\"] == 2\n            assert res[\"profile\"][\"Iterators profile\"][0][\"Type\"] == \"VECTOR\"\n            assert res[\"total_results\"] == 2\n            assert \"a\" == res[\"results\"][0][\"id\"]\n            assert \"0\" == res[\"results\"][0][\"extra_attributes\"][\"__v_score\"]\n\n\nclass TestDifferentFieldTypesSearch(SearchTestsBase):\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.4.3\", \"search\")\n    def test_vector_field(self, client):\n        client.flushdb()\n        client.ft().create_index(\n            (\n                VectorField(\n                    \"v\", \"HNSW\", {\"TYPE\": \"FLOAT32\", \"DIM\": 2, \"DISTANCE_METRIC\": \"L2\"}\n                ),\n            )\n        )\n        client.hset(\"a\", \"v\", \"aaaaaaaa\")\n        client.hset(\"b\", \"v\", \"aaaabaaa\")\n        client.hset(\"c\", \"v\", \"aaaaabaa\")\n\n        query = \"*=>[KNN 2 @v $vec]\"\n        q = Query(query).return_field(\"__v_score\").sort_by(\"__v_score\", True)\n        res = client.ft().search(q, query_params={\"vec\": \"aaaaaaaa\"})\n\n        if is_resp2_connection(client):\n            assert \"a\" == res.docs[0].id\n            assert \"0\" == res.docs[0].__getattribute__(\"__v_score\")\n        else:\n            assert \"a\" == res[\"results\"][0][\"id\"]\n            assert \"0\" == res[\"results\"][0][\"extra_attributes\"][\"__v_score\"]\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.4.3\", \"search\")\n    def test_vector_field_error(self, r):\n        r.flushdb()\n\n        # sortable tag\n        with pytest.raises(Exception):\n            r.ft().create_index((VectorField(\"v\", \"HNSW\", {}, sortable=True),))\n\n        # not supported algorithm\n        with pytest.raises(Exception):\n            r.ft().create_index((VectorField(\"v\", \"SORT\", {}),))\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.4.3\", \"search\")\n    def test_text_params(self, client):\n        client.flushdb()\n        client.ft().create_index((TextField(\"name\"),))\n\n        client.hset(\"doc1\", mapping={\"name\": \"Alice\"})\n        client.hset(\"doc2\", mapping={\"name\": \"Bob\"})\n        client.hset(\"doc3\", mapping={\"name\": \"Carol\"})\n\n        params_dict = {\"name1\": \"Alice\", \"name2\": \"Bob\"}\n        q = Query(\"@name:($name1 | $name2 )\")\n        res = client.ft().search(q, query_params=params_dict)\n        if is_resp2_connection(client):\n            assert 2 == res.total\n            assert \"doc1\" == res.docs[0].id\n            assert \"doc2\" == res.docs[1].id\n        else:\n            assert 2 == res[\"total_results\"]\n            assert \"doc1\" == res[\"results\"][0][\"id\"]\n            assert \"doc2\" == res[\"results\"][1][\"id\"]\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.4.3\", \"search\")\n    def test_numeric_params(self, client):\n        client.flushdb()\n        client.ft().create_index((NumericField(\"numval\"),))\n\n        client.hset(\"doc1\", mapping={\"numval\": 101})\n        client.hset(\"doc2\", mapping={\"numval\": 102})\n        client.hset(\"doc3\", mapping={\"numval\": 103})\n\n        params_dict = {\"min\": 101, \"max\": 102}\n        q = Query(\"@numval:[$min $max]\")\n        res = client.ft().search(q, query_params=params_dict)\n\n        if is_resp2_connection(client):\n            assert 2 == res.total\n            assert \"doc1\" == res.docs[0].id\n            assert \"doc2\" == res.docs[1].id\n        else:\n            assert 2 == res[\"total_results\"]\n            assert \"doc1\" == res[\"results\"][0][\"id\"]\n            assert \"doc2\" == res[\"results\"][1][\"id\"]\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.4.3\", \"search\")\n    def test_geo_params(self, client):\n        client.ft().create_index(GeoField(\"g\"))\n        client.hset(\"doc1\", mapping={\"g\": \"29.69465, 34.95126\"})\n        client.hset(\"doc2\", mapping={\"g\": \"29.69350, 34.94737\"})\n        client.hset(\"doc3\", mapping={\"g\": \"29.68746, 34.94882\"})\n\n        params_dict = {\n            \"lat\": \"34.95126\",\n            \"lon\": \"29.69465\",\n            \"radius\": 1000,\n            \"units\": \"km\",\n        }\n        q = Query(\"@g:[$lon $lat $radius $units]\")\n        res = client.ft().search(q, query_params=params_dict)\n        _assert_search_result(client, res, [\"doc1\", \"doc2\", \"doc3\"])\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"7.4.0\")\n    @skip_ifmodversion_lt(\"2.10.0\", \"search\")\n    def test_geoshapes_query_intersects_and_disjoint(self, client):\n        client.ft().create_index((GeoShapeField(\"g\", coord_system=GeoShapeField.FLAT)))\n        client.hset(\"doc_point1\", mapping={\"g\": \"POINT (10 10)\"})\n        client.hset(\"doc_point2\", mapping={\"g\": \"POINT (50 50)\"})\n        client.hset(\n            \"doc_polygon1\", mapping={\"g\": \"POLYGON ((20 20, 25 35, 35 25, 20 20))\"}\n        )\n        client.hset(\n            \"doc_polygon2\",\n            mapping={\"g\": \"POLYGON ((60 60, 65 75, 70 70, 65 55, 60 60))\"},\n        )\n\n        intersection = client.ft().search(\n            Query(\"@g:[intersects $shape]\").dialect(3),\n            query_params={\"shape\": \"POLYGON((15 15, 75 15, 50 70, 20 40, 15 15))\"},\n        )\n        _assert_search_result(client, intersection, [\"doc_point2\", \"doc_polygon1\"])\n\n        disjunction = client.ft().search(\n            Query(\"@g:[disjoint $shape]\").dialect(3),\n            query_params={\"shape\": \"POLYGON((15 15, 75 15, 50 70, 20 40, 15 15))\"},\n        )\n        _assert_search_result(client, disjunction, [\"doc_point1\", \"doc_polygon2\"])\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.10.0\", \"search\")\n    def test_geoshapes_query_contains_and_within(self, client):\n        client.ft().create_index((GeoShapeField(\"g\", coord_system=GeoShapeField.FLAT)))\n        client.hset(\"doc_point1\", mapping={\"g\": \"POINT (10 10)\"})\n        client.hset(\"doc_point2\", mapping={\"g\": \"POINT (50 50)\"})\n        client.hset(\n            \"doc_polygon1\", mapping={\"g\": \"POLYGON ((20 20, 25 35, 35 25, 20 20))\"}\n        )\n        client.hset(\n            \"doc_polygon2\",\n            mapping={\"g\": \"POLYGON ((60 60, 65 75, 70 70, 65 55, 60 60))\"},\n        )\n\n        contains_a = client.ft().search(\n            Query(\"@g:[contains $shape]\").dialect(3),\n            query_params={\"shape\": \"POINT(25 25)\"},\n        )\n        _assert_search_result(client, contains_a, [\"doc_polygon1\"])\n\n        contains_b = client.ft().search(\n            Query(\"@g:[contains $shape]\").dialect(3),\n            query_params={\"shape\": \"POLYGON((24 24, 24 26, 25 25, 24 24))\"},\n        )\n        _assert_search_result(client, contains_b, [\"doc_polygon1\"])\n\n        within = client.ft().search(\n            Query(\"@g:[within $shape]\").dialect(3),\n            query_params={\"shape\": \"POLYGON((15 15, 75 15, 50 70, 20 40, 15 15))\"},\n        )\n        _assert_search_result(client, within, [\"doc_point2\", \"doc_polygon1\"])\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"7.9.0\")\n    def test_vector_search_with_int8_type(self, client):\n        client.ft().create_index(\n            (\n                VectorField(\n                    \"v\", \"FLAT\", {\"TYPE\": \"INT8\", \"DIM\": 2, \"DISTANCE_METRIC\": \"L2\"}\n                ),\n            )\n        )\n\n        a = [1.5, 10]\n        b = [123, 100]\n        c = [1, 1]\n\n        client.hset(\"a\", \"v\", np.array(a, dtype=np.int8).tobytes())\n        client.hset(\"b\", \"v\", np.array(b, dtype=np.int8).tobytes())\n        client.hset(\"c\", \"v\", np.array(c, dtype=np.int8).tobytes())\n\n        query = Query(\"*=>[KNN 2 @v $vec as score]\").no_content()\n        query_params = {\"vec\": np.array(a, dtype=np.int8).tobytes()}\n\n        assert 2 in query.get_args()\n\n        res = client.ft().search(query, query_params=query_params)\n        if is_resp2_connection(client):\n            assert res.total == 2\n        else:\n            assert res[\"total_results\"] == 2\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"7.9.0\")\n    def test_vector_search_with_uint8_type(self, client):\n        client.ft().create_index(\n            (\n                VectorField(\n                    \"v\", \"FLAT\", {\"TYPE\": \"UINT8\", \"DIM\": 2, \"DISTANCE_METRIC\": \"L2\"}\n                ),\n            )\n        )\n\n        a = [1.5, 10]\n        b = [123, 100]\n        c = [1, 1]\n\n        client.hset(\"a\", \"v\", np.array(a, dtype=np.uint8).tobytes())\n        client.hset(\"b\", \"v\", np.array(b, dtype=np.uint8).tobytes())\n        client.hset(\"c\", \"v\", np.array(c, dtype=np.uint8).tobytes())\n\n        query = Query(\"*=>[KNN 2 @v $vec as score]\").no_content()\n        query_params = {\"vec\": np.array(a, dtype=np.uint8).tobytes()}\n\n        assert 2 in query.get_args()\n\n        res = client.ft().search(query, query_params=query_params)\n        if is_resp2_connection(client):\n            assert res.total == 2\n        else:\n            assert res[\"total_results\"] == 2\n\n\nclass TestPipeline(SearchTestsBase):\n    @pytest.mark.redismod\n    @skip_if_redis_enterprise()\n    def test_search_commands_in_pipeline(self, client):\n        p = client.ft().pipeline()\n        p.create_index((TextField(\"txt\"),))\n        p.hset(\"doc1\", mapping={\"txt\": \"foo bar\"})\n        p.hset(\"doc2\", mapping={\"txt\": \"foo bar\"})\n        q = Query(\"foo bar\").with_payloads()\n        p.search(q)\n        res = p.execute()\n        if is_resp2_connection(client):\n            assert res[:3] == [\"OK\", True, True]\n            assert 2 == res[3][0]\n            assert \"doc1\" == res[3][1]\n            assert \"doc2\" == res[3][4]\n            assert res[3][5] is None\n            assert res[3][3] == res[3][6] == [\"txt\", \"foo bar\"]\n        else:\n            assert res[:3] == [\"OK\", True, True]\n            assert 2 == res[3][\"total_results\"]\n            assert \"doc1\" == res[3][\"results\"][0][\"id\"]\n            assert \"doc2\" == res[3][\"results\"][1][\"id\"]\n            assert res[3][\"results\"][0][\"payload\"] is None\n            assert (\n                res[3][\"results\"][0][\"extra_attributes\"]\n                == res[3][\"results\"][1][\"extra_attributes\"]\n                == {\"txt\": \"foo bar\"}\n            )\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.4.0\")\n    def test_hybrid_search_query_with_pipeline(self, client):\n        p = client.ft().pipeline()\n        p.create_index(\n            (\n                TextField(\"txt\"),\n                VectorField(\n                    \"embedding\",\n                    \"FLAT\",\n                    {\"TYPE\": \"FLOAT32\", \"DIM\": 4, \"DISTANCE_METRIC\": \"L2\"},\n                ),\n            )\n        )\n\n        p.hset(\n            \"doc1\",\n            mapping={\n                \"txt\": \"foo bar\",\n                \"embedding\": np.array([1, 2, 3, 4], dtype=np.float32).tobytes(),\n            },\n        )\n        p.hset(\n            \"doc2\",\n            mapping={\n                \"txt\": \"foo bar\",\n                \"embedding\": np.array([1, 2, 2, 3], dtype=np.float32).tobytes(),\n            },\n        )\n\n        # set search query\n        search_query = HybridSearchQuery(\"foo\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding\",\n            vector_data=\"$vec\",\n        )\n\n        hybrid_query = HybridQuery(\n            search_query,\n            vsim_query,\n        )\n\n        p.hybrid_search(\n            query=hybrid_query,\n            params_substitution={\n                \"vec\": np.array([2, 2, 3, 3], dtype=np.float32).tobytes()\n            },\n        )\n        res = p.execute()\n\n        # the default results count limit is 10\n        assert res[:3] == [\"OK\", 2, 2]\n        hybrid_search_res = res[3]\n        if is_resp2_connection(client):\n            # it doesn't get parsed to object in pipeline\n            assert hybrid_search_res[0] == \"total_results\"\n            assert hybrid_search_res[1] == 2\n            assert hybrid_search_res[2] == \"results\"\n            assert len(hybrid_search_res[3]) == 2\n            assert hybrid_search_res[4] == \"warnings\"\n            assert hybrid_search_res[5] == []\n            assert hybrid_search_res[6] == \"execution_time\"\n            assert float(hybrid_search_res[7]) > 0\n        else:\n            assert hybrid_search_res[\"total_results\"] == 2\n            assert len(hybrid_search_res[\"results\"]) == 2\n            assert hybrid_search_res[\"warnings\"] == []\n            assert hybrid_search_res[\"execution_time\"] > 0\n\n\nclass TestSearchWithVamana(SearchTestsBase):\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.4.3\", \"search\")\n    @skip_if_server_version_lt(\"8.1.224\")\n    def test_svs_vamana_l2_distance_metric(self, client):\n        client.ft().create_index(\n            (\n                VectorField(\n                    \"v\",\n                    \"SVS-VAMANA\",\n                    {\"TYPE\": \"FLOAT32\", \"DIM\": 3, \"DISTANCE_METRIC\": \"L2\"},\n                ),\n            )\n        )\n\n        # L2 distance test vectors\n        vectors = [[1.0, 0.0, 0.0], [2.0, 0.0, 0.0], [0.0, 1.0, 0.0], [5.0, 0.0, 0.0]]\n\n        for i, vec in enumerate(vectors):\n            client.hset(f\"doc{i}\", \"v\", np.array(vec, dtype=np.float32).tobytes())\n\n        query = Query(\"*=>[KNN 3 @v $vec as score]\").sort_by(\"score\").no_content()\n        query_params = {\"vec\": np.array(vectors[0], dtype=np.float32).tobytes()}\n\n        res = client.ft().search(query, query_params=query_params)\n        if is_resp2_connection(client):\n            assert res.total == 3\n            assert \"doc0\" == res.docs[0].id\n        else:\n            assert res[\"total_results\"] == 3\n            assert \"doc0\" == res[\"results\"][0][\"id\"]\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.4.3\", \"search\")\n    @skip_if_server_version_lt(\"8.1.224\")\n    def test_svs_vamana_cosine_distance_metric(self, client):\n        client.ft().create_index(\n            (\n                VectorField(\n                    \"v\",\n                    \"SVS-VAMANA\",\n                    {\"TYPE\": \"FLOAT32\", \"DIM\": 3, \"DISTANCE_METRIC\": \"COSINE\"},\n                ),\n            )\n        )\n\n        vectors = [\n            [1.0, 0.0, 0.0],\n            [0.707, 0.707, 0.0],\n            [0.0, 1.0, 0.0],\n            [-1.0, 0.0, 0.0],\n        ]\n\n        for i, vec in enumerate(vectors):\n            client.hset(f\"doc{i}\", \"v\", np.array(vec, dtype=np.float32).tobytes())\n\n        query = Query(\"*=>[KNN 3 @v $vec as score]\").sort_by(\"score\").no_content()\n        query_params = {\"vec\": np.array(vectors[0], dtype=np.float32).tobytes()}\n\n        res = client.ft().search(query, query_params=query_params)\n        if is_resp2_connection(client):\n            assert res.total == 3\n            assert \"doc0\" == res.docs[0].id\n        else:\n            assert res[\"total_results\"] == 3\n            assert \"doc0\" == res[\"results\"][0][\"id\"]\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.4.3\", \"search\")\n    @skip_if_server_version_lt(\"8.1.224\")\n    def test_svs_vamana_ip_distance_metric(self, client):\n        client.ft().create_index(\n            (\n                VectorField(\n                    \"v\",\n                    \"SVS-VAMANA\",\n                    {\"TYPE\": \"FLOAT32\", \"DIM\": 3, \"DISTANCE_METRIC\": \"IP\"},\n                ),\n            )\n        )\n\n        vectors = [[1.0, 2.0, 3.0], [2.0, 1.0, 1.0], [3.0, 3.0, 3.0], [0.1, 0.1, 0.1]]\n\n        for i, vec in enumerate(vectors):\n            client.hset(f\"doc{i}\", \"v\", np.array(vec, dtype=np.float32).tobytes())\n\n        query = Query(\"*=>[KNN 3 @v $vec as score]\").sort_by(\"score\").no_content()\n        query_params = {\"vec\": np.array(vectors[0], dtype=np.float32).tobytes()}\n\n        res = client.ft().search(query, query_params=query_params)\n        if is_resp2_connection(client):\n            assert res.total == 3\n            assert \"doc2\" == res.docs[0].id\n        else:\n            assert res[\"total_results\"] == 3\n            assert \"doc2\" == res[\"results\"][0][\"id\"]\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.4.3\", \"search\")\n    @skip_if_server_version_lt(\"8.1.224\")\n    def test_svs_vamana_basic_functionality(self, client):\n        client.ft().create_index(\n            (\n                VectorField(\n                    \"v\",\n                    \"SVS-VAMANA\",\n                    {\"TYPE\": \"FLOAT32\", \"DIM\": 4, \"DISTANCE_METRIC\": \"L2\"},\n                ),\n            )\n        )\n\n        vectors = [\n            [1.0, 2.0, 3.0, 4.0],\n            [2.0, 3.0, 4.0, 5.0],\n            [3.0, 4.0, 5.0, 6.0],\n            [10.0, 11.0, 12.0, 13.0],\n        ]\n\n        for i, vec in enumerate(vectors):\n            client.hset(f\"doc{i}\", \"v\", np.array(vec, dtype=np.float32).tobytes())\n\n        query = \"*=>[KNN 3 @v $vec]\"\n        q = Query(query).return_field(\"__v_score\").sort_by(\"__v_score\", True)\n        res = client.ft().search(\n            q, query_params={\"vec\": np.array(vectors[0], dtype=np.float32).tobytes()}\n        )\n\n        if is_resp2_connection(client):\n            assert res.total == 3\n            assert \"doc0\" == res.docs[0].id  # Should be closest to itself\n            assert \"0\" == res.docs[0].__getattribute__(\"__v_score\")\n        else:\n            assert res[\"total_results\"] == 3\n            assert \"doc0\" == res[\"results\"][0][\"id\"]\n            assert \"0\" == res[\"results\"][0][\"extra_attributes\"][\"__v_score\"]\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.4.3\", \"search\")\n    @skip_if_server_version_lt(\"8.1.224\")\n    def test_svs_vamana_float16_type(self, client):\n        client.ft().create_index(\n            (\n                VectorField(\n                    \"v\",\n                    \"SVS-VAMANA\",\n                    {\"TYPE\": \"FLOAT16\", \"DIM\": 4, \"DISTANCE_METRIC\": \"L2\"},\n                ),\n            )\n        )\n\n        vectors = [[1.5, 2.5, 3.5, 4.5], [2.5, 3.5, 4.5, 5.5], [3.5, 4.5, 5.5, 6.5]]\n\n        for i, vec in enumerate(vectors):\n            client.hset(f\"doc{i}\", \"v\", np.array(vec, dtype=np.float16).tobytes())\n\n        query = Query(\"*=>[KNN 2 @v $vec as score]\").no_content()\n        query_params = {\"vec\": np.array(vectors[0], dtype=np.float16).tobytes()}\n\n        res = client.ft().search(query, query_params=query_params)\n        if is_resp2_connection(client):\n            assert res.total == 2\n            assert \"doc0\" == res.docs[0].id\n        else:\n            assert res[\"total_results\"] == 2\n            assert \"doc0\" == res[\"results\"][0][\"id\"]\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.4.3\", \"search\")\n    @skip_if_server_version_lt(\"8.1.224\")\n    def test_svs_vamana_float32_type(self, client):\n        client.ft().create_index(\n            (\n                VectorField(\n                    \"v\",\n                    \"SVS-VAMANA\",\n                    {\"TYPE\": \"FLOAT32\", \"DIM\": 4, \"DISTANCE_METRIC\": \"L2\"},\n                ),\n            )\n        )\n\n        vectors = [[1.0, 2.0, 3.0, 4.0], [2.0, 3.0, 4.0, 5.0], [3.0, 4.0, 5.0, 6.0]]\n\n        for i, vec in enumerate(vectors):\n            client.hset(f\"doc{i}\", \"v\", np.array(vec, dtype=np.float32).tobytes())\n\n        query = Query(\"*=>[KNN 2 @v $vec as score]\").no_content()\n        query_params = {\"vec\": np.array(vectors[0], dtype=np.float32).tobytes()}\n\n        res = client.ft().search(query, query_params=query_params)\n        if is_resp2_connection(client):\n            assert res.total == 2\n            assert \"doc0\" == res.docs[0].id\n        else:\n            assert res[\"total_results\"] == 2\n            assert \"doc0\" == res[\"results\"][0][\"id\"]\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.4.3\", \"search\")\n    @skip_if_server_version_lt(\"8.1.224\")\n    def test_svs_vamana_vector_search_with_default_dialect(self, client):\n        client.ft().create_index(\n            (\n                VectorField(\n                    \"v\",\n                    \"SVS-VAMANA\",\n                    {\"TYPE\": \"FLOAT32\", \"DIM\": 2, \"DISTANCE_METRIC\": \"L2\"},\n                ),\n            )\n        )\n\n        client.hset(\"a\", \"v\", \"aaaaaaaa\")\n        client.hset(\"b\", \"v\", \"aaaabaaa\")\n        client.hset(\"c\", \"v\", \"aaaaabaa\")\n\n        query = \"*=>[KNN 2 @v $vec]\"\n        q = Query(query).return_field(\"__v_score\").sort_by(\"__v_score\", True)\n        res = client.ft().search(q, query_params={\"vec\": \"aaaaaaaa\"})\n\n        if is_resp2_connection(client):\n            assert res.total == 2\n        else:\n            assert res[\"total_results\"] == 2\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.4.3\", \"search\")\n    @skip_if_server_version_lt(\"8.1.224\")\n    def test_svs_vamana_vector_field_basic(self):\n        field = VectorField(\n            \"v\",\n            \"SVS-VAMANA\",\n            {\"TYPE\": \"FLOAT32\", \"DIM\": 128, \"DISTANCE_METRIC\": \"COSINE\"},\n        )\n\n        # Check that the field was created successfully\n        assert field.name == \"v\"\n        assert field.args[0] == \"VECTOR\"\n        assert field.args[1] == \"SVS-VAMANA\"\n        assert field.args[2] == 6\n        assert \"TYPE\" in field.args\n        assert \"FLOAT32\" in field.args\n        assert \"DIM\" in field.args\n        assert 128 in field.args\n        assert \"DISTANCE_METRIC\" in field.args\n        assert \"COSINE\" in field.args\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.4.3\", \"search\")\n    @skip_if_server_version_lt(\"8.1.224\")\n    def test_svs_vamana_lvq8_compression(self, client):\n        client.ft().create_index(\n            (\n                VectorField(\n                    \"v\",\n                    \"SVS-VAMANA\",\n                    {\n                        \"TYPE\": \"FLOAT32\",\n                        \"DIM\": 8,\n                        \"DISTANCE_METRIC\": \"L2\",\n                        \"COMPRESSION\": \"LVQ8\",\n                        \"TRAINING_THRESHOLD\": 1024,\n                    },\n                ),\n            )\n        )\n\n        vectors = []\n        for i in range(20):\n            vec = [float(i + j) for j in range(8)]\n            vectors.append(vec)\n            client.hset(f\"doc{i}\", \"v\", np.array(vec, dtype=np.float32).tobytes())\n\n        query = Query(\"*=>[KNN 5 @v $vec as score]\").no_content()\n        query_params = {\"vec\": np.array(vectors[0], dtype=np.float32).tobytes()}\n\n        res = client.ft().search(query, query_params=query_params)\n        if is_resp2_connection(client):\n            assert res.total == 5\n            assert \"doc0\" == res.docs[0].id\n        else:\n            assert res[\"total_results\"] == 5\n            assert \"doc0\" == res[\"results\"][0][\"id\"]\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.4.3\", \"search\")\n    @skip_if_server_version_lt(\"8.1.224\")\n    def test_svs_vamana_compression_with_both_vector_types(self, client):\n        # Test FLOAT16 with LVQ8\n        client.ft(\"idx16\").create_index(\n            (\n                VectorField(\n                    \"v16\",\n                    \"SVS-VAMANA\",\n                    {\n                        \"TYPE\": \"FLOAT16\",\n                        \"DIM\": 8,\n                        \"DISTANCE_METRIC\": \"L2\",\n                        \"COMPRESSION\": \"LVQ8\",\n                        \"TRAINING_THRESHOLD\": 1024,\n                    },\n                ),\n            )\n        )\n\n        # Test FLOAT32 with LVQ8\n        client.ft(\"idx32\").create_index(\n            (\n                VectorField(\n                    \"v32\",\n                    \"SVS-VAMANA\",\n                    {\n                        \"TYPE\": \"FLOAT32\",\n                        \"DIM\": 8,\n                        \"DISTANCE_METRIC\": \"L2\",\n                        \"COMPRESSION\": \"LVQ8\",\n                        \"TRAINING_THRESHOLD\": 1024,\n                    },\n                ),\n            )\n        )\n\n        # Add data to both indices\n        for i in range(15):\n            vec = [float(i + j) for j in range(8)]\n            client.hset(f\"doc16_{i}\", \"v16\", np.array(vec, dtype=np.float16).tobytes())\n            client.hset(f\"doc32_{i}\", \"v32\", np.array(vec, dtype=np.float32).tobytes())\n\n        # Test both indices\n        query = Query(\"*=>[KNN 3 @v16 $vec as score]\").no_content()\n        res16 = client.ft(\"idx16\").search(\n            query,\n            query_params={\n                \"vec\": np.array(\n                    [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0], dtype=np.float16\n                ).tobytes()\n            },\n        )\n\n        query = Query(\"*=>[KNN 3 @v32 $vec as score]\").no_content()\n        res32 = client.ft(\"idx32\").search(\n            query,\n            query_params={\n                \"vec\": np.array(\n                    [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0], dtype=np.float32\n                ).tobytes()\n            },\n        )\n\n        if is_resp2_connection(client):\n            assert res16.total == 3\n            assert res32.total == 3\n        else:\n            assert res16[\"total_results\"] == 3\n            assert res32[\"total_results\"] == 3\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.4.3\", \"search\")\n    @skip_if_server_version_lt(\"8.1.224\")\n    def test_svs_vamana_construction_window_size(self, client):\n        client.ft().create_index(\n            (\n                VectorField(\n                    \"v\",\n                    \"SVS-VAMANA\",\n                    {\n                        \"TYPE\": \"FLOAT32\",\n                        \"DIM\": 6,\n                        \"DISTANCE_METRIC\": \"L2\",\n                        \"CONSTRUCTION_WINDOW_SIZE\": 300,\n                    },\n                ),\n            )\n        )\n\n        vectors = []\n        for i in range(20):\n            vec = [float(i + j) for j in range(6)]\n            vectors.append(vec)\n            client.hset(f\"doc{i}\", \"v\", np.array(vec, dtype=np.float32).tobytes())\n\n        query = Query(\"*=>[KNN 5 @v $vec as score]\").no_content()\n        query_params = {\"vec\": np.array(vectors[0], dtype=np.float32).tobytes()}\n\n        res = client.ft().search(query, query_params=query_params)\n        if is_resp2_connection(client):\n            assert res.total == 5\n            assert \"doc0\" == res.docs[0].id\n        else:\n            assert res[\"total_results\"] == 5\n            assert \"doc0\" == res[\"results\"][0][\"id\"]\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.4.3\", \"search\")\n    @skip_if_server_version_lt(\"8.1.224\")\n    def test_svs_vamana_graph_max_degree(self, client):\n        client.ft().create_index(\n            (\n                VectorField(\n                    \"v\",\n                    \"SVS-VAMANA\",\n                    {\n                        \"TYPE\": \"FLOAT32\",\n                        \"DIM\": 6,\n                        \"DISTANCE_METRIC\": \"COSINE\",\n                        \"GRAPH_MAX_DEGREE\": 64,\n                    },\n                ),\n            )\n        )\n\n        vectors = []\n        for i in range(25):\n            vec = [float(i + j) for j in range(6)]\n            vectors.append(vec)\n            client.hset(f\"doc{i}\", \"v\", np.array(vec, dtype=np.float32).tobytes())\n\n        query = Query(\"*=>[KNN 6 @v $vec as score]\").no_content()\n        query_params = {\"vec\": np.array(vectors[0], dtype=np.float32).tobytes()}\n\n        res = client.ft().search(query, query_params=query_params)\n        if is_resp2_connection(client):\n            assert res.total == 6\n            assert \"doc0\" == res.docs[0].id\n        else:\n            assert res[\"total_results\"] == 6\n            assert \"doc0\" == res[\"results\"][0][\"id\"]\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.4.3\", \"search\")\n    @skip_if_server_version_lt(\"8.1.224\")\n    def test_svs_vamana_search_window_size(self, client):\n        client.ft().create_index(\n            (\n                VectorField(\n                    \"v\",\n                    \"SVS-VAMANA\",\n                    {\n                        \"TYPE\": \"FLOAT32\",\n                        \"DIM\": 6,\n                        \"DISTANCE_METRIC\": \"L2\",\n                        \"SEARCH_WINDOW_SIZE\": 20,\n                    },\n                ),\n            )\n        )\n\n        vectors = []\n        for i in range(30):\n            vec = [float(i + j) for j in range(6)]\n            vectors.append(vec)\n            client.hset(f\"doc{i}\", \"v\", np.array(vec, dtype=np.float32).tobytes())\n\n        query = Query(\"*=>[KNN 8 @v $vec as score]\").no_content()\n        query_params = {\"vec\": np.array(vectors[0], dtype=np.float32).tobytes()}\n\n        res = client.ft().search(query, query_params=query_params)\n        if is_resp2_connection(client):\n            assert res.total == 8\n            assert \"doc0\" == res.docs[0].id\n        else:\n            assert res[\"total_results\"] == 8\n            assert \"doc0\" == res[\"results\"][0][\"id\"]\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.4.3\", \"search\")\n    @skip_if_server_version_lt(\"8.1.224\")\n    def test_svs_vamana_epsilon_parameter(self, client):\n        client.ft().create_index(\n            (\n                VectorField(\n                    \"v\",\n                    \"SVS-VAMANA\",\n                    {\n                        \"TYPE\": \"FLOAT32\",\n                        \"DIM\": 6,\n                        \"DISTANCE_METRIC\": \"L2\",\n                        \"EPSILON\": 0.05,\n                    },\n                ),\n            )\n        )\n\n        vectors = []\n        for i in range(20):\n            vec = [float(i + j) for j in range(6)]\n            vectors.append(vec)\n            client.hset(f\"doc{i}\", \"v\", np.array(vec, dtype=np.float32).tobytes())\n\n        query = Query(\"*=>[KNN 5 @v $vec as score]\").no_content()\n        query_params = {\"vec\": np.array(vectors[0], dtype=np.float32).tobytes()}\n\n        res = client.ft().search(query, query_params=query_params)\n        if is_resp2_connection(client):\n            assert res.total == 5\n            assert \"doc0\" == res.docs[0].id\n        else:\n            assert res[\"total_results\"] == 5\n            assert \"doc0\" == res[\"results\"][0][\"id\"]\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.4.3\", \"search\")\n    @skip_if_server_version_lt(\"8.1.224\")\n    def test_svs_vamana_all_build_parameters_combined(self, client):\n        client.ft().create_index(\n            (\n                VectorField(\n                    \"v\",\n                    \"SVS-VAMANA\",\n                    {\n                        \"TYPE\": \"FLOAT32\",\n                        \"DIM\": 8,\n                        \"DISTANCE_METRIC\": \"IP\",\n                        \"CONSTRUCTION_WINDOW_SIZE\": 250,\n                        \"GRAPH_MAX_DEGREE\": 48,\n                        \"SEARCH_WINDOW_SIZE\": 15,\n                        \"EPSILON\": 0.02,\n                    },\n                ),\n            )\n        )\n\n        vectors = []\n        for i in range(35):\n            vec = [float(i + j) for j in range(8)]\n            vectors.append(vec)\n            client.hset(f\"doc{i}\", \"v\", np.array(vec, dtype=np.float32).tobytes())\n\n        query = Query(\"*=>[KNN 7 @v $vec as score]\").no_content()\n        query_params = {\"vec\": np.array(vectors[0], dtype=np.float32).tobytes()}\n\n        res = client.ft().search(query, query_params=query_params)\n        if is_resp2_connection(client):\n            assert res.total == 7\n            doc_ids = [doc.id for doc in res.docs]\n            assert len(doc_ids) == 7\n        else:\n            assert res[\"total_results\"] == 7\n            doc_ids = [doc[\"id\"] for doc in res[\"results\"]]\n            assert len(doc_ids) == 7\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.4.3\", \"search\")\n    @skip_if_server_version_lt(\"8.1.224\")\n    def test_svs_vamana_comprehensive_configuration(self, client):\n        client.flushdb()\n        client.ft().create_index(\n            (\n                VectorField(\n                    \"v\",\n                    \"SVS-VAMANA\",\n                    {\n                        \"TYPE\": \"FLOAT16\",\n                        \"DIM\": 32,\n                        \"DISTANCE_METRIC\": \"COSINE\",\n                        \"COMPRESSION\": \"LVQ8\",\n                        \"CONSTRUCTION_WINDOW_SIZE\": 400,\n                        \"GRAPH_MAX_DEGREE\": 96,\n                        \"SEARCH_WINDOW_SIZE\": 25,\n                        \"EPSILON\": 0.03,\n                        \"TRAINING_THRESHOLD\": 2048,\n                    },\n                ),\n            )\n        )\n\n        vectors = []\n        for i in range(60):\n            vec = [float(i + j) for j in range(32)]\n            vectors.append(vec)\n            client.hset(f\"doc{i}\", \"v\", np.array(vec, dtype=np.float16).tobytes())\n\n        query = Query(\"*=>[KNN 10 @v $vec as score]\").no_content()\n        query_params = {\"vec\": np.array(vectors[0], dtype=np.float16).tobytes()}\n\n        res = client.ft().search(query, query_params=query_params)\n        if is_resp2_connection(client):\n            assert res.total == 10\n            assert \"doc0\" == res.docs[0].id\n        else:\n            assert res[\"total_results\"] == 10\n            assert \"doc0\" == res[\"results\"][0][\"id\"]\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.4.3\", \"search\")\n    @skip_if_server_version_lt(\"8.1.224\")\n    def test_svs_vamana_hybrid_text_vector_search(self, client):\n        client.flushdb()\n        client.ft().create_index(\n            (\n                TextField(\"title\"),\n                TextField(\"content\"),\n                VectorField(\n                    \"embedding\",\n                    \"SVS-VAMANA\",\n                    {\n                        \"TYPE\": \"FLOAT32\",\n                        \"DIM\": 6,\n                        \"DISTANCE_METRIC\": \"COSINE\",\n                        \"SEARCH_WINDOW_SIZE\": 20,\n                    },\n                ),\n            )\n        )\n\n        docs = [\n            {\n                \"title\": \"AI Research\",\n                \"content\": \"machine learning algorithms\",\n                \"embedding\": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0],\n            },\n            {\n                \"title\": \"Data Science\",\n                \"content\": \"statistical analysis methods\",\n                \"embedding\": [2.0, 3.0, 4.0, 5.0, 6.0, 7.0],\n            },\n            {\n                \"title\": \"Deep Learning\",\n                \"content\": \"neural network architectures\",\n                \"embedding\": [3.0, 4.0, 5.0, 6.0, 7.0, 8.0],\n            },\n            {\n                \"title\": \"Computer Vision\",\n                \"content\": \"image processing techniques\",\n                \"embedding\": [10.0, 11.0, 12.0, 13.0, 14.0, 15.0],\n            },\n        ]\n\n        for i, doc in enumerate(docs):\n            client.hset(\n                f\"doc{i}\",\n                mapping={\n                    \"title\": doc[\"title\"],\n                    \"content\": doc[\"content\"],\n                    \"embedding\": np.array(doc[\"embedding\"], dtype=np.float32).tobytes(),\n                },\n            )\n\n        # Hybrid query - text filter + vector similarity\n        query = \"(@title:AI|@content:machine)=>[KNN 2 @embedding $vec]\"\n        q = (\n            Query(query)\n            .return_field(\"__embedding_score\")\n            .sort_by(\"__embedding_score\", True)\n        )\n        res = client.ft().search(\n            q,\n            query_params={\n                \"vec\": np.array(\n                    [1.0, 2.0, 3.0, 4.0, 5.0, 6.0], dtype=np.float32\n                ).tobytes()\n            },\n        )\n\n        if is_resp2_connection(client):\n            assert res.total >= 1\n            doc_ids = [doc.id for doc in res.docs]\n            assert \"doc0\" in doc_ids\n        else:\n            assert res[\"total_results\"] >= 1\n            doc_ids = [doc[\"id\"] for doc in res[\"results\"]]\n            assert \"doc0\" in doc_ids\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.4.3\", \"search\")\n    @skip_if_server_version_lt(\"8.1.224\")\n    def test_svs_vamana_large_dimension_vectors(self, client):\n        client.flushdb()\n        client.ft().create_index(\n            (\n                VectorField(\n                    \"v\",\n                    \"SVS-VAMANA\",\n                    {\n                        \"TYPE\": \"FLOAT32\",\n                        \"DIM\": 512,\n                        \"DISTANCE_METRIC\": \"L2\",\n                        \"CONSTRUCTION_WINDOW_SIZE\": 300,\n                        \"GRAPH_MAX_DEGREE\": 64,\n                    },\n                ),\n            )\n        )\n\n        vectors = []\n        for i in range(10):\n            vec = [float(i + j) for j in range(512)]\n            vectors.append(vec)\n            client.hset(f\"doc{i}\", \"v\", np.array(vec, dtype=np.float32).tobytes())\n\n        query = Query(\"*=>[KNN 5 @v $vec as score]\").no_content()\n        query_params = {\"vec\": np.array(vectors[0], dtype=np.float32).tobytes()}\n\n        res = client.ft().search(query, query_params=query_params)\n        if is_resp2_connection(client):\n            assert res.total == 5\n            assert \"doc0\" == res.docs[0].id\n        else:\n            assert res[\"total_results\"] == 5\n            assert \"doc0\" == res[\"results\"][0][\"id\"]\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.4.3\", \"search\")\n    @skip_if_server_version_lt(\"8.1.224\")\n    def test_svs_vamana_training_threshold_behavior(self, client):\n        client.ft().create_index(\n            (\n                VectorField(\n                    \"v\",\n                    \"SVS-VAMANA\",\n                    {\n                        \"TYPE\": \"FLOAT32\",\n                        \"DIM\": 8,\n                        \"DISTANCE_METRIC\": \"L2\",\n                        \"COMPRESSION\": \"LVQ8\",\n                        \"TRAINING_THRESHOLD\": 1024,\n                    },\n                ),\n            )\n        )\n\n        vectors = []\n        for i in range(20):\n            vec = [float(i + j) for j in range(8)]\n            vectors.append(vec)\n            client.hset(f\"doc{i}\", \"v\", np.array(vec, dtype=np.float32).tobytes())\n\n            if i >= 5:\n                query = Query(\"*=>[KNN 3 @v $vec as score]\").no_content()\n                query_params = {\"vec\": np.array(vectors[0], dtype=np.float32).tobytes()}\n                res = client.ft().search(query, query_params=query_params)\n\n                if is_resp2_connection(client):\n                    assert res.total >= 1\n                else:\n                    assert res[\"total_results\"] >= 1\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.4.3\", \"search\")\n    @skip_if_server_version_lt(\"8.1.224\")\n    def test_svs_vamana_different_k_values(self, client):\n        client.ft().create_index(\n            (\n                VectorField(\n                    \"v\",\n                    \"SVS-VAMANA\",\n                    {\n                        \"TYPE\": \"FLOAT32\",\n                        \"DIM\": 6,\n                        \"DISTANCE_METRIC\": \"L2\",\n                        \"SEARCH_WINDOW_SIZE\": 15,\n                    },\n                ),\n            )\n        )\n\n        vectors = []\n        for i in range(25):\n            vec = [float(i + j) for j in range(6)]\n            vectors.append(vec)\n            client.hset(f\"doc{i}\", \"v\", np.array(vec, dtype=np.float32).tobytes())\n\n        for k in [1, 3, 5, 10, 15]:\n            query = Query(f\"*=>[KNN {k} @v $vec as score]\").no_content()\n            query_params = {\"vec\": np.array(vectors[0], dtype=np.float32).tobytes()}\n            res = client.ft().search(query, query_params=query_params)\n\n            if is_resp2_connection(client):\n                assert res.total == k\n                assert \"doc0\" == res.docs[0].id\n            else:\n                assert res[\"total_results\"] == k\n                assert \"doc0\" == res[\"results\"][0][\"id\"]\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.4.3\", \"search\")\n    @skip_if_server_version_lt(\"8.1.224\")\n    def test_svs_vamana_vector_field_error(self, client):\n        # sortable tag\n        with pytest.raises(Exception):\n            client.ft().create_index(\n                (VectorField(\"v\", \"SVS-VAMANA\", {}, sortable=True),)\n            )\n\n        # no_index tag\n        with pytest.raises(Exception):\n            client.ft().create_index(\n                (VectorField(\"v\", \"SVS-VAMANA\", {}, no_index=True),)\n            )\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.4.3\", \"search\")\n    @skip_if_server_version_lt(\"8.1.224\")\n    def test_svs_vamana_vector_search_with_parameters(self, client):\n        client.ft().create_index(\n            (\n                VectorField(\n                    \"v\",\n                    \"SVS-VAMANA\",\n                    {\n                        \"TYPE\": \"FLOAT32\",\n                        \"DIM\": 4,\n                        \"DISTANCE_METRIC\": \"L2\",\n                        \"CONSTRUCTION_WINDOW_SIZE\": 200,\n                        \"GRAPH_MAX_DEGREE\": 64,\n                        \"SEARCH_WINDOW_SIZE\": 40,\n                        \"EPSILON\": 0.01,\n                    },\n                ),\n            )\n        )\n\n        # Create test vectors\n        vectors = [\n            [1.0, 2.0, 3.0, 4.0],\n            [2.0, 3.0, 4.0, 5.0],\n            [3.0, 4.0, 5.0, 6.0],\n            [4.0, 5.0, 6.0, 7.0],\n            [5.0, 6.0, 7.0, 8.0],\n        ]\n\n        for i, vec in enumerate(vectors):\n            client.hset(f\"doc{i}\", \"v\", np.array(vec, dtype=np.float32).tobytes())\n\n        query = Query(\"*=>[KNN 3 @v $vec as score]\").no_content()\n        query_params = {\"vec\": np.array(vectors[0], dtype=np.float32).tobytes()}\n\n        res = client.ft().search(query, query_params=query_params)\n        if is_resp2_connection(client):\n            assert res.total == 3\n            assert \"doc0\" == res.docs[0].id\n        else:\n            assert res[\"total_results\"] == 3\n            assert \"doc0\" == res[\"results\"][0][\"id\"]\n\n    @pytest.mark.redismod\n    @skip_ifmodversion_lt(\"2.4.3\", \"search\")\n    @skip_if_server_version_lt(\"8.1.224\")\n    def test_svs_vamana_vector_search_with_parameters_leanvec(self, client):\n        client.ft().create_index(\n            (\n                VectorField(\n                    \"v\",\n                    \"SVS-VAMANA\",\n                    {\n                        \"TYPE\": \"FLOAT32\",\n                        \"DIM\": 8,\n                        \"DISTANCE_METRIC\": \"L2\",\n                        \"COMPRESSION\": \"LeanVec8x8\",  # LeanVec compression required for REDUCE\n                        \"CONSTRUCTION_WINDOW_SIZE\": 200,\n                        \"GRAPH_MAX_DEGREE\": 32,\n                        \"SEARCH_WINDOW_SIZE\": 15,\n                        \"EPSILON\": 0.01,\n                        \"TRAINING_THRESHOLD\": 1024,\n                        \"REDUCE\": 4,  # Half of DIM (8/2 = 4)\n                    },\n                ),\n            )\n        )\n\n        # Create test vectors (8-dimensional to match DIM)\n        vectors = [\n            [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0],\n            [2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0],\n            [3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0],\n            [4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0],\n            [5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0],\n        ]\n\n        for i, vec in enumerate(vectors):\n            client.hset(f\"doc{i}\", \"v\", np.array(vec, dtype=np.float32).tobytes())\n\n        query = Query(\"*=>[KNN 3 @v $vec as score]\").no_content()\n        query_params = {\"vec\": np.array(vectors[0], dtype=np.float32).tobytes()}\n\n        res = client.ft().search(query, query_params=query_params)\n        if is_resp2_connection(client):\n            assert res.total == 3\n            assert \"doc0\" == res.docs[0].id\n        else:\n            assert res[\"total_results\"] == 3\n            assert \"doc0\" == res[\"results\"][0][\"id\"]\n\n\nclass TestHybridSearch(SearchTestsBase):\n    def _create_hybrid_search_index(self, client, dim=4):\n        client.ft().create_index(\n            (\n                TextField(\"description\"),\n                NumericField(\"price\"),\n                TagField(\"color\"),\n                TagField(\"item_type\"),\n                NumericField(\"size\"),\n                VectorField(\n                    \"embedding\",\n                    \"FLAT\",\n                    {\n                        \"TYPE\": \"FLOAT32\",\n                        \"DIM\": dim,\n                        \"DISTANCE_METRIC\": \"L2\",\n                    },\n                ),\n                VectorField(\n                    \"embedding-hnsw\",\n                    \"HNSW\",\n                    {\n                        \"TYPE\": \"FLOAT32\",\n                        \"DIM\": dim,\n                        \"DISTANCE_METRIC\": \"L2\",\n                    },\n                ),\n            ),\n            definition=IndexDefinition(prefix=[\"item:\"]),\n        )\n        SearchTestsBase.waitForIndex(client, \"idx\")\n\n    @staticmethod\n    def _generate_random_vector(dim):\n        return [random.random() for _ in range(dim)]\n\n    @staticmethod\n    def _generate_random_str_data(dim):\n        chars = \"abcdefgh12345678\"\n        return \"\".join(random.choice(chars) for _ in range(dim))\n\n    @staticmethod\n    def _add_data_for_hybrid_search(\n        client,\n        items_sets=1,\n        randomize_data=False,\n        dim_for_random_data=4,\n        use_random_str_data=False,\n    ):\n        if randomize_data or use_random_str_data:\n            generate_data_func = (\n                TestHybridSearch._generate_random_str_data\n                if use_random_str_data\n                else TestHybridSearch._generate_random_vector\n            )\n\n            dim_for_random_data = (\n                dim_for_random_data * 4 if use_random_str_data else dim_for_random_data\n            )\n\n            items = [\n                (generate_data_func(dim_for_random_data), \"red shoes\"),\n                (generate_data_func(dim_for_random_data), \"green shoes with red laces\"),\n                (generate_data_func(dim_for_random_data), \"red dress\"),\n                (generate_data_func(dim_for_random_data), \"orange dress\"),\n                (generate_data_func(dim_for_random_data), \"black shoes\"),\n            ]\n        else:\n            items = [\n                ([1.0, 2.0, 7.0, 8.0], \"red shoes\"),\n                ([1.0, 4.0, 7.0, 8.0], \"green shoes with red laces\"),\n                ([1.0, 2.0, 6.0, 5.0], \"red dress\"),\n                ([2.0, 3.0, 6.0, 5.0], \"orange dress\"),\n                ([5.0, 6.0, 7.0, 8.0], \"black shoes\"),\n            ]\n        items = items * items_sets\n\n        pipeline = client.pipeline()\n        for i, vec in enumerate(items):\n            vec, description = vec\n            mapping = {\n                \"description\": description,\n                \"embedding\": np.array(vec, dtype=np.float32).tobytes()\n                if not use_random_str_data\n                else vec,\n                \"embedding-hnsw\": np.array(vec, dtype=np.float32).tobytes()\n                if not use_random_str_data\n                else vec,\n                \"price\": 15 + i % 4,\n                \"color\": description.split(\" \")[0],\n                \"item_type\": description.split(\" \")[1],\n                \"size\": 10 + i % 3,\n            }\n\n            pipeline.hset(\n                f\"item:{i}\",\n                mapping=mapping,\n            )\n        pipeline.execute()  # Execute all at once\n\n    @staticmethod\n    def _convert_dict_values_to_str(list_of_dicts):\n        res = []\n        for d in list_of_dicts:\n            res_dict = {}\n            for k, v in d.items():\n                if isinstance(v, list):\n                    res_dict[k] = [safe_str(x) for x in v]\n                else:\n                    res_dict[k] = safe_str(v)\n            res.append(res_dict)\n        return res\n\n    @staticmethod\n    def compare_list_of_dicts(actual, expected):\n        assert len(actual) == len(expected), (\n            f\"List of dicts length mismatch: {len(actual)} != {len(expected)}. \"\n            f\"Full dicts: actual:{actual}; expected:{expected}\"\n        )\n        for expected_dict_item in expected:\n            found = False\n            for actual_dict_item in actual:\n                if actual_dict_item == expected_dict_item:\n                    found = True\n                    break\n            if not found:\n                assert False, (\n                    f\"Dict {expected_dict_item} not found in actual list of dicts: {actual}. \"\n                    f\"All expected:{expected}\"\n                )\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.4.0\")\n    def test_basic_hybrid_search(self, client):\n        # Create index and add data\n        self._create_hybrid_search_index(client)\n        self._add_data_for_hybrid_search(client, items_sets=5)\n\n        # set search query\n        search_query = HybridSearchQuery(\"@color:{red} @color:{green}\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding\",\n            vector_data=\"$vec\",\n        )\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        res = client.ft().hybrid_search(\n            query=hybrid_query,\n            params_substitution={\n                \"vec\": np.array([-100, -200, -200, -300], dtype=np.float32).tobytes()\n            },\n        )\n\n        # the default results count limit is 10\n        if is_resp2_connection(client):\n            assert res.total_results == 10\n            assert len(res.results) == 10\n            assert res.warnings == []\n            assert res.execution_time > 0\n            assert all(isinstance(res.results[i][\"__score\"], bytes) for i in range(10))\n            assert all(isinstance(res.results[i][\"__key\"], bytes) for i in range(10))\n        else:\n            assert res[\"total_results\"] == 10\n            assert len(res[\"results\"]) == 10\n            assert res[\"warnings\"] == []\n            assert res[\"execution_time\"] > 0\n            assert all(isinstance(res[\"results\"][i][\"__score\"], str) for i in range(10))\n            assert all(isinstance(res[\"results\"][i][\"__key\"], str) for i in range(10))\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_hybrid_search_query_with_scorer(self, client):\n        # Create index and add data\n        self._create_hybrid_search_index(client)\n        self._add_data_for_hybrid_search(client, items_sets=10)\n\n        # set search query\n        search_query = HybridSearchQuery(\"shoes\")\n        search_query.scorer(\"TFIDF\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding\",\n            vector_data=\"$vec\",\n        )\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        combine_config = CombineResultsMethod(\n            CombinationMethods.LINEAR, ALPHA=1, BETA=0\n        )\n\n        postprocessing_config = HybridPostProcessingConfig()\n        postprocessing_config.load(\n            \"@description\", \"@color\", \"@price\", \"@size\", \"@__score\", \"@__item\"\n        )\n        postprocessing_config.limit(0, 2)\n\n        res = client.ft().hybrid_search(\n            query=hybrid_query,\n            combine_method=combine_config,\n            post_processing=postprocessing_config,\n            params_substitution={\n                \"vec\": np.array([1, 2, 2, 3], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n\n        expected_results_tfidf = [\n            {\n                \"description\": b\"red shoes\",\n                \"color\": b\"red\",\n                \"price\": b\"15\",\n                \"size\": b\"10\",\n                \"__score\": b\"2\",\n            },\n            {\n                \"description\": b\"green shoes with red laces\",\n                \"color\": b\"green\",\n                \"price\": b\"16\",\n                \"size\": b\"11\",\n                \"__score\": b\"2\",\n            },\n        ]\n\n        if is_resp2_connection(client):\n            assert res.total_results >= 2\n            assert len(res.results) == 2\n            assert res.results == expected_results_tfidf\n            assert res.warnings == []\n        else:\n            assert res[\"total_results\"] >= 2\n            assert len(res[\"results\"]) == 2\n            assert res[\"results\"] == self._convert_dict_values_to_str(\n                expected_results_tfidf\n            )\n            assert res[\"warnings\"] == []\n\n        search_query.scorer(\"BM25\")\n        res = client.ft().hybrid_search(\n            query=hybrid_query,\n            combine_method=combine_config,\n            post_processing=postprocessing_config,\n            params_substitution={\n                \"vec\": np.array([1, 2, 2, 3], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n        expected_results_bm25 = [\n            {\n                \"description\": b\"red shoes\",\n                \"color\": b\"red\",\n                \"price\": b\"15\",\n                \"size\": b\"10\",\n                \"__score\": b\"0.657894719299\",\n            },\n            {\n                \"description\": b\"green shoes with red laces\",\n                \"color\": b\"green\",\n                \"price\": b\"16\",\n                \"size\": b\"11\",\n                \"__score\": b\"0.657894719299\",\n            },\n        ]\n        if is_resp2_connection(client):\n            assert res.total_results >= 2\n            assert len(res.results) == 2\n            assert res.results == expected_results_bm25\n            assert res.warnings == []\n        else:\n            assert res[\"total_results\"] >= 2\n            assert len(res[\"results\"]) == 2\n            assert res[\"results\"] == self._convert_dict_values_to_str(\n                expected_results_bm25\n            )\n            assert res[\"warnings\"] == []\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_hybrid_search_query_with_supported_scorer(self, client):\n        # Create index and add data\n        self._create_hybrid_search_index(client)\n        self._add_data_for_hybrid_search(client, items_sets=10)\n\n        # set search query\n        search_query = HybridSearchQuery(\"shoes\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding\",\n            vector_data=\"$vec\",\n        )\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        supported_scorers = [\n            \"TFIDF\",\n            \"TFIDF.DOCNORM\",\n            \"BM25\",\n            \"BM25STD\",\n            \"BM25STD.TANH\",\n            \"DISMAX\",\n            \"DOCSCORE\",\n            \"HAMMING\",\n        ]\n        for scorer in supported_scorers:\n            search_query.scorer(scorer)\n\n            res = client.ft().hybrid_search(\n                query=hybrid_query,\n                params_substitution={\n                    \"vec\": np.array([1, 2, 2, 3], dtype=np.float32).tobytes()\n                },\n                timeout=10,\n            )\n            assert res is not None\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_hybrid_search_query_with_vsim_method_defined_query_init(self, client):\n        # Create index and add data\n        self._create_hybrid_search_index(client)\n        self._add_data_for_hybrid_search(client, items_sets=5, use_random_str_data=True)\n        # set search query\n        search_query = HybridSearchQuery(\"shoes\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding-hnsw\",\n            vector_data=\"$vec\",\n            vsim_search_method=VectorSearchMethods.KNN,\n            vsim_search_method_params={\"K\": 3, \"EF_RUNTIME\": 1},\n        )\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        res = client.ft().hybrid_search(\n            query=hybrid_query,\n            params_substitution={\"vec\": \"abcd1234efgh5678\"},\n            timeout=10,\n        )\n        if is_resp2_connection(client):\n            assert len(res.results) > 0\n            assert res.warnings == []\n        else:\n            assert len(res[\"results\"]) > 0\n            assert res[\"warnings\"] == []\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_hybrid_search_query_with_vsim_filter(self, client):\n        # Create index and add data\n        self._create_hybrid_search_index(client)\n        self._add_data_for_hybrid_search(client, items_sets=5, use_random_str_data=True)\n\n        search_query = HybridSearchQuery(\"@color:{missing}\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding\",\n            vector_data=\"$vec\",\n        )\n        vsim_query.filter(HybridFilter(\"@price:[15 16] @size:[10 11]\"))\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        postprocessing_config = HybridPostProcessingConfig()\n        postprocessing_config.load(\"@price\", \"@size\")\n\n        res = client.ft().hybrid_search(\n            query=hybrid_query,\n            post_processing=postprocessing_config,\n            params_substitution={\n                \"vec\": np.array([1, 2, 2, 3], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n        if is_resp2_connection(client):\n            assert len(res.results) > 0\n            assert res.warnings == []\n            for item in res.results:\n                assert item[\"price\"] in [b\"15\", b\"16\"]\n                assert item[\"size\"] in [b\"10\", b\"11\"]\n        else:\n            assert len(res[\"results\"]) > 0\n            assert res[\"warnings\"] == []\n            for item in res[\"results\"]:\n                assert item[\"price\"] in [\"15\", \"16\"]\n                assert item[\"size\"] in [\"10\", \"11\"]\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_hybrid_search_query_with_search_score_aliases(self, client):\n        # Create index and add data\n        self._create_hybrid_search_index(client)\n        self._add_data_for_hybrid_search(client, items_sets=1, use_random_str_data=True)\n\n        search_query = HybridSearchQuery(\"shoes\")\n        search_query.yield_score_as(\"search_score\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding\",\n            vector_data=\"$vec\",\n        )\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        res = client.ft().hybrid_search(\n            query=hybrid_query,\n            params_substitution={\"vec\": \"abcd1234efgh5678\"},\n            timeout=10,\n        )\n\n        if is_resp2_connection(client):\n            assert len(res.results) > 0\n            assert res.warnings == []\n            for item in res.results:\n                if item[\"__key\"] in [b\"item:0\", b\"item:1\", b\"item:4\"]:\n                    assert item[\"search_score\"] is not None\n                    assert item[\"__score\"] is not None\n                else:\n                    assert \"search_score\" not in item\n                    assert item[\"__score\"] is not None\n\n        else:\n            assert len(res[\"results\"]) > 0\n            assert res[\"warnings\"] == []\n            for item in res[\"results\"]:\n                if item[\"__key\"] in [\"item:0\", \"item:1\", \"item:4\"]:\n                    assert item[\"search_score\"] is not None\n                    assert item[\"__score\"] is not None\n                else:\n                    assert \"search_score\" not in item\n                    assert item[\"__score\"] is not None\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_hybrid_search_query_with_vsim_score_aliases(self, client):\n        # Create index and add data\n        self._create_hybrid_search_index(client)\n        self._add_data_for_hybrid_search(client, items_sets=1, use_random_str_data=True)\n\n        search_query = HybridSearchQuery(\"shoes\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding-hnsw\",\n            vector_data=\"$vec\",\n            vsim_search_method=VectorSearchMethods.KNN,\n            vsim_search_method_params={\"K\": 3, \"EF_RUNTIME\": 1},\n            yield_score_as=\"vsim_score\",\n        )\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        res = client.ft().hybrid_search(\n            query=hybrid_query,\n            params_substitution={\"vec\": \"abcd1234efgh5678\"},\n            timeout=10,\n        )\n\n        if is_resp2_connection(client):\n            assert len(res.results) > 0\n            assert res.warnings == []\n            for item in res.results:\n                if item[\"__key\"] in [b\"item:0\", b\"item:1\", b\"item:2\"]:\n                    assert item[\"vsim_score\"] is not None\n                    assert item[\"__score\"] is not None\n                else:\n                    assert \"vsim_score\" not in item\n                    assert item[\"__score\"] is not None\n\n        else:\n            assert len(res[\"results\"]) > 0\n            assert res[\"warnings\"] == []\n            for item in res[\"results\"]:\n                if item[\"__key\"] in [\"item:0\", \"item:1\", \"item:2\"]:\n                    assert item[\"vsim_score\"] is not None\n                    assert item[\"__score\"] is not None\n                else:\n                    assert \"vsim_score\" not in item\n                    assert item[\"__score\"] is not None\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_hybrid_search_query_with_combine_score_aliases(self, client):\n        # Create index and add data\n        self._create_hybrid_search_index(client)\n        self._add_data_for_hybrid_search(client, items_sets=1, use_random_str_data=True)\n\n        search_query = HybridSearchQuery(\"shoes\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding-hnsw\", vector_data=\"$vec\"\n        )\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n        combine_method = CombineResultsMethod(\n            CombinationMethods.LINEAR,\n            ALPHA=0.5,\n            BETA=0.5,\n            YIELD_SCORE_AS=\"combined_score\",\n        )\n\n        res = client.ft().hybrid_search(\n            query=hybrid_query,\n            combine_method=combine_method,\n            params_substitution={\"vec\": \"abcd1234efgh5678\"},\n            timeout=10,\n        )\n\n        if is_resp2_connection(client):\n            assert len(res.results) > 0\n            assert res.warnings == []\n            for item in res.results:\n                assert item[\"combined_score\"] is not None\n                assert \"__score\" not in item\n\n        else:\n            assert len(res[\"results\"]) > 0\n            assert res[\"warnings\"] == []\n            for item in res[\"results\"]:\n                assert item[\"combined_score\"] is not None\n                assert \"__score\" not in item\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_hybrid_search_query_with_combine_all_score_aliases(self, client):\n        # Create index and add data\n        self._create_hybrid_search_index(client)\n        self._add_data_for_hybrid_search(client, items_sets=1, use_random_str_data=True)\n\n        search_query = HybridSearchQuery(\"shoes\")\n        search_query.yield_score_as(\"search_score\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding-hnsw\",\n            vector_data=\"$vec\",\n            vsim_search_method=VectorSearchMethods.KNN,\n            vsim_search_method_params={\"K\": 3, \"EF_RUNTIME\": 1},\n            yield_score_as=\"vsim_score\",\n        )\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        combine_method = CombineResultsMethod(\n            CombinationMethods.LINEAR,\n            ALPHA=0.5,\n            BETA=0.5,\n            YIELD_SCORE_AS=\"combined_score\",\n        )\n\n        res = client.ft().hybrid_search(\n            query=hybrid_query,\n            combine_method=combine_method,\n            params_substitution={\"vec\": \"abcd1234efgh5678\"},\n            timeout=10,\n        )\n\n        if is_resp2_connection(client):\n            assert len(res.results) > 0\n            assert res.warnings == []\n            for item in res.results:\n                assert item[\"combined_score\"] is not None\n                assert \"__score\" not in item\n                if item[\"__key\"] in [b\"item:0\", b\"item:1\", b\"item:4\"]:\n                    assert item[\"search_score\"] is not None\n                else:\n                    assert \"search_score\" not in item\n                if item[\"__key\"] in [b\"item:0\", b\"item:1\", b\"item:2\"]:\n                    assert item[\"vsim_score\"] is not None\n                else:\n                    assert \"vsim_score\" not in item\n\n        else:\n            assert len(res[\"results\"]) > 0\n            assert res[\"warnings\"] == []\n            for item in res[\"results\"]:\n                assert item[\"combined_score\"] is not None\n                assert \"__score\" not in item\n                if item[\"__key\"] in [\"item:0\", \"item:1\", \"item:4\"]:\n                    assert item[\"search_score\"] is not None\n                else:\n                    assert \"search_score\" not in item\n                if item[\"__key\"] in [\"item:0\", \"item:1\", \"item:2\"]:\n                    assert item[\"vsim_score\"] is not None\n                else:\n                    assert \"vsim_score\" not in item\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_hybrid_search_query_with_vsim_knn(self, client):\n        # Create index and add data\n        self._create_hybrid_search_index(client)\n        self._add_data_for_hybrid_search(client, items_sets=10)\n\n        # set search query\n        # this query won't have results, so we will be able to validate vsim results\n        search_query = HybridSearchQuery(\"@color:{none}\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding\",\n            vector_data=\"$vec\",\n        )\n\n        vsim_query.vsim_method_params(VectorSearchMethods.KNN, K=3)\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        postprocessing_config = HybridPostProcessingConfig()\n\n        res = client.ft().hybrid_search(\n            query=hybrid_query,\n            post_processing=postprocessing_config,\n            params_substitution={\n                \"vec\": np.array([1, 2, 2, 3], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n        expected_results = [\n            {\"__key\": b\"item:2\", \"__score\": b\"0.016393442623\"},\n            {\"__key\": b\"item:7\", \"__score\": b\"0.0161290322581\"},\n            {\"__key\": b\"item:12\", \"__score\": b\"0.015873015873\"},\n        ]\n        if is_resp2_connection(client):\n            assert res.total_results == 3  # KNN top-k value\n            assert len(res.results) == 3\n            assert res.results == expected_results\n            assert res.warnings == []\n            assert res.execution_time > 0\n        else:\n            assert res[\"total_results\"] == 3  # KNN top-k value\n            assert len(res[\"results\"]) == 3\n            assert res[\"results\"] == self._convert_dict_values_to_str(expected_results)\n            assert res[\"warnings\"] == []\n            assert res[\"execution_time\"] > 0\n\n        vsim_query_with_hnsw = HybridVsimQuery(\n            vector_field_name=\"@embedding-hnsw\",\n            vector_data=\"$vec\",\n        )\n        vsim_query_with_hnsw.vsim_method_params(\n            VectorSearchMethods.KNN, K=3, EF_RUNTIME=1\n        )\n        hybrid_query_with_hnsw = HybridQuery(search_query, vsim_query_with_hnsw)\n\n        res2 = client.ft().hybrid_search(\n            query=hybrid_query_with_hnsw,\n            params_substitution={\n                \"vec\": np.array([1, 2, 2, 3], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n\n        expected_results2 = [\n            {\"__key\": b\"item:12\", \"__score\": b\"0.016393442623\"},\n            {\"__key\": b\"item:22\", \"__score\": b\"0.0161290322581\"},\n            {\"__key\": b\"item:27\", \"__score\": b\"0.015873015873\"},\n        ]\n        if is_resp2_connection(client):\n            assert res2.total_results == 3  # KNN top-k value\n            assert len(res2.results) == 3\n            assert res2.results == expected_results2\n            assert res2.warnings == []\n            assert res2.execution_time > 0\n        else:\n            assert res2[\"total_results\"] == 3  # KNN top-k value\n            assert len(res2[\"results\"]) == 3\n            assert res2[\"results\"] == self._convert_dict_values_to_str(\n                expected_results2\n            )\n            assert res2[\"warnings\"] == []\n            assert res2[\"execution_time\"] > 0\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_hybrid_search_query_with_vsim_range(self, client):\n        # Create index and add data\n        self._create_hybrid_search_index(client)\n        self._add_data_for_hybrid_search(client, items_sets=10)\n\n        # set search query\n        # this query won't have results, so we will be able to validate vsim results\n        search_query = HybridSearchQuery(\"@color:{none}\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding\",\n            vector_data=\"$vec\",\n        )\n\n        vsim_query.vsim_method_params(VectorSearchMethods.RANGE, RADIUS=2)\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        postprocessing_config = HybridPostProcessingConfig()\n        postprocessing_config.limit(0, 3)\n\n        res = client.ft().hybrid_search(\n            query=hybrid_query,\n            post_processing=postprocessing_config,\n            params_substitution={\n                \"vec\": np.array([1, 2, 7, 6], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n\n        expected_results = [\n            {\"__key\": b\"item:2\", \"__score\": b\"0.016393442623\"},\n            {\"__key\": b\"item:7\", \"__score\": b\"0.0161290322581\"},\n            {\"__key\": b\"item:12\", \"__score\": b\"0.015873015873\"},\n        ]\n        if is_resp2_connection(client):\n            assert res.total_results >= 3  # at least 3 results\n            assert len(res.results) == 3\n            assert res.results == expected_results\n            assert res.warnings == []\n            assert res.execution_time > 0\n        else:\n            assert res[\"total_results\"] >= 3\n            assert len(res[\"results\"]) == 3\n            assert res[\"results\"] == self._convert_dict_values_to_str(expected_results)\n            assert res[\"warnings\"] == []\n            assert res[\"execution_time\"] > 0\n\n        vsim_query_with_hnsw = HybridVsimQuery(\n            vector_field_name=\"@embedding-hnsw\",\n            vector_data=\"$vec\",\n        )\n\n        vsim_query_with_hnsw.vsim_method_params(\n            VectorSearchMethods.RANGE, RADIUS=2, EPSILON=0.5\n        )\n\n        hybrid_query_with_hnsw = HybridQuery(search_query, vsim_query_with_hnsw)\n\n        res = client.ft().hybrid_search(\n            query=hybrid_query_with_hnsw,\n            post_processing=postprocessing_config,\n            params_substitution={\n                \"vec\": np.array([1, 2, 7, 6], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n\n        expected_results_hnsw = [\n            {\"__key\": b\"item:27\", \"__score\": b\"0.016393442623\"},\n            {\"__key\": b\"item:12\", \"__score\": b\"0.0161290322581\"},\n            {\"__key\": b\"item:22\", \"__score\": b\"0.015873015873\"},\n        ]\n\n        if is_resp2_connection(client):\n            assert res.total_results >= 3\n            assert len(res.results) == 3\n            assert res.results == expected_results_hnsw\n            assert res.warnings == []\n            assert res.execution_time > 0\n        else:\n            assert res[\"total_results\"] >= 3\n            assert len(res[\"results\"]) == 3\n            assert res[\"results\"] == self._convert_dict_values_to_str(\n                expected_results_hnsw\n            )\n            assert res[\"warnings\"] == []\n            assert res[\"execution_time\"] > 0\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_hybrid_search_query_with_combine(self, client):\n        # Create index and add data\n        self._create_hybrid_search_index(client)\n        self._add_data_for_hybrid_search(client, items_sets=10)\n\n        # set search query\n        search_query = HybridSearchQuery(\"@color:{red}\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding\",\n            vector_data=\"$vec\",\n        )\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        combine_method_linear = CombineResultsMethod(\n            CombinationMethods.LINEAR, ALPHA=0.5, BETA=0.5\n        )\n\n        postprocessing_config = HybridPostProcessingConfig()\n        postprocessing_config.limit(0, 3)\n\n        res = client.ft().hybrid_search(\n            query=hybrid_query,\n            combine_method=combine_method_linear,\n            post_processing=postprocessing_config,\n            params_substitution={\n                \"vec\": np.array([1, 2, 7, 6], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n\n        expected_results = [\n            {\"__key\": b\"item:2\", \"__score\": b\"0.166666666667\"},\n            {\"__key\": b\"item:7\", \"__score\": b\"0.166666666667\"},\n            {\"__key\": b\"item:12\", \"__score\": b\"0.166666666667\"},\n        ]\n        if is_resp2_connection(client):\n            assert res.total_results >= 3\n            assert len(res.results) == 3\n            assert res.results == expected_results\n            assert res.warnings == []\n            assert res.execution_time > 0\n        else:\n            assert res[\"total_results\"] >= 3\n            assert len(res[\"results\"]) == 3\n            assert res[\"results\"] == self._convert_dict_values_to_str(expected_results)\n            assert res[\"warnings\"] == []\n            assert res[\"execution_time\"] > 0\n\n        # combine with RRF and WINDOW + CONSTANT\n        combine_method_rrf = CombineResultsMethod(\n            CombinationMethods.RRF, WINDOW=3, CONSTANT=0.5\n        )\n        res = client.ft().hybrid_search(\n            query=hybrid_query,\n            combine_method=combine_method_rrf,\n            post_processing=postprocessing_config,\n            params_substitution={\n                \"vec\": np.array([1, 2, 7, 6], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n\n        expected_results = [\n            {\"__key\": b\"item:2\", \"__score\": b\"1.06666666667\"},\n            {\"__key\": b\"item:0\", \"__score\": b\"0.666666666667\"},\n            {\"__key\": b\"item:7\", \"__score\": b\"0.4\"},\n        ]\n        if is_resp2_connection(client):\n            assert res.total_results >= 3\n            assert len(res.results) == 3\n            assert res.results == expected_results\n            assert res.warnings == []\n            assert res.execution_time > 0\n        else:\n            assert res[\"total_results\"] >= 3\n            assert len(res[\"results\"]) == 3\n            assert res[\"results\"] == self._convert_dict_values_to_str(expected_results)\n            assert res[\"warnings\"] == []\n            assert res[\"execution_time\"] > 0\n\n        # combine with RRF, not all possible params provided\n        combine_method_rrf_2 = CombineResultsMethod(CombinationMethods.RRF, WINDOW=3)\n        res = client.ft().hybrid_search(\n            query=hybrid_query,\n            combine_method=combine_method_rrf_2,\n            post_processing=postprocessing_config,\n            params_substitution={\n                \"vec\": np.array([1, 2, 7, 6], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n\n        expected_results = [\n            {\"__key\": b\"item:2\", \"__score\": b\"0.032522474881\"},\n            {\"__key\": b\"item:0\", \"__score\": b\"0.016393442623\"},\n            {\"__key\": b\"item:7\", \"__score\": b\"0.0161290322581\"},\n        ]\n        if is_resp2_connection(client):\n            assert res.total_results >= 3\n            assert len(res.results) == 3\n            assert res.results == expected_results\n            assert res.warnings == []\n            assert res.execution_time > 0\n        else:\n            assert res[\"total_results\"] >= 3\n            assert len(res[\"results\"]) == 3\n            assert res[\"results\"] == self._convert_dict_values_to_str(expected_results)\n            assert res[\"warnings\"] == []\n            assert res[\"execution_time\"] > 0\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_hybrid_search_query_with_load(self, client):\n        # Create index and add data\n        self._create_hybrid_search_index(client)\n        self._add_data_for_hybrid_search(client, items_sets=10)\n\n        # set search query\n        search_query = HybridSearchQuery(\"@color:{red|green|black}\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding\",\n            vector_data=\"$vec\",\n        )\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        combine_method = CombineResultsMethod(\n            CombinationMethods.LINEAR, ALPHA=0.5, BETA=0.5\n        )\n\n        postprocessing_config = HybridPostProcessingConfig()\n        postprocessing_config.load(\n            \"@description\", \"@color\", \"@price\", \"@size\", \"@__key AS item_key\"\n        )\n        postprocessing_config.limit(0, 1)\n\n        res = client.ft().hybrid_search(\n            query=hybrid_query,\n            combine_method=combine_method,\n            post_processing=postprocessing_config,\n            params_substitution={\n                \"vec\": np.array([1, 2, 7, 6], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n\n        expected_results = [\n            {\n                \"description\": b\"red dress\",\n                \"color\": b\"red\",\n                \"price\": b\"17\",\n                \"size\": b\"12\",\n                \"item_key\": b\"item:2\",\n            }\n        ]\n        if is_resp2_connection(client):\n            assert res.total_results >= 1\n            assert len(res.results) == 1\n            self.compare_list_of_dicts(res.results, expected_results)\n            assert res.warnings == []\n            assert res.execution_time > 0\n        else:\n            assert res[\"total_results\"] >= 1\n            assert len(res[\"results\"]) == 1\n            self.compare_list_of_dicts(\n                res[\"results\"], self._convert_dict_values_to_str(expected_results)\n            )\n            assert res[\"warnings\"] == []\n            assert res[\"execution_time\"] > 0\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    # @pytest.mark.repeat(6)\n    def test_hybrid_search_query_with_load_and_apply(self, client):\n        # Create index and add data\n        self._create_hybrid_search_index(client)\n        self._add_data_for_hybrid_search(client, items_sets=10)\n\n        # set search query\n        search_query = HybridSearchQuery(\"@color:{red}\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding\",\n            vector_data=\"$vec\",\n        )\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        postprocessing_config = HybridPostProcessingConfig()\n        postprocessing_config.load(\"@color\", \"@price\", \"@size\")\n        postprocessing_config.apply(\n            price_discount=\"@price - (@price * 0.1)\",\n            tax_discount=\"@price_discount * 0.2\",\n        )\n        postprocessing_config.limit(0, 3)\n\n        res = client.ft().hybrid_search(\n            query=hybrid_query,\n            post_processing=postprocessing_config,\n            params_substitution={\n                \"vec\": np.array([1, 2, 7, 6], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n\n        expected_results = [\n            {\n                \"color\": b\"red\",\n                \"price\": b\"15\",\n                \"size\": b\"10\",\n                \"price_discount\": b\"13.5\",\n                \"tax_discount\": b\"2.7\",\n            },\n            {\n                \"color\": b\"red\",\n                \"price\": b\"17\",\n                \"size\": b\"12\",\n                \"price_discount\": b\"15.3\",\n                \"tax_discount\": b\"3.06\",\n            },\n            {\n                \"color\": b\"red\",\n                \"price\": b\"18\",\n                \"size\": b\"11\",\n                \"price_discount\": b\"16.2\",\n                \"tax_discount\": b\"3.24\",\n            },\n        ]\n        if is_resp2_connection(client):\n            assert len(res.results) == 3\n            self.compare_list_of_dicts(res.results, expected_results)\n            assert res.warnings == []\n            assert res.execution_time > 0\n        else:\n            assert len(res[\"results\"]) == 3\n            self.compare_list_of_dicts(\n                res[\"results\"], self._convert_dict_values_to_str(expected_results)\n            )\n            assert res[\"warnings\"] == []\n            assert res[\"execution_time\"] > 0\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_hybrid_search_query_with_load_and_filter(self, client):\n        # Create index and add data\n        self._create_hybrid_search_index(client)\n        self._add_data_for_hybrid_search(client, items_sets=10)\n\n        # set search query\n        search_query = HybridSearchQuery(\"@color:{red|green|black}\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding\",\n            vector_data=\"$vec\",\n        )\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        postprocessing_config = HybridPostProcessingConfig()\n        postprocessing_config.load(\"@description\", \"@color\", \"@price\", \"@size\")\n        # for the postprocessing filter we need to filter on the loaded fields\n        # expecting all of them to be interpreted as strings - the initial filed types\n        # are not preserved\n        postprocessing_config.filter(HybridFilter('@price==\"15\"'))\n        postprocessing_config.limit(0, 3)\n\n        res = client.ft().hybrid_search(\n            query=hybrid_query,\n            post_processing=postprocessing_config,\n            params_substitution={\n                \"vec\": np.array([1, 2, 7, 6], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n\n        if is_resp2_connection(client):\n            assert len(res.results) == 3\n            for item in res.results:\n                assert item[\"price\"] == b\"15\"\n            assert res.warnings == []\n            assert res.execution_time > 0\n        else:\n            assert len(res[\"results\"]) == 3\n            for item in res[\"results\"]:\n                assert item[\"price\"] == \"15\"\n            assert res[\"warnings\"] == []\n            assert res[\"execution_time\"] > 0\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_hybrid_search_query_with_load_apply_and_params(self, client):\n        # Create index and add data\n        self._create_hybrid_search_index(client)\n        self._add_data_for_hybrid_search(client, items_sets=5, use_random_str_data=True)\n\n        # set search query\n        search_query = HybridSearchQuery(\"@color:{$color_criteria}\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding\",\n            vector_data=\"$vector\",\n        )\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        postprocessing_config = HybridPostProcessingConfig()\n        postprocessing_config.load(\"@description\", \"@color\", \"@price\")\n        postprocessing_config.apply(price_discount=\"@price - (@price * 0.1)\")\n        postprocessing_config.limit(0, 3)\n\n        params_substitution = {\n            \"vector\": \"abcd1234abcd5678\",\n            \"color_criteria\": \"red\",\n        }\n\n        res = client.ft().hybrid_search(\n            query=hybrid_query,\n            post_processing=postprocessing_config,\n            params_substitution=params_substitution,\n            timeout=10,\n        )\n\n        expected_results = [\n            {\n                \"description\": b\"red shoes\",\n                \"color\": b\"red\",\n                \"price\": b\"15\",\n                \"price_discount\": b\"13.5\",\n            },\n            {\n                \"description\": b\"red dress\",\n                \"color\": b\"red\",\n                \"price\": b\"17\",\n                \"price_discount\": b\"15.3\",\n            },\n            {\n                \"description\": b\"red shoes\",\n                \"color\": b\"red\",\n                \"price\": b\"16\",\n                \"price_discount\": b\"14.4\",\n            },\n        ]\n        if is_resp2_connection(client):\n            assert len(res.results) == 3\n            assert res.results == expected_results\n            assert res.warnings == []\n            assert res.execution_time > 0\n        else:\n            assert len(res[\"results\"]) == 3\n            assert res[\"results\"] == self._convert_dict_values_to_str(expected_results)\n            assert res[\"warnings\"] == []\n            assert res[\"execution_time\"] > 0\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_hybrid_search_query_with_limit(self, client):\n        # Create index and add data\n        self._create_hybrid_search_index(client)\n        self._add_data_for_hybrid_search(client, items_sets=10)\n\n        # set search query\n        search_query = HybridSearchQuery(\"@color:{red}\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding\",\n            vector_data=\"$vec\",\n        )\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        postprocessing_config = HybridPostProcessingConfig()\n        postprocessing_config.limit(0, 3)\n\n        res = client.ft().hybrid_search(\n            query=hybrid_query,\n            post_processing=postprocessing_config,\n            params_substitution={\n                \"vec\": np.array([1, 2, 7, 6], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n\n        if is_resp2_connection(client):\n            assert len(res.results) == 3\n            assert res.warnings == []\n        else:\n            assert len(res[\"results\"]) == 3\n            assert res[\"warnings\"] == []\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_hybrid_search_query_with_load_apply_and_sortby(self, client):\n        # Create index and add data\n        self._create_hybrid_search_index(client)\n        self._add_data_for_hybrid_search(client, items_sets=1)\n\n        # set search query\n        search_query = HybridSearchQuery(\"@color:{red|green}\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding\",\n            vector_data=\"$vec\",\n        )\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        postprocessing_config = HybridPostProcessingConfig()\n        postprocessing_config.load(\"@color\", \"@price\")\n        postprocessing_config.apply(price_discount=\"@price - (@price * 0.1)\")\n        postprocessing_config.sort_by(\n            SortbyField(\"@price_discount\", asc=False), SortbyField(\"@color\", asc=True)\n        )\n        postprocessing_config.limit(0, 5)\n\n        res = client.ft().hybrid_search(\n            query=hybrid_query,\n            post_processing=postprocessing_config,\n            params_substitution={\n                \"vec\": np.array([1, 2, 7, 6], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n\n        expected_results = [\n            {\"color\": b\"orange\", \"price\": b\"18\", \"price_discount\": b\"16.2\"},\n            {\"color\": b\"red\", \"price\": b\"17\", \"price_discount\": b\"15.3\"},\n            {\"color\": b\"green\", \"price\": b\"16\", \"price_discount\": b\"14.4\"},\n            {\"color\": b\"black\", \"price\": b\"15\", \"price_discount\": b\"13.5\"},\n            {\"color\": b\"red\", \"price\": b\"15\", \"price_discount\": b\"13.5\"},\n        ]\n        if is_resp2_connection(client):\n            assert res.total_results >= 5\n            assert len(res.results) == 5\n            # the order here should match because of the sort\n            assert res.results == expected_results\n            assert res.warnings == []\n            assert res.execution_time > 0\n        else:\n            assert res[\"total_results\"] >= 5\n            assert len(res[\"results\"]) == 5\n            # the order here should match because of the sort\n            assert res[\"results\"] == self._convert_dict_values_to_str(expected_results)\n            assert res[\"warnings\"] == []\n            assert res[\"execution_time\"] > 0\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_hybrid_search_query_with_timeout(self, client):\n        dim = 128\n        # Create index and add data\n        self._create_hybrid_search_index(client, dim=dim)\n        self._add_data_for_hybrid_search(\n            client,\n            items_sets=5000,\n            dim_for_random_data=dim,\n            use_random_str_data=True,\n        )\n\n        # set search query\n        search_query = HybridSearchQuery(\"*\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding-hnsw\",\n            vector_data=\"$vec\",\n        )\n        vsim_query.vsim_method_params(VectorSearchMethods.KNN, K=1000)\n        vsim_query.filter(\n            HybridFilter(\n                \"((@price:[15 16] @size:[10 11]) | (@price:[13 15] @size:[11 12])) @description:(shoes) -@description:(green)\"\n            )\n        )\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        combine_method = CombineResultsMethod(CombinationMethods.RRF, WINDOW=1000)\n\n        timeout = 5000  # 5 second timeout\n        res = client.ft().hybrid_search(\n            query=hybrid_query,\n            combine_method=combine_method,\n            params_substitution={\"vec\": \"abcd\" * dim},\n            timeout=timeout,\n        )\n\n        if is_resp2_connection(client):\n            assert len(res.results) > 0\n            assert res.warnings == []\n            assert res.execution_time > 0 and res.execution_time < timeout\n        else:\n            assert len(res[\"results\"]) > 0\n            assert res[\"warnings\"] == []\n            assert res[\"execution_time\"] > 0 and res[\"execution_time\"] < timeout\n\n        res = client.ft().hybrid_search(\n            query=hybrid_query,\n            params_substitution={\"vec\": \"abcd\" * dim},\n            timeout=1,\n        )  # 1 ms timeout\n        if is_resp2_connection(client):\n            assert (\n                b\"Timeout limit was reached (VSIM)\" in res.warnings\n                or b\"Timeout limit was reached (SEARCH)\" in res.warnings\n            )\n        else:\n            assert (\n                \"Timeout limit was reached (VSIM)\" in res[\"warnings\"]\n                or \"Timeout limit was reached (SEARCH)\" in res[\"warnings\"]\n            )\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_hybrid_search_query_with_load_and_groupby(self, client):\n        # Create index and add data\n        self._create_hybrid_search_index(client)\n        self._add_data_for_hybrid_search(client, items_sets=10)\n\n        # set search query\n        search_query = HybridSearchQuery(\"@color:{red|green}\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding\",\n            vector_data=\"$vec\",\n        )\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        postprocessing_config = HybridPostProcessingConfig()\n        postprocessing_config.load(\"@color\", \"@price\", \"@size\", \"@item_type\")\n        postprocessing_config.limit(0, 4)\n\n        postprocessing_config.group_by(\n            [\"@item_type\", \"@price\"],\n            reducers.count_distinct(\"@color\").alias(\"colors_count\"),\n            reducers.min(\"@size\"),\n        )\n\n        postprocessing_config.sort_by(SortbyField(\"@price\", asc=True))\n\n        res = client.ft().hybrid_search(\n            query=hybrid_query,\n            post_processing=postprocessing_config,\n            params_substitution={\n                \"vec\": np.array([1, 2, 7, 6], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n\n        expected_results = [\n            {\n                \"item_type\": b\"dress\",\n                \"price\": b\"15\",\n                \"colors_count\": b\"1\",\n                \"__generated_aliasminsize\": b\"10\",\n            },\n            {\n                \"item_type\": b\"shoes\",\n                \"price\": b\"15\",\n                \"colors_count\": b\"2\",\n                \"__generated_aliasminsize\": b\"10\",\n            },\n            {\n                \"item_type\": b\"shoes\",\n                \"price\": b\"16\",\n                \"colors_count\": b\"2\",\n                \"__generated_aliasminsize\": b\"10\",\n            },\n            {\n                \"item_type\": b\"dress\",\n                \"price\": b\"16\",\n                \"colors_count\": b\"1\",\n                \"__generated_aliasminsize\": b\"11\",\n            },\n        ]\n\n        if is_resp2_connection(client):\n            assert len(res.results) == 4\n            assert res.results == expected_results\n            assert res.warnings == []\n        else:\n            assert len(res[\"results\"]) == 4\n            assert res[\"results\"] == self._convert_dict_values_to_str(expected_results)\n            assert res[\"warnings\"] == []\n\n        postprocessing_config = HybridPostProcessingConfig()\n        postprocessing_config.load(\"@color\", \"@price\", \"@size\", \"@item_type\")\n        postprocessing_config.limit(0, 6)\n        postprocessing_config.sort_by(\n            SortbyField(\"@price\", asc=True),\n            SortbyField(\"@item_type\", asc=True),\n        )\n\n        postprocessing_config.group_by(\n            [\"@price\", \"@item_type\"],\n            reducers.count_distinct(\"@color\").alias(\"unique_colors_count\"),\n        )\n\n        res = client.ft().hybrid_search(\n            query=hybrid_query,\n            post_processing=postprocessing_config,\n            params_substitution={\n                \"vec\": np.array([1, 2, 7, 6], dtype=np.float32).tobytes()\n            },\n            timeout=1000,\n        )\n\n        expected_results = [\n            {\"price\": b\"15\", \"item_type\": b\"dress\", \"unique_colors_count\": b\"1\"},\n            {\"price\": b\"15\", \"item_type\": b\"shoes\", \"unique_colors_count\": b\"2\"},\n            {\"price\": b\"16\", \"item_type\": b\"dress\", \"unique_colors_count\": b\"1\"},\n            {\"price\": b\"16\", \"item_type\": b\"shoes\", \"unique_colors_count\": b\"2\"},\n            {\"price\": b\"17\", \"item_type\": b\"dress\", \"unique_colors_count\": b\"1\"},\n            {\"price\": b\"17\", \"item_type\": b\"shoes\", \"unique_colors_count\": b\"2\"},\n        ]\n        if is_resp2_connection(client):\n            assert len(res.results) == 6\n            assert res.results == expected_results\n            assert res.warnings == []\n        else:\n            assert len(res[\"results\"]) == 6\n            assert res[\"results\"] == self._convert_dict_values_to_str(expected_results)\n            assert res[\"warnings\"] == []\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_hybrid_search_query_with_cursor(self, client):\n        # Create index and add data\n        self._create_hybrid_search_index(client)\n        self._add_data_for_hybrid_search(client, items_sets=10)\n\n        # set search query\n        search_query = HybridSearchQuery(\"@color:{red|green}\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding\",\n            vector_data=\"$vec\",\n        )\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        res = client.ft().hybrid_search(\n            query=hybrid_query,\n            cursor=HybridCursorQuery(count=5, max_idle=100),\n            params_substitution={\n                \"vec\": np.array([1, 2, 7, 6], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n        if is_resp2_connection(client):\n            assert isinstance(res, HybridCursorResult)\n            assert res.search_cursor_id > 0\n            assert res.vsim_cursor_id > 0\n            search_cursor = aggregations.Cursor(res.search_cursor_id)\n            vsim_cursor = aggregations.Cursor(res.vsim_cursor_id)\n        else:\n            assert res[\"SEARCH\"] > 0\n            assert res[\"VSIM\"] > 0\n            search_cursor = aggregations.Cursor(res[\"SEARCH\"])\n            vsim_cursor = aggregations.Cursor(res[\"VSIM\"])\n\n        search_res_from_cursor = client.ft().aggregate(query=search_cursor)\n        if is_resp2_connection(client):\n            assert len(search_res_from_cursor.rows) == 5\n        else:\n            assert len(search_res_from_cursor[0][\"results\"]) == 5\n\n        vsim_res_from_cursor = client.ft().aggregate(query=vsim_cursor)\n        if is_resp2_connection(client):\n            assert len(vsim_res_from_cursor.rows) == 5\n        else:\n            assert len(vsim_res_from_cursor[0][\"results\"]) == 5\n\n    @pytest.mark.redismod\n    @skip_if_server_version_lt(\"8.3.224\")\n    def test_hybrid_search_query_with_multiple_loads_and_applies(self, client):\n        # Create index and add data\n        self._create_hybrid_search_index(client)\n        self._add_data_for_hybrid_search(client, items_sets=1)\n\n        # set search query\n        search_query = HybridSearchQuery(\"@color:{red|green}\")\n\n        vsim_query = HybridVsimQuery(\n            vector_field_name=\"@embedding\",\n            vector_data=\"$vec\",\n        )\n\n        hybrid_query = HybridQuery(search_query, vsim_query)\n\n        postprocessing_config = HybridPostProcessingConfig()\n        postprocessing_config.load(\"@color\", \"@price\")\n        postprocessing_config.load(\"@description\")\n        postprocessing_config.apply(discount_10_percents=\"@price - (@price * 0.1)\")\n        postprocessing_config.apply(\n            additional_discount=\"@discount_10_percents - (@discount_10_percents * 0.1)\"\n        )\n        postprocessing_config.filter(HybridFilter('@price==\"15\"'))\n        postprocessing_config.load(\"@description\")\n        postprocessing_config.sort_by(\n            SortbyField(\"@discount_10_percents\", asc=False),\n            SortbyField(\"@color\", asc=True),\n        )\n        postprocessing_config.limit(0, 5)\n\n        res = client.ft().hybrid_search(\n            query=hybrid_query,\n            post_processing=postprocessing_config,\n            params_substitution={\n                \"vec\": np.array([1, 2, 7, 6], dtype=np.float32).tobytes()\n            },\n            timeout=10,\n        )\n        print(res)\n        if is_resp2_connection(client):\n            assert len(res.results) == 2\n            for item in res.results:\n                assert item[\"color\"] is not None\n                assert item[\"price\"] is not None\n                assert item[\"description\"] is not None\n                assert item[\"discount_10_percents\"] is not None\n                assert item[\"additional_discount\"] is not None\n        else:\n            assert len(res[\"results\"]) == 2\n            for item in res[\"results\"]:\n                assert item[\"color\"] is not None\n                assert item[\"price\"] is not None\n                assert item[\"description\"] is not None\n                assert item[\"discount_10_percents\"] is not None\n                assert item[\"additional_discount\"] is not None\n"
  },
  {
    "path": "tests/test_sentinel.py",
    "content": "import socket\nfrom unittest import mock\n\nimport pytest\nfrom redis.client import StrictRedis\n\nimport redis.sentinel\nfrom redis import exceptions\nfrom redis.sentinel import (\n    MasterNotFoundError,\n    Sentinel,\n    SentinelConnectionPool,\n    SlaveNotFoundError,\n)\nfrom tests.conftest import is_resp2_connection\n\n\n@pytest.fixture(scope=\"module\")\ndef master_ip(master_host):\n    yield socket.gethostbyname(master_host[0])\n\n\nclass SentinelTestClient:\n    def __init__(self, cluster, id):\n        self.cluster = cluster\n        self.id = id\n\n    def sentinel_masters(self):\n        self.cluster.connection_error_if_down(self)\n        self.cluster.timeout_if_down(self)\n        return {self.cluster.service_name: self.cluster.master}\n\n    def sentinel_slaves(self, master_name):\n        self.cluster.connection_error_if_down(self)\n        self.cluster.timeout_if_down(self)\n        if master_name != self.cluster.service_name:\n            return []\n        return self.cluster.slaves\n\n    def execute_command(self, *args, **kwargs):\n        # wrapper  purely to validate the calls don't explode\n        from redis.client import bool_ok\n\n        return bool_ok\n\n\nclass SentinelTestCluster:\n    def __init__(self, servisentinel_ce_name=\"mymaster\", ip=\"127.0.0.1\", port=6379):\n        self.clients = {}\n        self.master = {\n            \"ip\": ip,\n            \"port\": port,\n            \"is_master\": True,\n            \"is_sdown\": False,\n            \"is_odown\": False,\n            \"num-other-sentinels\": 0,\n        }\n        self.service_name = servisentinel_ce_name\n        self.slaves = []\n        self.nodes_down = set()\n        self.nodes_timeout = set()\n\n    def connection_error_if_down(self, node):\n        if node.id in self.nodes_down:\n            raise exceptions.ConnectionError\n\n    def timeout_if_down(self, node):\n        if node.id in self.nodes_timeout:\n            raise exceptions.TimeoutError\n\n    def client(self, host, port, **kwargs):\n        return SentinelTestClient(self, (host, port))\n\n\n@pytest.fixture()\ndef cluster(request, master_ip):\n    def teardown():\n        redis.sentinel.Redis = saved_Redis\n\n    cluster = SentinelTestCluster(ip=master_ip)\n    saved_Redis = redis.sentinel.Redis\n    redis.sentinel.Redis = cluster.client\n    request.addfinalizer(teardown)\n    return cluster\n\n\n@pytest.fixture()\ndef sentinel(request, cluster):\n    return Sentinel([(\"foo\", 26379), (\"bar\", 26379)])\n\n\n@pytest.fixture()\ndef deployed_sentinel(request):\n    sentinel_ips = request.config.getoption(\"--sentinels\")\n    sentinel_endpoints = [\n        (ip.strip(), int(port.strip()))\n        for ip, port in (endpoint.split(\":\") for endpoint in sentinel_ips.split(\",\"))\n    ]\n    kwargs = {}\n    decode_responses = True\n\n    sentinel_kwargs = {\"decode_responses\": decode_responses}\n    force_master_ip = \"localhost\"\n\n    protocol = request.config.getoption(\"--protocol\", 2)\n\n    sentinel = Sentinel(\n        sentinel_endpoints,\n        force_master_ip=force_master_ip,\n        sentinel_kwargs=sentinel_kwargs,\n        socket_timeout=0.1,\n        protocol=protocol,\n        decode_responses=decode_responses,\n        **kwargs,\n    )\n    yield sentinel\n    for s in sentinel.sentinels:\n        s.close()\n\n\n@pytest.mark.onlynoncluster\ndef test_discover_master(sentinel, master_ip):\n    address = sentinel.discover_master(\"mymaster\")\n    assert address == (master_ip, 6379)\n\n\n@pytest.mark.onlynoncluster\ndef test_discover_master_error(sentinel):\n    with pytest.raises(MasterNotFoundError):\n        sentinel.discover_master(\"xxx\")\n\n\n@pytest.mark.onlynoncluster\ndef test_dead_pool(sentinel):\n    master = sentinel.master_for(\"mymaster\", db=9)\n    conn = master.connection_pool.get_connection()\n    conn.disconnect()\n    del master\n    conn.connect()\n\n\n@pytest.mark.onlynoncluster\ndef test_discover_master_sentinel_down(cluster, sentinel, master_ip):\n    # Put first sentinel 'foo' down\n    cluster.nodes_down.add((\"foo\", 26379))\n    address = sentinel.discover_master(\"mymaster\")\n    assert address == (master_ip, 6379)\n    # 'bar' is now first sentinel\n    assert sentinel.sentinels[0].id == (\"bar\", 26379)\n\n\n@pytest.mark.onlynoncluster\ndef test_discover_master_sentinel_timeout(cluster, sentinel, master_ip):\n    # Put first sentinel 'foo' down\n    cluster.nodes_timeout.add((\"foo\", 26379))\n    address = sentinel.discover_master(\"mymaster\")\n    assert address == (master_ip, 6379)\n    # 'bar' is now first sentinel\n    assert sentinel.sentinels[0].id == (\"bar\", 26379)\n\n\n@pytest.mark.onlynoncluster\ndef test_master_min_other_sentinels(cluster, master_ip):\n    sentinel = Sentinel([(\"foo\", 26379)], min_other_sentinels=1)\n    # min_other_sentinels\n    with pytest.raises(MasterNotFoundError):\n        sentinel.discover_master(\"mymaster\")\n    cluster.master[\"num-other-sentinels\"] = 2\n    address = sentinel.discover_master(\"mymaster\")\n    assert address == (master_ip, 6379)\n\n\n@pytest.mark.onlynoncluster\ndef test_master_odown(cluster, sentinel):\n    cluster.master[\"is_odown\"] = True\n    with pytest.raises(MasterNotFoundError):\n        sentinel.discover_master(\"mymaster\")\n\n\n@pytest.mark.onlynoncluster\ndef test_master_sdown(cluster, sentinel):\n    cluster.master[\"is_sdown\"] = True\n    with pytest.raises(MasterNotFoundError):\n        sentinel.discover_master(\"mymaster\")\n\n\n@pytest.mark.onlynoncluster\ndef test_discover_slaves(cluster, sentinel):\n    assert sentinel.discover_slaves(\"mymaster\") == []\n\n    cluster.slaves = [\n        {\"ip\": \"slave0\", \"port\": 1234, \"is_odown\": False, \"is_sdown\": False},\n        {\"ip\": \"slave1\", \"port\": 1234, \"is_odown\": False, \"is_sdown\": False},\n    ]\n    assert sentinel.discover_slaves(\"mymaster\") == [(\"slave0\", 1234), (\"slave1\", 1234)]\n\n    # slave0 -> ODOWN\n    cluster.slaves[0][\"is_odown\"] = True\n    assert sentinel.discover_slaves(\"mymaster\") == [(\"slave1\", 1234)]\n\n    # slave1 -> SDOWN\n    cluster.slaves[1][\"is_sdown\"] = True\n    assert sentinel.discover_slaves(\"mymaster\") == []\n\n    cluster.slaves[0][\"is_odown\"] = False\n    cluster.slaves[1][\"is_sdown\"] = False\n\n    # node0 -> DOWN\n    cluster.nodes_down.add((\"foo\", 26379))\n    assert sentinel.discover_slaves(\"mymaster\") == [(\"slave0\", 1234), (\"slave1\", 1234)]\n    cluster.nodes_down.clear()\n\n    # node0 -> TIMEOUT\n    cluster.nodes_timeout.add((\"foo\", 26379))\n    assert sentinel.discover_slaves(\"mymaster\") == [(\"slave0\", 1234), (\"slave1\", 1234)]\n\n\n@pytest.mark.onlynoncluster\ndef test_master_for(sentinel, master_ip):\n    master = sentinel.master_for(\"mymaster\", db=9)\n    assert master.ping()\n    assert master.connection_pool.master_address == (master_ip, 6379)\n\n    # Use internal connection check\n    master = sentinel.master_for(\"mymaster\", db=9, check_connection=True)\n    assert master.ping()\n\n\n@pytest.mark.onlynoncluster\ndef test_slave_for(cluster, sentinel):\n    cluster.slaves = [\n        {\"ip\": \"127.0.0.1\", \"port\": 6379, \"is_odown\": False, \"is_sdown\": False}\n    ]\n    slave = sentinel.slave_for(\"mymaster\", db=9)\n    assert slave.ping()\n\n\n@pytest.mark.onlynoncluster\ndef test_slave_for_slave_not_found_error(cluster, sentinel):\n    cluster.master[\"is_odown\"] = True\n    slave = sentinel.slave_for(\"mymaster\", db=9)\n    with pytest.raises(SlaveNotFoundError):\n        slave.ping()\n\n\n@pytest.mark.onlynoncluster\ndef test_slave_round_robin(cluster, sentinel, master_ip):\n    cluster.slaves = [\n        {\"ip\": \"slave0\", \"port\": 6379, \"is_odown\": False, \"is_sdown\": False},\n        {\"ip\": \"slave1\", \"port\": 6379, \"is_odown\": False, \"is_sdown\": False},\n    ]\n    pool = SentinelConnectionPool(\"mymaster\", sentinel)\n    rotator = pool.rotate_slaves()\n    assert next(rotator) in ((\"slave0\", 6379), (\"slave1\", 6379))\n    assert next(rotator) in ((\"slave0\", 6379), (\"slave1\", 6379))\n    # Fallback to master\n    assert next(rotator) == (master_ip, 6379)\n    with pytest.raises(SlaveNotFoundError):\n        next(rotator)\n\n\n@pytest.mark.onlynoncluster\ndef test_ckquorum(sentinel):\n    resp = sentinel.sentinel_ckquorum(\"mymaster\")\n    assert resp is True\n\n\n@pytest.mark.onlynoncluster\ndef test_flushconfig(sentinel):\n    resp = sentinel.sentinel_flushconfig()\n    assert resp is True\n\n\n@pytest.mark.onlynoncluster\ndef test_reset(cluster, sentinel):\n    cluster.master[\"is_odown\"] = True\n    resp = sentinel.sentinel_reset(\"mymaster\")\n    assert resp is True\n\n\n@pytest.mark.onlynoncluster\n@pytest.mark.parametrize(\"method_name\", [\"master_for\", \"slave_for\"])\ndef test_auto_close_pool(cluster, sentinel, method_name):\n    \"\"\"\n    Check that the connection pool created by the sentinel client is\n    automatically closed\n    \"\"\"\n\n    method = getattr(sentinel, method_name)\n    client = method(\"mymaster\", db=9)\n    pool = client.connection_pool\n    assert client.auto_close_connection_pool is True\n    calls = 0\n\n    def mock_disconnect():\n        nonlocal calls\n        calls += 1\n\n    with mock.patch.object(pool, \"disconnect\", mock_disconnect):\n        client.close()\n\n    assert calls == 1\n    pool.disconnect()\n\n\n# Tests against real sentinel instances\n@pytest.mark.onlynoncluster\ndef test_get_sentinels(deployed_sentinel):\n    resps = deployed_sentinel.sentinel_sentinels(\"redis-py-test\", return_responses=True)\n\n    # validate that the original command response is returned\n    assert isinstance(resps, list)\n\n    # validate that the command has been executed against all sentinels\n    # each response from each sentinel is returned\n    assert len(resps) > 1\n\n    # validate default behavior\n    resps = deployed_sentinel.sentinel_sentinels(\"redis-py-test\")\n    assert isinstance(resps, bool)\n\n\n@pytest.mark.onlynoncluster\ndef test_get_master_addr_by_name(deployed_sentinel):\n    resps = deployed_sentinel.sentinel_get_master_addr_by_name(\n        \"redis-py-test\", return_responses=True\n    )\n\n    # validate that the original command response is returned\n    assert isinstance(resps, list)\n\n    # validate that the command has been executed just once\n    # when executed once, only one response element is returned\n    assert len(resps) == 1\n\n    assert isinstance(resps[0], tuple)\n\n    # validate default behavior\n    resps = deployed_sentinel.sentinel_get_master_addr_by_name(\"redis-py-test\")\n    assert isinstance(resps, bool)\n\n\n@pytest.mark.onlynoncluster\ndef test_redis_master_usage(deployed_sentinel):\n    r = deployed_sentinel.master_for(\"redis-py-test\", db=0)\n    r.set(\"foo\", \"bar\")\n    assert r.get(\"foo\") == \"bar\"\n\n\n@pytest.mark.onlynoncluster\ndef test_sentinel_commands_with_strict_redis_client(request):\n    sentinel_ips = request.config.getoption(\"--sentinels\")\n    sentinel_host, sentinel_port = sentinel_ips.split(\",\")[0].split(\":\")\n    protocol = request.config.getoption(\"--protocol\", 2)\n\n    client = StrictRedis(\n        host=sentinel_host, port=sentinel_port, decode_responses=True, protocol=protocol\n    )\n    # skipping commands that change the state of the sentinel setup\n    assert isinstance(client.sentinel_get_master_addr_by_name(\"redis-py-test\"), tuple)\n    assert isinstance(client.sentinel_master(\"redis-py-test\"), dict)\n    if is_resp2_connection(client):\n        assert isinstance(client.sentinel_masters(), dict)\n    else:\n        masters = client.sentinel_masters()\n        assert isinstance(masters, list)\n        for master in masters:\n            assert isinstance(master, dict)\n\n    assert isinstance(client.sentinel_sentinels(\"redis-py-test\"), list)\n    assert isinstance(client.sentinel_slaves(\"redis-py-test\"), list)\n\n    assert isinstance(client.sentinel_ckquorum(\"redis-py-test\"), bool)\n\n    client.close()\n"
  },
  {
    "path": "tests/test_sentinel_managed_connection.py",
    "content": "import socket\n\nfrom redis._parsers.socket import SENTINEL\nfrom redis.retry import Retry\nfrom redis.sentinel import SentinelManagedConnection\nfrom redis.backoff import NoBackoff\nfrom unittest import mock\n\n\ndef test_connect_retry_on_timeout_error(master_host):\n    \"\"\"Test that the _connect function is retried in case of a timeout\"\"\"\n    connection_pool = mock.Mock()\n    connection_pool.get_master_address = mock.Mock(\n        return_value=(master_host[0], master_host[1])\n    )\n    conn = SentinelManagedConnection(\n        retry_on_timeout=True,\n        retry=Retry(NoBackoff(), 3),\n        connection_pool=connection_pool,\n    )\n    origin_connect = conn._connect\n    conn._connect = mock.Mock()\n\n    def mock_connect():\n        # connect only on the last retry\n        if conn._connect.call_count <= 2:\n            raise socket.timeout\n        else:\n            return origin_connect()\n\n    conn._connect.side_effect = mock_connect\n    conn.connect()\n    assert conn._connect.call_count == 3\n    assert connection_pool.get_master_address.call_count == 3\n    conn.disconnect()\n\n\nclass TestSentinelManagedConnectionReadResponseTimeout:\n    \"\"\"\n    Tests for timeout parameter propagation in SentinelManagedConnection.read_response().\n    \"\"\"\n\n    def test_read_response_accepts_timeout_parameter(self, master_host):\n        \"\"\"\n        Test that SentinelManagedConnection.read_response() accepts a timeout parameter.\n        \"\"\"\n        connection_pool = mock.Mock()\n        connection_pool.get_master_address = mock.Mock(\n            return_value=(master_host[0], master_host[1])\n        )\n        connection_pool.is_master = True\n        conn = SentinelManagedConnection(connection_pool=connection_pool)\n\n        # Mock the parent class's read_response to verify timeout is passed\n        with mock.patch.object(\n            SentinelManagedConnection.__bases__[0],\n            \"read_response\",\n            return_value=b\"OK\",\n        ) as mock_read_response:\n            conn.read_response(timeout=0.5)\n            mock_read_response.assert_called_once_with(\n                disable_decoding=False,\n                timeout=0.5,\n                disconnect_on_error=False,\n                push_request=False,\n            )\n\n    def test_read_response_timeout_default_is_sentinel(self, master_host):\n        \"\"\"\n        Test that the default timeout value is SENTINEL (not modified).\n        \"\"\"\n        connection_pool = mock.Mock()\n        connection_pool.get_master_address = mock.Mock(\n            return_value=(master_host[0], master_host[1])\n        )\n        connection_pool.is_master = True\n        conn = SentinelManagedConnection(connection_pool=connection_pool)\n\n        # Mock the parent class's read_response to verify default timeout\n        with mock.patch.object(\n            SentinelManagedConnection.__bases__[0],\n            \"read_response\",\n            return_value=b\"OK\",\n        ) as mock_read_response:\n            conn.read_response()\n            mock_read_response.assert_called_once_with(\n                disable_decoding=False,\n                timeout=SENTINEL,\n                disconnect_on_error=False,\n                push_request=False,\n            )\n\n    def test_read_response_timeout_none_passed_through(self, master_host):\n        \"\"\"\n        Test that timeout=None is passed through (for blocking behavior).\n        \"\"\"\n        connection_pool = mock.Mock()\n        connection_pool.get_master_address = mock.Mock(\n            return_value=(master_host[0], master_host[1])\n        )\n        connection_pool.is_master = True\n        conn = SentinelManagedConnection(connection_pool=connection_pool)\n\n        # Mock the parent class's read_response to verify timeout=None is passed\n        with mock.patch.object(\n            SentinelManagedConnection.__bases__[0],\n            \"read_response\",\n            return_value=b\"OK\",\n        ) as mock_read_response:\n            conn.read_response(timeout=None)\n            mock_read_response.assert_called_once_with(\n                disable_decoding=False,\n                timeout=None,\n                disconnect_on_error=False,\n                push_request=False,\n            )\n\n    def test_read_response_timeout_zero_passed_through(self, master_host):\n        \"\"\"\n        Test that timeout=0 is passed through (for non-blocking behavior).\n        \"\"\"\n        connection_pool = mock.Mock()\n        connection_pool.get_master_address = mock.Mock(\n            return_value=(master_host[0], master_host[1])\n        )\n        connection_pool.is_master = True\n        conn = SentinelManagedConnection(connection_pool=connection_pool)\n\n        # Mock the parent class's read_response to verify timeout=0 is passed\n        with mock.patch.object(\n            SentinelManagedConnection.__bases__[0],\n            \"read_response\",\n            return_value=b\"OK\",\n        ) as mock_read_response:\n            conn.read_response(timeout=0)\n            mock_read_response.assert_called_once_with(\n                disable_decoding=False,\n                timeout=0,\n                disconnect_on_error=False,\n                push_request=False,\n            )\n\n    def test_read_response_all_parameters_passed_through(self, master_host):\n        \"\"\"\n        Test that all parameters including timeout are correctly passed to parent.\n        \"\"\"\n        connection_pool = mock.Mock()\n        connection_pool.get_master_address = mock.Mock(\n            return_value=(master_host[0], master_host[1])\n        )\n        connection_pool.is_master = True\n        conn = SentinelManagedConnection(connection_pool=connection_pool)\n\n        # Mock the parent class's read_response to verify all params\n        with mock.patch.object(\n            SentinelManagedConnection.__bases__[0],\n            \"read_response\",\n            return_value=b\"OK\",\n        ) as mock_read_response:\n            conn.read_response(\n                disable_decoding=True,\n                timeout=1.5,\n                disconnect_on_error=True,\n                push_request=True,\n            )\n            mock_read_response.assert_called_once_with(\n                disable_decoding=True,\n                timeout=1.5,\n                disconnect_on_error=True,\n                push_request=True,\n            )\n"
  },
  {
    "path": "tests/test_ssl.py",
    "content": "import socket\nimport ssl\nfrom urllib.parse import urlparse\n\nimport pytest\nimport redis\nfrom redis.exceptions import ConnectionError, RedisError\n\nfrom .conftest import (\n    skip_if_cryptography,\n    skip_if_nocryptography,\n    skip_if_server_version_lt,\n)\nfrom .ssl_utils import CertificateType, get_tls_certificates, CN_USERNAME\n\n\n@pytest.mark.ssl\nclass TestSSL:\n    \"\"\"Tests for SSL connections\n\n    This relies on the --redis-ssl-url purely for rebuilding the client\n    and connecting to the appropriate port.\n    \"\"\"\n\n    @pytest.fixture(autouse=True)\n    def _set_ssl_certs(self, request):\n        self.tls_cert_subdir = request.session.config.REDIS_INFO[\"tls_cert_subdir\"]\n        self.client_certs = get_tls_certificates(self.tls_cert_subdir)\n        self.server_certs = get_tls_certificates(\n            self.tls_cert_subdir, cert_type=CertificateType.server\n        )\n\n    def test_ssl_with_invalid_cert(self, request):\n        ssl_url = request.config.option.redis_ssl_url\n        sslclient = redis.from_url(ssl_url)\n        with pytest.raises(ConnectionError) as e:\n            sslclient.ping()\n        assert \"SSL: CERTIFICATE_VERIFY_FAILED\" in str(e.value)\n        sslclient.close()\n\n    def test_ssl_connection(self, request):\n        ssl_url = request.config.option.redis_ssl_url\n        p = urlparse(ssl_url)[1].split(\":\")\n\n        r = redis.Redis(\n            host=p[0],\n            port=p[1],\n            ssl=True,\n            ssl_cert_reqs=\"none\",\n        )\n        assert r.ping()\n        r.close()\n\n    def test_ssl_connection_without_ssl(self, request):\n        ssl_url = request.config.option.redis_ssl_url\n        p = urlparse(ssl_url)[1].split(\":\")\n        r = redis.Redis(host=p[0], port=p[1], ssl=False)\n\n        with pytest.raises(ConnectionError) as e:\n            r.ping()\n        assert \"Connection closed by server\" in str(e.value)\n        r.close()\n\n    def test_validating_self_signed_certificate(self, request):\n        ssl_url = request.config.option.redis_ssl_url\n        p = urlparse(ssl_url)[1].split(\":\")\n        r = redis.Redis(\n            host=p[0],\n            port=p[1],\n            ssl=True,\n            ssl_certfile=self.client_certs.certfile,\n            ssl_keyfile=self.client_certs.keyfile,\n            ssl_cert_reqs=\"required\",\n            ssl_ca_certs=self.client_certs.ca_certfile,\n        )\n        assert r.ping()\n        r.close()\n\n    def test_validating_self_signed_string_certificate(self, request):\n        with open(self.client_certs.ca_certfile) as f:\n            cert_data = f.read()\n        ssl_url = request.config.option.redis_ssl_url\n        p = urlparse(ssl_url)[1].split(\":\")\n        r = redis.Redis(\n            host=p[0],\n            port=p[1],\n            ssl=True,\n            ssl_certfile=self.client_certs.certfile,\n            ssl_keyfile=self.client_certs.keyfile,\n            ssl_cert_reqs=\"required\",\n            ssl_ca_data=cert_data,\n        )\n        assert r.ping()\n        r.close()\n\n    @pytest.mark.parametrize(\n        \"ssl_ciphers\",\n        [\n            \"AES256-SHA:DHE-RSA-AES256-SHA:AES128-SHA:DHE-RSA-AES128-SHA\",\n            \"DHE-RSA-AES256-GCM-SHA384\",\n            \"ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305\",\n        ],\n    )\n    def test_ssl_connection_tls12_custom_ciphers(self, request, ssl_ciphers):\n        ssl_url = request.config.option.redis_ssl_url\n        p = urlparse(ssl_url)[1].split(\":\")\n        r = redis.Redis(\n            host=p[0],\n            port=p[1],\n            ssl=True,\n            ssl_cert_reqs=\"none\",\n            ssl_min_version=ssl.TLSVersion.TLSv1_3,\n            ssl_ciphers=ssl_ciphers,\n        )\n        assert r.ping()\n        r.close()\n\n    def test_ssl_connection_tls12_custom_ciphers_invalid(self, request):\n        ssl_url = request.config.option.redis_ssl_url\n        p = urlparse(ssl_url)[1].split(\":\")\n        r = redis.Redis(\n            host=p[0],\n            port=p[1],\n            ssl=True,\n            ssl_cert_reqs=\"none\",\n            ssl_min_version=ssl.TLSVersion.TLSv1_2,\n            ssl_ciphers=\"foo:bar\",\n        )\n        with pytest.raises(RedisError) as e:\n            r.ping()\n        assert \"No cipher can be selected\" in str(e.value)\n        r.close()\n\n    @pytest.mark.parametrize(\n        \"ssl_ciphers\",\n        [\n            \"TLS_CHACHA20_POLY1305_SHA256\",\n            \"TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256\",\n        ],\n    )\n    def test_ssl_connection_tls13_custom_ciphers(self, request, ssl_ciphers):\n        # TLSv1.3 does not support changing the ciphers\n        ssl_url = request.config.option.redis_ssl_url\n        p = urlparse(ssl_url)[1].split(\":\")\n        r = redis.Redis(\n            host=p[0],\n            port=p[1],\n            ssl=True,\n            ssl_cert_reqs=\"none\",\n            ssl_min_version=ssl.TLSVersion.TLSv1_2,\n            ssl_ciphers=ssl_ciphers,\n        )\n        with pytest.raises(RedisError) as e:\n            r.ping()\n        assert \"No cipher can be selected\" in str(e.value)\n        r.close()\n\n    def _create_oscp_conn(self, request):\n        ssl_url = request.config.option.redis_ssl_url\n        p = urlparse(ssl_url)[1].split(\":\")\n        r = redis.Redis(\n            host=p[0],\n            port=p[1],\n            ssl=True,\n            ssl_certfile=self.client_certs.certfile,\n            ssl_keyfile=self.client_certs.keyfile,\n            ssl_cert_reqs=\"required\",\n            ssl_ca_certs=self.client_certs.ca_certfile,\n            ssl_validate_ocsp=True,\n        )\n        return r\n\n    @skip_if_cryptography()\n    def test_ssl_ocsp_called(self, request):\n        r = self._create_oscp_conn(request)\n        with pytest.raises(RedisError) as e:\n            r.ping()\n        assert \"cryptography is not installed\" in str(e.value)\n        r.close()\n\n    @skip_if_nocryptography()\n    def test_ssl_ocsp_called_withcrypto(self, request):\n        r = self._create_oscp_conn(request)\n        with pytest.raises(ConnectionError) as e:\n            assert r.ping()\n        assert \"No AIA information present in ssl certificate\" in str(e.value)\n        r.close()\n\n    @skip_if_nocryptography()\n    def test_valid_ocsp_cert_http(self):\n        from redis.ocsp import OCSPVerifier\n\n        hostnames = [\"github.com\", \"aws.amazon.com\", \"ynet.co.il\"]\n        for hostname in hostnames:\n            context = ssl.create_default_context()\n            with socket.create_connection((hostname, 443)) as sock:\n                with context.wrap_socket(sock, server_hostname=hostname) as wrapped:\n                    ocsp = OCSPVerifier(wrapped, hostname, 443)\n                    assert ocsp.is_valid()\n\n    @skip_if_nocryptography()\n    def test_revoked_ocsp_certificate(self):\n        from redis.ocsp import OCSPVerifier\n\n        context = ssl.create_default_context()\n        hostname = \"revoked.badssl.com\"\n        with socket.create_connection((hostname, 443)) as sock:\n            with context.wrap_socket(sock, server_hostname=hostname) as wrapped:\n                ocsp = OCSPVerifier(wrapped, hostname, 443)\n                with pytest.raises(ConnectionError) as e:\n                    assert ocsp.is_valid()\n                assert \"REVOKED\" in str(e.value)\n\n    @skip_if_nocryptography()\n    def test_unauthorized_ocsp(self):\n        from redis.ocsp import OCSPVerifier\n\n        context = ssl.create_default_context()\n        hostname = \"stackoverflow.com\"\n        with socket.create_connection((hostname, 443)) as sock:\n            with context.wrap_socket(sock, server_hostname=hostname) as wrapped:\n                ocsp = OCSPVerifier(wrapped, hostname, 443)\n                with pytest.raises(ConnectionError):\n                    ocsp.is_valid()\n\n    @skip_if_nocryptography()\n    def test_ocsp_not_present_in_response(self):\n        from redis.ocsp import OCSPVerifier\n\n        context = ssl.create_default_context()\n        hostname = \"google.co.il\"\n        with socket.create_connection((hostname, 443)) as sock:\n            with context.wrap_socket(sock, server_hostname=hostname) as wrapped:\n                ocsp = OCSPVerifier(wrapped, hostname, 443)\n                with pytest.raises(ConnectionError) as e:\n                    assert ocsp.is_valid()\n                assert \"from the\" in str(e.value)\n\n    @skip_if_nocryptography()\n    def test_unauthorized_then_direct(self):\n        from redis.ocsp import OCSPVerifier\n\n        # these certificates on the socket end return unauthorized\n        # then the second call succeeds\n        hostnames = [\"wikipedia.org\", \"squarespace.com\"]\n        for hostname in hostnames:\n            context = ssl.create_default_context()\n            with socket.create_connection((hostname, 443)) as sock:\n                with context.wrap_socket(sock, server_hostname=hostname) as wrapped:\n                    ocsp = OCSPVerifier(wrapped, hostname, 443)\n                    assert ocsp.is_valid()\n\n    @skip_if_nocryptography()\n    def test_mock_ocsp_staple(self, request):\n        import OpenSSL\n\n        ssl_url = request.config.option.redis_ssl_url\n        p = urlparse(ssl_url)[1].split(\":\")\n        r = redis.Redis(\n            host=p[0],\n            port=p[1],\n            ssl=True,\n            ssl_certfile=self.client_certs.cert,\n            ssl_keyfile=self.client_certs.keyfile,\n            ssl_cert_reqs=\"required\",\n            ssl_ca_certs=self.client_certs.ca_certfile,\n            ssl_validate_ocsp=True,\n            ssl_ocsp_context=p,  # just needs to not be none\n        )\n\n        with pytest.raises(RedisError):\n            r.ping()\n        r.close()\n\n        ctx = OpenSSL.SSL.Context(OpenSSL.SSL.SSLv23_METHOD)\n        ctx.use_certificate_file(self.client_certs.cert)\n        ctx.use_privatekey_file(self.client_certs.keyfile)\n\n        r = redis.Redis(\n            host=p[0],\n            port=p[1],\n            ssl=True,\n            ssl_certfile=self.client_certs.cert,\n            ssl_keyfile=self.client_certs.keyfile,\n            ssl_cert_reqs=\"required\",\n            ssl_ca_certs=self.client_certs.ca_certfile,\n            ssl_ocsp_context=ctx,\n            ssl_ocsp_expected_cert=open(self.server_certs.ca_certfile, \"rb\").read(),\n            ssl_validate_ocsp_stapled=True,\n        )\n\n        with pytest.raises(ConnectionError) as e:\n            r.ping()\n        assert \"no ocsp response present\" in str(e.value)\n        r.close()\n\n        r = redis.Redis(\n            host=p[0],\n            port=p[1],\n            ssl=True,\n            ssl_certfile=self.client_certs.cert,\n            ssl_keyfile=self.client_certs.keyfile,\n            ssl_cert_reqs=\"required\",\n            ssl_ca_certs=self.client_certs.ca_certfile,\n            ssl_validate_ocsp_stapled=True,\n        )\n\n        with pytest.raises(ConnectionError) as e:\n            r.ping()\n        assert \"no ocsp response present\" in str(e.value)\n        r.close()\n\n    def test_cert_reqs_none_with_check_hostname(self, request):\n        \"\"\"Test that when ssl_cert_reqs=none is used with ssl_check_hostname=True,\n        the connection is created successfully with check_hostname internally set to False\"\"\"\n        ssl_url = request.config.option.redis_ssl_url\n        parsed_url = urlparse(ssl_url)\n        r = redis.Redis(\n            host=parsed_url.hostname,\n            port=parsed_url.port,\n            ssl=True,\n            ssl_cert_reqs=\"none\",\n            # Check that ssl_check_hostname is ignored, when ssl_cert_reqs=none\n            ssl_check_hostname=True,\n        )\n        try:\n            # Connection should be successful\n            assert r.ping()\n            # check_hostname should have been automatically set to False\n            assert r.connection_pool.connection_class == redis.SSLConnection\n            conn = r.connection_pool.make_connection()\n            assert conn.check_hostname is False\n        finally:\n            r.close()\n\n    def test_ssl_verify_flags_applied_to_context(self, request):\n        \"\"\"\n        Test that ssl_include_verify_flags and ssl_exclude_verify_flags\n        are properly applied to the SSL context\n        \"\"\"\n        ssl_url = request.config.option.redis_ssl_url\n        parsed_url = urlparse(ssl_url)\n\n        # Test with specific SSL verify flags\n        ssl_include_verify_flags = [\n            ssl.VerifyFlags.VERIFY_CRL_CHECK_LEAF,  # Disable strict verification\n            ssl.VerifyFlags.VERIFY_CRL_CHECK_CHAIN,  # Enable partial chain\n        ]\n\n        ssl_exclude_verify_flags = [\n            ssl.VerifyFlags.VERIFY_X509_STRICT,  # Disable trusted first\n        ]\n\n        r = redis.Redis(\n            host=parsed_url.hostname,\n            port=parsed_url.port,\n            ssl=True,\n            ssl_cert_reqs=\"none\",\n            ssl_include_verify_flags=ssl_include_verify_flags,\n            ssl_exclude_verify_flags=ssl_exclude_verify_flags,\n        )\n\n        try:\n            # Get the connection to trigger SSL context creation\n            conn = r.connection_pool.get_connection()\n            assert isinstance(conn, redis.SSLConnection)\n\n            # Verify the flags were processed by checking they're stored in connection\n            assert conn.ssl_include_verify_flags is not None\n            assert len(conn.ssl_include_verify_flags) == 2\n\n            assert conn.ssl_exclude_verify_flags is not None\n            assert len(conn.ssl_exclude_verify_flags) == 1\n\n            # Check each flag individually\n            for flag in ssl_include_verify_flags:\n                assert flag in conn.ssl_include_verify_flags, (\n                    f\"Flag {flag} not found in stored ssl_include_verify_flags\"\n                )\n            for flag in ssl_exclude_verify_flags:\n                assert flag in conn.ssl_exclude_verify_flags, (\n                    f\"Flag {flag} not found in stored ssl_exclude_verify_flags\"\n                )\n\n            # Test the actual SSL context created by the connection\n            # We need to create a mock socket and call _wrap_socket_with_ssl to get the context\n            import socket\n            import unittest.mock\n\n            mock_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n\n            try:\n                # Mock the wrap_socket method to capture the context\n                captured_context = None\n\n                def capture_context_wrap_socket(context_self, sock, **_kwargs):\n                    nonlocal captured_context\n                    captured_context = context_self\n                    # Don't actually wrap the socket, just return the original socket\n                    # to avoid connection errors\n                    return sock\n\n                with unittest.mock.patch.object(\n                    ssl.SSLContext, \"wrap_socket\", capture_context_wrap_socket\n                ):\n                    try:\n                        conn._wrap_socket_with_ssl(mock_sock)\n                    except Exception:\n                        # We expect this to potentially fail since we're not actually connecting\n                        # but we should have captured the context\n                        pass\n\n                # Validate that we captured a context and it has the correct flags applied\n                assert captured_context is not None, \"SSL context was not captured\"\n\n                # Verify that VERIFY_X509_STRICT was disabled (bit cleared)\n                assert not (\n                    captured_context.verify_flags & ssl.VerifyFlags.VERIFY_X509_STRICT\n                ), \"VERIFY_X509_STRICT should be disabled but is enabled\"\n\n                # Verify that VERIFY_CRL_CHECK_CHAIN was enabled (bit set)\n                assert (\n                    captured_context.verify_flags\n                    & ssl.VerifyFlags.VERIFY_CRL_CHECK_CHAIN\n                ), \"VERIFY_CRL_CHECK_CHAIN should be enabled but is disabled\"\n\n            finally:\n                mock_sock.close()\n\n        finally:\n            r.close()\n\n    @skip_if_server_version_lt(\"8.5.0\")\n    def test_ssl_authenticate_with_client_cert(self, request, r):\n        \"\"\"Test that when client certificate is used for authentication,\n        the connection is created successfully\"\"\"\n\n        try:\n            # Non SSL client, to setup ACL\n            assert r.acl_setuser(\n                CN_USERNAME,\n                enabled=True,\n                reset=True,\n                passwords=[\"+clientpass\"],\n                keys=[\"*\"],\n                commands=[\"+acl\"],\n            )\n        finally:\n            r.close()\n\n        ssl_url = request.config.option.redis_ssl_url\n        p = urlparse(ssl_url)[1].split(\":\")\n        client_cn_cert, client_cn_key, ca_cert = get_tls_certificates(\n            self.tls_cert_subdir, CertificateType.client_cn\n        )\n        r = redis.Redis(\n            host=p[0],\n            port=p[1],\n            ssl=True,\n            ssl_certfile=client_cn_cert,\n            ssl_keyfile=client_cn_key,\n            ssl_cert_reqs=\"required\",\n            ssl_ca_certs=ca_cert,\n        )\n        try:\n            assert r.acl_whoami() == CN_USERNAME\n        finally:\n            r.close()\n"
  },
  {
    "path": "tests/test_timeseries.py",
    "content": "import math\nimport time\nfrom time import sleep\n\nimport pytest\nimport redis\n\nfrom .conftest import (\n    _get_client,\n    assert_resp_response,\n    is_resp2_connection,\n    skip_if_server_version_gte,\n    skip_if_server_version_lt,\n    skip_ifmodversion_lt,\n)\n\n\n@pytest.fixture()\ndef decoded_r(request, stack_url):\n    with _get_client(\n        redis.Redis, request, decode_responses=True, from_url=stack_url\n    ) as client:\n        yield client\n\n\n@pytest.fixture\ndef client(decoded_r):\n    decoded_r.flushdb()\n    return decoded_r\n\n\n@pytest.mark.redismod\ndef test_create(client):\n    assert client.ts().create(1)\n    assert client.ts().create(2, retention_msecs=5)\n    assert client.ts().create(3, labels={\"Redis\": \"Labs\"})\n    assert client.ts().create(4, retention_msecs=20, labels={\"Time\": \"Series\"})\n    info = client.ts().info(4)\n    assert_resp_response(\n        client, 20, info.get(\"retention_msecs\"), info.get(\"retentionTime\")\n    )\n    assert \"Series\" == info[\"labels\"][\"Time\"]\n\n    # Test for a chunk size of 128 Bytes\n    assert client.ts().create(\"time-serie-1\", chunk_size=128)\n    info = client.ts().info(\"time-serie-1\")\n    assert_resp_response(client, 128, info.get(\"chunk_size\"), info.get(\"chunkSize\"))\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.4.0\", \"timeseries\")\ndef test_create_duplicate_policy(client):\n    # Test for duplicate policy\n    for duplicate_policy in [\"block\", \"last\", \"first\", \"min\", \"max\"]:\n        ts_name = f\"time-serie-ooo-{duplicate_policy}\"\n        assert client.ts().create(ts_name, duplicate_policy=duplicate_policy)\n        info = client.ts().info(ts_name)\n        assert_resp_response(\n            client,\n            duplicate_policy,\n            info.get(\"duplicate_policy\"),\n            info.get(\"duplicatePolicy\"),\n        )\n\n\n@pytest.mark.redismod\ndef test_alter(client):\n    assert client.ts().create(1)\n    info = client.ts().info(1)\n    assert_resp_response(\n        client, 0, info.get(\"retention_msecs\"), info.get(\"retentionTime\")\n    )\n    assert client.ts().alter(1, retention_msecs=10)\n    assert {} == client.ts().info(1)[\"labels\"]\n    info = client.ts().info(1)\n    assert_resp_response(\n        client, 10, info.get(\"retention_msecs\"), info.get(\"retentionTime\")\n    )\n    assert client.ts().alter(1, labels={\"Time\": \"Series\"})\n    assert \"Series\" == client.ts().info(1)[\"labels\"][\"Time\"]\n    info = client.ts().info(1)\n    assert_resp_response(\n        client, 10, info.get(\"retention_msecs\"), info.get(\"retentionTime\")\n    )\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.4.0\", \"timeseries\")\n@skip_if_server_version_gte(\"7.9.0\")\ndef test_alter_duplicate_policy_prior_redis_8(client):\n    assert client.ts().create(1)\n    info = client.ts().info(1)\n    assert_resp_response(\n        client, None, info.get(\"duplicate_policy\"), info.get(\"duplicatePolicy\")\n    )\n    assert client.ts().alter(1, duplicate_policy=\"min\")\n    info = client.ts().info(1)\n    assert_resp_response(\n        client, \"min\", info.get(\"duplicate_policy\"), info.get(\"duplicatePolicy\")\n    )\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.4.0\", \"timeseries\")\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_alter_duplicate_policy(client):\n    assert client.ts().create(1)\n    info = client.ts().info(1)\n    assert_resp_response(\n        client, \"block\", info.get(\"duplicate_policy\"), info.get(\"duplicatePolicy\")\n    )\n    assert client.ts().alter(1, duplicate_policy=\"min\")\n    info = client.ts().info(1)\n    assert_resp_response(\n        client, \"min\", info.get(\"duplicate_policy\"), info.get(\"duplicatePolicy\")\n    )\n\n\n@pytest.mark.redismod\ndef test_add(client):\n    assert 1 == client.ts().add(1, 1, 1)\n    assert 2 == client.ts().add(2, 2, 3, retention_msecs=10)\n    assert 3 == client.ts().add(3, 3, 2, labels={\"Redis\": \"Labs\"})\n    assert 4 == client.ts().add(\n        4, 4, 2, retention_msecs=10, labels={\"Redis\": \"Labs\", \"Time\": \"Series\"}\n    )\n\n    assert abs(time.time() - float(client.ts().add(5, \"*\", 1)) / 1000) < 1.0\n\n    info = client.ts().info(4)\n    assert_resp_response(\n        client, 10, info.get(\"retention_msecs\"), info.get(\"retentionTime\")\n    )\n    assert \"Labs\" == info[\"labels\"][\"Redis\"]\n\n    # Test for a chunk size of 128 Bytes on TS.ADD\n    assert client.ts().add(\"time-serie-1\", 1, 10.0, chunk_size=128)\n    info = client.ts().info(\"time-serie-1\")\n    assert_resp_response(client, 128, info.get(\"chunk_size\"), info.get(\"chunkSize\"))\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.4.0\", \"timeseries\")\ndef test_add_on_duplicate(client):\n    # Test for duplicate policy BLOCK\n    assert 1 == client.ts().add(\"time-serie-add-ooo-block\", 1, 5.0)\n    with pytest.raises(Exception):\n        client.ts().add(\"time-serie-add-ooo-block\", 1, 5.0, on_duplicate=\"block\")\n\n    # Test for duplicate policy LAST\n    assert 1 == client.ts().add(\"time-serie-add-ooo-last\", 1, 5.0)\n    assert 1 == client.ts().add(\"time-serie-add-ooo-last\", 1, 10.0, on_duplicate=\"last\")\n    assert 10.0 == client.ts().get(\"time-serie-add-ooo-last\")[1]\n\n    # Test for duplicate policy FIRST\n    assert 1 == client.ts().add(\"time-serie-add-ooo-first\", 1, 5.0)\n    assert 1 == client.ts().add(\n        \"time-serie-add-ooo-first\", 1, 10.0, on_duplicate=\"first\"\n    )\n    assert 5.0 == client.ts().get(\"time-serie-add-ooo-first\")[1]\n\n    # Test for duplicate policy MAX\n    assert 1 == client.ts().add(\"time-serie-add-ooo-max\", 1, 5.0)\n    assert 1 == client.ts().add(\"time-serie-add-ooo-max\", 1, 10.0, on_duplicate=\"max\")\n    assert 10.0 == client.ts().get(\"time-serie-add-ooo-max\")[1]\n\n    # Test for duplicate policy MIN\n    assert 1 == client.ts().add(\"time-serie-add-ooo-min\", 1, 5.0)\n    assert 1 == client.ts().add(\"time-serie-add-ooo-min\", 1, 10.0, on_duplicate=\"min\")\n    assert 5.0 == client.ts().get(\"time-serie-add-ooo-min\")[1]\n\n\n@pytest.mark.redismod\ndef test_madd(client):\n    client.ts().create(\"a\")\n    assert [1, 2, 3] == client.ts().madd([(\"a\", 1, 5), (\"a\", 2, 10), (\"a\", 3, 15)])\n\n\n@pytest.mark.redismod\ndef test_madd_missing_timeseries(client):\n    response = client.ts().madd([(\"a\", 1, 5), (\"a\", 2, 10)])\n    assert isinstance(response, list)\n    assert len(response) == 2\n    assert isinstance(response[0], redis.ResponseError)\n    assert isinstance(response[1], redis.ResponseError)\n\n\n@pytest.mark.redismod\ndef test_incrby_decrby(client):\n    for _ in range(100):\n        assert client.ts().incrby(1, 1)\n        sleep(0.001)\n    assert 100 == client.ts().get(1)[1]\n    for _ in range(100):\n        assert client.ts().decrby(1, 1)\n        sleep(0.001)\n    assert 0 == client.ts().get(1)[1]\n\n    assert client.ts().incrby(2, 1.5, timestamp=5)\n    assert_resp_response(client, client.ts().get(2), (5, 1.5), [5, 1.5])\n    assert client.ts().incrby(2, 2.25, timestamp=7)\n    assert_resp_response(client, client.ts().get(2), (7, 3.75), [7, 3.75])\n    assert client.ts().decrby(2, 1.5, timestamp=15)\n    assert_resp_response(client, client.ts().get(2), (15, 2.25), [15, 2.25])\n\n    # Test for a chunk size of 128 Bytes on TS.INCRBY\n    assert client.ts().incrby(\"time-serie-1\", 10, chunk_size=128)\n    info = client.ts().info(\"time-serie-1\")\n    assert_resp_response(client, 128, info.get(\"chunk_size\"), info.get(\"chunkSize\"))\n\n    # Test for a chunk size of 128 Bytes on TS.DECRBY\n    assert client.ts().decrby(\"time-serie-2\", 10, chunk_size=128)\n    info = client.ts().info(\"time-serie-2\")\n    assert_resp_response(client, 128, info.get(\"chunk_size\"), info.get(\"chunkSize\"))\n\n\n@pytest.mark.redismod\ndef test_create_and_delete_rule(client):\n    # test rule creation\n    time = 100\n    client.ts().create(1)\n    client.ts().create(2)\n    client.ts().createrule(1, 2, \"avg\", 100)\n    for i in range(50):\n        client.ts().add(1, time + i * 2, 1)\n        client.ts().add(1, time + i * 2 + 1, 2)\n    client.ts().add(1, time * 2, 1.5)\n    assert round(client.ts().get(2)[1], 5) == 1.5\n    info = client.ts().info(1)\n    if is_resp2_connection(client):\n        assert info.rules[0][1] == 100\n    else:\n        assert info[\"rules\"][\"2\"][0] == 100\n\n    # test rule deletion\n    client.ts().deleterule(1, 2)\n    info = client.ts().info(1)\n    assert not info[\"rules\"]\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.10.0\", \"timeseries\")\ndef test_del_range(client):\n    try:\n        client.ts().delete(\"test\", 0, 100)\n    except redis.ResponseError as e:\n        assert \"key does not exist\" in str(e)\n\n    for i in range(100):\n        client.ts().add(1, i, i % 7)\n    assert 22 == client.ts().delete(1, 0, 21)\n    assert [] == client.ts().range(1, 0, 21)\n    assert_resp_response(client, client.ts().range(1, 22, 22), [(22, 1.0)], [[22, 1.0]])\n\n\n@pytest.mark.redismod\ndef test_range(client):\n    for i in range(100):\n        client.ts().add(1, i, i % 7)\n    assert 100 == len(client.ts().range(1, 0, 200))\n    for i in range(100):\n        client.ts().add(1, i + 200, i % 7)\n    assert 200 == len(client.ts().range(1, 0, 500))\n    # last sample isn't returned\n    assert 20 == len(\n        client.ts().range(1, 0, 500, aggregation_type=\"avg\", bucket_size_msec=10)\n    )\n    assert 10 == len(client.ts().range(1, 0, 500, count=10))\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.10.0\", \"timeseries\")\ndef test_range_advanced(client):\n    for i in range(100):\n        client.ts().add(1, i, i % 7)\n        client.ts().add(1, i + 200, i % 7)\n\n    assert 2 == len(\n        client.ts().range(\n            1,\n            0,\n            500,\n            filter_by_ts=[i for i in range(10, 20)],\n            filter_by_min_value=1,\n            filter_by_max_value=2,\n        )\n    )\n    res = client.ts().range(\n        1, 0, 10, aggregation_type=\"count\", bucket_size_msec=10, align=\"+\"\n    )\n    assert_resp_response(client, res, [(0, 10.0), (10, 1.0)], [[0, 10.0], [10, 1.0]])\n    res = client.ts().range(\n        1, 0, 10, aggregation_type=\"count\", bucket_size_msec=10, align=5\n    )\n    assert_resp_response(client, res, [(0, 5.0), (5, 6.0)], [[0, 5.0], [5, 6.0]])\n    res = client.ts().range(1, 0, 10, aggregation_type=\"twa\", bucket_size_msec=10)\n    assert_resp_response(client, res, [(0, 2.55), (10, 3.0)], [[0, 2.55], [10, 3.0]])\n\n\n@pytest.mark.onlynoncluster\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.8.0\", \"timeseries\")\ndef test_range_latest(client: redis.Redis):\n    timeseries = client.ts()\n    timeseries.create(\"t1\")\n    timeseries.create(\"t2\")\n    timeseries.createrule(\"t1\", \"t2\", aggregation_type=\"sum\", bucket_size_msec=10)\n    timeseries.add(\"t1\", 1, 1)\n    timeseries.add(\"t1\", 2, 3)\n    timeseries.add(\"t1\", 11, 7)\n    timeseries.add(\"t1\", 13, 1)\n    assert_resp_response(\n        client,\n        timeseries.range(\"t1\", 0, 20),\n        [(1, 1.0), (2, 3.0), (11, 7.0), (13, 1.0)],\n        [[1, 1.0], [2, 3.0], [11, 7.0], [13, 1.0]],\n    )\n    assert_resp_response(client, timeseries.range(\"t2\", 0, 10), [(0, 4.0)], [[0, 4.0]])\n    res = timeseries.range(\"t2\", 0, 10, latest=True)\n    assert_resp_response(client, res, [(0, 4.0), (10, 8.0)], [[0, 4.0], [10, 8.0]])\n    assert_resp_response(\n        client, timeseries.range(\"t2\", 0, 9, latest=True), [(0, 4.0)], [[0, 4.0]]\n    )\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.8.0\", \"timeseries\")\ndef test_range_bucket_timestamp(client: redis.Redis):\n    timeseries = client.ts()\n    timeseries.create(\"t1\")\n    timeseries.add(\"t1\", 15, 1)\n    timeseries.add(\"t1\", 17, 4)\n    timeseries.add(\"t1\", 51, 3)\n    timeseries.add(\"t1\", 73, 5)\n    timeseries.add(\"t1\", 75, 3)\n    assert_resp_response(\n        client,\n        timeseries.range(\n            \"t1\", 0, 100, align=0, aggregation_type=\"max\", bucket_size_msec=10\n        ),\n        [(10, 4.0), (50, 3.0), (70, 5.0)],\n        [[10, 4.0], [50, 3.0], [70, 5.0]],\n    )\n    assert_resp_response(\n        client,\n        timeseries.range(\n            \"t1\",\n            0,\n            100,\n            align=0,\n            aggregation_type=\"max\",\n            bucket_size_msec=10,\n            bucket_timestamp=\"+\",\n        ),\n        [(20, 4.0), (60, 3.0), (80, 5.0)],\n        [[20, 4.0], [60, 3.0], [80, 5.0]],\n    )\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.8.0\", \"timeseries\")\ndef test_range_empty(client: redis.Redis):\n    timeseries = client.ts()\n    timeseries.create(\"t1\")\n    timeseries.add(\"t1\", 15, 1)\n    timeseries.add(\"t1\", 17, 4)\n    timeseries.add(\"t1\", 51, 3)\n    timeseries.add(\"t1\", 73, 5)\n    timeseries.add(\"t1\", 75, 3)\n    assert_resp_response(\n        client,\n        timeseries.range(\n            \"t1\", 0, 100, align=0, aggregation_type=\"max\", bucket_size_msec=10\n        ),\n        [(10, 4.0), (50, 3.0), (70, 5.0)],\n        [[10, 4.0], [50, 3.0], [70, 5.0]],\n    )\n    res = timeseries.range(\n        \"t1\", 0, 100, align=0, aggregation_type=\"max\", bucket_size_msec=10, empty=True\n    )\n    for i in range(len(res)):\n        if math.isnan(res[i][1]):\n            res[i] = (res[i][0], None)\n    resp2_expected = [\n        (10, 4.0),\n        (20, None),\n        (30, None),\n        (40, None),\n        (50, 3.0),\n        (60, None),\n        (70, 5.0),\n    ]\n    resp3_expected = [\n        [10, 4.0],\n        (20, None),\n        (30, None),\n        (40, None),\n        [50, 3.0],\n        (60, None),\n        [70, 5.0],\n    ]\n    assert_resp_response(client, res, resp2_expected, resp3_expected)\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.10.0\", \"timeseries\")\ndef test_rev_range(client):\n    for i in range(100):\n        client.ts().add(1, i, i % 7)\n    assert 100 == len(client.ts().range(1, 0, 200))\n    for i in range(100):\n        client.ts().add(1, i + 200, i % 7)\n    assert 200 == len(client.ts().range(1, 0, 500))\n    # first sample isn't returned\n    assert 20 == len(\n        client.ts().revrange(1, 0, 500, aggregation_type=\"avg\", bucket_size_msec=10)\n    )\n    assert 10 == len(client.ts().revrange(1, 0, 500, count=10))\n    assert 2 == len(\n        client.ts().revrange(\n            1,\n            0,\n            500,\n            filter_by_ts=[i for i in range(10, 20)],\n            filter_by_min_value=1,\n            filter_by_max_value=2,\n        )\n    )\n    assert_resp_response(\n        client,\n        client.ts().revrange(\n            1, 0, 10, aggregation_type=\"count\", bucket_size_msec=10, align=\"+\"\n        ),\n        [(10, 1.0), (0, 10.0)],\n        [[10, 1.0], [0, 10.0]],\n    )\n    assert_resp_response(\n        client,\n        client.ts().revrange(\n            1, 0, 10, aggregation_type=\"count\", bucket_size_msec=10, align=1\n        ),\n        [(1, 10.0), (0, 1.0)],\n        [[1, 10.0], [0, 1.0]],\n    )\n    assert_resp_response(\n        client,\n        client.ts().revrange(1, 0, 10, aggregation_type=\"twa\", bucket_size_msec=10),\n        [(10, 3.0), (0, 2.55)],\n        [[10, 3.0], [0, 2.55]],\n    )\n\n\n@pytest.mark.onlynoncluster\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.8.0\", \"timeseries\")\ndef test_revrange_latest(client: redis.Redis):\n    timeseries = client.ts()\n    timeseries.create(\"t1\")\n    timeseries.create(\"t2\")\n    timeseries.createrule(\"t1\", \"t2\", aggregation_type=\"sum\", bucket_size_msec=10)\n    timeseries.add(\"t1\", 1, 1)\n    timeseries.add(\"t1\", 2, 3)\n    timeseries.add(\"t1\", 11, 7)\n    timeseries.add(\"t1\", 13, 1)\n    res = timeseries.revrange(\"t2\", 0, 10)\n    assert_resp_response(client, res, [(0, 4.0)], [[0, 4.0]])\n    res = timeseries.revrange(\"t2\", 0, 10, latest=True)\n    assert_resp_response(client, res, [(10, 8.0), (0, 4.0)], [[10, 8.0], [0, 4.0]])\n    res = timeseries.revrange(\"t2\", 0, 9, latest=True)\n    assert_resp_response(client, res, [(0, 4.0)], [[0, 4.0]])\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.8.0\", \"timeseries\")\ndef test_revrange_bucket_timestamp(client: redis.Redis):\n    timeseries = client.ts()\n    timeseries.create(\"t1\")\n    timeseries.add(\"t1\", 15, 1)\n    timeseries.add(\"t1\", 17, 4)\n    timeseries.add(\"t1\", 51, 3)\n    timeseries.add(\"t1\", 73, 5)\n    timeseries.add(\"t1\", 75, 3)\n    assert_resp_response(\n        client,\n        timeseries.revrange(\n            \"t1\", 0, 100, align=0, aggregation_type=\"max\", bucket_size_msec=10\n        ),\n        [(70, 5.0), (50, 3.0), (10, 4.0)],\n        [[70, 5.0], [50, 3.0], [10, 4.0]],\n    )\n    assert_resp_response(\n        client,\n        timeseries.range(\n            \"t1\",\n            0,\n            100,\n            align=0,\n            aggregation_type=\"max\",\n            bucket_size_msec=10,\n            bucket_timestamp=\"+\",\n        ),\n        [(20, 4.0), (60, 3.0), (80, 5.0)],\n        [[20, 4.0], [60, 3.0], [80, 5.0]],\n    )\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.8.0\", \"timeseries\")\ndef test_revrange_empty(client: redis.Redis):\n    timeseries = client.ts()\n    timeseries.create(\"t1\")\n    timeseries.add(\"t1\", 15, 1)\n    timeseries.add(\"t1\", 17, 4)\n    timeseries.add(\"t1\", 51, 3)\n    timeseries.add(\"t1\", 73, 5)\n    timeseries.add(\"t1\", 75, 3)\n    assert_resp_response(\n        client,\n        timeseries.revrange(\n            \"t1\", 0, 100, align=0, aggregation_type=\"max\", bucket_size_msec=10\n        ),\n        [(70, 5.0), (50, 3.0), (10, 4.0)],\n        [[70, 5.0], [50, 3.0], [10, 4.0]],\n    )\n    res = timeseries.revrange(\n        \"t1\", 0, 100, align=0, aggregation_type=\"max\", bucket_size_msec=10, empty=True\n    )\n    for i in range(len(res)):\n        if math.isnan(res[i][1]):\n            res[i] = (res[i][0], None)\n    resp2_expected = [\n        (70, 5.0),\n        (60, None),\n        (50, 3.0),\n        (40, None),\n        (30, None),\n        (20, None),\n        (10, 4.0),\n    ]\n    resp3_expected = [\n        [70, 5.0],\n        (60, None),\n        [50, 3.0],\n        (40, None),\n        (30, None),\n        (20, None),\n        [10, 4.0],\n    ]\n    assert_resp_response(client, res, resp2_expected, resp3_expected)\n\n\n@pytest.mark.onlynoncluster\n@pytest.mark.redismod\ndef test_mrange(client):\n    client.ts().create(1, labels={\"Test\": \"This\", \"team\": \"ny\"})\n    client.ts().create(2, labels={\"Test\": \"This\", \"Taste\": \"That\", \"team\": \"sf\"})\n    for i in range(100):\n        client.ts().add(1, i, i % 7)\n        client.ts().add(2, i, i % 11)\n\n    res = client.ts().mrange(0, 200, filters=[\"Test=This\"])\n    assert 2 == len(res)\n    if is_resp2_connection(client):\n        assert 100 == len(res[0][\"1\"][1])\n\n        res = client.ts().mrange(0, 200, filters=[\"Test=This\"], count=10)\n        assert 10 == len(res[0][\"1\"][1])\n\n        for i in range(100):\n            client.ts().add(1, i + 200, i % 7)\n        res = client.ts().mrange(\n            0, 500, filters=[\"Test=This\"], aggregation_type=\"avg\", bucket_size_msec=10\n        )\n        assert 2 == len(res)\n        assert 20 == len(res[0][\"1\"][1])\n\n        # test withlabels\n        assert {} == res[0][\"1\"][0]\n        res = client.ts().mrange(0, 200, filters=[\"Test=This\"], with_labels=True)\n        assert {\"Test\": \"This\", \"team\": \"ny\"} == res[0][\"1\"][0]\n    else:\n        assert 100 == len(res[\"1\"][2])\n\n        res = client.ts().mrange(0, 200, filters=[\"Test=This\"], count=10)\n        assert 10 == len(res[\"1\"][2])\n\n        for i in range(100):\n            client.ts().add(1, i + 200, i % 7)\n        res = client.ts().mrange(\n            0, 500, filters=[\"Test=This\"], aggregation_type=\"avg\", bucket_size_msec=10\n        )\n        assert 2 == len(res)\n        assert 20 == len(res[\"1\"][2])\n\n        # test withlabels\n        assert {} == res[\"1\"][0]\n        res = client.ts().mrange(0, 200, filters=[\"Test=This\"], with_labels=True)\n        assert {\"Test\": \"This\", \"team\": \"ny\"} == res[\"1\"][0]\n\n\n@pytest.mark.onlynoncluster\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.10.0\", \"timeseries\")\ndef test_multi_range_advanced(client):\n    client.ts().create(1, labels={\"Test\": \"This\", \"team\": \"ny\"})\n    client.ts().create(2, labels={\"Test\": \"This\", \"Taste\": \"That\", \"team\": \"sf\"})\n    for i in range(100):\n        client.ts().add(1, i, i % 7)\n        client.ts().add(2, i, i % 11)\n\n    # test with selected labels\n    res = client.ts().mrange(0, 200, filters=[\"Test=This\"], select_labels=[\"team\"])\n    if is_resp2_connection(client):\n        assert {\"team\": \"ny\"} == res[0][\"1\"][0]\n        assert {\"team\": \"sf\"} == res[1][\"2\"][0]\n\n        # test with filterby\n        res = client.ts().mrange(\n            0,\n            200,\n            filters=[\"Test=This\"],\n            filter_by_ts=[i for i in range(10, 20)],\n            filter_by_min_value=1,\n            filter_by_max_value=2,\n        )\n        assert [(15, 1.0), (16, 2.0)] == res[0][\"1\"][1]\n\n        # test groupby\n        res = client.ts().mrange(\n            0, 3, filters=[\"Test=This\"], groupby=\"Test\", reduce=\"sum\"\n        )\n        assert [(0, 0.0), (1, 2.0), (2, 4.0), (3, 6.0)] == res[0][\"Test=This\"][1]\n        res = client.ts().mrange(\n            0, 3, filters=[\"Test=This\"], groupby=\"Test\", reduce=\"max\"\n        )\n        assert [(0, 0.0), (1, 1.0), (2, 2.0), (3, 3.0)] == res[0][\"Test=This\"][1]\n        res = client.ts().mrange(\n            0, 3, filters=[\"Test=This\"], groupby=\"team\", reduce=\"min\"\n        )\n        assert 2 == len(res)\n        assert [(0, 0.0), (1, 1.0), (2, 2.0), (3, 3.0)] == res[0][\"team=ny\"][1]\n        assert [(0, 0.0), (1, 1.0), (2, 2.0), (3, 3.0)] == res[1][\"team=sf\"][1]\n\n        # test align\n        res = client.ts().mrange(\n            0,\n            10,\n            filters=[\"team=ny\"],\n            aggregation_type=\"count\",\n            bucket_size_msec=10,\n            align=\"-\",\n        )\n        assert [(0, 10.0), (10, 1.0)] == res[0][\"1\"][1]\n        res = client.ts().mrange(\n            0,\n            10,\n            filters=[\"team=ny\"],\n            aggregation_type=\"count\",\n            bucket_size_msec=10,\n            align=5,\n        )\n        assert [(0, 5.0), (5, 6.0)] == res[0][\"1\"][1]\n    else:\n        assert {\"team\": \"ny\"} == res[\"1\"][0]\n        assert {\"team\": \"sf\"} == res[\"2\"][0]\n\n        # test with filterby\n        res = client.ts().mrange(\n            0,\n            200,\n            filters=[\"Test=This\"],\n            filter_by_ts=[i for i in range(10, 20)],\n            filter_by_min_value=1,\n            filter_by_max_value=2,\n        )\n        assert [[15, 1.0], [16, 2.0]] == res[\"1\"][2]\n\n        # test groupby\n        res = client.ts().mrange(\n            0, 3, filters=[\"Test=This\"], groupby=\"Test\", reduce=\"sum\"\n        )\n        assert [[0, 0.0], [1, 2.0], [2, 4.0], [3, 6.0]] == res[\"Test=This\"][3]\n        res = client.ts().mrange(\n            0, 3, filters=[\"Test=This\"], groupby=\"Test\", reduce=\"max\"\n        )\n        assert [[0, 0.0], [1, 1.0], [2, 2.0], [3, 3.0]] == res[\"Test=This\"][3]\n        res = client.ts().mrange(\n            0, 3, filters=[\"Test=This\"], groupby=\"team\", reduce=\"min\"\n        )\n        assert 2 == len(res)\n        assert [[0, 0.0], [1, 1.0], [2, 2.0], [3, 3.0]] == res[\"team=ny\"][3]\n        assert [[0, 0.0], [1, 1.0], [2, 2.0], [3, 3.0]] == res[\"team=sf\"][3]\n\n        # test align\n        res = client.ts().mrange(\n            0,\n            10,\n            filters=[\"team=ny\"],\n            aggregation_type=\"count\",\n            bucket_size_msec=10,\n            align=\"-\",\n        )\n        assert [[0, 10.0], [10, 1.0]] == res[\"1\"][2]\n        res = client.ts().mrange(\n            0,\n            10,\n            filters=[\"team=ny\"],\n            aggregation_type=\"count\",\n            bucket_size_msec=10,\n            align=5,\n        )\n        assert [[0, 5.0], [5, 6.0]] == res[\"1\"][2]\n\n\n@pytest.mark.onlynoncluster\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.8.0\", \"timeseries\")\ndef test_mrange_latest(client: redis.Redis):\n    timeseries = client.ts()\n    timeseries.create(\"t1\")\n    timeseries.create(\"t2\", labels={\"is_compaction\": \"true\"})\n    timeseries.create(\"t3\")\n    timeseries.create(\"t4\", labels={\"is_compaction\": \"true\"})\n    timeseries.createrule(\"t1\", \"t2\", aggregation_type=\"sum\", bucket_size_msec=10)\n    timeseries.createrule(\"t3\", \"t4\", aggregation_type=\"sum\", bucket_size_msec=10)\n    timeseries.add(\"t1\", 1, 1)\n    timeseries.add(\"t1\", 2, 3)\n    timeseries.add(\"t1\", 11, 7)\n    timeseries.add(\"t1\", 13, 1)\n    timeseries.add(\"t3\", 1, 1)\n    timeseries.add(\"t3\", 2, 3)\n    timeseries.add(\"t3\", 11, 7)\n    timeseries.add(\"t3\", 13, 1)\n    assert_resp_response(\n        client,\n        client.ts().mrange(0, 10, filters=[\"is_compaction=true\"], latest=True),\n        [{\"t2\": [{}, [(0, 4.0), (10, 8.0)]]}, {\"t4\": [{}, [(0, 4.0), (10, 8.0)]]}],\n        {\n            \"t2\": [{}, {\"aggregators\": []}, [[0, 4.0], [10, 8.0]]],\n            \"t4\": [{}, {\"aggregators\": []}, [[0, 4.0], [10, 8.0]]],\n        },\n    )\n\n\n@pytest.mark.onlynoncluster\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.10.0\", \"timeseries\")\ndef test_multi_reverse_range(client):\n    client.ts().create(1, labels={\"Test\": \"This\", \"team\": \"ny\"})\n    client.ts().create(2, labels={\"Test\": \"This\", \"Taste\": \"That\", \"team\": \"sf\"})\n    for i in range(100):\n        client.ts().add(1, i, i % 7)\n        client.ts().add(2, i, i % 11)\n\n    res = client.ts().mrange(0, 200, filters=[\"Test=This\"])\n    assert 2 == len(res)\n    if is_resp2_connection(client):\n        assert 100 == len(res[0][\"1\"][1])\n    else:\n        assert 100 == len(res[\"1\"][2])\n\n    res = client.ts().mrange(0, 200, filters=[\"Test=This\"], count=10)\n    if is_resp2_connection(client):\n        assert 10 == len(res[0][\"1\"][1])\n    else:\n        assert 10 == len(res[\"1\"][2])\n\n    for i in range(100):\n        client.ts().add(1, i + 200, i % 7)\n    res = client.ts().mrevrange(\n        0, 500, filters=[\"Test=This\"], aggregation_type=\"avg\", bucket_size_msec=10\n    )\n    assert 2 == len(res)\n    if is_resp2_connection(client):\n        assert 20 == len(res[0][\"1\"][1])\n        assert {} == res[0][\"1\"][0]\n    else:\n        assert 20 == len(res[\"1\"][2])\n        assert {} == res[\"1\"][0]\n\n    # test withlabels\n    res = client.ts().mrevrange(0, 200, filters=[\"Test=This\"], with_labels=True)\n    if is_resp2_connection(client):\n        assert {\"Test\": \"This\", \"team\": \"ny\"} == res[0][\"1\"][0]\n    else:\n        assert {\"Test\": \"This\", \"team\": \"ny\"} == res[\"1\"][0]\n\n    # test with selected labels\n    res = client.ts().mrevrange(0, 200, filters=[\"Test=This\"], select_labels=[\"team\"])\n    if is_resp2_connection(client):\n        assert {\"team\": \"ny\"} == res[0][\"1\"][0]\n        assert {\"team\": \"sf\"} == res[1][\"2\"][0]\n    else:\n        assert {\"team\": \"ny\"} == res[\"1\"][0]\n        assert {\"team\": \"sf\"} == res[\"2\"][0]\n\n    # test filterby\n    res = client.ts().mrevrange(\n        0,\n        200,\n        filters=[\"Test=This\"],\n        filter_by_ts=[i for i in range(10, 20)],\n        filter_by_min_value=1,\n        filter_by_max_value=2,\n    )\n    if is_resp2_connection(client):\n        assert [(16, 2.0), (15, 1.0)] == res[0][\"1\"][1]\n    else:\n        assert [[16, 2.0], [15, 1.0]] == res[\"1\"][2]\n\n    # test groupby\n    res = client.ts().mrevrange(\n        0, 3, filters=[\"Test=This\"], groupby=\"Test\", reduce=\"sum\"\n    )\n    if is_resp2_connection(client):\n        assert [(3, 6.0), (2, 4.0), (1, 2.0), (0, 0.0)] == res[0][\"Test=This\"][1]\n    else:\n        assert [[3, 6.0], [2, 4.0], [1, 2.0], [0, 0.0]] == res[\"Test=This\"][3]\n    res = client.ts().mrevrange(\n        0, 3, filters=[\"Test=This\"], groupby=\"Test\", reduce=\"max\"\n    )\n    if is_resp2_connection(client):\n        assert [(3, 3.0), (2, 2.0), (1, 1.0), (0, 0.0)] == res[0][\"Test=This\"][1]\n    else:\n        assert [[3, 3.0], [2, 2.0], [1, 1.0], [0, 0.0]] == res[\"Test=This\"][3]\n    res = client.ts().mrevrange(\n        0, 3, filters=[\"Test=This\"], groupby=\"team\", reduce=\"min\"\n    )\n    assert 2 == len(res)\n    if is_resp2_connection(client):\n        assert [(3, 3.0), (2, 2.0), (1, 1.0), (0, 0.0)] == res[0][\"team=ny\"][1]\n        assert [(3, 3.0), (2, 2.0), (1, 1.0), (0, 0.0)] == res[1][\"team=sf\"][1]\n    else:\n        assert [[3, 3.0], [2, 2.0], [1, 1.0], [0, 0.0]] == res[\"team=ny\"][3]\n        assert [[3, 3.0], [2, 2.0], [1, 1.0], [0, 0.0]] == res[\"team=sf\"][3]\n\n    # test align\n    res = client.ts().mrevrange(\n        0,\n        10,\n        filters=[\"team=ny\"],\n        aggregation_type=\"count\",\n        bucket_size_msec=10,\n        align=\"-\",\n    )\n    if is_resp2_connection(client):\n        assert [(10, 1.0), (0, 10.0)] == res[0][\"1\"][1]\n    else:\n        assert [[10, 1.0], [0, 10.0]] == res[\"1\"][2]\n    res = client.ts().mrevrange(\n        0,\n        10,\n        filters=[\"team=ny\"],\n        aggregation_type=\"count\",\n        bucket_size_msec=10,\n        align=1,\n    )\n    if is_resp2_connection(client):\n        assert [(1, 10.0), (0, 1.0)] == res[0][\"1\"][1]\n    else:\n        assert [[1, 10.0], [0, 1.0]] == res[\"1\"][2]\n\n\n@pytest.mark.onlynoncluster\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.8.0\", \"timeseries\")\ndef test_mrevrange_latest(client: redis.Redis):\n    timeseries = client.ts()\n    timeseries.create(\"t1\")\n    timeseries.create(\"t2\", labels={\"is_compaction\": \"true\"})\n    timeseries.create(\"t3\")\n    timeseries.create(\"t4\", labels={\"is_compaction\": \"true\"})\n    timeseries.createrule(\"t1\", \"t2\", aggregation_type=\"sum\", bucket_size_msec=10)\n    timeseries.createrule(\"t3\", \"t4\", aggregation_type=\"sum\", bucket_size_msec=10)\n    timeseries.add(\"t1\", 1, 1)\n    timeseries.add(\"t1\", 2, 3)\n    timeseries.add(\"t1\", 11, 7)\n    timeseries.add(\"t1\", 13, 1)\n    timeseries.add(\"t3\", 1, 1)\n    timeseries.add(\"t3\", 2, 3)\n    timeseries.add(\"t3\", 11, 7)\n    timeseries.add(\"t3\", 13, 1)\n    assert_resp_response(\n        client,\n        client.ts().mrevrange(0, 10, filters=[\"is_compaction=true\"], latest=True),\n        [{\"t2\": [{}, [(10, 8.0), (0, 4.0)]]}, {\"t4\": [{}, [(10, 8.0), (0, 4.0)]]}],\n        {\n            \"t2\": [{}, {\"aggregators\": []}, [[10, 8.0], [0, 4.0]]],\n            \"t4\": [{}, {\"aggregators\": []}, [[10, 8.0], [0, 4.0]]],\n        },\n    )\n\n\n@pytest.mark.redismod\ndef test_get(client):\n    name = \"test\"\n    client.ts().create(name)\n    assert not client.ts().get(name)\n    client.ts().add(name, 2, 3)\n    assert 2 == client.ts().get(name)[0]\n    client.ts().add(name, 3, 4)\n    assert 4 == client.ts().get(name)[1]\n\n\n@pytest.mark.onlynoncluster\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.8.0\", \"timeseries\")\ndef test_get_latest(client: redis.Redis):\n    timeseries = client.ts()\n    timeseries.create(\"t1\")\n    timeseries.create(\"t2\")\n    timeseries.createrule(\"t1\", \"t2\", aggregation_type=\"sum\", bucket_size_msec=10)\n    timeseries.add(\"t1\", 1, 1)\n    timeseries.add(\"t1\", 2, 3)\n    timeseries.add(\"t1\", 11, 7)\n    timeseries.add(\"t1\", 13, 1)\n    assert_resp_response(client, timeseries.get(\"t2\"), (0, 4.0), [0, 4.0])\n    assert_resp_response(\n        client, timeseries.get(\"t2\", latest=True), (10, 8.0), [10, 8.0]\n    )\n\n\n@pytest.mark.onlynoncluster\n@pytest.mark.redismod\ndef test_mget(client):\n    client.ts().create(1, labels={\"Test\": \"This\"})\n    client.ts().create(2, labels={\"Test\": \"This\", \"Taste\": \"That\"})\n    act_res = client.ts().mget([\"Test=This\"])\n    exp_res = [{\"1\": [{}, None, None]}, {\"2\": [{}, None, None]}]\n    exp_res_resp3 = {\"1\": [{}, []], \"2\": [{}, []]}\n    assert_resp_response(client, act_res, exp_res, exp_res_resp3)\n    client.ts().add(1, \"*\", 15)\n    client.ts().add(2, \"*\", 25)\n    res = client.ts().mget([\"Test=This\"])\n    if is_resp2_connection(client):\n        assert 15 == res[0][\"1\"][2]\n        assert 25 == res[1][\"2\"][2]\n    else:\n        assert 15 == res[\"1\"][1][1]\n        assert 25 == res[\"2\"][1][1]\n    res = client.ts().mget([\"Taste=That\"])\n    if is_resp2_connection(client):\n        assert 25 == res[0][\"2\"][2]\n    else:\n        assert 25 == res[\"2\"][1][1]\n\n    # test with_labels\n    if is_resp2_connection(client):\n        assert {} == res[0][\"2\"][0]\n    else:\n        assert {} == res[\"2\"][0]\n    res = client.ts().mget([\"Taste=That\"], with_labels=True)\n    if is_resp2_connection(client):\n        assert {\"Taste\": \"That\", \"Test\": \"This\"} == res[0][\"2\"][0]\n    else:\n        assert {\"Taste\": \"That\", \"Test\": \"This\"} == res[\"2\"][0]\n\n\n@pytest.mark.onlynoncluster\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.8.0\", \"timeseries\")\ndef test_mget_latest(client: redis.Redis):\n    timeseries = client.ts()\n    timeseries.create(\"t1\")\n    timeseries.create(\"t2\", labels={\"is_compaction\": \"true\"})\n    timeseries.createrule(\"t1\", \"t2\", aggregation_type=\"sum\", bucket_size_msec=10)\n    timeseries.add(\"t1\", 1, 1)\n    timeseries.add(\"t1\", 2, 3)\n    timeseries.add(\"t1\", 11, 7)\n    timeseries.add(\"t1\", 13, 1)\n    res = timeseries.mget(filters=[\"is_compaction=true\"])\n    assert_resp_response(client, res, [{\"t2\": [{}, 0, 4.0]}], {\"t2\": [{}, [0, 4.0]]})\n    res = timeseries.mget(filters=[\"is_compaction=true\"], latest=True)\n    assert_resp_response(client, res, [{\"t2\": [{}, 10, 8.0]}], {\"t2\": [{}, [10, 8.0]]})\n\n\n@pytest.mark.redismod\ndef test_info(client):\n    client.ts().create(1, retention_msecs=5, labels={\"currentLabel\": \"currentData\"})\n    info = client.ts().info(1)\n    assert_resp_response(\n        client, 5, info.get(\"retention_msecs\"), info.get(\"retentionTime\")\n    )\n    assert info[\"labels\"][\"currentLabel\"] == \"currentData\"\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.4.0\", \"timeseries\")\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_info_duplicate_policy(client):\n    client.ts().create(1, retention_msecs=5, labels={\"currentLabel\": \"currentData\"})\n    info = client.ts().info(1)\n    assert_resp_response(\n        client, \"block\", info.get(\"duplicate_policy\"), info.get(\"duplicatePolicy\")\n    )\n\n    client.ts().create(\"time-serie-2\", duplicate_policy=\"min\")\n    info = client.ts().info(\"time-serie-2\")\n    assert_resp_response(\n        client, \"min\", info.get(\"duplicate_policy\"), info.get(\"duplicatePolicy\")\n    )\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.4.0\", \"timeseries\")\n@skip_if_server_version_gte(\"7.9.0\")\ndef test_info_duplicate_policy_prior_redis_8(client):\n    client.ts().create(1, retention_msecs=5, labels={\"currentLabel\": \"currentData\"})\n    info = client.ts().info(1)\n    assert_resp_response(\n        client, None, info.get(\"duplicate_policy\"), info.get(\"duplicatePolicy\")\n    )\n\n    client.ts().create(\"time-serie-2\", duplicate_policy=\"min\")\n    info = client.ts().info(\"time-serie-2\")\n    assert_resp_response(\n        client, \"min\", info.get(\"duplicate_policy\"), info.get(\"duplicatePolicy\")\n    )\n\n\n@pytest.mark.redismod\n@pytest.mark.onlynoncluster\ndef test_query_index(client):\n    client.ts().create(1, labels={\"Test\": \"This\"})\n    client.ts().create(2, labels={\"Test\": \"This\", \"Taste\": \"That\"})\n    assert 2 == len(client.ts().queryindex([\"Test=This\"]))\n    assert 1 == len(client.ts().queryindex([\"Taste=That\"]))\n    assert_resp_response(client, client.ts().queryindex([\"Taste=That\"]), [2], [\"2\"])\n\n\n@pytest.mark.redismod\ndef test_pipeline(client):\n    pipeline = client.ts().pipeline()\n    pipeline.create(\"with_pipeline\")\n    for i in range(100):\n        pipeline.add(\"with_pipeline\", i, 1.1 * i)\n    pipeline.execute()\n\n    info = client.ts().info(\"with_pipeline\")\n    assert_resp_response(\n        client, 99, info.get(\"last_timestamp\"), info.get(\"lastTimestamp\")\n    )\n    assert_resp_response(\n        client, 100, info.get(\"total_samples\"), info.get(\"totalSamples\")\n    )\n    assert client.ts().get(\"with_pipeline\")[1] == 99 * 1.1\n\n\n@pytest.mark.redismod\ndef test_uncompressed(client):\n    client.ts().create(\"compressed\")\n    client.ts().create(\"uncompressed\", uncompressed=True)\n    for i in range(1000):\n        client.ts().add(\"compressed\", i, i)\n        client.ts().add(\"uncompressed\", i, i)\n    compressed_info = client.ts().info(\"compressed\")\n    uncompressed_info = client.ts().info(\"uncompressed\")\n    if is_resp2_connection(client):\n        assert compressed_info.memory_usage < uncompressed_info.memory_usage\n    else:\n        assert compressed_info[\"memoryUsage\"] < uncompressed_info[\"memoryUsage\"]\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.12.0\", \"timeseries\")\ndef test_create_with_insertion_filters(client):\n    client.ts().create(\n        \"time-series-1\",\n        duplicate_policy=\"last\",\n        ignore_max_time_diff=5,\n        ignore_max_val_diff=10.0,\n    )\n    assert 1000 == client.ts().add(\"time-series-1\", 1000, 1.0)\n    assert 1010 == client.ts().add(\"time-series-1\", 1010, 11.0)\n    assert 1010 == client.ts().add(\"time-series-1\", 1013, 10.0)\n    assert 1020 == client.ts().add(\"time-series-1\", 1020, 11.5)\n    assert 1021 == client.ts().add(\"time-series-1\", 1021, 22.0)\n\n    data_points = client.ts().range(\"time-series-1\", \"-\", \"+\")\n    assert_resp_response(\n        client,\n        data_points,\n        [(1000, 1.0), (1010, 11.0), (1020, 11.5), (1021, 22.0)],\n        [[1000, 1.0], [1010, 11.0], [1020, 11.5], [1021, 22.0]],\n    )\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.12.0\", \"timeseries\")\ndef test_create_with_insertion_filters_other_duplicate_policy(client):\n    client.ts().create(\n        \"time-series-1\",\n        ignore_max_time_diff=5,\n        ignore_max_val_diff=10.0,\n    )\n    assert 1000 == client.ts().add(\"time-series-1\", 1000, 1.0)\n    assert 1010 == client.ts().add(\"time-series-1\", 1010, 11.0)\n    # Still accepted because the duplicate_policy is not `last`.\n    assert 1013 == client.ts().add(\"time-series-1\", 1013, 10.0)\n\n    data_points = client.ts().range(\"time-series-1\", \"-\", \"+\")\n    assert_resp_response(\n        client,\n        data_points,\n        [(1000, 1.0), (1010, 11.0), (1013, 10)],\n        [[1000, 1.0], [1010, 11.0], [1013, 10]],\n    )\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.12.0\", \"timeseries\")\ndef test_alter_with_insertion_filters(client):\n    assert 1000 == client.ts().add(\"time-series-1\", 1000, 1.0)\n    assert 1010 == client.ts().add(\"time-series-1\", 1010, 11.0)\n    assert 1013 == client.ts().add(\"time-series-1\", 1013, 10.0)\n\n    client.ts().alter(\n        \"time-series-1\",\n        duplicate_policy=\"last\",\n        ignore_max_time_diff=5,\n        ignore_max_val_diff=10.0,\n    )\n\n    assert 1013 == client.ts().add(\"time-series-1\", 1015, 11.5)\n\n    data_points = client.ts().range(\"time-series-1\", \"-\", \"+\")\n    assert_resp_response(\n        client,\n        data_points,\n        [(1000, 1.0), (1010, 11.0), (1013, 10.0)],\n        [[1000, 1.0], [1010, 11.0], [1013, 10.0]],\n    )\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.12.0\", \"timeseries\")\ndef test_add_with_insertion_filters(client):\n    assert 1000 == client.ts().add(\n        \"time-series-1\",\n        1000,\n        1.0,\n        duplicate_policy=\"last\",\n        ignore_max_time_diff=5,\n        ignore_max_val_diff=10.0,\n    )\n\n    assert 1000 == client.ts().add(\"time-series-1\", 1004, 3.0)\n\n    data_points = client.ts().range(\"time-series-1\", \"-\", \"+\")\n    assert_resp_response(client, data_points, [(1000, 1.0)], [[1000, 1.0]])\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.12.0\", \"timeseries\")\ndef test_incrby_with_insertion_filters(client):\n    assert 1000 == client.ts().incrby(\n        \"time-series-1\",\n        1.0,\n        timestamp=1000,\n        duplicate_policy=\"last\",\n        ignore_max_time_diff=5,\n        ignore_max_val_diff=10.0,\n    )\n\n    assert 1000 == client.ts().incrby(\"time-series-1\", 3.0, timestamp=1000)\n\n    data_points = client.ts().range(\"time-series-1\", \"-\", \"+\")\n    assert_resp_response(client, data_points, [(1000, 1.0)], [[1000, 1.0]])\n\n    assert 1000 == client.ts().incrby(\"time-series-1\", 10.1, timestamp=1000)\n\n    data_points = client.ts().range(\"time-series-1\", \"-\", \"+\")\n    assert_resp_response(client, data_points, [(1000, 11.1)], [[1000, 11.1]])\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.12.0\", \"timeseries\")\ndef test_decrby_with_insertion_filters(client):\n    assert 1000 == client.ts().decrby(\n        \"time-series-1\",\n        1.0,\n        timestamp=1000,\n        duplicate_policy=\"last\",\n        ignore_max_time_diff=5,\n        ignore_max_val_diff=10.0,\n    )\n\n    assert 1000 == client.ts().decrby(\"time-series-1\", 3.0, timestamp=1000)\n\n    data_points = client.ts().range(\"time-series-1\", \"-\", \"+\")\n    assert_resp_response(client, data_points, [(1000, -1.0)], [[1000, -1.0]])\n\n    assert 1000 == client.ts().decrby(\"time-series-1\", 10.1, timestamp=1000)\n\n    data_points = client.ts().range(\"time-series-1\", \"-\", \"+\")\n    assert_resp_response(client, data_points, [(1000, -11.1)], [[1000, -11.1]])\n\n\n@pytest.mark.redismod\n@skip_ifmodversion_lt(\"1.12.0\", \"timeseries\")\ndef test_madd_with_insertion_filters(client):\n    client.ts().create(\n        \"time-series-1\",\n        duplicate_policy=\"last\",\n        ignore_max_time_diff=5,\n        ignore_max_val_diff=10.0,\n    )\n    assert 1010 == client.ts().add(\"time-series-1\", 1010, 1.0)\n    assert [1010, 1010, 1020, 1021] == client.ts().madd(\n        [\n            (\"time-series-1\", 1011, 11.0),\n            (\"time-series-1\", 1013, 10.0),\n            (\"time-series-1\", 1020, 2.0),\n            (\"time-series-1\", 1021, 22.0),\n        ]\n    )\n\n    data_points = client.ts().range(\"time-series-1\", \"-\", \"+\")\n    assert_resp_response(\n        client,\n        data_points,\n        [(1010, 1.0), (1020, 2.0), (1021, 22.0)],\n        [[1010, 1.0], [1020, 2.0], [1021, 22.0]],\n    )\n\n\n@pytest.mark.redismod\n@skip_if_server_version_lt(\"8.5.0\")\ndef test_range_with_count_nan_count_all_aggregators(client):\n    client.ts().create(\n        \"temperature:2:32\",\n    )\n\n    # Fill with values\n    assert client.ts().add(\"temperature:2:32\", 1000, \"NaN\") == 1000\n    assert client.ts().add(\"temperature:2:32\", 1003, 25) == 1003\n    assert client.ts().add(\"temperature:2:32\", 1005, \"NaN\") == 1005\n    assert client.ts().add(\"temperature:2:32\", 1006, \"NaN\") == 1006\n\n    # Ensure we count only NaN values\n    data_points = client.ts().range(\n        \"temperature:2:32\",\n        1000,\n        1006,\n        aggregation_type=\"countNan\",\n        bucket_size_msec=1000,\n    )\n    assert_resp_response(\n        client,\n        data_points,\n        [(1000, 3)],\n        [[1000, 3]],\n    )\n\n    # Ensure we count ALL values\n    data_points = client.ts().range(\n        \"temperature:2:32\",\n        1000,\n        1006,\n        aggregation_type=\"countAll\",\n        bucket_size_msec=1000,\n    )\n    assert_resp_response(\n        client,\n        data_points,\n        [(1000, 4)],\n        [[1000, 4]],\n    )\n\n\n@pytest.mark.redismod\n@skip_if_server_version_lt(\"8.5.0\")\ndef test_rev_range_with_count_nan_count_all_aggregators(client):\n    client.ts().create(\n        \"temperature:2:32\",\n    )\n\n    # Fill with values\n    assert client.ts().add(\"temperature:2:32\", 1000, \"NaN\") == 1000\n    assert client.ts().add(\"temperature:2:32\", 1003, 25) == 1003\n    assert client.ts().add(\"temperature:2:32\", 1005, \"NaN\") == 1005\n    assert client.ts().add(\"temperature:2:32\", 1006, \"NaN\") == 1006\n\n    # Ensure we count only NaN values\n    data_points = client.ts().revrange(\n        \"temperature:2:32\",\n        1000,\n        1006,\n        aggregation_type=\"countNan\",\n        bucket_size_msec=1000,\n    )\n    assert_resp_response(\n        client,\n        data_points,\n        [(1000, 3)],\n        [[1000, 3]],\n    )\n\n    # Ensure we count ALL values\n    data_points = client.ts().revrange(\n        \"temperature:2:32\",\n        1000,\n        1006,\n        aggregation_type=\"countAll\",\n        bucket_size_msec=1000,\n    )\n    assert_resp_response(\n        client,\n        data_points,\n        [(1000, 4)],\n        [[1000, 4]],\n    )\n\n\n@pytest.mark.redismod\n@skip_if_server_version_lt(\"8.5.0\")\ndef test_mrange_with_count_nan_count_all_aggregators(client):\n    client.ts().create(\n        \"temperature:A\",\n        labels={\"type\": \"temperature\", \"name\": \"A\"},\n    )\n    client.ts().create(\n        \"temperature:B\",\n        labels={\"type\": \"temperature\", \"name\": \"B\"},\n    )\n\n    # Fill with values\n    assert client.ts().madd(\n        [(\"temperature:A\", 1000, \"NaN\"), (\"temperature:A\", 1001, 27)]\n    )\n    assert client.ts().madd(\n        [(\"temperature:B\", 1000, \"NaN\"), (\"temperature:B\", 1001, 28)]\n    )\n\n    # Ensure we count only NaN values\n    data_points = client.ts().mrange(\n        1000,\n        1001,\n        aggregation_type=\"countNan\",\n        bucket_size_msec=1000,\n        filters=[\"type=temperature\"],\n    )\n    assert_resp_response(\n        client,\n        data_points,\n        [\n            {\"temperature:A\": [{}, [(1000, 1.0)]]},\n            {\"temperature:B\": [{}, [(1000, 1.0)]]},\n        ],\n        {\n            \"temperature:A\": [{}, {\"aggregators\": [\"countnan\"]}, [[1000, 1.0]]],\n            \"temperature:B\": [{}, {\"aggregators\": [\"countnan\"]}, [[1000, 1.0]]],\n        },\n    )\n\n    # Ensure we count ALL values\n    data_points = client.ts().mrange(\n        1000,\n        1001,\n        aggregation_type=\"countAll\",\n        bucket_size_msec=1000,\n        filters=[\"type=temperature\"],\n    )\n    assert_resp_response(\n        client,\n        data_points,\n        [\n            {\"temperature:A\": [{}, [(1000, 2.0)]]},\n            {\"temperature:B\": [{}, [(1000, 2.0)]]},\n        ],\n        {\n            \"temperature:A\": [{}, {\"aggregators\": [\"countall\"]}, [[1000, 2.0]]],\n            \"temperature:B\": [{}, {\"aggregators\": [\"countall\"]}, [[1000, 2.0]]],\n        },\n    )\n\n\n@pytest.mark.redismod\n@skip_if_server_version_lt(\"8.5.0\")\ndef test_mrevrange_with_count_nan_count_all_aggregators(client):\n    client.ts().create(\n        \"temperature:A\",\n        labels={\"type\": \"temperature\", \"name\": \"A\"},\n    )\n    client.ts().create(\n        \"temperature:B\",\n        labels={\"type\": \"temperature\", \"name\": \"B\"},\n    )\n\n    # Fill with values\n    assert client.ts().madd(\n        [(\"temperature:A\", 1000, \"NaN\"), (\"temperature:A\", 1001, 27)]\n    )\n    assert client.ts().madd(\n        [(\"temperature:B\", 1000, \"NaN\"), (\"temperature:B\", 1001, 28)]\n    )\n\n    # Ensure we count only NaN values\n    data_points = client.ts().mrevrange(\n        1000,\n        1001,\n        aggregation_type=\"countNan\",\n        bucket_size_msec=1000,\n        filters=[\"type=temperature\"],\n    )\n    assert_resp_response(\n        client,\n        data_points,\n        [\n            {\"temperature:A\": [{}, [(1000, 1.0)]]},\n            {\"temperature:B\": [{}, [(1000, 1.0)]]},\n        ],\n        {\n            \"temperature:A\": [{}, {\"aggregators\": [\"countnan\"]}, [[1000, 1.0]]],\n            \"temperature:B\": [{}, {\"aggregators\": [\"countnan\"]}, [[1000, 1.0]]],\n        },\n    )\n\n    # Ensure we count ALL values\n    data_points = client.ts().mrevrange(\n        1000,\n        1001,\n        aggregation_type=\"countAll\",\n        bucket_size_msec=1000,\n        filters=[\"type=temperature\"],\n    )\n    assert_resp_response(\n        client,\n        data_points,\n        [\n            {\"temperature:A\": [{}, [(1000, 2.0)]]},\n            {\"temperature:B\": [{}, [(1000, 2.0)]]},\n        ],\n        {\n            \"temperature:A\": [{}, {\"aggregators\": [\"countall\"]}, [[1000, 2.0]]],\n            \"temperature:B\": [{}, {\"aggregators\": [\"countall\"]}, [[1000, 2.0]]],\n        },\n    )\n"
  },
  {
    "path": "tests/test_utils.py",
    "content": "from datetime import datetime\nimport warnings\nimport pytest\nfrom redis.utils import (\n    compare_versions,\n    deprecated_function,\n    deprecated_args,\n    experimental_method,\n    experimental_args,\n)\n\n\n@pytest.mark.parametrize(\n    \"version1,version2,expected_res\",\n    [\n        (\"1.0.0\", \"0.9.0\", -1),\n        (\"1.0.0\", \"1.0.0\", 0),\n        (\"0.9.0\", \"1.0.0\", 1),\n        (\"1.09.0\", \"1.9.0\", 0),\n        (\"1.090.0\", \"1.9.0\", -1),\n        (\"1\", \"0.9.0\", -1),\n        (\"1\", \"1.0.0\", 0),\n    ],\n    ids=[\n        \"version1 > version2\",\n        \"version1 == version2\",\n        \"version1 < version2\",\n        \"version1 == version2 - different minor format\",\n        \"version1 > version2 - different minor format\",\n        \"version1 > version2 - major version only\",\n        \"version1 == version2 - major version only\",\n    ],\n)\ndef test_compare_versions(version1, version2, expected_res):\n    assert compare_versions(version1, version2) == expected_res\n\n\ndef redis_server_time(client):\n    seconds, milliseconds = client.time()\n    timestamp = float(f\"{seconds}.{milliseconds}\")\n    return datetime.fromtimestamp(timestamp)\n\n\n# Tests for deprecated_function decorator\nclass TestDeprecatedFunction:\n    def test_sync_function_warns(self):\n        @deprecated_function(reason=\"use new_func\", version=\"1.0.0\")\n        def old_func():\n            return \"result\"\n\n        with warnings.catch_warnings(record=True) as w:\n            warnings.simplefilter(\"always\")\n            result = old_func()\n            assert result == \"result\"\n            assert len(w) == 1\n            assert issubclass(w[0].category, DeprecationWarning)\n            assert \"old_func\" in str(w[0].message)\n            assert \"use new_func\" in str(w[0].message)\n            assert \"1.0.0\" in str(w[0].message)\n\n    def test_preserves_function_metadata(self):\n        @deprecated_function()\n        def documented_func():\n            \"\"\"This is the docstring.\"\"\"\n            pass\n\n        assert documented_func.__name__ == \"documented_func\"\n        assert documented_func.__doc__ == \"This is the docstring.\"\n\n\n# Tests for deprecated_args decorator\nclass TestDeprecatedArgs:\n    def test_sync_function_warns_on_deprecated_arg(self):\n        @deprecated_args(args_to_warn=[\"old_param\"], reason=\"use new_param\")\n        def func_with_args(new_param=None, old_param=None):\n            return new_param or old_param\n\n        with warnings.catch_warnings(record=True) as w:\n            warnings.simplefilter(\"always\")\n            result = func_with_args(old_param=\"value\")\n            assert result == \"value\"\n            assert len(w) == 1\n            assert issubclass(w[0].category, DeprecationWarning)\n            assert \"old_param\" in str(w[0].message)\n\n    def test_sync_function_no_warning_on_allowed_arg(self):\n        @deprecated_args(args_to_warn=[\"*\"], allowed_args=[\"allowed_param\"])\n        def func_with_allowed(allowed_param=None):\n            return allowed_param\n\n        with warnings.catch_warnings(record=True) as w:\n            warnings.simplefilter(\"always\")\n            result = func_with_allowed(allowed_param=\"value\")\n            assert result == \"value\"\n            assert len(w) == 0\n\n    def test_wildcard_warns_all_args(self):\n        @deprecated_args(args_to_warn=[\"*\"])\n        def func_all_deprecated(param1=None, param2=None):\n            return (param1, param2)\n\n        with warnings.catch_warnings(record=True) as w:\n            warnings.simplefilter(\"always\")\n            result = func_all_deprecated(param1=\"a\", param2=\"b\")\n            assert result == (\"a\", \"b\")\n            assert len(w) == 1\n            assert \"param1\" in str(w[0].message) or \"param2\" in str(w[0].message)\n\n\n# Tests for experimental_method decorator\nclass TestExperimentalMethod:\n    def test_sync_function_warns(self):\n        @experimental_method()\n        def experimental_func():\n            return \"experimental_result\"\n\n        with warnings.catch_warnings(record=True) as w:\n            warnings.simplefilter(\"always\")\n            result = experimental_func()\n            assert result == \"experimental_result\"\n            assert len(w) == 1\n            assert issubclass(w[0].category, UserWarning)\n            assert \"experimental_func\" in str(w[0].message)\n\n\n# Tests for experimental_args decorator\nclass TestExperimentalArgs:\n    def test_sync_function_warns_on_experimental_arg(self):\n        @experimental_args(args_to_warn=[\"beta_param\"])\n        def func_with_experimental(stable_param=None, beta_param=None):\n            return stable_param or beta_param\n\n        with warnings.catch_warnings(record=True) as w:\n            warnings.simplefilter(\"always\")\n            result = func_with_experimental(beta_param=\"beta_value\")\n            assert result == \"beta_value\"\n            assert len(w) == 1\n            assert issubclass(w[0].category, UserWarning)\n            assert \"beta_param\" in str(w[0].message)\n\n    def test_no_warning_when_no_args_provided(self):\n        @experimental_args(args_to_warn=[\"beta_param\"])\n        def func_no_args():\n            return \"no_args\"\n\n        with warnings.catch_warnings(record=True) as w:\n            warnings.simplefilter(\"always\")\n            result = func_no_args()\n            assert result == \"no_args\"\n            assert len(w) == 0\n"
  },
  {
    "path": "tests/test_vsets.py",
    "content": "import json\nimport random\nimport numpy as np\nimport pytest\nimport redis\nfrom redis.commands.vectorset.commands import QuantizationOptions\n\nfrom .conftest import (\n    _get_client,\n    skip_if_server_version_lt,\n)\n\n\n@pytest.fixture\ndef d_client(request):\n    r = _get_client(redis.Redis, request, decode_responses=True)\n\n    r.flushdb()\n    return r\n\n\n@pytest.fixture\ndef client(request):\n    r = _get_client(redis.Redis, request, decode_responses=False)\n\n    r.flushdb()\n    return r\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_add_elem_with_values(d_client):\n    float_array = [1, 4.32, 0.11]\n    resp = d_client.vset().vadd(\"myset\", float_array, \"elem1\")\n    assert resp == 1\n\n    emb = d_client.vset().vemb(\"myset\", \"elem1\")\n    assert _validate_quantization(float_array, emb, tolerance=0.1)\n\n    with pytest.raises(redis.DataError):\n        d_client.vset().vadd(\"myset_invalid_data\", None, \"elem1\")\n\n    with pytest.raises(redis.DataError):\n        d_client.vset().vadd(\"myset_invalid_data\", [12, 45], None, reduce_dim=3)\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_add_elem_with_vector(d_client):\n    float_array = [1, 4.32, 0.11]\n    # Convert the list of floats to a byte array in fp32 format\n    byte_array = _to_fp32_blob_array(float_array)\n    resp = d_client.vset().vadd(\"myset\", byte_array, \"elem1\")\n    assert resp == 1\n\n    emb = d_client.vset().vemb(\"myset\", \"elem1\")\n    assert _validate_quantization(float_array, emb, tolerance=0.1)\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_add_elem_reduced_dim(d_client):\n    float_array = [1, 4.32, 0.11, 0.5, 0.9]\n    resp = d_client.vset().vadd(\"myset\", float_array, \"elem1\", reduce_dim=3)\n    assert resp == 1\n\n    dim = d_client.vset().vdim(\"myset\")\n    assert dim == 3\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_add_elem_cas(d_client):\n    float_array = [1, 4.32, 0.11, 0.5, 0.9]\n    resp = d_client.vset().vadd(\"myset\", vector=float_array, element=\"elem1\", cas=True)\n    assert resp == 1\n\n    emb = d_client.vset().vemb(\"myset\", \"elem1\")\n    assert _validate_quantization(float_array, emb, tolerance=0.1)\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_add_elem_no_quant(d_client):\n    float_array = [1, 4.32, 0.11, 0.5, 0.9]\n    resp = d_client.vset().vadd(\n        \"myset\",\n        vector=float_array,\n        element=\"elem1\",\n        quantization=QuantizationOptions.NOQUANT,\n    )\n    assert resp == 1\n\n    emb = d_client.vset().vemb(\"myset\", \"elem1\")\n    assert _validate_quantization(float_array, emb, tolerance=0.0)\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_add_elem_bin_quant(d_client):\n    float_array = [1, 4.32, 0.0, 0.05, -2.9]\n    resp = d_client.vset().vadd(\n        \"myset\",\n        vector=float_array,\n        element=\"elem1\",\n        quantization=QuantizationOptions.BIN,\n    )\n    assert resp == 1\n\n    emb = d_client.vset().vemb(\"myset\", \"elem1\")\n    expected_array = [1, 1, -1, 1, -1]\n    assert _validate_quantization(expected_array, emb, tolerance=0.0)\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_add_elem_q8_quant(d_client):\n    float_array = [1, 4.32, 10.0, -21, -2.9]\n    resp = d_client.vset().vadd(\n        \"myset\",\n        vector=float_array,\n        element=\"elem1\",\n        quantization=QuantizationOptions.BIN,\n    )\n    assert resp == 1\n\n    emb = d_client.vset().vemb(\"myset\", \"elem1\")\n    expected_array = [1, 1, 1, -1, -1]\n    assert _validate_quantization(expected_array, emb, tolerance=0.0)\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_add_elem_ef(d_client):\n    d_client.vset().vadd(\"myset\", vector=[5, 55, 65, -20, 30], element=\"elem1\")\n    d_client.vset().vadd(\"myset\", vector=[-40, -40.32, 10.0, -4, 2.9], element=\"elem2\")\n\n    float_array = [1, 4.32, 10.0, -21, -2.9]\n    resp = d_client.vset().vadd(\"myset\", float_array, \"elem3\", ef=1)\n    assert resp == 1\n\n    emb = d_client.vset().vemb(\"myset\", \"elem3\")\n    assert _validate_quantization(float_array, emb, tolerance=0.1)\n\n    sim = d_client.vset().vsim(\"myset\", input=\"elem3\", with_scores=True)\n    assert len(sim) == 3\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_add_elem_with_attr(d_client):\n    float_array = [1, 4.32, 10.0, -21, -2.9]\n    attrs_dict = {\"key1\": \"value1\", \"key2\": \"value2\"}\n    resp = d_client.vset().vadd(\n        \"myset\",\n        vector=float_array,\n        element=\"elem3\",\n        attributes=attrs_dict,\n    )\n    assert resp == 1\n\n    emb = d_client.vset().vemb(\"myset\", \"elem3\")\n    assert _validate_quantization(float_array, emb, tolerance=0.1)\n\n    attr_saved = d_client.vset().vgetattr(\"myset\", \"elem3\")\n    assert attr_saved == attrs_dict\n\n    resp = d_client.vset().vadd(\n        \"myset\",\n        vector=float_array,\n        element=\"elem4\",\n        attributes={},\n    )\n    assert resp == 1\n\n    emb = d_client.vset().vemb(\"myset\", \"elem4\")\n    assert _validate_quantization(float_array, emb, tolerance=0.1)\n\n    attr_saved = d_client.vset().vgetattr(\"myset\", \"elem4\")\n    assert attr_saved is None\n\n    resp = d_client.vset().vadd(\n        \"myset\",\n        vector=float_array,\n        element=\"elem5\",\n        attributes=json.dumps(attrs_dict),\n    )\n    assert resp == 1\n\n    emb = d_client.vset().vemb(\"myset\", \"elem5\")\n    assert _validate_quantization(float_array, emb, tolerance=0.1)\n\n    attr_saved = d_client.vset().vgetattr(\"myset\", \"elem5\")\n    assert attr_saved == attrs_dict\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_add_elem_with_numlinks(d_client):\n    elements_count = 100\n    vector_dim = 10\n    for i in range(elements_count):\n        float_array = [random.randint(0, 10) for x in range(vector_dim)]\n        d_client.vset().vadd(\n            \"myset\",\n            float_array,\n            f\"elem{i}\",\n            numlinks=8,\n        )\n\n    float_array = [1, 4.32, 0.11, 0.5, 0.9, 0.1, 0.2, 0.3, 0.4, 0.5]\n    resp = d_client.vset().vadd(\"myset\", float_array, \"elem_numlinks\", numlinks=8)\n    assert resp == 1\n\n    emb = d_client.vset().vemb(\"myset\", \"elem_numlinks\")\n    assert _validate_quantization(float_array, emb, tolerance=0.5)\n\n    numlinks_all_layers = d_client.vset().vlinks(\"myset\", \"elem_numlinks\")\n    for neighbours_list_for_layer in numlinks_all_layers:\n        assert len(neighbours_list_for_layer) <= 8\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_vsim_count(d_client):\n    elements_count = 30\n    vector_dim = 800\n    for i in range(elements_count):\n        float_array = [random.uniform(0, 10) for x in range(vector_dim)]\n        d_client.vset().vadd(\n            \"myset\",\n            float_array,\n            f\"elem{i}\",\n            numlinks=64,\n        )\n\n    vsim = d_client.vset().vsim(\"myset\", input=\"elem1\")\n    assert len(vsim) == 10\n    assert isinstance(vsim, list)\n    assert isinstance(vsim[0], str)\n\n    vsim = d_client.vset().vsim(\"myset\", input=\"elem1\", count=5)\n    assert len(vsim) == 5\n    assert isinstance(vsim, list)\n    assert isinstance(vsim[0], str)\n\n    vsim = d_client.vset().vsim(\"myset\", input=\"elem1\", count=50)\n    assert len(vsim) == 30\n    assert isinstance(vsim, list)\n    assert isinstance(vsim[0], str)\n\n    vsim = d_client.vset().vsim(\"myset\", input=\"elem1\", count=15)\n    assert len(vsim) == 15\n    assert isinstance(vsim, list)\n    assert isinstance(vsim[0], str)\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_vsim_with_scores(d_client):\n    elements_count = 20\n    vector_dim = 50\n    for i in range(elements_count):\n        float_array = [random.uniform(0, 10) for x in range(vector_dim)]\n        d_client.vset().vadd(\n            \"myset\",\n            float_array,\n            f\"elem{i}\",\n            numlinks=64,\n        )\n\n    vsim = d_client.vset().vsim(\"myset\", input=\"elem1\", with_scores=True)\n    assert len(vsim) == 10\n    assert isinstance(vsim, dict)\n    assert isinstance(vsim[\"elem1\"], float)\n    assert 0 <= vsim[\"elem1\"] <= 1\n\n\n@skip_if_server_version_lt(\"8.2.0\")\ndef test_vsim_with_attribs_attribs_set(d_client):\n    elements_count = 5\n    vector_dim = 10\n    attrs_dict = {\"key1\": \"value1\", \"key2\": \"value2\"}\n    for i in range(elements_count):\n        float_array = [random.uniform(0, 5) for x in range(vector_dim)]\n        d_client.vset().vadd(\n            \"myset\",\n            float_array,\n            f\"elem{i}\",\n            numlinks=64,\n            attributes=attrs_dict if i % 2 == 0 else None,\n        )\n\n    vsim = d_client.vset().vsim(\"myset\", input=\"elem1\", with_attribs=True)\n    assert len(vsim) == 5\n    assert isinstance(vsim, dict)\n    assert vsim[\"elem1\"] is None\n    assert vsim[\"elem2\"] == attrs_dict\n\n\n@skip_if_server_version_lt(\"8.2.0\")\ndef test_vsim_with_scores_and_attribs_attribs_set(d_client):\n    elements_count = 5\n    vector_dim = 10\n    attrs_dict = {\"key1\": \"value1\", \"key2\": \"value2\"}\n    for i in range(elements_count):\n        float_array = [random.uniform(0, 5) for x in range(vector_dim)]\n        d_client.vset().vadd(\n            \"myset\",\n            float_array,\n            f\"elem{i}\",\n            numlinks=64,\n            attributes=attrs_dict if i % 2 == 0 else None,\n        )\n\n    vsim = d_client.vset().vsim(\n        \"myset\", input=\"elem1\", with_scores=True, with_attribs=True\n    )\n    assert len(vsim) == 5\n    assert isinstance(vsim, dict)\n    assert isinstance(vsim[\"elem1\"], dict)\n    assert \"score\" in vsim[\"elem1\"]\n    assert \"attributes\" in vsim[\"elem1\"]\n    assert isinstance(vsim[\"elem1\"][\"score\"], float)\n    assert vsim[\"elem1\"][\"attributes\"] is None\n\n    assert isinstance(vsim[\"elem2\"], dict)\n    assert \"score\" in vsim[\"elem2\"]\n    assert \"attributes\" in vsim[\"elem2\"]\n    assert isinstance(vsim[\"elem2\"][\"score\"], float)\n    assert vsim[\"elem2\"][\"attributes\"] == attrs_dict\n\n\n@skip_if_server_version_lt(\"8.2.0\")\ndef test_vsim_with_attribs_attribs_not_set(d_client):\n    elements_count = 20\n    vector_dim = 50\n    for i in range(elements_count):\n        float_array = [random.uniform(0, 10) for x in range(vector_dim)]\n        d_client.vset().vadd(\n            \"myset\",\n            float_array,\n            f\"elem{i}\",\n            numlinks=64,\n        )\n\n    vsim = d_client.vset().vsim(\"myset\", input=\"elem1\", with_attribs=True)\n    assert len(vsim) == 10\n    assert isinstance(vsim, dict)\n    assert vsim[\"elem1\"] is None\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_vsim_with_different_vector_input_types(d_client):\n    elements_count = 10\n    vector_dim = 5\n    for i in range(elements_count):\n        float_array = [random.uniform(0, 10) for x in range(vector_dim)]\n        attributes = {\"index\": i, \"elem_name\": f\"elem_{i}\"}\n        d_client.vset().vadd(\n            \"myset\",\n            float_array,\n            f\"elem_{i}\",\n            numlinks=4,\n            attributes=attributes,\n        )\n    sim = d_client.vset().vsim(\"myset\", input=\"elem_1\")\n    assert len(sim) == 10\n    assert isinstance(sim, list)\n\n    float_array = [1, 4.32, 0.0, 0.05, -2.9]\n    sim_to_float_array = d_client.vset().vsim(\"myset\", input=float_array)\n    assert len(sim_to_float_array) == 10\n    assert isinstance(sim_to_float_array, list)\n\n    fp32_vector = _to_fp32_blob_array(float_array)\n    sim_to_fp32_vector = d_client.vset().vsim(\"myset\", input=fp32_vector)\n    assert len(sim_to_fp32_vector) == 10\n    assert isinstance(sim_to_fp32_vector, list)\n    assert sim_to_float_array == sim_to_fp32_vector\n\n    with pytest.raises(redis.DataError):\n        d_client.vset().vsim(\"myset\", input=None)\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_vsim_unexisting(d_client):\n    float_array = [1, 4.32, 0.11, 0.5, 0.9]\n    d_client.vset().vadd(\"myset\", vector=float_array, element=\"elem1\", cas=True)\n\n    with pytest.raises(redis.ResponseError):\n        d_client.vset().vsim(\"myset\", input=\"elem_not_existing\")\n\n    sim = d_client.vset().vsim(\"myset_not_existing\", input=\"elem1\")\n    assert sim == []\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_vsim_with_filter(d_client):\n    elements_count = 50\n    vector_dim = 800\n    for i in range(elements_count):\n        float_array = [random.uniform(10, 20) for x in range(vector_dim)]\n        attributes = {\"index\": i, \"elem_name\": f\"elem_{i}\"}\n        d_client.vset().vadd(\n            \"myset\",\n            float_array,\n            f\"elem_{i}\",\n            numlinks=4,\n            attributes=attributes,\n        )\n    float_array = [-random.uniform(10, 20) for x in range(vector_dim)]\n    attributes = {\"index\": elements_count, \"elem_name\": \"elem_special\"}\n    d_client.vset().vadd(\n        \"myset\",\n        float_array,\n        \"elem_special\",\n        numlinks=4,\n        attributes=attributes,\n    )\n    sim = d_client.vset().vsim(\"myset\", input=\"elem_1\", filter=\".index > 10\")\n    assert len(sim) == 10\n    assert isinstance(sim, list)\n    for elem in sim:\n        assert int(elem.split(\"_\")[1]) > 10\n\n    sim = d_client.vset().vsim(\n        \"myset\",\n        input=\"elem_1\",\n        filter=\".index > 10 and .index < 15 and .elem_name in ['elem_12', 'elem_17']\",\n    )\n    assert len(sim) == 1\n    assert isinstance(sim, list)\n    assert sim[0] == \"elem_12\"\n\n    sim = d_client.vset().vsim(\n        \"myset\",\n        input=\"elem_1\",\n        filter=\".index > 25 and .elem_name in ['elem_12', 'elem_17', 'elem_19']\",\n        ef=100,\n    )\n    assert len(sim) == 0\n    assert isinstance(sim, list)\n\n    sim = d_client.vset().vsim(\n        \"myset\",\n        input=\"elem_1\",\n        filter=\".index > 28 and .elem_name in ['elem_12', 'elem_17', 'elem_special']\",\n        filter_ef=1,\n    )\n    assert len(sim) == 0, (\n        f\"Expected 0 results, but got {len(sim)} with filter_ef=1, sim: {sim}\"\n    )\n    assert isinstance(sim, list)\n\n    sim = d_client.vset().vsim(\n        \"myset\",\n        input=\"elem_1\",\n        filter=\".index > 28 and .elem_name in ['elem_12', 'elem_17', 'elem_special']\",\n        filter_ef=500,\n    )\n    assert len(sim) == 1\n    assert isinstance(sim, list)\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_vsim_truth_no_thread_enabled(d_client):\n    elements_count = 100\n    vector_dim = 50\n    for i in range(1, elements_count + 1):\n        float_array = [i * vector_dim for _ in range(vector_dim)]\n        d_client.vset().vadd(\"myset\", float_array, f\"elem_{i}\")\n\n    d_client.vset().vadd(\"myset\", [-22 for _ in range(vector_dim)], \"elem_man_2\")\n\n    sim_without_truth = d_client.vset().vsim(\n        \"myset\", input=\"elem_man_2\", with_scores=True, count=30\n    )\n    sim_truth = d_client.vset().vsim(\n        \"myset\", input=\"elem_man_2\", with_scores=True, count=30, truth=True\n    )\n\n    assert len(sim_without_truth) == 30\n    assert len(sim_truth) == 30\n\n    assert isinstance(sim_without_truth, dict)\n    assert isinstance(sim_truth, dict)\n\n    sim_no_thread = d_client.vset().vsim(\n        \"myset\", input=\"elem_man_2\", with_scores=True, no_thread=True\n    )\n\n    assert len(sim_no_thread) == 10\n    assert isinstance(sim_no_thread, dict)\n\n\n@skip_if_server_version_lt(\"8.2.0\")\ndef test_vsim_epsilon(d_client):\n    d_client.vset().vadd(\"myset\", [2, 1, 1], \"a\")\n    d_client.vset().vadd(\"myset\", [2, 0, 1], \"b\")\n    d_client.vset().vadd(\"myset\", [2, 0, 0], \"c\")\n    d_client.vset().vadd(\"myset\", [2, 0, 2], \"d\")\n    d_client.vset().vadd(\"myset\", [-2, -1, -1], \"e\")\n\n    res1 = d_client.vset().vsim(\"myset\", [2, 1, 1])\n    assert 5 == len(res1)\n\n    res2 = d_client.vset().vsim(\"myset\", [2, 1, 1], epsilon=0.5)\n    assert 4 == len(res2)\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_vdim(d_client):\n    float_array = [1, 4.32, 0.11, 0.5, 0.9, 0.1, 0.2]\n    d_client.vset().vadd(\"myset\", float_array, \"elem1\")\n\n    dim = d_client.vset().vdim(\"myset\")\n    assert dim == len(float_array)\n\n    d_client.vset().vadd(\"myset_reduced\", float_array, \"elem1\", reduce_dim=4)\n    reduced_dim = d_client.vset().vdim(\"myset_reduced\")\n    assert reduced_dim == 4\n\n    with pytest.raises(redis.ResponseError):\n        d_client.vset().vdim(\"myset_unexisting\")\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_vcard(d_client):\n    n = 20\n    for i in range(n):\n        float_array = [random.uniform(0, 10) for x in range(1, 8)]\n        d_client.vset().vadd(\"myset\", float_array, f\"elem{i}\")\n\n    card = d_client.vset().vcard(\"myset\")\n    assert card == n\n\n    with pytest.raises(redis.ResponseError):\n        d_client.vset().vdim(\"myset_unexisting\")\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_vrem(d_client):\n    n = 3\n    for i in range(n):\n        float_array = [random.uniform(0, 10) for x in range(1, 8)]\n        d_client.vset().vadd(\"myset\", float_array, f\"elem{i}\")\n\n    resp = d_client.vset().vrem(\"myset\", \"elem2\")\n    assert resp == 1\n\n    card = d_client.vset().vcard(\"myset\")\n    assert card == n - 1\n\n    resp = d_client.vset().vrem(\"myset\", \"elem2\")\n    assert resp == 0\n\n    card = d_client.vset().vcard(\"myset\")\n    assert card == n - 1\n\n    resp = d_client.vset().vrem(\"myset_unexisting\", \"elem1\")\n    assert resp == 0\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_vemb_bin_quantization(d_client):\n    e = [1, 4.32, 0.0, 0.05, -2.9]\n    d_client.vset().vadd(\n        \"myset\",\n        e,\n        \"elem\",\n        quantization=QuantizationOptions.BIN,\n    )\n    emb_no_quant = d_client.vset().vemb(\"myset\", \"elem\")\n    assert emb_no_quant == [1, 1, -1, 1, -1]\n\n    emb_no_quant_raw = d_client.vset().vemb(\"myset\", \"elem\", raw=True)\n    assert emb_no_quant_raw[\"quantization\"] == \"bin\"\n    assert isinstance(emb_no_quant_raw[\"raw\"], bytes)\n    assert isinstance(emb_no_quant_raw[\"l2\"], float)\n    assert \"range\" not in emb_no_quant_raw\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_vemb_q8_quantization(d_client):\n    e = [1, 10.32, 0.0, 2.05, -12.5]\n    d_client.vset().vadd(\"myset\", e, \"elem\", quantization=QuantizationOptions.Q8)\n\n    emb_q8_quant = d_client.vset().vemb(\"myset\", \"elem\")\n    assert _validate_quantization(e, emb_q8_quant, tolerance=0.1)\n\n    emb_q8_quant_raw = d_client.vset().vemb(\"myset\", \"elem\", raw=True)\n    assert emb_q8_quant_raw[\"quantization\"] == \"int8\"\n    assert isinstance(emb_q8_quant_raw[\"raw\"], bytes)\n    assert isinstance(emb_q8_quant_raw[\"l2\"], float)\n    assert isinstance(emb_q8_quant_raw[\"range\"], float)\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_vemb_no_quantization(d_client):\n    e = [1, 10.32, 0.0, 2.05, -12.5]\n    d_client.vset().vadd(\"myset\", e, \"elem\", quantization=QuantizationOptions.NOQUANT)\n\n    emb_no_quant = d_client.vset().vemb(\"myset\", \"elem\")\n    assert _validate_quantization(e, emb_no_quant, tolerance=0.1)\n\n    emb_no_quant_raw = d_client.vset().vemb(\"myset\", \"elem\", raw=True)\n    assert emb_no_quant_raw[\"quantization\"] == \"f32\"\n    assert isinstance(emb_no_quant_raw[\"raw\"], bytes)\n    assert isinstance(emb_no_quant_raw[\"l2\"], float)\n    assert \"range\" not in emb_no_quant_raw\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_vemb_default_quantization(d_client):\n    e = [1, 5.32, 0.0, 0.25, -5]\n    d_client.vset().vadd(\"myset\", vector=e, element=\"elem\")\n\n    emb_default_quant = d_client.vset().vemb(\"myset\", \"elem\")\n    assert _validate_quantization(e, emb_default_quant, tolerance=0.1)\n\n    emb_default_quant_raw = d_client.vset().vemb(\"myset\", \"elem\", raw=True)\n    assert emb_default_quant_raw[\"quantization\"] == \"int8\"\n    assert isinstance(emb_default_quant_raw[\"raw\"], bytes)\n    assert isinstance(emb_default_quant_raw[\"l2\"], float)\n    assert isinstance(emb_default_quant_raw[\"range\"], float)\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_vemb_fp32_quantization(d_client):\n    float_array_fp32 = [1, 4.32, 0.11]\n    # Convert the list of floats to a byte array in fp32 format\n    byte_array = _to_fp32_blob_array(float_array_fp32)\n    d_client.vset().vadd(\"myset\", byte_array, \"elem\")\n\n    emb_fp32_quant = d_client.vset().vemb(\"myset\", \"elem\")\n    assert _validate_quantization(float_array_fp32, emb_fp32_quant, tolerance=0.1)\n\n    emb_fp32_quant_raw = d_client.vset().vemb(\"myset\", \"elem\", raw=True)\n    assert emb_fp32_quant_raw[\"quantization\"] == \"int8\"\n    assert isinstance(emb_fp32_quant_raw[\"raw\"], bytes)\n    assert isinstance(emb_fp32_quant_raw[\"l2\"], float)\n    assert isinstance(emb_fp32_quant_raw[\"range\"], float)\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_vemb_unexisting(d_client):\n    emb_not_existing = d_client.vset().vemb(\"not_existing\", \"elem\")\n    assert emb_not_existing is None\n\n    e = [1, 5.32, 0.0, 0.25, -5]\n    d_client.vset().vadd(\"myset\", vector=e, element=\"elem\")\n    emb_elem_not_existing = d_client.vset().vemb(\"myset\", \"not_existing\")\n    assert emb_elem_not_existing is None\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_vlinks(d_client):\n    elements_count = 100\n    vector_dim = 800\n    for i in range(elements_count):\n        float_array = [random.uniform(0, 10) for x in range(vector_dim)]\n        d_client.vset().vadd(\n            \"myset\",\n            float_array,\n            f\"elem{i}\",\n            numlinks=8,\n        )\n\n    element_links_all_layers = d_client.vset().vlinks(\"myset\", \"elem1\")\n    assert len(element_links_all_layers) >= 1\n    for neighbours_list_for_layer in element_links_all_layers:\n        assert isinstance(neighbours_list_for_layer, list)\n        for neighbour in neighbours_list_for_layer:\n            assert isinstance(neighbour, str)\n\n    elem_links_all_layers_with_scores = d_client.vset().vlinks(\n        \"myset\", \"elem1\", with_scores=True\n    )\n    assert len(elem_links_all_layers_with_scores) >= 1\n    for neighbours_dict_for_layer in elem_links_all_layers_with_scores:\n        assert isinstance(neighbours_dict_for_layer, dict)\n        for neighbour_key, score_value in neighbours_dict_for_layer.items():\n            assert isinstance(neighbour_key, str)\n            assert isinstance(score_value, float)\n\n    float_array = [0.75, 0.25, 0.5, 0.1, 0.9]\n    d_client.vset().vadd(\"myset_one_elem_only\", float_array, \"elem1\")\n    elem_no_neighbours_with_scores = d_client.vset().vlinks(\n        \"myset_one_elem_only\", \"elem1\", with_scores=True\n    )\n    assert len(elem_no_neighbours_with_scores) >= 1\n    for neighbours_dict_for_layer in elem_no_neighbours_with_scores:\n        assert isinstance(neighbours_dict_for_layer, dict)\n        assert len(neighbours_dict_for_layer) == 0\n\n    elem_no_neighbours_no_scores = d_client.vset().vlinks(\n        \"myset_one_elem_only\", \"elem1\"\n    )\n    assert len(elem_no_neighbours_no_scores) >= 1\n    for neighbours_list_for_layer in elem_no_neighbours_no_scores:\n        assert isinstance(neighbours_list_for_layer, list)\n        assert len(neighbours_list_for_layer) == 0\n\n    unexisting_element_links = d_client.vset().vlinks(\"myset\", \"unexisting_elem\")\n    assert unexisting_element_links is None\n\n    unexisting_vset_links = d_client.vset().vlinks(\"myset_unexisting\", \"elem1\")\n    assert unexisting_vset_links is None\n\n    unexisting_element_links = d_client.vset().vlinks(\n        \"myset\", \"unexisting_elem\", with_scores=True\n    )\n    assert unexisting_element_links is None\n\n    unexisting_vset_links = d_client.vset().vlinks(\n        \"myset_unexisting\", \"elem1\", with_scores=True\n    )\n    assert unexisting_vset_links is None\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_vinfo(d_client):\n    elements_count = 100\n    vector_dim = 800\n    for i in range(elements_count):\n        float_array = [random.uniform(0, 10) for x in range(vector_dim)]\n        d_client.vset().vadd(\n            \"myset\",\n            float_array,\n            f\"elem{i}\",\n            numlinks=8,\n            quantization=QuantizationOptions.BIN,\n        )\n\n    vset_info = d_client.vset().vinfo(\"myset\")\n    assert vset_info[\"quant-type\"] == \"bin\"\n    assert vset_info[\"vector-dim\"] == vector_dim\n    assert vset_info[\"size\"] == elements_count\n    assert vset_info[\"max-level\"] > 0\n    assert vset_info[\"hnsw-max-node-uid\"] == elements_count\n\n    unexisting_vset_info = d_client.vset().vinfo(\"myset_unexisting\")\n    assert unexisting_vset_info is None\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_vset_vget_attributes(d_client):\n    float_array = [1, 4.32, 0.11]\n    attributes = {\"key1\": \"value1\", \"key2\": \"value2\"}\n\n    # validate vgetattrs when no attributes are set with vadd\n    resp = d_client.vset().vadd(\"myset\", float_array, \"elem1\")\n    assert resp == 1\n\n    attrs = d_client.vset().vgetattr(\"myset\", \"elem1\")\n    assert attrs is None\n\n    # validate vgetattrs when attributes are set with vadd\n    resp = d_client.vset().vadd(\n        \"myset_with_attrs\", float_array, \"elem1\", attributes=attributes\n    )\n    assert resp == 1\n\n    attrs = d_client.vset().vgetattr(\"myset_with_attrs\", \"elem1\")\n    assert attrs == attributes\n\n    # Set attributes and get attributes\n    resp = d_client.vset().vsetattr(\"myset\", \"elem1\", attributes)\n    assert resp == 1\n    attr_saved = d_client.vset().vgetattr(\"myset\", \"elem1\")\n    assert attr_saved == attributes\n\n    # Set attributes to None\n    resp = d_client.vset().vsetattr(\"myset\", \"elem1\", None)\n    assert resp == 1\n    attr_saved = d_client.vset().vgetattr(\"myset\", \"elem1\")\n    assert attr_saved is None\n\n    # Set attributes to empty dict\n    resp = d_client.vset().vsetattr(\"myset\", \"elem1\", {})\n    assert resp == 1\n    attr_saved = d_client.vset().vgetattr(\"myset\", \"elem1\")\n    assert attr_saved is None\n\n    # Set attributes provided as string\n    resp = d_client.vset().vsetattr(\"myset\", \"elem1\", json.dumps(attributes))\n    assert resp == 1\n    attr_saved = d_client.vset().vgetattr(\"myset\", \"elem1\")\n    assert attr_saved == attributes\n\n    # Set attributes to unexisting element\n    resp = d_client.vset().vsetattr(\"myset\", \"elem2\", attributes)\n    assert resp == 0\n    attr_saved = d_client.vset().vgetattr(\"myset\", \"elem2\")\n    assert attr_saved is None\n\n    # Set attributes to unexisting vset\n    resp = d_client.vset().vsetattr(\"myset_unexisting\", \"elem1\", attributes)\n    assert resp == 0\n    attr_saved = d_client.vset().vgetattr(\"myset_unexisting\", \"elem1\")\n    assert attr_saved is None\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_vrandmember(d_client):\n    elements = [\"elem1\", \"elem2\", \"elem3\"]\n    for elem in elements:\n        float_array = [random.uniform(0, 10) for x in range(1, 8)]\n        d_client.vset().vadd(\"myset\", float_array, element=elem)\n\n    random_member = d_client.vset().vrandmember(\"myset\")\n    assert random_member in elements\n\n    members_list = d_client.vset().vrandmember(\"myset\", count=2)\n    assert len(members_list) == 2\n    assert all(member in elements for member in members_list)\n\n    # Test with count greater than the number of elements\n    members_list = d_client.vset().vrandmember(\"myset\", count=10)\n    assert len(members_list) == len(elements)\n    assert all(member in elements for member in members_list)\n\n    # Test with negative count\n    members_list = d_client.vset().vrandmember(\"myset\", count=-2)\n    assert len(members_list) == 2\n    assert all(member in elements for member in members_list)\n\n    # Test with count equal to the number of elements\n    members_list = d_client.vset().vrandmember(\"myset\", count=len(elements))\n    assert len(members_list) == len(elements)\n    assert all(member in elements for member in members_list)\n\n    # Test with count equal to 0\n    members_list = d_client.vset().vrandmember(\"myset\", count=0)\n    assert members_list == []\n\n    # Test with count equal to 1\n    members_list = d_client.vset().vrandmember(\"myset\", count=1)\n    assert len(members_list) == 1\n    assert members_list[0] in elements\n\n    # Test with count equal to -1\n    members_list = d_client.vset().vrandmember(\"myset\", count=-1)\n    assert len(members_list) == 1\n    assert members_list[0] in elements\n\n    # Test with unexisting vset & without count\n    members_list = d_client.vset().vrandmember(\"myset_unexisting\")\n    assert members_list is None\n\n    # Test with unexisting vset & count\n    members_list = d_client.vset().vrandmember(\"myset_unexisting\", count=5)\n    assert members_list == []\n\n\n@skip_if_server_version_lt(\"8.2.0\")\ndef test_8_2_new_vset_features_without_decoding_responces(client):\n    # test vadd\n    elements = [\"elem1\", \"elem2\", \"elem3\"]\n    attrs_dict = {\"key1\": \"value1\", \"key2\": \"value2\"}\n    for elem in elements:\n        float_array = [random.uniform(0.5, 10) for x in range(0, 8)]\n        resp = client.vset().vadd(\n            \"myset\", float_array, element=elem, attributes=attrs_dict\n        )\n        assert resp == 1\n\n    # test vsim with attributes\n    vsim_with_attribs = client.vset().vsim(\"myset\", input=\"elem1\", with_attribs=True)\n    assert len(vsim_with_attribs) == 3\n    assert isinstance(vsim_with_attribs, dict)\n    assert isinstance(vsim_with_attribs[b\"elem1\"], dict)\n    assert vsim_with_attribs[b\"elem1\"] == attrs_dict\n\n    # test vsim with score and attributes\n    vsim_with_scores_and_attribs = client.vset().vsim(\n        \"myset\", input=\"elem1\", with_scores=True, with_attribs=True\n    )\n    assert len(vsim_with_scores_and_attribs) == 3\n    assert isinstance(vsim_with_scores_and_attribs, dict)\n    assert isinstance(vsim_with_scores_and_attribs[b\"elem1\"], dict)\n    assert \"score\" in vsim_with_scores_and_attribs[b\"elem1\"]\n    assert \"attributes\" in vsim_with_scores_and_attribs[b\"elem1\"]\n    assert isinstance(vsim_with_scores_and_attribs[b\"elem1\"][\"score\"], float)\n    assert isinstance(vsim_with_scores_and_attribs[b\"elem1\"][\"attributes\"], dict)\n    assert vsim_with_scores_and_attribs[b\"elem1\"][\"attributes\"] == attrs_dict\n\n\n@skip_if_server_version_lt(\"7.9.0\")\ndef test_vset_commands_without_decoding_responces(client):\n    # test vadd\n    elements = [\"elem1\", \"elem2\", \"elem3\"]\n    attrs_dict = {\"key1\": \"value1\", \"key2\": \"value2\"}\n    for elem in elements:\n        float_array = [random.uniform(0.5, 10) for x in range(0, 8)]\n        resp = client.vset().vadd(\n            \"myset\", float_array, element=elem, attributes=attrs_dict\n        )\n        assert resp == 1\n\n    # test vemb\n    emb = client.vset().vemb(\"myset\", \"elem1\")\n    assert len(emb) == 8\n    assert isinstance(emb, list)\n    assert all(isinstance(x, float) for x in emb), f\"Expected float values, got {emb}\"\n\n    emb_raw = client.vset().vemb(\"myset\", \"elem1\", raw=True)\n    assert emb_raw[\"quantization\"] == b\"int8\"\n    assert isinstance(emb_raw[\"raw\"], bytes)\n    assert isinstance(emb_raw[\"l2\"], float)\n    assert isinstance(emb_raw[\"range\"], float)\n\n    # test vsim\n    vsim = client.vset().vsim(\"myset\", input=\"elem1\")\n    assert len(vsim) == 3\n    assert isinstance(vsim, list)\n    assert isinstance(vsim[0], bytes)\n\n    # test vsim with scores\n    vsim_with_scores = client.vset().vsim(\"myset\", input=\"elem1\", with_scores=True)\n    assert len(vsim_with_scores) == 3\n    assert isinstance(vsim_with_scores, dict)\n    assert isinstance(vsim_with_scores[b\"elem1\"], float)\n\n    # test vlinks - no scores\n    element_links_all_layers = client.vset().vlinks(\"myset\", \"elem1\")\n    assert len(element_links_all_layers) >= 1\n    for neighbours_list_for_layer in element_links_all_layers:\n        assert isinstance(neighbours_list_for_layer, list)\n        for neighbour in neighbours_list_for_layer:\n            assert isinstance(neighbour, bytes)\n    # test vlinks with scores\n    elem_links_all_layers_with_scores = client.vset().vlinks(\n        \"myset\", \"elem1\", with_scores=True\n    )\n    assert len(elem_links_all_layers_with_scores) >= 1\n    for neighbours_dict_for_layer in elem_links_all_layers_with_scores:\n        assert isinstance(neighbours_dict_for_layer, dict)\n        for neighbour_key, score_value in neighbours_dict_for_layer.items():\n            assert isinstance(neighbour_key, bytes)\n            assert isinstance(score_value, float)\n\n    # test vinfo\n    vset_info = client.vset().vinfo(\"myset\")\n    assert vset_info[b\"quant-type\"] == b\"int8\"\n    assert vset_info[b\"vector-dim\"] == 8\n    assert vset_info[b\"size\"] == len(elements)\n    assert vset_info[b\"max-level\"] >= 0\n    assert vset_info[b\"hnsw-max-node-uid\"] == len(elements)\n\n    # test vgetattr\n    attributes = {\"key1\": \"value1\", \"key2\": \"value2\"}\n    client.vset().vsetattr(\"myset\", \"elem1\", attributes)\n    attrs = client.vset().vgetattr(\"myset\", \"elem1\")\n    assert attrs == attributes\n\n    # test vrandmember\n    random_member = client.vset().vrandmember(\"myset\")\n    assert isinstance(random_member, bytes)\n    assert random_member.decode(\"utf-8\") in elements\n\n    members_list = client.vset().vrandmember(\"myset\", count=2)\n    assert len(members_list) == 2\n    assert all(member.decode(\"utf-8\") in elements for member in members_list)\n\n\ndef _to_fp32_blob_array(float_array):\n    \"\"\"\n    Convert a list of floats to a byte array in fp32 format.\n    \"\"\"\n    # Convert the list of floats to a NumPy array with dtype np.float32\n    arr = np.array(float_array, dtype=np.float32)\n    # Convert the NumPy array to a byte array\n    byte_array = arr.tobytes()\n    return byte_array\n\n\ndef _validate_quantization(original, quantized, tolerance=0.1):\n    original = np.array(original, dtype=np.float32)\n    quantized = np.array(quantized, dtype=np.float32)\n\n    max_diff = np.max(np.abs(original - quantized))\n    if max_diff > tolerance:\n        return False\n    else:\n        return True\n\n\n@skip_if_server_version_lt(\"8.4.0\")\ndef test_vrange_basic(d_client):\n    \"\"\"Test basic VRANGE functionality with lexicographical ordering.\"\"\"\n    # Add elements with different names\n    elements = [\"apple\", \"banana\", \"cherry\", \"date\", \"elderberry\"]\n    for elem in elements:\n        d_client.vset().vadd(\"myset\", [1.0, 2.0, 3.0], elem)\n\n    # Test full range\n    result = d_client.vset().vrange(\"myset\", \"-\", \"+\")\n    assert result == elements\n    assert len(result) == 5\n\n    # Test inclusive range\n    result = d_client.vset().vrange(\"myset\", \"[banana\", \"[date\")\n    assert result == [\"banana\", \"cherry\", \"date\"]\n\n    # Test exclusive range\n    result = d_client.vset().vrange(\"myset\", \"(banana\", \"(date\")\n    assert result == [\"cherry\"]\n\n\n@skip_if_server_version_lt(\"8.4.0\")\ndef test_vrange_with_count(d_client):\n    \"\"\"Test VRANGE with count parameter.\"\"\"\n    # Add elements\n    elements = [\"a\", \"b\", \"c\", \"d\", \"e\", \"f\", \"g\"]\n    for elem in elements:\n        d_client.vset().vadd(\"myset\", [1.0, 2.0], elem)\n\n    # Test with positive count\n    result = d_client.vset().vrange(\"myset\", \"-\", \"+\", count=3)\n    assert len(result) == 3\n    assert result == [\"a\", \"b\", \"c\"]\n\n    # Test with count larger than set size\n    result = d_client.vset().vrange(\"myset\", \"-\", \"+\", count=100)\n    assert len(result) == 7\n    assert result == elements\n\n    # Test with count = 0\n    result = d_client.vset().vrange(\"myset\", \"-\", \"+\", count=0)\n    assert result == []\n\n    # Test with negative count (should return all)\n    result = d_client.vset().vrange(\"myset\", \"-\", \"+\", count=-1)\n    assert len(result) == 7\n    assert result == elements\n\n\n@skip_if_server_version_lt(\"8.4.0\")\ndef test_vrange_iteration(d_client):\n    \"\"\"Test VRANGE for stateless iteration.\"\"\"\n    # Add elements\n    elements = [f\"elem{i:03d}\" for i in range(20)]\n    for elem in elements:\n        d_client.vset().vadd(\"myset\", [1.0, 2.0], elem)\n\n    # Iterate through all elements, 5 at a time\n    all_results = []\n    start = \"-\"\n    while True:\n        result = d_client.vset().vrange(\"myset\", start, \"+\", count=5)\n        if not result:\n            break\n        all_results.extend(result)\n        # Continue from the last element (exclusive)\n        start = f\"({result[-1]}\"\n\n    assert len(all_results) == 20\n    assert all_results == elements\n\n\n@skip_if_server_version_lt(\"8.4.0\")\ndef test_vrange_empty_key(d_client):\n    \"\"\"Test VRANGE on non-existent key.\"\"\"\n    result = d_client.vset().vrange(\"nonexistent\", \"-\", \"+\")\n    assert result == []\n\n    result = d_client.vset().vrange(\"nonexistent\", \"[a\", \"[z\", count=10)\n    assert result == []\n\n\n@skip_if_server_version_lt(\"8.4.0\")\ndef test_vrange_special_characters(d_client):\n    \"\"\"Test VRANGE with elements containing special characters.\"\"\"\n    # Add elements with special characters\n    elements = [\"a:1\", \"a:2\", \"b:1\", \"b:2\", \"c:1\"]\n    for elem in elements:\n        d_client.vset().vadd(\"myset\", [1.0, 2.0], elem)\n\n    # Test range with prefix\n    result = d_client.vset().vrange(\"myset\", \"[a:\", \"[a:9\")\n    assert result == [\"a:1\", \"a:2\"]\n\n    result = d_client.vset().vrange(\"myset\", \"[b:\", \"[b:9\")\n    assert result == [\"b:1\", \"b:2\"]\n\n\n@skip_if_server_version_lt(\"8.4.0\")\ndef test_vrange_single_element(d_client):\n    \"\"\"Test VRANGE with a single element.\"\"\"\n    d_client.vset().vadd(\"myset\", [1.0, 2.0], \"single\")\n\n    result = d_client.vset().vrange(\"myset\", \"-\", \"+\")\n    assert result == [\"single\"]\n\n    result = d_client.vset().vrange(\"myset\", \"[single\", \"[single\")\n    assert result == [\"single\"]\n\n    result = d_client.vset().vrange(\"myset\", \"(single\", \"+\")\n    assert result == []\n\n\n@skip_if_server_version_lt(\"8.4.0\")\ndef test_vrange_lexicographical_order(d_client):\n    \"\"\"Test that VRANGE returns elements in correct lexicographical order.\"\"\"\n    # Add elements in random order\n    elements = [\"zebra\", \"apple\", \"mango\", \"banana\", \"cherry\"]\n    for elem in elements:\n        d_client.vset().vadd(\"myset\", [1.0, 2.0], elem)\n\n    # Should return in sorted order\n    result = d_client.vset().vrange(\"myset\", \"-\", \"+\")\n    expected = sorted(elements)\n    assert result == expected\n\n\n@skip_if_server_version_lt(\"8.4.0\")\ndef test_vrange_numeric_strings(d_client):\n    \"\"\"Test VRANGE with numeric string elements.\"\"\"\n    # Add numeric strings (lexicographical order, not numeric)\n    elements = [\"1\", \"10\", \"2\", \"20\", \"3\"]\n    for elem in elements:\n        d_client.vset().vadd(\"myset\", [1.0, 2.0], elem)\n\n    # Lexicographical order: \"1\", \"10\", \"2\", \"20\", \"3\"\n    result = d_client.vset().vrange(\"myset\", \"-\", \"+\")\n    expected = sorted(elements)  # [\"1\", \"10\", \"2\", \"20\", \"3\"]\n    assert result == expected\n\n    # Range from \"1\" to \"2\" (inclusive)\n    result = d_client.vset().vrange(\"myset\", \"[1\", \"[2\")\n    assert result == [\"1\", \"10\", \"2\"]\n"
  },
  {
    "path": "tests/testdata/jsontestdata.py",
    "content": "nested_large_key = r\"\"\"\n{\n  \"jkra\": [\n    154,\n    4472,\n    [\n      8567,\n      false,\n      363.84,\n      5276,\n      \"ha\",\n      \"rizkzs\",\n      93\n    ],\n    false\n  ],\n  \"hh\": 20.77,\n  \"mr\": 973.217,\n  \"ihbe\": [\n    68,\n    [\n      true,\n      {\n        \"lqe\": [\n          486.363,\n          [\n            true,\n            {\n              \"mp\": {\n                \"ory\": \"rj\",\n                \"qnl\": \"tyfrju\",\n                \"hf\": None\n              },\n              \"uooc\": 7418,\n              \"xela\": 20,\n              \"bt\": 7014,\n              \"ia\": 547,\n              \"szec\": 68.73\n            },\n            None\n          ],\n          3622,\n          \"iwk\",\n          None\n        ],\n        \"fepi\": 19.954,\n        \"ivu\": {\n          \"rmnd\": 65.539,\n          \"bk\": 98,\n          \"nc\": \"bdg\",\n          \"dlb\": {\n            \"hw\": {\n              \"upzz\": [\n                true,\n                {\n                  \"nwb\": [\n                    4259.47\n                  ],\n                  \"nbt\": \"yl\"\n                },\n                false,\n                false,\n                65,\n                [\n                  [\n                    [],\n                    629.149,\n                    \"lvynqh\",\n                    \"hsk\",\n                    [],\n                    2011.932,\n                    true,\n                    []\n                  ],\n                  None,\n                  \"ymbc\",\n                  None\n                ],\n                \"aj\",\n                97.425,\n                \"hc\",\n                58\n              ]\n            },\n            \"jq\": true,\n            \"bi\": 3333,\n            \"hmf\": \"pl\",\n            \"mrbj\": [\n              true,\n              false\n            ]\n          }\n        },\n        \"hfj\": \"lwk\",\n        \"utdl\": \"aku\",\n        \"alqb\": [\n          74,\n          534.389,\n          7235,\n          [\n            None,\n            false,\n            None\n          ]\n        ]\n      },\n      None,\n      {\n        \"lbrx\": {\n          \"vm\": \"ubdrbb\"\n        },\n        \"tie\": \"iok\",\n        \"br\": \"ojro\"\n      },\n      70.558,\n      [\n        {\n          \"mmo\": None,\n          \"dryu\": None\n        }\n      ]\n    ],\n    true,\n    None,\n    false,\n    {\n      \"jqun\": 98,\n      \"ivhq\": [\n        [\n          [\n            675.936,\n            [\n              520.15,\n              1587.4,\n              false\n            ],\n            \"jt\",\n            true,\n            {\n              \"bn\": None,\n              \"ygn\": \"cve\",\n              \"zhh\": true,\n              \"aak\": 9165,\n              \"skx\": true,\n              \"qqsk\": 662.28\n            },\n            {\n              \"eio\": 9933.6,\n              \"agl\": None,\n              \"pf\": false,\n              \"kv\": 5099.631,\n              \"no\": None,\n              \"shly\": 58\n            },\n            [\n              None,\n              [\n                \"uiundu\",\n                726.652,\n                false,\n                94.92,\n                259.62,\n                {\n                  \"ntqu\": None,\n                  \"frv\": None,\n                  \"rvop\": \"upefj\",\n                  \"jvdp\": {\n                    \"nhx\": [],\n                    \"bxnu\": {},\n                    \"gs\": None,\n                    \"mqho\": None,\n                    \"xp\": 65,\n                    \"ujj\": {}\n                  },\n                  \"ts\": false,\n                  \"kyuk\": [\n                    false,\n                    58,\n                    {},\n                    \"khqqif\"\n                  ]\n                },\n                167,\n                true,\n                \"bhlej\",\n                53\n              ],\n              64,\n              {\n                \"eans\": \"wgzfo\",\n                \"zfgb\": 431.67,\n                \"udy\": [\n                  {\n                    \"gnt\": [],\n                    \"zeve\": {}\n                  },\n                  {\n                    \"pg\": {},\n                    \"vsuc\": {},\n                    \"dw\": 19,\n                    \"ffo\": \"uwsh\",\n                    \"spk\": \"pjdyam\",\n                    \"mc\": [],\n                    \"wunb\": {},\n                    \"qcze\": 2271.15,\n                    \"mcqx\": None\n                  },\n                  \"qob\"\n                ],\n                \"wo\": \"zy\"\n              },\n              {\n                \"dok\": None,\n                \"ygk\": None,\n                \"afdw\": [\n                  7848,\n                  \"ah\",\n                  None\n                ],\n                \"foobar\": 3.141592,\n                \"wnuo\": {\n                  \"zpvi\": {\n                    \"stw\": true,\n                    \"bq\": {},\n                    \"zord\": true,\n                    \"omne\": 3061.73,\n                    \"bnwm\": \"wuuyy\",\n                    \"tuv\": 7053,\n                    \"lepv\": None,\n                    \"xap\": 94.26\n                  },\n                  \"nuv\": false,\n                  \"hhza\": 539.615,\n                  \"rqw\": {\n                    \"dk\": 2305,\n                    \"wibo\": 7512.9,\n                    \"ytbc\": 153,\n                    \"pokp\": None,\n                    \"whzd\": None,\n                    \"judg\": [],\n                    \"zh\": None\n                  },\n                  \"bcnu\": \"ji\",\n                  \"yhqu\": None,\n                  \"gwc\": true,\n                  \"smp\": {\n                    \"fxpl\": 75,\n                    \"gc\": [],\n                    \"vx\": 9352.895,\n                    \"fbzf\": 4138.27,\n                    \"tiaq\": 354.306,\n                    \"kmfb\": {},\n                    \"fxhy\": [],\n                    \"af\": 94.46,\n                    \"wg\": {},\n                    \"fb\": None\n                  }\n                },\n                \"zvym\": 2921,\n                \"hhlh\": [\n                  45,\n                  214.345\n                ],\n                \"vv\": \"gqjoz\"\n              },\n              [\n                \"uxlu\",\n                None,\n                \"utl\",\n                64,\n                [\n                  2695\n                ],\n                [\n                  false,\n                  None,\n                  [\n                    \"cfcrl\",\n                    [],\n                    [],\n                    562,\n                    1654.9,\n                    {},\n                    None,\n                    \"sqzud\",\n                    934.6\n                  ],\n                  {\n                    \"hk\": true,\n                    \"ed\": \"lodube\",\n                    \"ye\": \"ziwddj\",\n                    \"ps\": None,\n                    \"ir\": {},\n                    \"heh\": false\n                  },\n                  true,\n                  719,\n                  50.56,\n                  [\n                    99,\n                    6409,\n                    None,\n                    4886,\n                    \"esdtkt\",\n                    {},\n                    None\n                  ],\n                  [\n                    false,\n                    \"bkzqw\"\n                  ]\n                ],\n                None,\n                6357\n              ],\n              {\n                \"asvv\": 22.873,\n                \"vqm\": {\n                  \"drmv\": 68.12,\n                  \"tmf\": 140.495,\n                  \"le\": None,\n                  \"sanf\": [\n                    true,\n                    [],\n                    \"vyawd\",\n                    false,\n                    76.496,\n                    [],\n                    \"sdfpr\",\n                    33.16,\n                    \"nrxy\",\n                    \"antje\"\n                  ],\n                  \"yrkh\": 662.426,\n                  \"vxj\": true,\n                  \"sn\": 314.382,\n                  \"eorg\": None\n                },\n                \"bavq\": [\n                  21.18,\n                  8742.66,\n                  {\n                    \"eq\": \"urnd\"\n                  },\n                  56.63,\n                  \"fw\",\n                  [\n                    {},\n                    \"pjtr\",\n                    None,\n                    \"apyemk\",\n                    [],\n                    [],\n                    false,\n                    {}\n                  ],\n                  {\n                    \"ho\": None,\n                    \"ir\": 124,\n                    \"oevp\": 159,\n                    \"xdrv\": 6705,\n                    \"ff\": [],\n                    \"sx\": false\n                  },\n                  true,\n                  None,\n                  true\n                ],\n                \"zw\": \"qjqaap\",\n                \"hr\": {\n                  \"xz\": 32,\n                  \"mj\": 8235.32,\n                  \"yrtv\": None,\n                  \"jcz\": \"vnemxe\",\n                  \"ywai\": [\n                    None,\n                    564,\n                    false,\n                    \"vbr\",\n                    54.741\n                  ],\n                  \"vw\": 82,\n                  \"wn\": true,\n                  \"pav\": true\n                },\n                \"vxa\": 881\n              },\n              \"bgt\",\n              \"vuzk\",\n              857\n            ]\n          ]\n        ],\n        None,\n        None,\n        {\n          \"xyzl\": \"nvfff\"\n        },\n        true,\n        13\n      ],\n      \"npd\": None,\n      \"ha\": [\n        [\n          \"du\",\n          [\n            980,\n            {\n              \"zdhd\": [\n                129.986,\n                [\n                  \"liehns\",\n                  453,\n                  {\n                    \"fuq\": false,\n                    \"dxpn\": {},\n                    \"hmpx\": 49,\n                    \"zb\": \"gbpt\",\n                    \"vdqc\": None,\n                    \"ysjg\": false,\n                    \"gug\": 7990.66\n                  },\n                  \"evek\",\n                  [\n                    {}\n                  ],\n                  \"dfywcu\",\n                  9686,\n                  None\n                ]\n              ],\n              \"gpi\": {\n                \"gt\": {\n                  \"qe\": 7460,\n                  \"nh\": \"nrn\",\n                  \"czj\": 66.609,\n                  \"jwd\": true,\n                  \"rb\": \"azwwe\",\n                  \"fj\": {\n                    \"csn\": true,\n                    \"foobar\": 1.61803398875,\n                    \"hm\": \"efsgw\",\n                    \"zn\": \"vbpizt\",\n                    \"tjo\": 138.15,\n                    \"teo\": {},\n                    \"hecf\": [],\n                    \"ls\": false\n                  }\n                },\n                \"xlc\": 7916,\n                \"jqst\": 48.166,\n                \"zj\": \"ivctu\"\n              },\n              \"jl\": 369.27,\n              \"mxkx\": None,\n              \"sh\": [\n                true,\n                373,\n                false,\n                \"sdis\",\n                6217,\n                {\n                  \"ernm\": None,\n                  \"srbo\": 90.798,\n                  \"py\": 677,\n                  \"jgrq\": None,\n                  \"zujl\": None,\n                  \"odsm\": {\n                    \"pfrd\": None,\n                    \"kwz\": \"kfvjzb\",\n                    \"ptkp\": false,\n                    \"pu\": None,\n                    \"xty\": None,\n                    \"ntx\": [],\n                    \"nq\": 48.19,\n                    \"lpyx\": []\n                  },\n                  \"ff\": None,\n                  \"rvi\": [\n                    \"ych\",\n                    {},\n                    72,\n                    9379,\n                    7897.383,\n                    true,\n                    {},\n                    999.751,\n                    false\n                  ]\n                },\n                true\n              ],\n              \"ghe\": [\n                24,\n                {\n                  \"lpr\": true,\n                  \"qrs\": true\n                },\n                true,\n                false,\n                7951.94,\n                true,\n                2690.54,\n                [\n                  93,\n                  None,\n                  None,\n                  \"rlz\",\n                  true,\n                  \"ky\",\n                  true\n                ]\n              ],\n              \"vet\": false,\n              \"olle\": None\n            },\n            \"jzm\",\n            true\n          ],\n          None,\n          None,\n          19.17,\n          7145,\n          \"ipsmk\"\n        ],\n        false,\n        {\n          \"du\": 6550.959,\n          \"sps\": 8783.62,\n          \"nblr\": {\n            \"dko\": 9856.616,\n            \"lz\": {\n              \"phng\": \"dj\"\n            },\n            \"zeu\": 766,\n            \"tn\": \"dkr\"\n          },\n          \"xa\": \"trdw\",\n          \"gn\": 9875.687,\n          \"dl\": None,\n          \"vuql\": None\n        },\n        {\n          \"qpjo\": None,\n          \"das\": {\n            \"or\": {\n              \"xfy\": None,\n              \"xwvs\": 4181.86,\n              \"yj\": 206.325,\n              \"bsr\": [\n                \"qrtsh\"\n              ],\n              \"wndm\": {\n                \"ve\": 56,\n                \"jyqa\": true,\n                \"ca\": None\n              },\n              \"rpd\": 9906,\n              \"ea\": \"dvzcyt\"\n            },\n            \"xwnn\": 9272,\n            \"rpx\": \"zpr\",\n            \"srzg\": {\n              \"beo\": 325.6,\n              \"sq\": None,\n              \"yf\": None,\n              \"nu\": [\n                377,\n                \"qda\",\n                true\n              ],\n              \"sfz\": \"zjk\"\n            },\n            \"kh\": \"xnpj\",\n            \"rk\": None,\n            \"hzhn\": [\n              None\n            ],\n            \"uio\": 6249.12,\n            \"nxrv\": 1931.635,\n            \"pd\": None\n          },\n          \"pxlc\": true,\n          \"mjer\": false,\n          \"hdev\": \"msr\",\n          \"er\": None\n        },\n        \"ug\",\n        None,\n        \"yrfoix\",\n        503.89,\n        563\n      ],\n      \"tcy\": 300,\n      \"me\": 459.17,\n      \"tm\": [\n        134.761,\n        \"jcoels\",\n        None\n      ],\n      \"iig\": 945.57,\n      \"ad\": \"be\"\n    },\n    \"ltpdm\",\n    None,\n    14.53\n  ],\n  \"xi\": \"gxzzs\",\n  \"zfpw\": 1564.87,\n  \"ow\": None,\n  \"tm\": [\n    46,\n    876.85\n  ],\n  \"xejv\": None\n}\n\"\"\"  # noqa\n"
  },
  {
    "path": "tests/testdata/titles.csv",
    "content": "bhoj shala,1\nradhika balakrishnan,1\nltm,1\nsterlite energy,1\ntroll doll,11\nsonnontio,1\nnickelodeon netherlands kids choice awards,1\njamaica national basketball team,5\nclan mackenzie,1\nsecure attention key,3\ntemplate talk indo pakistani war of 1971,1\nhassan firouzabadi,2\ncarter alan,1\nalan levy,1\ntim severin,2\nfaux pas derived from chinese pronunciation,1\njruby,3\ntobias nielsén,1\navro 571 buffalo,1\ntreasury stock,17\nשלום,10\noxygen 19,1\nntru,4\ntennis racquet,1\nplace of birth,4\ncouncil of canadians,1\nurshu,1\namerican hotel,1\ndow corning corporation,3\nlanguage based learning disability,3\nmeri aashiqui tum se hi,30\nspecificity,9\nedward l hedden,1\npelli chesukundam,2\nof love and shadows,4\nfort san felipe,2\namerican express gold card dress of lizzy gardiner,4\njovian,5\nkitashinagawa station,1\nradhi jaidi,1\ncordelia scaife may,2\nminor earth major sky,1\nbunty lawless stakes,1\nhigh capacity color barcode,3\nlyla lerrol,1\ncrawford roberts,1\ncollin balester,1\nugo crousillat,1\nom prakash chautala,3\nizzy hoyland,1\nthe poet,2\ndaryl sabara,6\naromatic acid,2\nreina sofia,1\nswierczek masovian voivodeship,1\nhousing segregation in the united states,2\nkaren maser,1\nscaptia beyonceae,2\nkitakyushu city,1\nhtc desire 610,4\ndostoevsky,3\nportal victorian era,1\nbose–einstein correlations,3\nralph hodgson,1\nracquet club,2\nwalter camp man of the year,1\naustralian movies,1\nk04he,1\naustralia–india relations,2\njohn william howard thompson,1\npro cathedral,1\npaddyfield pipit,2\nbook finance,1\nford maverick,10\nslurve,4\nmnozil brass,2\nfiesta 9 1/8 inch square luncheon plate sunflower,1\nkorsi,1\ndraft 140th operations group,2\ncamp,29\nseries acceleration,1\naljouf,1\ndemocratic party of new mexico,2\nunited kingdom general election debates 2010,2\nmadura strait,2\nback examination,1\nborgata,2\nil ritorno di tobia,3\novaphagous,1\nmotörhead,9\nhellmaster,1\nrichard keynes,1\ncryogenic treatment,3\nmonte porzio,1\ntransliteration of arabic,1\nanti catholic,2\na very merry pooh year,2\nsuffixes in hebrew,3\nbarr body,16\nalaska constitution,1\njuan garrido,1\nyi lijun,1\nwawa inc,2\nendre kelemen,1\nl brands,18\nlr44,1\ncoat of arms of the nagorno karabakh republic,1\nantonino fernandez,1\nsalisbury roller girls,1\nzayat,2\nian meadows,2\nsemigalia,1\nkhloe and lamar,2\nholding,1\nlarchmont edgewater,1\ndynamic parcel distribution,6\nseaworld,30\nassistant secretary of war,1\ndigital currency,14\nmazomanie wisconsin,1\nsujatha rangarajan,8\nstreet child,1\nanna sheehan,1\nviolence jack,2\nsanti solari,1\ntemplate talk texas in the civil war,1\ncolorss foundation,1\nfaucaria,1\nalfred gardyne de chastelain,2\ntramp,1\ncannington ontario,2\npenguinone,1\ncardiac arrest,2\nsumman grouper,1\ncyndis list,1\ncbs,2\nsalminus brasiliensis,2\nkodiak bear,26\ncinemascore,9\nphragmidium,1\ncity of vultures,1\nlawrence g romo,1\nchandni chowk to china,1\nscarp retreat,1\nrosses point,1\ncarretera de cádiz,1\nchamunda,8\nbattle of stalingrad,1\nwho came first,2\nsalome,5\nportuguese historical museum,3\nwestfield sarasota square,1\nmuehrckes nails,3\nkennebec north carolina,1\namerican classical league,1\nhow do you like them apples,1\nmark halperin,20\ncirco,1\nturner classic movies,2\naustralian rules football in sweden,1\nhousehold silver,3\nfrank baird,1\nescape from east berlin,2\na village romeo and juliet,1\nwally nesbitt,6\njoseph renzulli,2\nspalding gray,1\ndangaria kandha,1\npms asterisk,2\nopenal,1\nromy haag,1\nmh message handling system,4\npioneer 4,4\nhmcs stettler,1\ngangsta,10\nmajor third,4\njoan osbourn,1\nmount columbia,2\nactive galactic nucleus,14\nrobert clary,8\neva pracht,1\nion implantation,5\nrydell poepon,4\nballer blockin,2\nenfield chase railway station,1\nserge aurier,13\nflorin vlaicu,1\nvan diemens land,9\nkrishnapur bagalkot,1\noleksandr zinchenko,96\ncollaborations,2\nhecla,2\namber marshall,7\ninácio henrique de gouveia,1\nbronze age korea,1\nslc punk,5\nryan jack,2\nclathrus ruber,6\nangel of death,4\nvalentines park,1\nextra pyramidal,1\nkiami davael,1\noleg i shuplyak,1\nnidum,2\nfriendship of salem,2\nbèze,3\narnold weinstock,1\nable,1\ns d ugamchand,1\nthe omega glory,2\nami james,3\ndenmark at the 1968 summer olympics,1\nkill me again,1\nrichmond town square,1\nguy domville,1\njessica simpson,1\nkinship care,1\nbrugge railway station,2\nunobtainium,16\ncarl johan bernadotte,3\nacacia concinna,5\nepinomis,1\ninterlachen country club,1\ncompromise tariff,1\nfairchild jk,1\ndog trainer,1\nbrian dabul,1\ncai yong,1\njezebel,7\naugarten porcelain,1\nsummerslam 1992,1\nion andoni goikoetxea,2\ndominican church vienna,1\niffhs worlds best club coach,2\nuruguayan presidential election 2009,2\nsaving the queen,1\nun cadavre,1\nhistory of the jews in france,4\nwbyg,1\ncharles de brosses,2\nhuman weapon,2\nhaunted castle,3\naustin maestro,1\nsearch for extra terrestrial intelligence,1\nsuwon,9\ncost per impression,1\nosney lock,1\nmarkus eriksson,1\ncultural depictions of tony blair,2\nerich kempka,3\npornogrind,5\nchekhov,1\nmarilinda garcia,2\nhard drive,1\nsmall arms,9\nexploration of north america,8\ninternational korfball federation,1\nphotographic lens design,4\nk hari prasad,1\nlebanese forces,3\ngreece at the 2004 summer olympics,1\nlets trim our hair in accordance with the socialist lifestyle,2\nbattle of cassinga,5\ndonald and the wheel,1\nvti transmission,1\ngille chlerig earl of mar,1\nheart of atlanta motel inc v united states,6\noh yeah,3\ncarol decker,5\nprajakta shukre,4\nprofiling,17\nthukima,1\nthe great waldo search,1\nnick vincent,2\nthe decision of the appeals jury is final and can only be overruled by a decision of the executive committee 2e,1\ncivilization board game,1\nerasmus+,1\neden phillpotts,1\nunleash the beast,1\nvaroujan hakhbandian,1\nfermats last theorem,1\nconan the indomitable,1\nvagrant records,1\nhouse of villehardouin,1\nzoneyesha ulatha,1\nashur bel nisheshu,1\nten wijngaerde,2\nlgi homes,1\namerican nietzsche a history of an icon and his ideas,1\neuropean magpie,3\npablo soto,1\nterminiello v chicago,1\nvladimir cosma,2\nbattle of yunnan burma road,1\nophirodexia,1\nthudar,1\nnorthern irish,2\nbohemond of tarente,1\nanita moorjani,5\nserra do gerês,1\nfort horsted,1\nmetre gauge,2\nstage show,3\ncommon flexor sheath of hand,2\nconall corc,1\narray slicing,6\nschüfftan process,1\nanmol malik,3\nout cold,2\nantiknock,2\nmoss force,1\npaul medhurst,1\nsomonauk illinois,1\ngeorge crum,11\nbaby talk,6\ndaniel mann,4\nvacuum flask,10\nprostitution in the republic of ireland,5\nbutch jones,7\nfeminism in ukraine,1\nst marys church kilmore county wexford,1\nsonny emory,1\nsatsuma han,1\nelben,1\nthe best of the rippingtons,3\nm3p,1\nboat sharing,1\niisco,1\nhoftoren,1\ncannabis in the united kingdom,6\ntemplate talk germany districts saxony anhalt,1\njean baptiste dutrou bornier,1\nteylers museum,1\nsimons problem,2\ngerardus huysmans,1\npupillary distance,5\njane lowe,1\npalais de justice brussels,1\nhillsdale free will baptist college,1\nraf wattisham,2\nparnataara,1\njensen beach campus of the florida institute of technology,1\nscottish gypsy and traveller groups,3\ncliffs shaft mine museum,3\nroaring forties,4\nwhere in time is carmen sandiego?,2\nperfect field,1\nrob schamberger,1\nlcd soundsystem,10\nalan rathbone,26\nsetup,1\ngliding over all,4\ndastur,1\nflensburger brauerei,3\nberkeley global campus at richmond bay,1\nkanakapura,1\nmineworkers union of namibia,1\ntokneneng,3\nmapuche textiles,3\nperanakan beaded slippers,1\ngoodra,2\nkanab ut,1\nthe gold act 1968,4\ngrey langur,1\nprocol harum,5\nchris alexander,1\nft walton beach metropolitan area,3\ndimensionless quantity,16\nthe science of mind,1\nalfons schone,1\neuparthenos nubilis,1\nbatrachotoxin,5\nfabric live 22,1\nmchenry boatwright,1\nlangney sports club,1\nakela jones,1\nlookout,2\nmatsuo tsurayaba,2\ngeneral jackson,3\nhair removal,14\nafrican party for the independence of cape verde,4\nreplica trick,1\nbromfenac,2\nmake someone happy,1\nsam pancake,1\ndenys finch hatton,10\nlatin rhythm albums,1\nmain bronchus,1\ncampidoglio,4\ncathaoirleach,1\nemress justina,1\nsulzbach hesse,1\nnoncicatricial alopecia,1\nsylvan place,4\nstalag i c,1\nleague of extraordinary gentlemen,1\nsergey korolyov,2\nserbian presidential election 1997,1\nbarnes lake millers lake michigan,1\nchristmas island health centre,1\ndayton ballet,2\ngilles fauconnier,1\nharald svergja,1\njoanna newsom discography,2\nastro xi yue hd,1\ncode sharing,3\ndreamcast vmu,1\narmand emmanuel du plessis duc de richelieu,1\necole supérieure des arts du cirque,2\ngerry mulligan,12\nkaaka kaaka,1\nmexico at the 2012 summer olympics,4\nbar wizards,2\nchristmas is almost here again,2\nsterling heights michigan,4\ngaultheria procumbens,3\neben etzebeth,8\nviktorija Čmilytė,1\nlos angeles county california,39\nfamily entertainment,2\nquantum well,9\nelton,1\nallan frewin jones,1\ndaniela ruah,32\ngkd legend,1\ncoffman–graham algorithm,1\nsanta clara durango,1\nbrian protheroe,3\ncrawler transporter,10\nlakshman,3\nfes el bali,2\nmary a krupsak,1\nirish rugby football union,5\nneuropsychiatry,2\njosé pirela,1\nbonaire status referendum 2015,1\nit,2\nplayhouse in the park,1\nalexander yakovlev,7\nold bear,1\ngraph tool,2\nmerseyside west,1\nromanian armies in the battle of stalingrad,1\ndark they were and golden eyed,1\naidan obrien,8\ntown and davis,1\nsuum cuique,3\ngerman american day,2\nnorthampton county pennsylvania,3\ncandidates of the south australian state election 2010,1\nvenator marginatus,2\nk60an,1\ntemplate talk campaignbox seven years war european,1\nmaravi,1\nflaithbertach ua néill,1\njunction ohio,1\ndave walter,1\nlondon transport board,1\ntuyuka,1\nthe moodys,3\nnoel,3\neugen richter,1\ncowanshannock township armstrong county pennsylvania,1\npre columbian gold museum,1\nlac demosson,1\nlincosamides,9\nthe vegas connection,1\nstephen e harris,1\nalkali feldspar,2\nbrant hansen,1\ndraft carnatic music stub,4\nthe chemicals between us,1\nblood and bravery,1\nsan diego flash,3\ncovert channel,5\nernest w adams,1\nhills brothers coffee,1\ncosmic background explorer,4\ninternational union of pure and applied physics,2\nvladimir kramnik,21\nhinterland,2\ntinker bell and the legend of the neverbeast,5\nophisops jerdonii,1\nfine gold,1\nnet explosive quantity,3\nmiss colorado teen usa,3\nroyal philharmonic orchestra discography,1\nelyazid maddour,1\nmatthew kelly,2\ntemplating language,1\njapan campaign,2\nbarack obama on mass surveillance,2\nthomas r donahue,1\nold right,4\nspencer kimball,1\ngolden kela awards,1\nblinn college,3\nw k simms,1\nquinto romano,1\nrichard mulrooney,1\nmr backup z64,1\nmonetization of us in kind food aid,1\nalex chilton,2\npropaganda in the peoples republic of china,4\njiří skalák,8\nm5 stuart tank,1\ntemplate talk ap defensive players of the year,1\ncrisis,2\nazuchi momoyama period,1\ncare and maintenance,2\na$ap mob,3\nnear field communication,111\nhips hips hooray,1\npromotional cd,1\nandean hairy armadillo,1\ntrigueros del valle,1\nelmwood illinois,1\ncantonment florida,2\nmargo t oge,1\nnational park service,36\nmonongalia county ballpark,3\nbakemonogatari,6\nfelicia michaels,1\ninstitute of oriental studies of the russian academy of sciences,2\neconomy of eritrea,2\nvincenzo chiarenza,1\nmicroelectronics,4\nfresno state bulldogs mens basketball,1\nmaotou,1\nblokely,1\nduplicati,3\ngoud,2\nniki reiser,1\nedward leonard ellington,1\njaswant singh of marwar,1\nbiharsharif,1\ndynasty /trackback/,1\nmachrihanish,4\njay steinberg,1\npeter luger steak house,3\npalookaville,1\nferrari grand prix results,2\nbankruptcy discharge,2\nmike mccue,2\nnuestra belleza méxico 2013,2\nalex neal bullen,1\ngus macdonald baron macdonald of tradeston,2\nflorida circuit court,1\nhaarp,2\nv pudur block,1\ngrocer,1\nshmuel hanavi,1\nisaqueena falls,2\njean moulin university,1\nfinal fantasy collection,1\ntemplate talk american frontier,1\nchex quest,4\nmuslim students association,2\nmarco pique,1\njinja safari,1\nthe collection,9\nurban districts of germany,5\nrajiv chilaka,1\nzion,2\nvf 32,1\nunited states commission on civil rights,2\nzazam,1\nbarnettas,4\nrebecca blasband,1\nlincoln village,1\nfilm soundtracks,1\nangus t jones,77\nsnuppy,3\nw/indexphp,30\nfile talk american world war ii senior military officials 1945jpeg,1\nworship leader,1\nein qiniya,1\nbuxton maine,1\nmatt dewitt,1\nbéla bollobás,3\nearlysville union church,1\nbae/mcdonnell douglas harrier ii gr9,1\ncalifornian condor,2\nprogressive enhancement,15\nits not my time,4\necw on tnn,2\nihop,36\naeronautical chart,1\nclique width,1\nfuengirola,8\narchicebus achilles,2\ncomparison of alcopops,1\ncarla anderson hills,1\nroanoke county virginia,2\njaílson alves dos santos,1\nrameses revenge,1\nkaycee stroh,5\nles experts,1\nniels skousen,1\napollo hoax theories,1\nmercedes w204,2\nenhanced mitigation experience toolkit,15\nbert barnes,1\nserializability,6\nten plagues of egypt,1\njoe l brown,1\ncategory talk high importance chicago bears articles,1\nstephen caffrey,3\neuropean border surveillance system,2\nachytonix,1\nm2 machine gun,1\ngurieli,1\nkunefe,1\nm33 helmet,3\nlittle carmine,1\nsmush,3\njosé horacio gómez,1\nproduct recall,1\negger,1\nwisconsin highway 55,1\nharbledown,1\nlow copy repeats,1\ncurt gentry,1\nunited colors of benetton,1\nadiabatic shear band,2\npea galaxy,1\nwhere are you now,1\ndils,1\nsurprise s1,1\nsenate oceans caucus,2\nwindsor new hampshire,1\na hawk and a hacksaw,1\ni love it loud,2\nmilbcom,1\nold world vulture,7\ncamara v municipal court of city and county of san francisco,1\nski dubai,1\nst cyprians school,2\naibo,1\nticker symbol,2\nhendrik houthakker,1\nshivering,5\njacob arminius,1\nmowming,1\npanjiva,2\nnamco libble rabble,5\nrudolph bing,1\nsindhi cap,2\nlogician,1\nford xa falcon,2\nthe sunny side up show,1\nhelen adams,2\nkharchin,1\nbrittany maynard,13\nkim kyu jong,1\nmessier 103,3\nleon boiler,1\nthe rapeman,1\ntwa flight 3,4\nleading ladies,1\ndelta octantis,2\nqatari nationality law,1\nlionel cripps,1\njosé daniel carreño,1\ncrypsotidia longicosta,1\npolish falcons,1\nhighlands north gauteng,1\nthe florida channel,1\noreste barale,1\nghazi of iraq,2\ncharles grandison finney,4\nahmet ali,1\nabbeytown,1\ncaribou,3\nbig two,2\nalien,14\naslantaş dam,3\ntheme of the traitor and the hero,1\nvladimir solovyov,1\nlaguna ojo de liebre,1\nclive barton,1\nebrahim daoud nonoo,1\nrichard goodwin keats,2\nback to the who tour 51,1\nentertainmentwise,1\nja preston,1\njohn astin,19\nstrict function,1\ncam ranh international airport,2\ngary pearson,1\nsven väth,8\ntoad,6\njohnny pace,1\nhunt stockwell,1\nrolando schiavi,1\nclaudia grassl,1\noxford nova scotia,1\nmaryland sheep and wool festival,1\nconquest of bread,1\nerevan,1\ncomparison of islamic and jewish dietary laws,11\nsheila burnford,1\nestevan payan,1\nocean butterflies international,7\nthe royal winnipeg rifles,1\ngreen goblin in other media,2\nvideo gaming in japan,8\nchurch of the guanche people,4\ngustav hartlaub,2\nian mcgeechan,4\nhammer and sickle,17\nkonkiep river,1\nceri richards,1\ndecentralized,2\ndepth psychology,3\ncentennial parkway,1\nyugoslav monitor vardar,1\nbattle of bobbili,2\nmagnus iii of sweden,1\nengland c national football team,2\nthuraakunu,1\nbab el ehr,1\nkoi,1\ncully wilson,1\nmoney laundering,1\nstirling western australia,1\njennifer dinoia,1\neureka street,1\nmessage / call my name,1\nmake in maharashtra,4\nhuckleberry creek patrol cabin,1\nalmost famous,5\ntruck nuts,4\nvocus communications,1\ngikwik,1\nbattle of bataan,4\nconfluence pennsylvania,2\nislander 23,1\nmv skorpios ii,1\nsingle wire earth return,1\npolitics of odisha,1\ncrédit du nord,3\npiper methysticum,2\ncoble,2\nkathleen a mattea,1\ncoachella valley music and arts festival,50\ntooniverse,1\nspofforth castle,1\narabian knight,2\ntwo airlines policy,1\nhinduja group,17\nswagg alabama,1\nportuguese profanity,1\nloomis gang,2\nnina veselova,2\naegyrcitherium,1\nbees in paradise,1\nbéládys anomaly,3\nbadalte rishtey,1\nfirst bank fc,1\ncystoseira,1\nred book of endangered languages,1\nrose,6\nterry mcgurrin,3\njason hawke,1\npeter chernin,1\ntu 204,1\nthe man who walked alone,1\ntool grade steel,1\nwrist spin,1\none step forward two steps back,1\ntheodor boveri,1\nheunginjimun,1\nfama–french three factor model,34\nbilly whitehurst,1\nrip it up,4\nred lorry yellow lorry,4\nnao tōyama,8\ngeneral macarthur,1\nrabi oscillation,2\ndevín,1\nolympus e 420,1\nhydra entertainment,1\nchris cheney,3\nrio all suite hotel and casino,3\nthe death gate cycle,2\nfatima,1\nkamomioya shrine,1\nfive nights at freddys 3,14\nthe broom of the system,3\nrobert blincoe,1\nhistory of wells fargo,9\npinocytosis,4\nleaf phoenix,1\nwxmw,2\ntommy henriksen,13\ngeri halliwell discography,2\nblade runneri have seen things you would not believe,1\nmadhwa brahmins,1\ni/o ventures,1\nedorisi master ekhosuehi,2\njunior orange bowl,1\nkhit,2\nsue jones,1\nimmortalized,35\ncity building series,4\nquran translation,1\nunited states consulate,1\ndose response relationship,1\ncaitriona,1\ncolocolo,21\nmedea class destroyer,1\nvaastav,1\netc1,1\njohn altoon,2\nthylacine,113\ncycling at the 1924 summer olympics,1\nmargaret nagle,1\nsuperpower,57\ngülşen,1\nanthems to the welkin at dusk,4\nyerevan united fc,1\nthe family fang,14\ndomain,4\nhigh speed rail in india,14\ntrifolium pratense,7\nflorida mountains,2\nnational city corp,5\nlength of us participation in major wars,2\nacacia acanthoclada,1\noffas dyke path,2\nenduro,7\nhoward center,1\nlittlebits,4\nplácido domingo jr,1\nhookdale illinois,1\nthe love language,1\ncupids arrows,1\ndc talk,7\nmaesopsis eminii,1\nhere comes goodbye,1\nfreddie foreman,5\nmarvel comics publishers,1\nconsolidated city–county,5\ncountess marianne bernadotte of wisborg,1\nlos angeles baptist high school,1\nmaglalatik,1\ndeo,2\nmeilichiu,1\nwade coleman,1\nmonster soul,2\njulion alvarez,2\nplatinum 166,1\nshark week,12\nhossbach memorandum,4\njack c massey,3\nardore,1\nphilosopher king,5\ndynamic random access memory,5\nbronze age in southeastern europe,1\ntamil films of 2012,1\nnathalie cely,1\nitalian capital,1\noptic tract,3\nshakti kumar,1\nwho killed bruce lee,1\nparlement of brittany,3\nsan juan national historic site,2\nlivewell,2\ntemplate talk om,1\nal bell,2\npzl w 3 sokół,8\ndurrës rail station,3\ndavid stubbs,1\npharmacon,3\nrailfan,7\ncomics by country,2\ncullen baker,1\nmaximum subarray problem,19\noutlaws and angels,1\nparadise falls,2\nmathias pogba,28\ndonella meadows,4\njohn leconte,2\nswaziland national football team,7\ngabriele detti,2\nif ever youre in my arms again,1\nchristian basso,1\nhelen shapiro,7\ntaisha abelar,1\nfluid dynamics,1\nernest wilberforce,1\nkocaeli university,2\nbritish m class submarine,1\nmodern woodmen of america,1\nlas posadas,3\nfederal budget of germany,2\nliberation front of chad,1\nsandomierz,5\nap italian language and culture,1\nmanuel gonzález,1\ngeorgian military road,2\nclear creek county colorado,1\nmatt clark,2\ntest tube,18\nak 47,1\ndiège,1\nlondon school of economics+,1\nmichael york,14\nhalf eagle,6\nstrike force,1\ntype 054 frigate,2\nsino indian relations,7\nfern,3\nlouvencourt,1\nghb receptor,2\nchondrolaryngoplasty,2\nandrew lewer,1\nross king,1\ncolpix records,1\noctober 28,1\ntatsunori hara,1\nrossana lópez león,1\nhaskell texas,3\ntower subway,2\nwaspstrumental,1\ntemplate talk nba anniversary teams,1\ngeorge leo leech,1\nstill nothing moves you,1\nblood cancer,3\nbuffy lynne williams,1\ndpgc u know what im throwin up,1\ndaniel nadler,1\nkhalifa sankaré,2\nhomo genus,1\ngarðar thór cortes,3\nveyyil,1\nmatt dodge,1\nhipponix subrufus,1\nanostraca,1\nhartshill park,1\npurple acid phosphatases,1\naustromyrtus dulcis,1\nshamirpet lake,1\nfavila of asturias,2\nacute gastroenteritis,1\ndalton cache pleasant camp border crossing,1\nurobilinogen,13\nss kawartha park,1\nprofessional chess association,1\nspecies extinction,1\ngapa hele bi sata,1\nphyllis lyon and del martin,1\nuk–us extradition treaty of 2003,1\na woman killed with kindness,1\nhow bizarre,1\nnorm augustine,1\ngeil,1\nvolleyball at the 2015 southeast asian games,2\njim ottaviani,1\nchekmagushevskiy district,1\ninformation search process,2\nqueer,63\nwilliam pidgeon,1\namelia adamo,1\nnato ouvrage \"g\",1\ntamsin beaumont,1\neconomy of syria,13\ndouglas dc 8 20,1\ntama and friends,4\npringles,22\nkannada grammar,7\nlotoja,1\npeony,1\nbmmi,1\neurovision song contest 1992,11\ncerro blanco metro station,1\nsherlock the riddle of the crown jewels,4\ndorsa cato,1\nnkg2d,8\nspecific heat,6\nnokia 6310i,2\ntergum,2\nbahai temple,1\ndal segno,5\nleigh chapman,2\ntupolev tu 144,60\nflight of ideas,1\nrita montaner,1\nvivien a schmidt,1\nbattle of the treasury islands,2\nthree kinds of evil destination,1\nrichlite,1\nmedinilla,2\ntimeline of aids,1\ncolin renfrew baron renfrew of kaimsthorn,2\nhélène rollès,1\npedro winter,1\nsabine free state,1\nbrzeg,1\npalisades park,1\ngas gangrene,11\ndotyk,2\ndaniela kix,1\ncanna,16\nproperty list,9\njohn hamburg,1\ndunk island,5\nalbreda,1\nscammed yankees,1\nwireball,3\njunior 4,1\nabsolutely anything,15\nlinux operating system,1\nsolsbury hill,15\nnotopholia,1\nscottish heraldry,2\ntemplate talk paper data storage media,1\ncategory talk religion in ancient sparta,1\ncategory talk cancer deaths in puerto rico,1\nmid michigan community college,2\ntvb anniversary awards,1\nfrederick taylor gates,1\nomoiyari yosan,3\njournal of the physical society of japan,1\nkings in the corner,2\nnungua,1\namerika,4\npacific marine environmental laboratory,1\nthe thought exchange,1\nitalian bee,5\nroma in spain,1\nsirinart,1\ncrandon wisconsin,1\nshubnikov–de haas effect,6\nportrait of maria portinari,4\ncolin mcmanus,1\nuniversal personal telecommunications,1\nroyal docks,4\nbrecon and radnorshire,3\neilema caledonica,1\nchalon sur saône,8\ntoyota grand hiace,1\nsophorose,1\nsemirefined 2bwax,1\nmechanics institute chess club,1\nthe culture high,2\ndont wake me up,1\ntranscaucasian mole vole,1\nharry zvi tabor,1\nvhs assault rifle,1\nplaying possum,2\nomar minaya,2\nprivate university,1\nyuki togashi,3\nski free,2\nsay no more,1\ndiving at the 1999 summer universiade,1\narmando sosa peña,1\ntimur tekkal,1\njura elektroapparate,1\npornographic magazine,1\ntukur yusuf buratai,1\nkeep on moving,1\nlaboulbeniomycetes,1\nchiropractor solve problems,1\nmark s allen,3\ncommittees of the european parliament,4\nblondie,7\nveblungsnes,1\nbank vault,10\nsmiling irish eyes,1\nrobert kalina,2\npolarization ellipse,2\nhuntingdon priory,1\nenergy in the united kingdom,34\nhamble,1\nraja sikander zaman,1\nperigea hippia,1\ncollege of liberal arts and sciences,1\nbootblock,1\nnato reporting names,2\nthe serpentwar saga,1\nreformed churches in the netherlands,1\ncollaborative document review,4\ncombat mission beyond overlord,3\nvlra,2\npat st john,1\noceanid,5\nitapetinga,1\ninsane championship wrestling,9\nnathaniel gorham,1\nestadio metropolitano de fútbol de lara,2\nwilliam of saint amour,2\nnew york drama critics circle award,1\nalliant rq 6 outrider,2\nilsan,1\ntop model po russki,1\nwoolens,1\nrutledge minnesota,1\njoigny coach crash,2\nzhou enlai the last perfect revolutionary,1\nthe theoretical minimum,1\narrow security,1\njohn shelton wilder,2\njasdf,2\nkatie may,2\namerican jewish military history project,1\nbusiness professionals of america,1\nquestioned document examination,5\nmotorola a760,1\namerican steel & wire,1\nlouis armstrong at the crescendo vol 1,1\nedward vernon,3\nmaria taipaleenmäki,1\nmargical history tour,2\njar jar,1\naustralian oxford dictionary,2\nrevenue service,2\nodoardo farnese hereditary prince of parma,1\nweekend in new england,1\nlaurence harbor new jersey,2\naramark tower,1\nstealers wheel,1\ncephalon,1\ndawnguard,1\nsaintsbury,2\nsaint fuscien,1\nryoko kuninaka,1\nfarm to market road 1535,1\nalan kennedy,2\nesteban casagolda,1\nshin angyo onshi,1\nwilliam gowland,1\neastern religions,6\nkenny lala,1\nalphonso davies,1\ntadamasa hayashi,1\nmeet the parents,2\ncalvinist church,1\nristorante paradiso,1\njose joaquim champalimaud,1\nolis,1\nmill hill school,2\nlockroy,1\nbattle of princeton,10\ncent,8\nbrough superior ss80,1\nras al khaima club,3\nwashington international university,3\nbradley kasal,2\nmiguel Ángel varvello,1\noxygen permeability,1\nfemoral circumflex artery,1\ngolden sun dark dawn,4\npusarla sindhu,1\ntoyota winglet,1\nwind profiler,1\nmontefiore medical center,2\ntemplate talk guitar hero series,3\nlittle leaf linden,1\nramana,4\nislam in the czech republic,2\nmanuel vitorino,1\njoseph radetzky von radetz,3\nfrancois damiens,1\nparasite fighter,1\nfriday night at st andrews,3\nhurbazum,1\nhaidhausen,1\npetabox,2\nsalmonella enteritidis,2\nmatthew r denver,1\nde la salle,1\nanti terrorism act 2015,6\nbrugsen,1\nmountain times,1\ncolumbia basin project,1\ncommon wallaroo,2\nclepsis brunneograpta,1\nred hot + dance,1\nmao fumei,1\ndark shrew,1\ncoach,8\ncome saturday morning,1\naanmai thavarael,1\nhellenia,1\ndonate life america,2\nplot of beauty and the beast toronto musical,1\nbirths in 1243,3\nmain page/wiki/portal technology,8\ncambridgeshire archives and local studies,1\nbig pines california,1\npegasus in popular culture,4\nbaron glendonbrook,1\nyour face sounds familiar,5\nboom tube,2\nrichard gough,8\nthe new beginning in niigata,3\namerican academy of health physics,1\nplain,9\ntushino airfield,1\nking george v coronation medal,1\ngeologic overpressure,1\nseille,1\ncalorimeter,25\nfrench civil service,1\ndavid l paterson,1\nchinese gunboat chung shan,2\nrhizobium inoculants,1\nwizard,4\nbaghestan,1\npaustian house,2\nellen pompeo,55\ndamien williams,1\ntomoe tamiyasu,1\nacute epithelial keratitis,1\ncasey abrams,8\nmendozite,1\nkantian ethics,2\nmcclure syndicate,1\ntokyo metro,6\ncuisine of guinea bissau,1\nmossberg 500,18\nmollie gillen,1\nabove and beyond party,1\njoey carbone,1\nfaulkner state community college,1\ntetsuya ishikawa,1\nelectric flag,3\nmeet the feebles,2\nkplm,1\nwhen we were twenty one,1\nhorus bird,2\nyouth in revolt,8\nspongebob squarepants revenge of the flying dutchman,3\nehow,5\nnikos xydakis,2\nziprasidone,19\nulsan airport,1\nflechtingen,1\ndave christian,3\ndelaware national guard,1\nskaria thomas,1\niraca,1\nkkhi,2\nswimming at the 2015 world aquatics championships – mens 1500 metre freestyle,2\ncrossing lines,37\njohn du cane,1\ni8,1\nbauer pottery,1\naffinity sutton,4\nlotus 119,1\nuss arleigh burke,1\npalmar interossei,2\nnofx discography,4\nbwia west indies airways,3\ngopala ii,1\nnorth fork correctional facility,1\nszeged 2011,1\nmilligram per cent,2\nhalas and batchelor,1\nwhat the day owes the night,1\nsighișoara medieval festival,5\nscarning railway station,1\ncambridge hospital,1\namnesia labyrinth,2\ncokie roberts,7\nsavings identity,3\npravia,1\nmcgrath,4\npakistan boy scouts association,1\ndan carpenter,2\nmarikina–infanta highway,2\ngenetic analysis,2\ntemplate talk ohio state university,1\nthomas chamberlain,4\nmoe book,1\ncoyote waits,1\nblack protestant,1\nneetu singh,19\nmahmoud sarsak,1\ncasa loma,28\nbedivere,8\nboundary park,2\ndanger danger,14\njennifer coolidge,49\npop ya collar,1\ncollaboration with the axis powers during world war ii,10\ngreenskeepers,1\nthe dukes children,1\nalaska off road warriors,1\ntwenty five satang coin,1\ntemplate talk private equity investors,2\namerican red cross,24\njason shepherd,1\ngeorgetown college,2\nocean countess,1\nammonium magnesium phosphate,1\ncommunity supported agriculture,5\nphilosophy of suicide,4\nyard ramp,2\ncaptain germany,1\nbob klapisch,1\ni will never let you down,2\nfebruary 11,6\nron dennis,13\nrancid,16\nthe mall blackburn,1\nsouth high school,6\ncharles allen culberson,1\norganizational behavior,66\nautomatic route selection,1\nuss the sullivans,9\nyo no creo en los hombres,1\njanet,1\nserena armstrong jones viscountess linley,3\nlouisiana–lafayette ragin cajuns mens basketball,1\nflower films,1\nmichelle ellsworth,1\nnorbertine rite,2\nspanish mump,1\nshah jahan,67\nfraser coast region,1\nmatt cornwell,1\nnra,1\ncrested butte mountain resort,1\ncollege football playoff national championship,2\ncraig heaney,4\ndevil weed,1\nsatsuki sho,1\njordaan brown,1\nlittle annie,4\nthiha htet aung,1\nthe disreputable history of frankie landau banks,1\nmickey lewis,1\neldar nizamutdinov,1\nm1825 forage cap,1\nantonina makarova,1\nmopani district municipality,2\nal jahra sc,1\nchaim topol,4\ntum saath ho jab apne,1\npiff the magic dragon,7\nimagining argentina,1\nni 62,1\nphys rev lett,1\nthe peoples political party,1\ncasoto,1\npopular movement of the revolution,4\nhuntingtown maryland,1\nla bohème,33\nkhirbat al jawfa,1\nlycksele zoo,1\ndeveti krug,2\ncuba at the 2000 summer olympics,2\nrose wilson,7\nsammy lee,2\ndave sheridan,10\nuniversal records,2\nantiquities trade,3\nshoveller,1\ntapered integration,1\nparker pen company,4\nmushahid hussain syed,1\nnynehead,1\ncounter reformation,2\nnhl on nbc,11\nronny rosenthal,2\narsenie todiraş,3\nlobster random,1\nhalliburton,37\ngordon county georgia,1\nbelle isle florida,3\nmolly stanton,3\ngreen crombec,1\ngeodesist,2\nabd al rahman al sufi,4\ndemography of japan,26\nlive xxx tv,5\nnaihanchi,1\ncofinite,1\nmsnbot,5\nclausard,1\nmimidae,1\nwind direction,15\nirrational winding of a torus,1\ntursiops truncatus,1\ntrustee,1\nlumacaftor/ivacaftor,2\nbalancing lake,2\nshoe trees,1\ncycling at the 1928 summer olympics – mens team pursuit,1\ncalponia harrisonfordi,1\nhindu rate of growth,1\ndee gordon,7\npassion white flag,2\nfrog skin,1\nrudolf eucken,2\nbayantal govisümber,1\nchristopher a iannella,1\nrobert myers,1\njames simons,1\nmeng xuenong,1\nabayomi olonisakin,1\nmilton wynants,1\ncincinnatus powell,1\natomic bomb band,1\nhopfield network,12\njet pocket top must,1\nthe state of the world,1\nwelf i duke of bavaria,2\namerican civil liberties union v national security agency,3\nelizabeth fedde,1\nlibrarything,2\nkim fletcher,1\ntracy island,2\npraise song for the day,1\nsuperstar,7\newen spencer,1\nback striped weasel,1\ncs concordia chiajna,1\nbruce curry,1\nmalificent,1\ndr b r ambedkar university,2\nriver plate,1\ndesha county arkansas,1\nharare declaration,2\npatrick dehornoy,1\npaul alan cox,2\nauckland mounted rifles regiment,1\nmikoyan gurevich dis,3\ncorn exchange manchester,2\nsharpshooter,1\nthe new york times manga best sellers of 2013,1\nmax perutz,2\nandrei makolov,1\ninazuma eleven saikyō gundan Ōga shūrai,2\ntatra 816,1\nashwin sanghi,8\npipestone township michigan,1\ncraig shoemaker,1\ndavid bateson,1\nlew lehr,1\ncrewe to manchester line,2\nsamurai champloo,36\ntali ploskov,2\njanet sobel,3\nkabe station,1\nrippon,1\nalexander iii equestrian,1\nlouban,2\nthe twelfth night,1\ndelaware state forest,1\nthe amazing race china 3,1\nbrillouins theorem,1\nextreme north,3\nsuper frelon,1\ngeorge watsons,1\nmungo park,1\nworkin together,3\nboy,12\nbrownsville toros,1\nkim lim,1\nfutsal,63\nmotoring taxation in the united kingdom,1\naccelerator physics codes,1\narytenoid cartilage,3\nthe price of beauty,3\nlife on the murder scene,2\nhydrophysa psyllalis,1\njürgen brandt,2\neconomic history association,2\nthe sandwich girl,1\nheber macmahon,1\nvolume 1 sound magic,2\nsan francisco–oakland–hayward ca metropolitan statistical area,9\nharriet green,7\ntarnawa kolonia,1\neur1 movement certificate,20\nanna nolan,2\ngulf of gökova,1\nhavertown,2\norlando scandrick,4\ndoug owston correctional centre,1\nasterionella,4\nespostoa,1\nranked voting system,10\ncommercial law,39\nkirk,1\nmongolian cuisine,8\nturfanosuchus,1\narthur anderson,4\nsven olof lindholm,1\nbatherton,1\ndimetrodon,1\npianos become the teeth,1\nunited kingdom in the eurovision song contest 1976,1\nmedieval,11\nit bites,1\nion television,8\nseaboard system railroad,3\nsayan mountains,3\nmusaffah,1\ncharles de foucauld,3\nurgh a music war,1\ntranslit,1\namerican revolutionary war/article from the 1911 encyclopedia part 1,1\nuss mauna kea,1\npowder burn,1\nbald faced hornet,9\nproducer of the year,1\nthe most wanted man,1\nclear history,8\nmikael lilius,1\nclass invariant,4\nforever michael,3\ngoofing off,3\ntower viewer,3\nclaudiu marin,1\nnicolas cage,1\nwaol,2\ns10 nbc respirator,2\neducation outreach,1\ngyeongsan,2\ntemplate talk saints2008draftpicks,1\nbotaurus,1\nfrancis harper,1\nmauritanian general election 1971,1\nkirsty roper,2\nnon steroidal anti inflammatory drug,17\nnearchus of elea,2\nresistance to antiviral drugs,1\nraghavendra rajkumar,5\ntemplate talk cc sa/sandbox,1\nwashington gubernatorial election 2012,2\npaul lovens,1\nexpress freighters australia,2\nbunny bleu,2\nosaka prefecture,2\nfederal reserve bank of boston,4\nhacı ahmet,1\nunderground chapter 1,10\nfilippo simeoni,2\nthe wonderful wizard of oz,3\nsailing away,1\navelino gomez memorial award,1\nbadger,65\nhongkou football stadium,3\nbenjamin f cheatham,2\nfair isaac,2\nkwab,1\nal hank aaron award,3\ngender in dutch grammar,1\nidiom neutral,2\nda lata,1\ntuu languages,1\nderivations are used,1\nclete patterson,1\ndanish folklore,4\nandroid app //orgwikipedia/http/enmwikipediaorg/wiki/westfield academy,1\ntoto,8\nea,1\nvictory bond tour,1\ncredai,2\nhérin,1\nst james louisiana,1\nnecrolestes,2\ncable knit,1\nsaunderstown,1\nus route 52 in ohio,1\nsailors rest tennessee,1\nadlai stevenson i,6\nmiscibility,13\nhelp footnotes,13\nmurrell belanger,1\nnew holland pennsylvania,5\nhaldanodon,1\nfeminine psychology,2\nriot city wrestling,1\nmobile content management system,2\nzinio,1\ncentral differencing scheme,2\nenoch,2\nusp florence admax,1\nmaester aemon,7\nnorman \"lechero\" st john,1\nice racing,1\ntiger cub economies,6\nklaipėda region,12\nwu qian,8\nmalayalam films of 1987,1\nestadio nuevo la victoria,1\nnanotoxicology,2\nhot revolver,1\nnives ivankovic,1\nglen edward rogers,5\nepicene,3\neochaid ailtlethan,1\njudiciary of finland,1\nen jersey,1\nstatc,1\natta kim,1\nmizi research,2\nacs applied materials & interfaces,1\nthank god youre here,9\nloneliness,8\nh e b plus,2\ncorella bohol,1\nmoney in the bank,59\ngolden circle air t bird,1\nflash forward,1\ncategory talk philippine television series by network,1\ndfmda,1\nthe road to wellville,8\nernst tüscher,1\ncommission,14\nabdul rahman bin faisal,6\noversea chinese banking corporation,7\nray malavasi,1\nal qadisiyah fc,4\nanisfield wolf book award,1\njacques van rees,1\njakki tha motamouth,1\nscoop,1\npiti,2\ncarlos reyes,1\nv o chidambaram pillai,6\ndiamonds sparkle,1\nthe great transformation,5\ncardston alberta temple,1\nla vendetta,1\nmiyota nagano,1\nnational shrine of st elizabeth ann seton,2\nchaotic,1\nbreastfeeding and hiv,1\nfriedemann schulz von thun,1\nmukhammas,2\nfishbowl worldwide media,1\nmohamed amin,3\njohn densmore,10\nsuryadevara nayaks,1\nmetal gear solid peace walker,12\nché café,2\nold growth,1\nlake view cemetery,1\nkonigsberg class cruiser,1\ncourts of law,1\nnova scotia peninsula,3\njairam ramesh,4\nportal kerala/introduction,1\nedinburgh 50 000 – the final push,1\nludachristmas,3\nmotion blur,1\ndeliberative process privilege,2\nbubblegram,1\nsimon breach grenade,2\ntess henley,1\ngojinjo daiko,1\ncommon support aircraft,2\nzelda rubinstein,9\nyolanda kakabadse,1\namerican studio woodturning movement,1\nrichard carpenter,67\nvehicle door,3\ntransmission system operator,9\nchrista campbell,9\nmarolles en brie,1\nkorsholma castle,1\nmurder of annie le,3\nkims,1\nzionist union,8\nportal current events/june 2004,2\nmarination,8\ncap haïtien international airport,2\nfujima kansai,1\nvampire weekend discography,3\nmoncton coliseum,2\nwing chair,1\nel laco,2\ncastle fraser,1\ntemplate talk greek political parties,1\nsociety finch,1\nchief executive officer,4\nbattle of bloody run,3\ncoat of arms of tunisia,2\nnishi kawaguchi station,1\ncolonoscopy,30\nvic tayback,5\nlonnie mack discography,3\nyusuf salman yusuf,2\nmarco simone,4\nsaint just,1\nelizabeth taylor filmography,6\nhaglöfs,2\nyunis al astal,1\ndaymond john,36\nbedd y cawr hillfort,1\ndurjoy datta,1\nwealtheow,1\naaron mceneff,1\nculture in berlin,1\ntemple of saturn,6\nnermin zolotić,1\nthe darwin awards,1\npatricio pérez,1\nchris levine,1\nmisanthropic,1\ndragster,2\neldar,19\nchrzanowo gmina szelków,1\nzimmerberg base tunnel,6\njakob schaffner,1\ncalifornia gubernatorial recall election 2003,1\ntommy moe,1\nbikrami calendar,1\nmama said,11\nhellenic armed forces,8\ncandy box,3\nmonstervision,3\nkachin independent army,1\npro choice,1\ntshiluba language,1\ntrucial states,9\ncollana,1\nbest music video short form,1\npokémon +giratina+and+the+sky+warrior,1\netteldorf,1\nacademic grading in chile,2\nland and liberty,3\naustralian bureau of meteorology,1\ncheoin gu,1\nwilliam henry green,1\newsd,2\ngate of hell,1\nsioux falls regional airport,3\nnevelj zsenit,1\nbevo lebourveau,1\nranjana ami ar asbona,1\nshaun fleming,1\njean antoine siméon fort,1\nsports book,1\nvedran smailović,3\nsimple harmonic motion,29\nwikipedia talk wikiproject film/archive 16,1\nprincess jasmine,13\ngreat bustard,5\nallred unit,1\ncheng san,1\nmini paceman,1\nflavoprotein,2\nstorage wars canada,3\nuniversity rowing,2\ncategory talk wikiproject saskatchewan communities,1\nthe washington sun,1\nrotary dial,6\nhailar district,1\nassistant secretary of the air force,2\nthe décoration for the yellow house,5\nchris mclennan,1\nthe cincinnati kid,4\neducation in the republic of ireland,15\nsteve brodie,2\ncountry club of detroit,1\nwazner,1\nportal spain,4\nsenna,3\nwilliam j bernd house,1\nbalaji baji rao,8\nworth dying for,1\ncool ruler,1\nturn your lights down low,2\nmavroudis bougaidis,1\nnational registry emergency medical technician,1\njames young,8\neyewire,1\ndark matters twisted but true/,1\njosé pascual monzo,1\ngerman election 1928,2\nlinton vassell,1\nconvention on the participation of foreigners in public life at local level,1\nthorium fuel cycle,5\nhoneybaby honeybaby,1\ngolestan palace,3\nlombok international airport,11\nmainichi daily news,1\nk&p,1\nliberal network for latin america,1\ncádiz memorial,1\ngrupo corripio,1\nelie and earlsferry,1\nisidore geoffroy saint hilaire,1\nal salmiya sc,2\npiano sonata hob xvi/33,1\ne f bleiler,1\nnational register of historic places listings in york county virginia,3\ngupta empire,2\ngerman immigration to the united states,1\nthrough gates of splendor,2\niap,1\nlove takes wing,1\ntours de merle,1\naleksey zelensky,1\npaul almond,2\nboston cambridge quincy ma nh metropolitan statistical area,1\nkomiks presents dragonna,1\nprincess victoire of france,1\nalan pownall,3\ntilak nagar,2\nlg life sciences co ltd,8\nbefore their eyes,1\nlabor right,5\nmichiko to hatchin,1\nsusan p graber,1\nxii,1\nhanswulf,1\nsymbol rate,17\nmyo18b,2\nrowing at the 2010 asian games – mens coxed eight,1\ncaspar weinberger jr,2\nbettle juice,1\nbattle of the morannon,7\ndarlington county south carolina,1\nmayfield pennsylvania,1\nruwerrupt de mad,1\nluthfi assyaukanie,1\nfiat panda,30\nwickiup reservoir,1\ntanabe–sugano diagram,6\nalexander sacher masoch prize,1\nintracellular transport,1\nchurch of the val de grâce,1\njebel ad dair,1\nrosalind e krauss,6\ncross origin resource sharing,97\nreadiness to sacrifice,1\ncreel terrazas family,1\nphase portrait,9\nsubepithelial connective tissue graft,1\nlake malawi,18\nphillips & drew,1\nernst vom rath,2\ninfinitus,1\ngeneva convention for the amelioration of the condition of the wounded and sick in armies in the field,2\nworld heritage,1\ndole whip,8\nleveling effect,1\nbioship,3\nvanilloids,2\nsuperionic conductor,1\nbasil bernstein,7\narmin b cremers,2\nszlichtyngowa,1\nbeixinqiao station,1\nunited states presidential election in utah 1980,1\nwatson v united states,3\nwillie mcgill,1\nmelle belgium,1\nal majmaah,1\nmesolimbic dopamine pathway,1\nsix flags new england,5\nacp,2\ngeostrategy,2\noriginal folk blues,1\nwentworth military academy,1\nbromodichloromethane,3\ndoublet,4\ntawfiq al rabiah,1\nsergej jakirović,1\nmako surgical corp,3\nempire of lies,1\nold southwest,1\nbay of arguin,1\nbringing up buddy,1\nmustapha hadji,7\nraymond kopa,7\nevil horde,1\nkettering england,1\nextravaganza,1\nchristian labour party,2\njoice mujuru,6\nv,15\nle père,4\nmy fathers dragon,2\ncumulus cloud,32\nfantasy on themes from mozarts figaro and don giovanni,1\npostpone indefinitely,1\nextreme point,1\niraq–israel relations,1\nhenry le scrope 3rd baron scrope of masham,1\nrating beer,1\nclaude alvin villee jr,2\nclackamas town center,2\nroope latvala,4\nrichard bethell 1st baron westbury,1\nryan gosling,1\nyelina salas,1\namicus,1\ncecilia bowes lyon countess of strathmore and kinghorne,6\nprogramming style,9\nnow and then,9\nsomethingawful,1\nnuka hiva campaign,1\nbostongurka,2\njorge luis ochoa vázquez,1\nphilip burton,1\nrainbow fish,7\nroad kill,5\nchristiane frenette,2\nas if,1\npaul ricard,1\nroberto dañino,1\nshoyu,1\njakarta,96\ndean keith simonton,1\nmastocytosis,19\nhiroko yakushimaru,3\nproblem of other minds,2\njaunutis,1\ntfp deficiency,1\naccess atlantech edutainment,1\nkristian thulesen dahl,1\nwilliam wei,1\nandy san dimas,10\nkempten/allgäu,1\naugustus caesar,9\nconrad janis,1\ntugaya lanao del sur,1\nsecond generation antipsychotics,1\nanema e core,2\nsucking the 70s,1\nthe czars,2\nvakulabharanam,1\nf double sharp,3\nprymnesin,1\ndick bavetta,2\nbilly jones,3\ncolumbine,4\nfile talk joseph bidenjpg,1\nmandelbrot set,79\nconstant elasticity of variance model,2\nmorris method,1\nal shamal stadium,5\nhes alright,1\nmadurai massacre,1\nphilip kwon,2\nchristadelphians,7\nthis man is dangerous,2\nkiowa creek community church,1\npier paolo vergerio,1\norder of the most holy annunciation,2\njohn plender,1\nvallée de joux,2\ngraysby,1\nludwig minkus,3\npotato aphid,1\nbánh bột chiên,1\nwilhelmstraße,1\nfee waybill,1\ndesigned to sell,1\nironfall invasion,2\nlieutenant governor of the isle of man,1\nthird reading,2\neleanor roosevelt high school,1\nsu zhe,1\nheat conductivity,1\nsi satchanalai national park,1\netale space,1\nfaq,24\nlow carbohydrate diet,1\ndifferentiation of integrals,1\nkarl fogel,2\ntom chapman,3\njames gamble rogers,2\njeff rector,1\nburkut,9\njoe robinson,1\nturtle flambeau flowage,1\nmoves like jagger,3\nturbaco,1\noghuz turk,2\nlatent human error,5\nsquare number,17\nrugby football league championship third division,2\naltoona pennsylvania,23\ncircus tent,1\nsatirical novel,1\nclaoxylon,1\nbarbaros class frigate,4\noyer and terminer,2\ntelephone numbers in the bahamas,1\nthomas c krajeski,2\nmv glenachulish,1\nsports broadcasting contracts in australia,3\ncar audio,1\nted lewis,2\neric bogosian/robotstxt,2\nfurman university japanese garden,1\njed clampett,2\nflintstone,2\nc of tranquility,2\nrutali,2\nberkhamsted place,1\nwissam ben yedder,13\nnt5e,1\nerol onaran,1\nallium amplectens,1\nthe three musketeers,2\nnorth eastern alberta junior b hockey league,1\ndoggie daddy,1\nlauma,1\nthe love racket,1\neta hoffman,1\nryans four,3\nomerta – city of gangsters,1\nhumberview secondary school,2\nparels,1\nthe descent,1\nevgenia linetskaya,1\nmanhunt international 1994,1\namerican society of animal science,1\namerican samoa national rugby union team,1\nfaster faster,1\nall creatures great and small,1\nmama said knock you out,9\nrozhdestveno memorial estate,2\nwizard of odd,1\nlugalbanda,4\nbeardsley minnesota,1\nthe rogue prince,10\nuss escambia,1\nstormy weather,3\ncouleurs sur paris,1\nmadrigal,4\ncolin tibbett,1\nlemelson–mit prize,2\nphonetical singing,1\nglucophage,3\nsuetonius,10\nungra,1\nblack and white minstrel,1\nwoolwich west by election 1975,1\ntrolleybuses in wellington,2\njason macdonald,3\nussr state prize,2\nrobert m anderson,1\nkichijōji,1\napache kid wilderness,1\nsneaky pete,8\nedward knight,1\nfabiano santacroce,1\nhemendra kumar ray,1\nsweat therapy,1\nstewart onan,2\nisrael–turkey relations,1\nnatalie krill,5\nclinoporus biporosus,1\nkosmos 2470,2\nvladislav sendecki,1\nhealthcare in madagascar,1\ntemplate talk 2010 european ryder cup team,1\nrichard lyons,1\ntransfer of undertakings regs 2006,3\nimage processor,3\nalvin wyckoff,1\nkōbō abe,1\nkettle valley rail trail,1\nmy baby just cares for me,3\nu28,1\nwestern australia police,10\nscincidae,1\npartitionism,1\nglenmorangie distillery tour,1\nriver cave,1\nszilárd tóth,1\ni dont want nobody to give me nothing,1\ncity,67\nannabel dover,2\nplacebo discography,8\nshowbiz,8\nsolio ranch,1\nloan,191\nmorgan james,10\ninternational federation of film critics,3\nthe frankenstones,2\npastor bonus,1\nbilly purvis,1\nthe gunfighters,1\nsandefjord,2\nohio wine,2\nfor the love of a man,1\ndrifters,10\nilhéus,1\nbikini frankenstein,1\nsubterranean homesick alien,1\nchemical nomenclature,17\ngreat wicomico river,1\ningrid caven,1\njapanese destroyer takanami,1\nnosler partition,1\nwagaman northern territory,1\nslovak presidential election 2019,1\nfuggerei,12\nal hibah,1\nirish war of independence,2\njoan smallwood,1\nanthony j celebrezze jr,1\nmercedes benz m130 engine,2\nphineas and ferb,2\nbelgium womens national football team,3\nreynevan,1\njoe,1\nalan wilson,1\nepha3,1\nbelarus national handball team,1\nphaedra,14\nmove,2\namateur rocketry,3\nepizootic hemorrhagic disease,5\nprague derby,4\nbasilica of st thérèse lisieux,1\npompeianus,1\nsolved game,3\ntramacet,19\nessar energy,3\nlumbar stenosis,1\npart,24\nhải vân tunnel,1\nvsm group,3\nwalter hooper,2\nconsumer needs,1\nbell helicopter,18\nlaunde abbey,2\nramune,10\ndeclarations of war during world war ii,1\nsaint laurent de la salanque,1\nbalkenbrij,1\nbalgheim,1\nout of the box,13\ncappella,1\nnational pharmaceutical pricing authority,4\nfriend and foe,1\nnew democracy,1\neastern phoebe,2\nisipum of geumgwan gaya,1\ntel quel,1\ntraveler,12\nsuperbeast,1\noddsac,1\nzamora spain,1\ndeclaration of state sovereignty of the russian soviet federative socialist republic,1\nchumash painted cave state historic park california,3\nzentiva,1\nbritish rail class 88,5\nwest indies cricket board,3\npauli jørgensen,1\npunisher kills the marvel universe,7\nwilliam de percy,1\nvehicle production group,4\nuc irvine anteaters mens volleyball,2\ndong sik yoon,1\nhyæna,2\ncanadian industries limited,1\nmr ii,1\njim muhwezi,1\ncitizen jane,2\nnight and day concert,1\ndouble precision floating point format,2\nherbal liqueurs,1\nthe fixed period,5\npip/taz,1\nlesser caucasus,2\nuragasmanhandiya,2\nalternative words for british,2\nkhuzaima qutbuddin,1\nhelmut balderis,2\nwesley r edens,1\nscott sassa,4\nmutant mudds,3\neast krotz springs louisiana,1\nleonard frey,3\ncounting sort,15\nleandro gonzález pírez,2\nshula marks,1\nsierville,1\ncalifornia commission on teacher credentialing,1\nraymond loewy,10\nbeevor foundry,1\ndog snapper,2\nhitman contracts,5\neduard herzog,1\nwittard nemesis of ragnarok,1\ncape may light,1\nal saunders,3\ndistant earth,2\nbeam of light,2\narent we all?,1\nveridicality,1\nprivate enterprise,3\nrambhadracharya,3\ndps,5\nbeckdorf,1\nrúaidhrí de valera,1\nvivian bang,3\nsugar pine,1\nvn parameswaran pillai,1\nhenry ross perot sr,1\nthe arcadian,1\nthe record,6\ng turner howard iii,1\noleksandr usyk,12\nmumbai suburban district,5\nvicente dutra,1\npaean,1\nscottish piping society of london,1\ningot,11\nalex obrien,6\nautonomous counties of china,1\nkaleorid,1\nremix & repent,3\ngender performativity,7\ngodheadsilo,1\ntonsilloliths,1\nla dawri,1\nkiran more,3\nbillboard music award for woman of the year,1\ntahitian ukulele,1\nbuick lacrosse,14\ndraft helen milner jury sent home for the night,2\nhistory of japanese cuisine,6\ntime tunnel,1\nalbert odyssey 2,1\noysters rockefeller,4\njim mahon,1\nevolutionary invasion analysis,1\nsunk cost fallacy,3\nuniversidad de manila,1\nmorgan crucible,1\nsouthern miss golden eagles football,2\nhoratio alger,13\nbiological psychopathology,1\nhollywood,115\nproduct manager,21\nthomas burgh 3rd baron burgh,1\nstan hack,1\npeloponesian war,1\nrepublic of china presidential election 2004,2\nsanitarium,4\ngrowthgate,1\nsamuel e anderson,1\nbobo faulkner,1\nkaffebrenneriet,1\nmonponsett pond seaplane base,1\npowers of horror,3\nviburnum burkwoodii,1\nnew suez canal,5\ngerardo ortíz,2\njaphia life,1\npaul pastur,1\nfuller craft museum,1\nnomal valley,1\ninaugural address,1\nsaint Étienne du vigan,1\nlip ribbon microphone,2\nmary cheney,2\npiebald,6\nkadambas,1\ntransportation in omaha,7\nbefore the league,1\nfeltham and heston by election 2011,1\naboriginal music of canada,3\ndnssec,6\nsshtunnels,1\nrobin benway,1\nswimming at the 1968 summer olympics – mens 4 x 200 metre freestyle relay,1\ncommission internationale permanente pour lepreuve des armes à feu portatives,3\ndeath rock,1\nhugo junkers,6\ngmt,3\nkeanu reeves,2\nbeverly kansas,1\ncharlotte blair parker,1\nkids,5\nweight bench,1\nkiasmos,8\nbasque country autonomous basketball team,1\ngideon toury,2\ngugak/,1\ntexass 32nd congressional district,2\nhave you ever been lonely,1\ntake the weather with you,1\nchukchi,1\nthe magicians wife,1\njuan manuel bordeu,1\nport gaverne,1\nmusic for films iii,1\nnorthern edo masquerades,1\nhang gliding,15\nmarine corps logistics base barstow,2\ncentury iii mall,1\npeter tarlow,1\nthermal hall effect,1\ndavid ogden stiers,18\nwebmonkey,1\nfive cereals,2\nosceola washington,1\nclover virginia,2\nsphinginae,2\nstuart brace,1\nal di meola discography,7\nsunflowers,1\nhasty generalization,4\npolish athletic association,1\nthe purge 3,2\nbitetti combat mma 4,1\nhiroko nagata,2\nmona seilitz,1\nmixed member proportional representation,7\nrancho temecula,2\nsinai,1\nnorrmalmstorg robbery,5\nsilesian walls,1\nfloyd stahl,1\ngary becker,1\nknowledge engineering,5\nport of mobile,1\nluckiest girl alive,2\nilya rabinovich,1\nbridge,3\nel general,3\ncornerstone schools,1\ngozmo,1\ncharles courtney curran,1\nbroker,32\nus senate committee on banking housing and urban affairs,2\nretroversion of the sovereignty to the people,1\ngiorgi baramidze,1\nlars grael,1\nabdul qadir,3\npgrep,2\ncategory talk seasons in danish womens football,1\nmalus sieversii,1\ngod squad,4\ncategory of acts,1\nmelkote,1\nlinda langston,1\nsherry romanado,1\nmontana sky,8\nhistory of burkina faso,1\niso 639 kxu,1\nlos angeles fire department museum and memorial,1\nrecognize,1\nder bewegte mann,6\ndavy pröpper,1\noutline of vehicles,2\ngesta francorum,1\nsidney w pink,1\nronald pierce,1\nmartin munkácsi,1\nnord noreg,1\naccounting rate of return,7\nurwerk,1\nalbert gallo,1\nantennaria dioica,3\ntransport in sudan,2\nfladry,1\ncumayeri,1\nbennington college,11\npêro de alenquer,2\nsixth man,1\nwilliam i of aquitaine,1\nradisson diamond,1\nbelgian united nations command,1\nvenus genetrix,1\nsayesha saigal,14\ninverse dynamics,2\nnational constitutional assembly,1\nhoney bear,4\ncertosa di pavia,2\nselective breeding,31\nlet your conscience be your guide,1\nhan hyun jun,1\nclosed loop,8\ntemplate talk golf major championships master,1\ntwin oaks community virginia,1\nred flag,3\nhousing authority of new orleans,2\njoice heth,4\ntoñito,1\nivan pavlov,2\nmadanapalle,4\nptat,1\nrenger van der zande,1\nanaerobic metabolism,2\npatrick osullivan,1\nshirakoya okuma,1\npermian high school,9\nthomas h ford,1\nsouthfield high school,1\nreligion in kuwait,2\nnathrop colorado,1\nhefner hugh m,1\nwhitney bashor,1\npope shenouda iii of alexandria,7\nthomas henderson,1\ntokka and rahzar,13\nwindows thumbnail cache,3\nconsumer council for water,1\nsake bombs and happy endings,1\nlothlÃ³rien,1\nthe space bar,4\nsakuma rail park,1\noas albay,3\ndan frankel,1\ncliff hillegass,1\niron sky,12\npentile matrix family,1\noregon system,1\ncalifornia sea lion,7\njeanneau,2\nmeadowhall interchange,1\nlille catholic university,1\nnuñomoral,1\nvending machine,30\nxarelto,1\njonbenét ramsey,3\nprogresso castelmaggiore,1\ntacticity,6\nwing arms,1\ngag,2\nhank greenberg,8\ngarda síochána,14\npuggy,1\np sainath,1\nthe year of living dangerously,9\narmy reserve components overseas training ribbon,1\nhmas nestor,1\njohn beckwith,1\nflorida constitution,2\nyonne,3\nbenoît richaud,1\nmamilla pool,2\ngerald bull,14\ndavid halberstam,12\nmy fair son,2\nncaa division iii womens golf championships,1\nanniela,1\nking county,1\nkamil jankovský,1\nsynaptic,3\nrab,6\nswitched mode regulator,1\nhistory of biochemistry,1\nhalaf,2\nhenry colley,1\nco postcode area,3\nsocial finance uk,1\ncercospora,2\nthe dao,1\nunité radicale,2\nshinji hashimoto,3\ntommy remengesau,3\nisobel gowdie,2\nmys prasad,9\nnational palace museum of korea,1\nbasílica del salvador,2\nno stone unturned,2\nwalton group,1\nforamen ovale,1\nslavic neopaganism,1\niowa county wisconsin,3\nmelodi grand prix junior,1\njarndyce and jarndyce,3\ntalagunda,1\nnicholas of autrecourt,1\nsubstitution box,3\nthe power of the daleks,1\nreal gas,6\nedward w hincks,1\nkangxi dictionary,5\nnatural world,1\nh h asquith,21\nfrancis steegmuller,1\nsasha roiz,3\nmedia manipulation,1\nlooking for comedy in the muslim world,2\nbytown,4\nprevisualization,1\nrita ora discography,11\nkiersey oklahoma,1\nhenry greville 3rd earl of warwick,1\ndraft,4\nphenolate,1\ni believe,1\nvirologist,1\nrelief in abstract,1\neastern medical college,1\npurveyance,2\nascending to infinity,2\nsportstime ohio,2\nchurch of wells,1\nivory joe hunter,1\nwayne mcgregor,2\nluna 17,4\nviscount portman,2\nwikipedia talk wikipedia signpost/2009 07 27/technology report,1\nnegramaro,1\nbarking owl,2\ni need you,2\nbrockway mountain drive,1\ntemplate talk albatros aircraft,1\nfuture shock,11\nchina national highway 317,1\nlaurent gbagbo,7\nplum pudding model,18\nleague of the rural people of finland,1\ndundees rising,1\nnikon f55,1\nolympic deaths,5\ngemma jones,19\nhafsa bint al hajj al rukuniyya,1\npersonal child health record,1\nlogic in computer science,11\nbhyve,3\nhothouse,1\nlog house,6\nlibrary of celsus,2\nthe lizzie bennet diaries,1\nleave this town the b sides ep,1\nestimated time of arrival,8\nchariotry in ancient egypt,2\namerican precision museum,1\ndimos moutsis,1\nscriptlet,1\nsomething in the wind,1\nsharka blue,1\ntime on the cross the economics of american negro slavery,1\ntomislav kiš,1\nkhalid islambouli,7\nbankruptcy abuse prevention and consumer protection act,7\ngračanica bosnia and herzegovina,2\njungs theory of neurosis,5\nmgm animation,1\nsoviet support for iran during the iran–iraq war,3\nnative american,1\ntemplate talk nigeria squad 1994 fifa world cup,1\nnorwegian lutheran church,4\nadia barnes,1\ncoatings,1\nmehdi hajizadeh,1\nthe dead matter cemetery gates,1\nfuzzy little creatures,1\nwaje,7\nanji,1\nheinz haber,1\nturkish albums chart,1\nsebastian steinberg,1\nprice fixing cases,2\nbellator 48,1\nedgar r champlin,1\notto hermann leopold heckmann,1\nbishops stortford fc,4\nstern–volmer relationship,6\nmorgan quitno,2\nfive star general,1\niso 13406 2,1\nblack prince,11\nleopard kung fu,1\nfelix wong,5\nmary claire king,6\nalvar lidell,1\nplayonline,1\ninfantry branch,1\nandrew pattison,1\njohn turmel,1\nkent,74\nedwin palmer hoyt,1\ncaptivity narratives,1\njaguar xj220,1\nhms tanatside,2\nnew faces,2\nedward levy lawson 1st baron burnham,1\nsamuel woodfill,3\njewish partisans,9\nabandonware,16\nearly islamic philosophy,2\nsleeper cell,5\nmedia of africa,2\nsan andreas,3\nluxuria,2\negon hostovský,3\npelagibacteraceae,1\nmartin william currie,1\nborescope,21\nnarratives of islamic origins the beginnings of islamic historical writing,1\nlecompton constitution,2\naxé bahia,2\npaul goodman,1\ntemplate talk washington nationals roster navbox,1\na saucerful of secrets,2\ndavid carol macdonnell mather,1\nportal buddhism,3\nflorestópolis,1\nalecs+golf+ab,1\nbank alfalah,1\nfrank pellegrino,3\nloutre,1\nerp4it,2\nmonument to joe louis,2\nwitch trial of nogaredo,1\nsabrina santiago,2\nno night so long,3\nhelena carter,1\nrenya mutaguchi,3\nyo yogi,4\nbolivarian alliance for the americas,3\ncooper boone,1\nuss iowa,24\nmitsuo iso,2\ncranberry,1\nbatrachotomus,1\nrichard lester,5\nbermudo pérez de traba,1\nrosser reeves ruby,1\ntelecommunications in morocco,4\ni a richards,1\nnidhal guessoum,1\nlilliefors test,6\nthe silenced,5\nmambilla plateau,1\nsociology of health and illness,3\ntereza chlebovská,2\nbismoll,3\nkim suna,1\nscream of the demon lover,1\njoan van ark,7\nintended nationally determined contributions,6\ndietary supplement,16\nlast chance mining museum,1\nsavoia marchetti s65,1\nif i can dream,1\nmaharet and mekare,4\nnea anchialos national airport,2\namerican journal of digestive diseases,1\nchance,2\nlockheed f 94c starfire,1\nthe game game,1\nkuzey güney,3\nsemmering base tunnel,1\nthree mile island,1\nevaluation function,1\nrobert mckee,4\ncarmelo soria,1\nmoneta nova,1\npīnyīn,1\ninternational submarine band,3\nelections in the bahamas,5\npowell alabama,1\nkmgv,1\ncharles stuart duke of kendal,2\necho and narcissus,7\ntrencrom hill,1\nashwini dutt,1\nthe herzegovina museum,1\nliverpool fc–manchester united fc rivalry,12\nkerber,1\nflakpanzer 38,8\ndemographics of bihar,2\nrico reeds,1\nvandenberg afb space launch complex 3,1\nwiesendangen,1\nlamm,1\nallen doyle,2\nanusree,5\nbroad spectrum,1\nbay middleton,2\nconnect savannah,1\nhistory of immigration to canada,22\nwaco fm,3\nnakano takeko,1\nmurnau am staffelsee,2\nminarchy,1\nhaymans dwarf epauletted fruit bat,1\nbrachyglottis repanda,1\nassociative,1\nmississippi aerial river transit,1\nstefano siragusa,2\ngregor the overlander,3\nmarine raider,1\npogorzans,1\nsportcity,2\ngarancahua creek,1\nvincent dimartino,3\nninja,2\nnatural history museum of bern,1\nrevolutionary catalonia,4\nchiayi,1\nalix strachey,3\nlooe island,1\ncollege football usa 96,1\noff peak return,1\nminsk 1 airport,1\nevangelical lutheran church in burma,2\nriemann–roch theorem,1\nthe comic strip,2\nvladimir istomin,1\namerica again,2\nbrown treecreeper,1\namerican high school,1\npowerglide,2\noolitic limestone,1\ndaz1,1\njarrow vikings,1\npierre philippe thomire,1\ndorothy cadman,1\ngaston palewski,3\ntwin river bridges,1\nim yours,1\nambrose dudley 3rd earl of warwick,3\nssim,2\noriginal hits,1\ncosmonaut,9\nspecial educational needs and disability act 2001,4\nwill you speak this word,1\nhistory of wolverhampton wanderers fc,1\ndon lawrence,1\ntokyo metropolitan museum of photography,1\norduspor,1\njohn lukacs,3\npatrice collazo,1\nlords resistance army insurgency,5\nronald \"slim\" williams,5\ndrivin for linemen 200,1\nnicolò da ponte,1\nbucky pope,1\newing miles brown,2\nugly kid joe,28\namerican flight 11,1\nlouzouer,1\ndistrict hospital agra,1\njessica jane applegate,1\nsexuality educators,1\nserie a scandal of 2006,1\nat war with reality,1\nstephen wiltshire,13\nvechigen switzerland,1\nrikki clarke,3\nrayakottai,1\npermanent magnet electric motor,1\nqazi imdadul haq,1\nplywood,49\nntr telugu desam party,1\nskin lightening,1\nroyal natal national park,1\nuss mcdougal,2\nqueen of the sun,1\nkaranjachromene,1\non 90,1\nenrique márquez,1\nsiegfried and roy,1\ncity manager,6\nwrdg,1\nwhy i am not a christian,3\nprotein coding region,1\nroyal bank of queensland gympie,1\nbritish invasions of the river plate,2\nyasufumi nakanoue,1\nmagnetic man,1\nkickback,3\ntillandsia subg allardtia,1\nnorth american nr 349,1\nedict of amboise,1\nst andrew square edinburgh,2\nflag of washington,2\ntimeless,2\nnew york state route 125,3\nfudge,3\nsingle entry bookkeeping system,5\nrefractive surgery,8\nbi monthly,1\npark high school stanmore,1\nnorton anthology of english literature,1\nmichael wines,1\ngaff rig,1\nkosmos 1793,1\nmajor facilitator superfamily,2\ntalpur dynasty,1\nbyron bradfute,1\nquercitello,1\nrcmp national protective security program,1\nann kobayashi,1\nrecurring saturday night live characters and sketches,3\nabraham hill,1\nnagapattinam district,4\npidgeon,3\nmycalessos,1\ntechnical university of hamburg,1\nelectric shock&ei=ahp0tbk0emvo gbe v2bbw&sa=x&oi=translate&ct=result&resnum=2&ved=0ceaq7gewaq&prev=/search?q=electric+shock&hl=da&biw=1024&bih=618&prmd=ivns,2\naim 54 phoenix,18\nundercut,5\ngokhale memorial girls college,1\ndigital penetration,19\ncentre for peace studies tromsø,1\nrichie williams,1\nwalloon region,1\nalbany city hall,2\nmaxine carr,4\nanglosphere,18\neffect of world war i on children in the united states,1\njosh bell,1\ngerman thaya,1\nbrian murphy,3\nmarguerite countess of blessington,1\nleak,1\nbubble point,5\ninternational federation of human rights,1\nclubcorp,2\ngreater philadelphia,1\ndaniel albright,1\nmacas,1\nroses,4\nwoleu ntem,1\nshades of blue,1\nsay aah,2\ncurtiss sbc,1\nion andone,1\nfirstborn,1\nmarringarr language,2\nann e todd,1\nnative american day,4\nstand my ground,1\nbavington,1\nclassification of indigenous peoples of the americas,2\nalways,6\nleola south dakota,1\npsycilicibin,2\nroy rogers,1\nmarmalade,1\nnational prize of the gdr,1\nshilp guru,1\nm2 e 50,1\njorge majfud,2\ncutter and bone,1\nwilliam steeves,1\nlisa swerling,2\ngrace quigley,5\ntelecommunications in yemen,1\nrarotonga international airport,7\ncycling at the 2010 central american and caribbean games,2\nmazda b3000,1\nhanwencun,1\nadurfrazgird,1\nivan ivanov vano,1\nyhwh,1\nqarshi,4\noshibori,2\nuppada,1\niain clough,1\npainted desert,7\ntugzip,1\nmy little pony fighting is magic,143\npantheon,2\nchinese people in zambia,1\nyves saint laurent,3\ntexas helicopter m79t jet wasp ii,1\nforever reign,1\ncharlotte crosby,32\nealdormen,9\ncopper phosphate,2\nmean absolute difference,5\nhôtel de soubise,5\njosh rees,2\nnon commissioned officer,70\ngb jones,1\nim feeling you,2\nbook of shadows,9\nbrain trauma,1\nsulpitius verulanus,1\nvikranth,5\nspace adaptation syndrome,6\nunited states presidential election in hawaii 1988,1\njoe garner,4\nriver suir bridge,2\nthe beach boys medley,1\njoyce castle,1\nchristophe wargnier,1\nik people,2\nsketch show,1\nbuena vista police department,1\nfile talk layzie bone clevelandjpg,1\ngillian osullivan,3\nprince albert of saxe coburg and gotha,2\nberean academy,1\nmotorcraft quality parts 500,1\nfrederick law olmsted,21\nborn this way,9\nsterling virginia,4\nif wishes were horses beggars would ride,1\nsection mark,1\ntapi,1\nnavy cross,1\nhousekeeper,1\ngian battista marino,1\nplaná,1\nchiromantes haematocheir,1\ncolonial life & accident insurance company,4\naduana building,2\nkim johnston ulrich,1\nberkelium 254,1\nm&t bank corp,2\nsit up,1\nsheknows,1\nphantom lady,1\nbruce kamsinky,1\ncommercial drive,1\nchinese people in the netherlands,1\nsylvia young theatre school,4\ninfluenza a virus subtype h2n3,1\ndracut,2\nnate webster,1\nvila velebita,1\nuaz patriot,4\ndemocratic unification party,1\nalexander slidell mackenzie,1\nportland mulino airport,1\nfirst person shooter,2\nthe temporary widow,1\nterry austin,1\nthe foremans treachery,1\nhms blenheim,1\nsodium dichloro s triazinetrione,1\nkurt becher,1\ncumberland gap tn,1\nnewton cotes,1\ndaphne guinness,6\ninternal tide,1\ngod and gender in hinduism,2\nhowlin for you,1\nstellarator,14\ncavea,3\nfaye ginsburg,1\nlady cop,3\ntemplate talk yugoslavia squad 1986 fiba world championship,1\nsolidarity economy,1\nsecond presidency of carlos andrés pérez,1\nbora bora,71\nxfs,1\nchristina bonde,1\nagriculture in australia,20\nscenic drive,1\nrichard mantell,1\nmotordrome,1\nbroadview hawks,1\nmisty,2\ninternational bank of commerce,2\nistanbul sapphire,5\nchangkat keruing,1\nthe hotel inspector unseen,1\ntharwa australian capital territory,2\nstrauss,2\nshock film,1\nulick burke 1st marquess of clanricarde,2\nvalencia cathedral,5\nkay bojesen,1\npalogneux,1\ntexas beltway 8,1\njackie walorski,7\ncapital punishment in montana,1\nbyte pair encoding,2\nupper deerfield township new jersey,2\nlucca comics & games,1\nlee chae young,1\nczar alexander ii,1\nkool ad,6\nleopold van limburg stirum,1\njohn dunn,1\npoliceman,2\nwhat dreams may come,3\ngrant ginder,1\nchieverfueil,2\nlong island express,1\nmalmö sweden,2\nsong for my father,1\nsee saw,2\njean jacques françois le barbier,5\ndo rag,11\ndsb bank,2\ndavical,6\ncervical cap,1\ngershon yankelewitz,1\nthe last hurrah,4\ncategory talk educational institutions established in 1906,1\ntour pleyel,1\nleón klimovsky,1\nphyoe phyoe aung,1\nphil sawyer,2\nandroid app //orgwikipedia/http/enmwikipediaorg/wiki/swiftkey,1\ndeontological,3\njuan dixon,12\nrobert pine,4\nalexander tilloch galt,2\ncommon tailorbird,12\nderailed,7\nmike campbell,3\nterminator 2 3 d battle across time,3\ntechnische universität münchen,4\nbaloana,1\nechis leucogaster,1\nlahore pigeon,1\nwilliam de beauchamp 9th earl of warwick,2\nerin go bragh,14\neconomics u$a,1\nvillafranca montes de oca,1\npope eusebius,2\nmartin kruskal,1\nfélix de blochausen,1\njeff jacoby,1\nmark krein,2\ntravis wester,2\nfort louis de la louisiane,1\nweddingwire,2\nping,54\ndon swayze,8\nsteve hamilton,3\nrhenish,1\nwinrar,3\nbirths in 1561,4\ncopyright law of the netherlands,2\nfloodland,9\ntamil nadu tourism development corporation,1\ndolls house,1\nchkrootkit,1\nsearch for the hero,1\navenal,1\ntini,2\npatamona,1\naspendos international opera and ballet festival,2\nfelix cora jr,5\nyellow cardinal,2\nantony jay,1\nconda,1\na tramp shining,1\nwilliam miller,1\nholomictic lake,2\ngrowler,2\nthe violence of summer,1\nmeerschaum,3\ncd138,1\nkarl friedrich may,1\nhistory of iraq,2\nhenry ford,139\nrumwold,1\nbeatrice di tenda,1\nblaze,1\nnick corfield,1\nwalt longmire,5\neleazar maccabeus,1\nbusiness edition,1\nkarl oyston,4\ngypsy beats and balkan bangers,1\nfa premier league 2004 05,1\nagawan radar bomb scoring site,1\nthe hall of the dead,1\ncombat training centre,1\nmoroccan portuguese conflicts,2\npokipsy,1\nminor characters in csi crime scene investigation,1\nmiguel molina,1\nbuckypaper,2\nmagazine,4\nforget about it,2\nmarco schällibaum,1\nr d smith,1\nnfl playoff results,2\nfour score,1\ncentenary bank,2\nlondon borough of camden,12\nbhumij,1\ncounter reformation/trackback/,1\nbilly volek,1\ncover song,1\nawang bay,1\ndouglas fitzgerald dowd,3\narchitecture of ancient greece,5\nny1,2\nacademy award for best visual effects,3\nhistory of the mbta,2\ntriangle group,1\ncharles r fenwick,1\nberenice i of egypt,1\nwindow detector,1\ncorruption perception index,1\nleffrinckoucke,1\nlee anna clark,1\nburndy,2\ninset day,2\namerican association of motor vehicle administrators,1\nckm matrix,1\nangiopoietin 1,1\nsteven marsh,1\nopen reading frame,27\ntelesystems,1\npastoral poetry,1\nwest wycombe park,2\nlithium,7\nnogales international airport,1\nwajków,1\nsls 1,1\ntrillo,2\nmax s,1\nverndale,1\nyes sir i can boogie,1\nblog spam,10\ndaniel veyt,1\nwilliam brown,3\ntakami yoshimoto,1\njosh greenberg,4\ngeoffrey heyworth 1st baron heyworth,1\nmedeina,3\nanja steinlechner,1\nriviera beach florida,2\ngerris wilkinson,1\nnorth american lutheran church,1\npaul dillett,11\nproto euphratean language,1\nbest selling books,2\npumpellyite,1\nbusiness objects,1\nfodor,2\nxanadu,3\nlondon river,1\ndraft juan de orduña,2\nbarriemore barlow,3\njew harp,1\nbirmingham,1\ntitus davis,1\nmarch 2012 gaza–israel clashes,1\nenergy demand management,2\naquarium of the americas,3\ntto,1\nl h c tippett,1\noptical fiber,88\nonești,2\nstanley ntagali,1\nprussian blue,1\nbill kovach,2\nhip pointer,3\nalessandra amoroso,4\nfleet racing,1\nnavy maryland rivalry,1\ncornering force,1\nthe mighty quest for epic loot,5\nkatalyst,2\nthe beef seeds,1\nshack out on 101,1\naircraft carrier operations,1\noverseas province,2\ninstitute of state and law,1\nlight truck,5\nplastics in the construction industry,2\nlittle zizou,2\ncongenic,2\nadriaen van utrecht,1\nbrian mcgrath,3\nparvati,1\njason gwynne,1\nkphp,1\nmiryusif mirbabayev,1\nkōriyama castle,3\nthe making of a legend gone with the wind,2\nshot traps,1\nawa tag team championship,1\nlittlebourne,2\nfranchot tone,4\njohn dudley 2nd earl of warwick,2\nmass spec,1\nfinal fantasy vi,44\ngerry ellis,1\nadon olam,3\nman 24310,1\np n okeke ojiudu,1\nunqi,1\nsnom,1\nbruce bagemihl,1\ncategory talk animals described in 1932,1\nmetalist oblast sports complex,1\ncolley harman scotland,1\nsuka,1\nanita sarkeesian,81\nkazakhstan national under 17 football team,1\nym,2\nmatt barnes,1\ntour phare,1\nbellus–claisen rearrangement,2\nturkey at the 2012 summer olympics,1\nirréversible,32\numbilical nonseverance,1\nwood stave,1\nindian pentecostal church of god,1\ncamponotus nearcticus,3\njohn tesh,13\nsyncline,4\nskins,50\nkelsey manitoba,1\nalkayida,2\npolyglotism,17\nforensic statistics,2\nram vilas sharma,8\npearl jam,71\ndj max fever,1\nislamic view of miracles,5\nkds,1\nalabama cavefish,1\njohanna drucker,1\ntom wolk,4\nrottenburg,2\ngoshen connecticut,2\nmaker media,1\nmorphett street adelaide,1\nkeystone hotel,1\nbaseball hall of fame balloting 2005,1\ngongzhuling south railway station,1\nss charles bulfinch,1\nsig mkmo,1\ncartman finds love,2\nembassy of syria in washington dc,1\ncharles prince of wales,175\nteachings of the prophet joseph smith,1\ncharles iv,1\nalethea steven,1\ntype i rifle,2\na peter bailey,1\nbrain cancer,1\neric l clay,2\njett bandy,1\nmoro rebellion,9\neustachów,1\navianca el salvador,2\ndont stop the party,4\nreciprocal function,1\ndagmar damková,1\nhautmont,1\npenguin english dictionary,2\nwaddie mitchell,1\ntechnician fourth grade,3\nhot girls in love,1\ncritérium du dauphiné,59\nlove song,2\nroger ii,2\nwhitbread book award,1\nthomas colepeper 2nd baron colepeper,2\na king and no king,1\nbig fish & begonia,5\nmayville new york,2\nmolecularity,1\ned romero,1\none watt initiative,3\njeremy hellickson,2\nwilliam morgan,1\ngiammario piscitella,1\neastern lesser bamboo lemur,1\npadre abad district,1\ndon brodie,1\nfacts on the ground,1\nundeniable evolution and the science of creation,1\njohn of giscala,1\nbryce harper,45\ngabriela irimia,1\nempire earth mobile,1\nthe queen vic,1\nhelen rowland,1\nmixed nuts,5\nmalacosteus niger,2\ngeorge r r martin/a song of ice and fire,1\nbrock osweiler,11\ntough,1\noutline of agriculture,4\nsea wolf,1\nmo vaughn,4\nthe brood of erys,1\ncomposite unit training exercise,1\nisabella acres,4\nthe jersey,5\ncoal creek bridge,1\nhabana libre,1\nnicole pulliam,1\njohn shortland,1\ndaniel pollen,1\nmagic kit,1\nbaruch adonai l&,1\na daughters a daughter,2\nlaughlin nevada,11\ntubercule,1\nlouis laurie,1\ninternet boom,3\nconversion of paul,1\ncomparison of software calculators,1\nchoctaw freedmen,2\njosh eady,1\nhôpital charles lemoyne,2\nu mobile,2\njohn tomlinson,1\nbaré esporte clube,2\ntuğçe güder,2\nhighams park railway station,4\nnewport east,1\nclothing industry,6\nscott rosenberg,6\nmy 5 wives,2\nmatt godfrey,1\nport ellen,2\nwinecoff hotel fire,1\nfide world chess championship 2005,2\nlara piper,1\nthe little mermaid,1\nfoxmail,6\npenn lyon homes,1\nstockholm opera,1\namerican journal of theology,1\nbernard gorcey,3\nrodger collins,1\nclarkeulia sepiaria,1\nkorean era name,3\nmelide ticino,1\nunknown to no one,1\nasilinae,1\nscânteia train accident,1\nparti de la liberté et de la justice sociale,1\nfalkland islands sovereignty dispute,13\ncastile,10\nfrench battleship flandre,1\nnils taube,1\nanisa haghdadi,1\nwilliam tell told again,2\nmagister,3\nzgc 7,1\nnational agricultural cooperative marketing federation of india,3\nles bingaman,1\nchebfun,1\nportal current events/august 2014,2\neparchy of oradea mare,1\ntempo and mode in evolution,2\nseili,1\nboniface,3\nsupportersvereniging ajax,1\nsupport team,1\nlactometer,1\ntwice as sweet,1\nspruce pine mining district,2\nbanknotes of the east african shilling,1\ncerebral cortex,3\ntagalogs,1\ngerman diaspora,8\ngrammelot,1\nmax a,1\ncategory talk vienna culture,1\ncheung kong graduate school of business,1\nthree certainties,1\nmultani,3\nbarry callebaut,15\njoanne mcneil,1\nz grill,4\ncommonwealth of australia constitution act 1900,1\nganzorigiin mandakhnaran,1\npeter h schultz,1\nea pga tour,3\nscars & memories,1\nexodus from lydda,1\nstates reorganisation act 1956,4\nguy brown,1\nhorsebridge,1\narthur mafokate,1\naldus manutius,5\namerican daylight,3\njean chaufourier,2\nedmond de caillou,1\nhms iron duke,9\ndispleased records,1\nquantum turing machine,3\nncert textbook controversies,2\ndracs,1\nbeyrouth governorate,1\nstaphylococcus caprae,1\ntankard,2\nsurfaid international,1\nhohenthurn,2\nmission x 41,1\nprofessional wrestling hall of fame,2\ngeorge mountbatten 4th marquess of milford haven,2\nathletics at the 2012 summer paralympics womens club throw f31 32/51,1\nknots and crosses,1\nedge vector,1\nphilippe arthuys,1\nbaron raglan,1\nodell beckham jr,3\nelfriede geiringer,1\nhyflux,1\nauthor level metrics,2\nieee fellow,1\npori brigade,3\npolyphenol antioxidant,1\nthe brothers,8\nkakaji Ōita,1\nshyam srinivasan,2\nshahid kapoor,88\nchuckie williams,1\ncolonial,4\nroman spain,1\nconvolvulus pluricaulis,1\nwilliam j burns international detective agency,1\naccessibility for ontarians with disabilities act 2005,1\nlinguist,1\nagonist,2\nxiaozi,1\nholker hall,1\nnovatium,1\nalois jirásek,1\nlesser crested tern,1\nnames of european cities in different languages z,1\nhydrogen cooled turbogenerator,2\nindian airlines flight 257,1\nunited states attorney for the northern district of indiana,1\nthis is us,11\ntransaction capabilities application part,1\nculiacán,6\nhash based message authentication code,65\nheinz murach,1\ndual citizen,2\nzhizn’ za tsarya,1\ngabriel taborin technical school foundation inc,1\ndeaths in july 1999,1\naponi vi arizona,1\namish in the city,2\ngoodbye cruel world,1\nst augustine grass,10\nmoesi,1\nviolette leduc,3\nmethyl formate,9\nyou walk away,1\nthe traveler,1\nbond,89\nmoa cuba,3\nhebrew medicine,1\nwomen in the russian and soviet military,2\nhelp log,2\ncuillin,5\nback fire,14\nsalesrepresentativesbiz,1\nhogsnort rupert,1\ndwarf minke whale,1\nembassy of albania ottawa,1\ncotai water jet,1\nst lucie county florida,8\nwesselman,1\namerican indian art,1\nrichard arkless,1\ntrolleybuses in bergen,1\nvama buzăului,1\nfar east movement,9\nthrees a crowd,1\ninsane,3\nlinux technology center,4\npatty duke,24\nsmuckers,1\nkapalua,1\namf futsal world cup,5\numes chandra college,1\njnanappana,2\nbar bar bar,1\nberetta m951,2\nlibertarian anarchism,1\nfart proudly,4\npeyton place,5\nphase detection autofocus,1\ncavalry in the american civil war,9\nclass stratification,1\nbattle of cockpit point,1\nregiment van heutsz,2\nana rivas logan,1\nnenya,1\nwestland wah 64 apache,1\nroslyn harbor new york,3\naugust wilhelm von hofmann,1\nprofessional baseball,2\ndouglas feith,1\npogrom,21\naušra kėdainiai,1\npseudopeptidoglycan,4\narquà petrarca,1\nwayampi,1\nconservative government 1866 1868,1\nworld naked bike ride,28\nfruitvale oil field,2\nshuttle buran,1\nrobert c pruyn,1\ntotem,1\nmegalotheca,1\nnkechi egbe,1\njames p comeford,1\nheavens memo pad,7\ncauca valley,1\njungfraujoch railway station,2\nseo in guk,24\nbold for delphi,1\nmultiple frames interface,1\nzhenli ye gon,6\nkyabram victoria,1\ntwo stars for peace solution,1\ncouette flow,9\nnew formalism,2\ntemplate talk 1930s comedy film stub,1\ntemplate talk scream,1\njoona toivio,4\niaaf silver label road race,1\nsuper bowl xxviii,5\ni aint never,1\npaul little racing,1\njacobite rising of 1715,3\nkatherine archuleta,1\nprogrammable logic device,12\nfootsteps of our fathers,2\nonce upon a tour,1\ntauck,1\nbudapest memorandum on security assurances,5\nprostitution in chad,2\nbebedouro,2\nvice,2\nmadredeus,1\np diddy,1\nprincess alice of the united kingdom,20\njerry hairston jr,1\nneo noir,3\nself evaluation motives,1\nrelativity the special and the general theory,2\nthe sign of four,3\nkevin deyoung,1\nrobin long,1\nmokshaa helsa,1\nnagaon,1\naniceto esquivel sáenz,1\nsda,2\ngerman battlecruiser gneisenau,1\nassisted reproductive technology,12\ncmmg,1\nvision of you,1\nkeshia chanté discography,1\nbiofuel in the united kingdom,1\nkatinka ingabogovinanana,1\nhutt valley,1\ngarwol dong,1\ntunceli province,3\nedwin bickerstaff,1\nhalloween 3 awesomeland,1\ncanadian records in track and field,1\nubisoft são paulo,1\nmidstream,16\njethro tull,4\nchildhoods end,55\nss rohilla,1\nlagranges four square theorem,6\nbucky pizzarelli,3\njannik bandowski,80\nguðni Ágústsson,1\nmultidimensional probability distribution,1\nbrno–tuřany airport,2\nbroughtonia,5\ncold hands warm heart,1\nsimone biles,32\nbf homes parañaque,2\nakaflieg köln ls11,3\nstreet fighter legacy,2\nbeautiful kisses,1\nfirst modern olympics,1\nmacbook air,1\ndublab,1\nsilent night deadly night,6\nearth defense force 2025,2\ngrant township carroll county iowa,1\ngary williams,1\nmalmö aviation,1\ngeographical pricing,2\nanaheim memorial medical center,1\nmary+mallon,1\nhenry a byroade,1\nwawasan 2020,4\neurovision dance contest,6\nlydia polgreen,1\npilsen kansas,1\ncolin sampson,1\nneelamegha perumal temple,1\njames bye,2\ncanadian federation of agriculture,1\nf w de klerk,34\nbob casey jr,3\nnorthport east,1\nelian gonzalez affair,1\naleksei bibik,1\nanthony dias blue,1\npyaar ke side effects,4\nfusako kitashirakawa,1\ncal robertson,4\nshandong national cultural heritage list,1\npolice story 3 super cop,5\nthe third ingredient,3\ndean horrix,1\npico el león,1\ncesar chavez street,1\nprospered,1\nchildren in cocoa production,5\ngervase helwys,1\nbinary digit,1\nkovai sarala,4\nmathematics and music,1\nmacroglossum,1\nf gary gray,21\nbroadsoft,2\ncachan,4\nbukkake,21\nchurch of st margaret of scotland,1\nchristopher cockerell,3\namsterdam oud zuid,1\ncounty of bogong,1\nintel mobile communications,1\nthe legend of white fang,1\nmillwright,19\nwill buckley,1\nbill jelen,2\ntemplate talk san francisco 49ers coach navbox,1\namalia garcía,1\nbecause he lives,1\nair charts,1\nstade edmond machtens,1\nhenry stommel,1\ndxgi,1\nmisr el makasa sc,1\nchad price,2\ncarl henning wijkmark,1\nacanthogorgiidae,1\ndiqduq,1\nprelog strain,2\ncrispin the cross of lead,4\navraham adan,2\nbarbershop arranging,1\nfree x tv,1\neric guillot,1\nkht,1\nnever a dull moment,1\nlwów school of mathematics,1\nsears centre,3\nchin state,6\nvan halen 2007 2008 tour,1\nrobert weinberg,3\nfierté montréal,2\nvince jack,1\nheikki kuula,1\narchitecture of the republic of macedonia,1\nglossary of education terms,1\naleksandra szwed,1\nmilitary history of europe,3\nexeter central railway station,1\nstaroselye,1\nlee thomas,7\nsaint peters square,2\nromanization of hispania,2\nfile talk dodecahedrongif,1\nsigned and sealed in blood,8\ncolleges of worcester consortium,1\ndistrict electoral divisions,1\ngalkot,1\nking África,3\nmonetary policy,57\nbrp ang pangulo,2\nbattle of mạo khê,1\nair tube,1\nruth ashton taylor,2\nkeith jensen,1\nheadland alabama,1\nwillie loomis,1\ninteractive data extraction and analysis,2\ngeorgetown city hall,2\nchuck es in love,2\nweeksville brooklyn,1\nanatoly sagalevich,2\nbrowett lindley & co,1\nbarnawartha victoria,1\npop,2\nblack balance,2\naceratorchis,1\nemmeline pethick lawrence baroness pethick lawrence,1\nosso buco,1\nherminie cadolle,2\ntelegram & gazette,2\nle van hieu,1\npine honey,2\nnexvax2,1\nleicester north railway station,1\njacqueline foster,1\nbill handel,3\nnizami street,1\nradke,1\nbob mulder,1\nambroise thomas,4\ncarles puigdemont i casamajó,1\ncallable bond,6\ntesco metro,2\nmohan dharia,1\ngreat hammerhead,12\nvinko coce,3\njohn mayne,1\ncobb cloverleaf,1\nuhlan,10\ngiulio migliaccio,1\nbelmont university,6\nrinucumab,1\nkearny high school,1\nchūgen,1\nstages,2\nboar%27s head carol,1\nknight of the bath,1\nayres thrush,7\nsing hallelujah,1\nthe tender land,2\nwholesale banking,1\njean jacques perrey,5\nmaxime bossis,2\nsherman records,1\nalan osório da costa silva,1\nfannie willis johnson house,1\nblacks equation,2\nlevinthals paradox,2\nthomas scully,2\nnecron,3\nuniversity of alberta school of business,5\nlake shetek,1\ntoby maduot,1\ngavriil golovkin,1\nsweetwater,3\natlantic revolutions,2\njaime reyes (comics,1\nkajang by election 2014,1\nmycotoxigenic,1\nsan marco altarpiece,2\nline impedance stabilization network,2\nsantiago hernández,1\njazzland,3\nhost–guest chemistry,4\ngiovanni florio,2\nst marylebone school,1\nacqua fragile,1\nthe horse whisperer,10\ndon francis,1\nmike molesevich,1\nbrad wright,1\nnorth melbourne football club,3\nbrady dragmire,1\nmargaret snowling,2\nwing chun terms,4\nmckey sullivan,1\nderek ford,1\ncache bus,1\nbernie grant arts centre,2\namata francisca,1\nsinha,2\nlarissa loukianenko,1\noceans apart&sa=u&ved=0ahukewjw4n6eqdblahun7gmkhxxebd8qfgg4mag&usg=afqjcnhhjagrbamjgaxc7rpsso4i9z jgw,1\nanemone heart,2\nalison mcinnes,1\njuan lindo,1\nmahesh bhupati,1\nbaháí faith in taiwan,5\ncinema impero,1\ntemplate talk rob thomas,1\nlikin,1\nscience & faith,1\nfort saint elmo,3\ndelhi kumar,6\njuha lallukka,1\nsituational sexual behavior,2\nmilligan indiana,1\nwilliam em lands,1\nkarl anselm duke of urach,2\nhérold goulon,1\nvedic mathematics,20\nmove to this,1\nkoussan,1\nfloored,1\nraghu nandan mandal,1\nangels gods secret agents,1\northogonal,2\nthe little house on the prairie,1\nchilean pintail,1\nguardian angel,2\nst leonard maryland,1\ngreen parties in the united kingdom,1\ntime to say goodbye,1\nalba michigan,2\nharbourfront centre,1\ncorner tube boiler,1\nconsensus government,1\nppru 1,1\ncorporate anniversary,4\nsazerac company,5\nkyle friend,1\nbmw k1100lt,1\npergola marche,1\ncommonwealth of kentucky,2\ntaiwan passport,2\nclare quilty,1\ndomenico caprioli,1\nfrank m hull,1\ncheng sui,2\nnazi board games,3\nspark bridge,1\nderrick thomas,6\nwunnumin 1,1\nemotion remixed +,4\nbrian howard dix,2\nbrigalow queensland,2\nburgi dynasty,1\napolonia supermercados,1\nbrandon lafell,2\none day,24\nnara period,9\ntemplate talk the land before time,1\nassyrians in iraq,1\ntrade union reform and employment rights act 1993,2\ntemplate talk evansville crimson giants seasons,1\nboys be smile / 目覚めた朝にはきみが隣に,2\nkapuloan sundha kecil,1\nhuman impact of internet use,1\nkolkata metro line 2,3\nsaint pardoux morterolles,1\ncarfin grotto,2\nsamuel johnson prize,3\nfrench royal family,1\nandroid app //orgwikipedia/http/enmwikipediaorg/wiki/victoria park,1\nmazda xedos 9,1\nmăiestrit,1\npetroleum economist,2\npenetration,2\nadrian rawlins,8\nplutonium 239,11\nculture of montreal,1\nbritish germans,2\nwarszawa wesoła railway station,1\nlorenzo di bonaventura,6\nmilitary ranks of estonia,1\nuss flint,8\narthur f defranzo,1\nsadeh,1\njammu and kashmir,3\nigor budan,2\ncharmila,2\nchoi,1\nmohammed ali khan walajah,1\nsourabh varma,1\nafter here through midland,1\nmartyn day,1\njustin larouche,1\nillinoiss 6th congressional district,4\njackson wy,1\ntyson apostol,4\nmitch morse,1\nrobert davila,1\ncanons regular of saint john cantius,1\ngiant girdled lizard,2\ncascade volcanoes,5\nfools day,1\ncordyline indivisa,1\npueraria,2\nswiss folklore,4\nmeretz,3\nunited states senate elections 1836 and 1837,1\nbaby i need your love/ easy come easy go,1\nbutrus al bustani,2\nthe lion the lamb the man,1\nrushikulya,1\nbrickworks,3\nalliance party of kenya,1\nludlow college,1\ninternationalism,11\nernest halliwell,1\nconstantine phipps 1st marquess of normanby,1\nkari ye bozorg,1\nsignal flow,4\ni beam,1\ndevils lake,1\nunion of artists of the ussr,2\nindex of saint kitts and nevis related articles,1\nethernet physical layer,18\ndimensional analysis,16\nanatomical directions,2\nsupreme court of guam,1\nsentul kuala lumpur,2\nducefixion,1\nred breasted merganser,4\nreservation,3\nin the land of blood and honey,9\nkate spade,2\nalbina airstrip,1\nkankakee,1\nservicelink,2\ncastilleja levisecta,1\ntonmeister,2\nchanda sahib,1\nlists of patriarchs archbishops and bishops,1\nmach zehnder modulator,1\ngiants causeway,79\nliteral,7\nuss gerald r ford,1\nmonster hunter portable 3rd,3\nbayern munich v norwich city,1\nbanking industry,1\nprankton united,1\nst elmo w acosta,1\nspeech disorder,9\nwelcome to my dna,1\nnouriel roubini,6\narthur kill,2\nbill grundy,7\njake gyllenhaal,1\nworld bowl 2000,1\nwnt7a,1\npink flamingo,2\ntridentine calendar,1\nray ratto,1\nf 88 voodoo,1\nsuper star,4\nondřej havelka,1\nsophia dorothea of celle,12\nclavulina tepurumenga,1\nvampire bats,4\nihsan,1\nocotea foetens,1\ngannett inc,1\nkemira,4\ngre–nal,2\nfarm bureau mutual,1\npete fox,1\nlet him have it,3\nbackwoods home magazine,6\nte reo maori remixes,1\nhussain andaryas,1\nbagun sumbrai,1\nthe westin paris – vendôme,4\nxochiquetzal,4\nplayers tour championship 2013/2014,1\npicnic,7\njosh elliott,5\nernak,3\ngracias,1\nk280ff,1\nbandaranaike–chelvanayakam pact,1\npatrick baert,1\nnausicaä of the valley of the wind,33\nal jurisich,1\ntwitter,230\nwindow,38\nthe power hour,1\nduplex worm,1\nsonam bajwa,16\nbaljit singh deo,1\nindian jews,1\noutline of madagascar,1\noutback 8,1\ndye fig,1\nbritish columbia recall and initiative referendum 1991,1\nfelipe suau,1\nnorth perry ohio,1\ngilbeys gin,1\nphilippe cavoret,1\nluděk pachman,1\nthe it girl,1\ndragonnades,1\nrick debruhl,2\nxpath 20,2\nsean mcnulty,1\nwilliam moser,1\ninternational centre for the settlement of investment disputes,1\nmendes napoli,2\ncanadian rugby championship,1\nbattle of maidstone,2\nboulevard theatre,2\nsnow sheep,3\npenalty corner,1\nmichael ricketts,5\ncrocodile,2\njob safety analysis,5\nduffy antigen,1\ncounties of virginia,1\na place to bury strangers,5\nsocialist workers’ party of iran,1\nwlw t,1\ncore autosport,1\nwest francia,10\nkaren kilgariff,2\npacific tsunami museum,1\nfirst avenue,1\ntroubadour,1\ngreat podil fire,1\nchilean presidential referendum 1988,1\npavol schmidt,1\nhandguard,1\ncrime without passion,1\ndio at donington uk live 1983 & 1987,1\noptic nerves,1\nwake forest school of medicine,1\nnew jersey jewish news,2\nluke boden,2\nchris hicky,1\nbeforu,2\nverch,1\nst roch,3\ncivitas,1\ntmrevolution,3\njamie spencer,1\nbond beam,1\nmegan fox,4\nbattle of bayan,1\njapan airlines flight 472,1\nyuen kay san,1\nthe friendly ghost,1\nrice,14\njack dellal,16\nlee ranaldo,9\nthe overlanders,1\nearl castle stewart,5\nfirst down,1\nrheum maximowiczii,1\nwashington state republican party,2\nostwald bas rhin,1\ntennessee open,1\nkenneth kister,1\nted kennedy,72\npreben elkjaer,1\nindia reynolds,2\nsantagata de goti,1\nhenrietta churchill 2nd duchess of marlborough,1\ncreteil,1\nntt data,3\nzoot allures,4\ntheatre of ancient greece,29\nbujinkan,6\nclube ferroviário da huíla,2\nnhn,4\nhp series 80,2\ninterstate 15,4\nmoszczanka,1\nlawnside school district,1\nvirunga mountains,5\nhallway,1\nserb peoples radical party,1\nfree dance,1\nmishawaka amphitheatre,1\ndeerhead kansas,1\nutopiayile rajavu,1\njohn w olver transit center,1\nfuta tooro,1\ndigoxigenin,5\nthomas schirrmacher,1\ntwipra kingdom,1\npulpwood,6\nthink blue linux,1\nraho city taxi,1\nfrederic remington art museum,1\nwajdi mouawad,1\nsemi automatic firearm,12\nphyllis chase,1\nmalden new york,1\nthe aetiology of hysteria,2\nmy maserati does 185,1\nfriedrich wilhelm von jagow,1\napne rang hazaar,1\nbór greater poland voivodeship,1\nindia rubber,2\nbring your daughter to the slaughter,4\nyasser radwan,1\nkuala ketil,1\nnotre dame de paris,1\nyuanjiang,1\nfengjuan,1\ntockenham,1\ntransnistrian presidential election 1991,1\ngautami,28\nprovidenciales airport,1\ndonald chumley,1\nmiddle finger,8\ncalke abbey,4\nthou shalt not kill,1\ntrail,7\nbattle of dunkirk,43\neyre yorke block,3\nmactan,3\namerican ninja warrior,2\nnevel papperman,1\nninja storm power rangers,1\nuss castle rock,1\nturcos,1\nphilippine sea frontier,1\nirom chanu sharmila,7\nfor the first time,2\nstian ringstad,1\ntréon,1\nhiro fujikake,1\nrenewable energy in norway,4\ndedh ishqiya,18\nleucothoe,2\necmo,2\nknfm,1\ngangnam gu,1\noadby town fc,1\nclamperl,2\nmummy cave,2\nkenneth d bailey,2\npeter freuchen,2\ndayanand bandodkar,2\nshawn crahan,16\nbarbara trentham,2\nuniversity of virginia school of nursing,1\nvöckla,1\nintuitive surgical inc,1\ncyncoed,4\njohn l stevens,1\ndaniel farabello,1\ntrent harmon,5\nferoze gandhi unchahar thermal power station,1\nsamuel powell,1\npan slavic,1\nswimming at the 1992 summer olympics – womens 4 × 100 metre freestyle relay,1\nhuman behaviour,2\nsiege of port royal,3\neridug,1\nlafee,1\nnorth bethesda trail,1\nscheveningen system,1\nspecial penn thing,1\npserimos,1\npravda vítězí,1\nwiki dankowska,1\ntranscript,13\nsecond inauguration of grover cleveland,1\nspent fuel,1\nertms regional,2\nfrederick scherger,1\nnivis,1\nherbert hugo menges,1\nkapitan sino,1\nsamson,34\nminae mizumura,2\ngro kvinlog,1\nchasing shadows,2\nd j fontana,1\nmassively multiplayer online game,27\ncapture of new orleans,8\nmeat puppet,1\namerican pet products manufacturers association,3\nvillardonnel,1\nsessile serrated adenoma,3\npatch products,1\nlodovico altieri,1\nportal,2\njake maskall,4\nthe shops at la cantera,8\nstage struck,5\nelizabeth m tamposi,2\ntaylor swift,22\nforum spam,9\nbarry cowdrill,3\npatagopteryx,2\nkorg ms 2000,1\nhmas dubbo,2\nss khaplang,2\nkevin kelly,1\npunk goes pop volume 5,3\nspurt,2\nbristol pound,5\nmilitary history of finland during world war ii,10\nlaguardia,1\njosé marcó del pont,1\nconditional expectation,18\nthe beat goes on,1\npatricia buckley ebrey,1\nali ibn yusuf,2\ncaristii,1\nwilliam l brandon,1\nfomite,5\nbarcelona el prat airport,7\nmattequartier,4\ninvading the sacred,1\njefferson station,3\nchibalo,1\nphil voyles,1\nramen,41\narchbishopric of athens,1\nrobert arnot,1\ndiethylhydroxylamine,2\nchristian vazquez,1\nservage hosting,1\nufo alien invasion,1\nblackburn railway station,3\nperformance metric,19\npencilings,1\nphosphoenolpyruvate,1\nunder lights,2\ndiego de la hoya,1\nfelipe caicedo,5\njimmy arguello,1\ncielo dalcamo,1\njan navrátil,1\nlinear pottery culture,9\nwbga,1\nk36dd,1\ndie hard 2,22\ncompanding,8\nthis is the modern world,10\ncosmology,26\ncraig borten,1\nred pelicans,1\nac gilbert,2\nfougasse,1\nleonardos robot,4\njohn of whithorn,2\ndavid prescott barrows,2\nhttp cookie,168\nemilia telese,6\nherăstrău park,2\nlauro villar,1\nearl of lincoln,1\nborn again,2\nmilan rufus,1\nweper,2\nlevitt bernstein,1\njean de thevenot,1\njill paton walsh,2\nleudal,1\nkyle mccafferty,1\npluralistic walkthrough,2\ngreetings to the new brunette,3\nangus maccoll,1\nloco live,2\npalm i705,1\nsaila laakkonen,1\nssta,1\nbuch,1\neduardo cunha,7\nmarie bouliard,1\nmystic society,2\nchu jus house,1\nboob tube,8\nil mestiere della vita,1\nhadley fraser,7\nmarek larwood,2\nimperial knight,2\nadbc,1\nhoudini,8\npatrice talon,3\niodamoeba,1\nlong march,26\nnyinba,1\nmaurice dunkley,1\nnew south wales state election 1874–75,1\njohn lee carroll,1\npoya bridge,1\ncategory talk military units and formations established in 2004,1\nthe family values tour 1999,2\nbrødrene hartmann,1\nmiomelon,1\njohn moran bailey,1\nsan juan archipelago,1\ncome as you are,7\nhypo niederösterreich,1\nsaturn vi,2\ncherokee county kansas,1\nmaher abu remeleh,1\nfile talk jb grace singlejpg,1\ncount paris,8\ntemplate talk anime and manga,1\nkntv,4\nganges river dolphin,4\njerry pacht,1\nrapid response,1\ncrunch bandicoot,1\nbig gay love,2\njohn mckay,1\nbareq,1\nnikon d2x,1\nintercontinental paris le grand hotel,1\noakland alternative high school,1\nekow eshun,1\njimmy fortune,1\namerican gladiator,2\nella sophia armitage,1\nunited we stand what more can i give,5\nmaruti suzuki celerio,1\ngeraldo rivera/trackback/,1\ndogs tobramycin contain a primary amine,1\nhot coffee mod,11\nshriners,25\nmora missouri,1\nseattle wa,1\nall star baseball 2003,1\ncomparison of android e book reader software,7\ncalling out loud,2\ninitiative 912,1\ncharles batchelor,2\nterry spraggan,2\nwallace thurman,2\nstefan smith,2\ngeorge holding,22\ninstitute of business administration sukkar,1\nstaten island new york,4\nvalency,1\nchintamani taluk,1\nmahatma gandhi,1\nco orbital,1\nepex spot,1\ntheodoric the great,3\nfk novi pazar,1\nzappas olympics,2\ngustav krupp von bohlen und halbach,1\nyasmany tomás,4\nnotre temps,1\ncats %,1\nintramolecular vibrational energy redistribution,1\ngraduate management admission test,49\nrobin fleming,1\ndaniel gadzhev,1\nachaean league,7\nthe four books,1\ntunica people,1\nmurray hurst,1\nhajipur,7\nwolfgang fischer,1\nbethel minnesota,2\nwincdemu,1\naleksandar luković,5\nzilog,6\nwill to live,1\npgc,1\ncaptain sky,1\neprobemide,1\ngunther plüschow,1\njackson laboratory,3\nss orontes,2\nbishop morlino,1\neldorado air force station,2\ntin oxide,1\njohn bell,2\najay banga,2\nnail polish remover induced contact dermatitis,1\nquinctia,1\na/n urm 25d signal generator,1\nthe art company,3\nseawind 300c,1\nhalf and half,7\nconstantia czirenberg,1\nhalifax county north carolina,4\ntunica vaginalis,9\nlife & times of michael k,2\nmethyl propionate,1\ncarla bley band,1\nus secret service,2\nmaría elena moyano,2\nlory meagher cup,9\nmalay sultanate,1\nthird lanark,1\nolivier dacourt,10\nangri,2\nukrainian catholic eparchy of saints peter and paul,1\nphosphinooxazolines,1\nallied health professions,24\nhydroxybenzoic acid,1\nsrinatha,3\nzone melting,5\nmiko,1\nrobert b downs,1\nresource management,3\nnew year tree,1\nagraw imazighen,1\ncatmando,8\npython ide,5\nrocky mount wilson roanoke rapids nc combined statistical area,1\nspanish crown,3\nianis zicu,1\nwilliam c hubbard,2\nislamic marital jurisprudence,5\nthe school of night,1\nkrdc,4\nel centro imperials,1\natiq uz zaman,1\nsliba zkha,1\nfile no mosquesvg,8\nherzegovinians,1\nparadise lost,1\nthe fairly oddparents,6\ncivic alliance,1\nanbu,3\nbroadcaster,2\nle bon,1\ncolumbus nebraska,4\ninuit people,1\nthe menace,6\nilya ilyich mechnikov,1\nalgonquin college,4\nseat córdoba wrc,1\neuropean route e30,6\nthree lakes florida,1\nk10de,1\nglyphonyx rhopalacanthus,1\nask rhod gilbert,1\nbolas criollas,1\ncounty borough of southport,1\nroll on mississippi,1\npulitzer prize for photography,7\nmark fisher,1\noakley g kelly,1\ntajikistani presidential election 1999,1\nthe relapse,4\nnabil bentaleb,8\napprentice,1\ndale brown,3\nstudebaker packard hawk series,1\nyu gi oh trading card game,14\nparalimni,2\ninstitut national polytechnique de toulouse,1\nto catch a spy,1\nhammer,4\nmount judi,2\nthomas posey,1\nmaxime baca,1\narthur susskind,1\nelkins constructors,2\nsiege of gaeta,1\npemex,1\nhenry o flipper award,1\nmccordsville indiana,1\ncarife,1\nprima donna,1\nproton,1\nhenry farrell,1\nrandall davidson,1\nhistory of georgia,11\nbeef tongue,4\nted spread,4\ndouglas xt 30,3\nheavenly mother,1\nmonte santangelo,1\nlothar matthaus,1\namerican party,2\ntire kingdom,1\nbastrop state park,3\njames maurice gavin,1\nblue bird all american,4\ntime and a word,10\nrunny babbit,1\nnordic regional airlines,6\nadvanced scientifics,2\nthe space traders,2\nmongol invasion of anatolia,1\nabu hayyan al gharnati,1\nlisa geoghan,3\nvalentia harbour railway station,1\nsilo,10\njimmy zhingchak,1\nglamma kid,1\nbonneville high school,1\nsecant line,5\nthe longshots,2\ncosta rican general election 1917,1\nan emotion away,1\nrawlins high school,1\ncold inflation pressure,4\nreceptionthe,2\ntom payne,8\ntb treatment,1\nhatikvah,8\nol yellow eyes is back,1\nvincent mroz,1\ntravis bickle,1\nqatar stars league 1985–86,1\nelectronic document management,1\norliska,1\ngáspár orbán,1\nsunabeda,1\ndonatus magnus,1\nlawrence e spivak,2\ncavalieri,1\naw kuchler,1\ncoat of arms of kuwait,1\nwallis–zieff–goldblatt syndrome,1\ndoug heffernan,3\ng3 battlecruiser,3\nimran abbas,1\nplymouth,1\ngould colorado,1\nin japan,1\ndelmar watson,1\nskygusty west virginia,1\nvesque sisters,1\nrushton triangular lodge,1\nitalic font,3\nwarner w hodgdon carolina 500,1\nblackamoors,5\nmagna cum laude,14\nfollow that horse,1\njean snella,1\nchris frith,1\nsoul power,2\nspare me the details,1\nymer xhaferi,1\nmurano glass,5\nmichel magras,1\nrashard and wallace go to white castle,1\nvenus figurines of malta,1\ndidnt we almost have it all,1\new,1\ndavid h koch institute for integrative cancer research,2\nblack coyote,1\npriob,2\npiera coppola,1\nbudhism,4\nsouth african class h1 4 8 2t,1\ndimitris papamichael+dimitris+papamixail,3\nsystem sensor,1\nfarragut class destroyer,1\nno down payment,1\nwilliam rogers,1\ndesperate choices to save my child,1\njoe launchbury,7\nqueen seondeok of silla,11\nadams county wisconsin,1\nbandhan bank,1\nx ray tubes,1\nsporadic group,1\nlozovaya,1\nmairead maguire,3\nroyal challengers bangalore in 2016,1\njanko of czarnków,1\nmarosormenyes,1\nthe deadly reclaim,1\nrick doblin,1\ngwen jorgensen,6\nshire of halls creek,1\ncarlton house,6\nurad bean,1\nbaton rouge louisiana,39\nkiel institute for the world economy,3\nthe satuc cup,1\nharlem division,1\nargonaut,2\nchoi jeongrye,2\noptical disc image,2\ngroesbeek canadian war cemetery,2\nrangpur india,1\nandroid n,72\ntjeld class patrol boat,1\ntogether for yes,2\ntender dracula,1\nshane nelson,1\npalazzo ducale urbino,1\nangels,4\ndouble centralizer theorem,1\nhomme,4\nworld heart federation,1\npatricia ja lee,4\na date with elvis,1\nsaints row,1\nlanzhou lamian,1\nsubcompact car,1\njojo discography,5\ngary,18\nglobal returnable asset identifier,1\naloysia weber,2\nemperor nero,2\nheavyweights,6\nhush records,1\nmewa textil service,2\nmichigan gubernatorial election 1986,1\nsolanine,9\nandré moritz,3\nforeign relations of china,12\nwilliam t anderson,3\nlindquist field,1\nbiggersdale hole,1\nmanayunk/norristown line,1\naliti,1\nbudhivanta,3\ntm forum,4\noff plan property,1\nwu xin the monster killer,4\naharon leib shteinman,1\nmark catano,1\nllanfihangel,1\natp–adp translocase,4\ntótkomlós,1\nnikita magaloff,1\nxo telescope,1\npseudomonas rhizosphaerae,1\npccooler,1\narcion therapeutics inc,8\noklahoma gubernatorial election 2010,1\nseed treatment,3\nconnecticut education network,1\ncompany85,1\nbryan molloy,1\nroupeiro,1\nwendt beach park,2\nentick v carrington,3\nfiremens auxiliary,1\nshotcrete,14\nsepharial,1\npoet laureate of virginia,1\nmusth,6\ndragon run state forest,3\nfocal point,10\npacific drilling,1\nintro,2\npriscus,1\nrokurō mochizuki,1\nbofur,2\ntiffany mount,1\nthanasis papazoglou,12\nlife is grand,1\nergersheim bas rhin,1\nmedical reserve corps,3\nanthony ashley cooper 2nd earl of shaftesbury,1\nuefa euro 2012 group a,32\namerica movil sab de cv,1\nchristopher cook,1\nvladimir makanin,1\nfile talk first battle of saratogausmaeduhistorygif,1\ndean foods,4\nlogical thinking,1\ntychonic system,1\nhand washing,17\nbioresonance therapy,4\ngünther burstyn,4\nreligion in the united kingdom,35\nbancroft ontario,2\nalberta enterprise group,1\nbelizean spanish,1\nminuscule 22,1\nhmga2,3\nsidama people,1\nshigeaki mori,2\nmoonstars,1\nhazard,24\nchilis,6\nrango,3\nkenichi itō,1\nisle of rum,1\nshortwood united fc,1\nbronx gangs,1\nheterometaboly,2\nbeagling,4\njurgen pommerenke,1\nrockin,1\nst maria maggiore,1\nphilipp reis,1\ntimeboxing,12\ntemplate talk tallahassee radio,1\naarti puri,2\njohn paul verree,2\nadam tomkins,1\nknoppers,1\nsven olov eriksson,1\nruth bowyer,1\nhöfðatorg tower 1,1\ncitywire,3\nhelen bosanquet,1\nulex europaeus,4\nrichard martyn,1\nhana sugisaki,2\nits all over now baby blue,6\nthe myths and legends of king arthur and the knights of the round table,2\ndooce,1\ngerman submarine u 9,1\ngeorge shearing,4\nbishop of winchester,3\nmaximilian karl lamoral odonnell,2\nhec edmundson,1\nmorgawr,3\nsovereign state,67\navignon—la mitis—matane—matapédia,1\nduramax v8 engine,12\nvilla rustica,2\ncarl dorsey,1\nclairol,6\nabruzzo,22\nmomsen lung,10\nm23 rebellion,2\nkira oreilly,1\nconstitutive relation,2\nbifrontal craniotomy,1\nbasilica of st nicholas amsterdam,2\nmarinus kraus,1\nmoog prodigy,2\nlucy hale,49\nlingiya,1\nidiopathic orbital inflammatory disease,3\nshaanxi youser group,1\napeirohedron,1\nprogram of all inclusive care for the elderly,2\ntv3 ghana,3\narnold schwarzenegger,338\nraquel carriedo tomás,1\ncincinnati playhouse in the park,2\ncolobomata,2\nstar craft 2,1\nyaaf,1\nfc santa clarita,1\nrelease me,3\nnotts county supporters trust,1\nwestchester airport,1\nslowhand at 70 – live at the royal albert hall,1\nbruce gray,2\nonly the good die young,1\nsewell thomas stadium,1\nkyle cook,1\nnorthwest passage,1\neurex airlines,1\nuss pierre,1\nfeitsui dam,1\nsales force,1\nobrien class destroyer,5\nsant longowal institute of engineering and technology,3\nunited states presidential election in oklahoma 1952,1\nedyta bartosiewicz,1\nmarquess of dorset,1\nwhiting wyoming,1\nakanda,1\njim brewster,1\nmozdok republic of north ossetia alania,1\nmaritime gendarmerie,2\nparesh patel,1\ncommunication art,1\nsanta anita handicap,2\ndahlia,44\nqikpad,1\npudhaiyal,3\noroshi,1\nioda,3\nwillis j gertsch,1\nscurvy grass,1\nbombing of rotterdam,2\ngagarin russia,1\ndynamic apnea without fins,1\nloess,14\nhans adolf krebs,4\nporęby stare,1\nkismat ki baazi,1\nmalcolm slesser,1\nblue crane route local municipality,1\njean michel basquiat,104\ncustoms trade partnership against terrorism,3\nlower cove newfoundland and labrador,1\naashiqui 2,6\nelliott lee,1\nedison electric light company,2\ni rigoberta menchú,1\nbattle of tennōji,2\ntransport workers union of america,1\nphysical review b,1\nway too far,1\nbreguet 941,1\nmanuel hegen,1\nthe blacklist,12\njohn dorahy,4\ncinderella sanyu,1\nluis castañeda lossio,1\nheadquarters of a military area,1\njbala people,2\npetrofac emirates,1\nins garuda,3\naustralia national rugby league team,2\nstate of emergency 2,3\nmexican sex comedy,2\nbaby anikha,1\nnotions,1\nandroid app //orgwikipedia/http/enmwikipediaorg/wiki/elasticity,1\nkissing you,2\nmontearagón,1\ngrzegorz proksa,3\nshook,1\nmay hegglin anomaly,1\nchrysler rb engine,2\ngmcsf,2\nblacksburg,1\nchris hollod,1\nthe new guy,1\nthulimbah queensland,1\nsust,1\nknight kadosh,2\ndetails,4\nnickel mining in new caledonia,3\neaster hotspot,1\nsurinamese interior war,1\nfield corn,2\nbolesław iii wrymouth,6\nlutwyche queensland,1\nmichael campbell,1\nmilitary ranks of turkey,3\nmícheal martin,1\nthe architects dream,2\njoel robert,1\nthomas smith,1\ninclusion probability,1\nfucked company,1\ngenderfluid,5\nlewisham by election 1891,1\nnet promoter,98\ndonald stewart,1\nxml base,2\nbhikhu parekh,4\nanthocharis cardamines,1\nvuosaari,1\ndemographics of burundi,1\ndst,1\ndavid ensor,2\nmount pavlof,1\nvince young,5\nst beunos ignatian spirituality centre,4\nezekiel 48,1\nlewis elliott chaze,1\ntemplate talk croatia squad 2012 mens european water polo championship,1\nthe voice of the philippines,4\nwhites ferry,1\ncananga odorata,9\nman of steel,2\njohn michael talbot,2\nsuperior oblique myokymia,2\nanisochilus,2\ne421,1\nmidnight rider,14\nmatrícula consular,1\nfirst nehru ministry,2\nchristopher mcculloch,2\nems chemie,12\ndominique martin,1\nuniversity club of washington dc,1\nnurse education,5\ntheyre coming to take me away ha haaa,1\nbill dauterive,4\nbelhar,1\nheel and toe,4\nuniversity of the arctic members,2\nmitava,1\nwjmx fm,1\nfather callahan,4\ndivine word academy of dagupan,1\nbogs,1\ndenny heck,2\nchurch of st james valletta,1\nfield cathedral of the polish army,1\nindian skimmer,1\nhistory of british airways,3\ninternational mobile subscriber identity,38\nsuzel roche,1\nsteven watt,1\nduke ellineton,1\nkirbys avalanche,4\n"
  },
  {
    "path": "util/wait-for-it.sh",
    "content": "#!/usr/bin/env bash\n# Use this script to test if a given TCP host/port are available\n#\n# Copyright (c) 2016 Giles Hall\n# The MIT License (MIT)\n#\n# Permission is hereby granted, free of charge, to any person obtaining a copy of\n# this software and associated documentation files (the \"Software\"), to deal in\n# the Software without restriction, including without limitation the rights to\n# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies\n# of the Software, and to permit persons to whom the Software is furnished to do\n# so, subject to the following conditions:\n#\n# The above copyright notice and this permission notice shall be included in all\n# copies or substantial portions of the Software.\n#\n# THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n# SOFTWARE.\n\nWAITFORIT_cmdname=${0##*/}\n\nechoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo \"$@\" 1>&2; fi }\n\nusage()\n{\n    cat << USAGE >&2\nUsage:\n    $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args]\n    -h HOST | --host=HOST       Host or IP under test\n    -p PORT | --port=PORT       TCP port under test\n                                Alternatively, you specify the host and port as host:port\n    -s | --strict               Only execute subcommand if the test succeeds\n    -q | --quiet                Don't output any status messages\n    -t TIMEOUT | --timeout=TIMEOUT\n                                Timeout in seconds, zero for no timeout\n    -- COMMAND ARGS             Execute command with args after the test finishes\nUSAGE\n    exit 1\n}\n\nwait_for()\n{\n    if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then\n        echoerr \"$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT\"\n    else\n        echoerr \"$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout\"\n    fi\n    WAITFORIT_start_ts=$(date +%s)\n    while :\n    do\n        if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then\n            nc -z $WAITFORIT_HOST $WAITFORIT_PORT\n            WAITFORIT_result=$?\n        else\n            (echo > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1\n            WAITFORIT_result=$?\n        fi\n        if [[ $WAITFORIT_result -eq 0 ]]; then\n            WAITFORIT_end_ts=$(date +%s)\n            echoerr \"$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds\"\n            break\n        fi\n        sleep 1\n    done\n    return $WAITFORIT_result\n}\n\nwait_for_wrapper()\n{\n    # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692\n    if [[ $WAITFORIT_QUIET -eq 1 ]]; then\n        timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT &\n    else\n        timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT &\n    fi\n    WAITFORIT_PID=$!\n    trap \"kill -INT -$WAITFORIT_PID\" INT\n    wait $WAITFORIT_PID\n    WAITFORIT_RESULT=$?\n    if [[ $WAITFORIT_RESULT -ne 0 ]]; then\n        echoerr \"$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT\"\n    fi\n    return $WAITFORIT_RESULT\n}\n\n# process arguments\nwhile [[ $# -gt 0 ]]\ndo\n    case \"$1\" in\n        *:* )\n        WAITFORIT_hostport=(${1//:/ })\n        WAITFORIT_HOST=${WAITFORIT_hostport[0]}\n        WAITFORIT_PORT=${WAITFORIT_hostport[1]}\n        shift 1\n        ;;\n        --child)\n        WAITFORIT_CHILD=1\n        shift 1\n        ;;\n        -q | --quiet)\n        WAITFORIT_QUIET=1\n        shift 1\n        ;;\n        -s | --strict)\n        WAITFORIT_STRICT=1\n        shift 1\n        ;;\n        -h)\n        WAITFORIT_HOST=\"$2\"\n        if [[ $WAITFORIT_HOST == \"\" ]]; then break; fi\n        shift 2\n        ;;\n        --host=*)\n        WAITFORIT_HOST=\"${1#*=}\"\n        shift 1\n        ;;\n        -p)\n        WAITFORIT_PORT=\"$2\"\n        if [[ $WAITFORIT_PORT == \"\" ]]; then break; fi\n        shift 2\n        ;;\n        --port=*)\n        WAITFORIT_PORT=\"${1#*=}\"\n        shift 1\n        ;;\n        -t)\n        WAITFORIT_TIMEOUT=\"$2\"\n        if [[ $WAITFORIT_TIMEOUT == \"\" ]]; then break; fi\n        shift 2\n        ;;\n        --timeout=*)\n        WAITFORIT_TIMEOUT=\"${1#*=}\"\n        shift 1\n        ;;\n        --)\n        shift\n        WAITFORIT_CLI=(\"$@\")\n        break\n        ;;\n        --help)\n        usage\n        ;;\n        *)\n        echoerr \"Unknown argument: $1\"\n        usage\n        ;;\n    esac\ndone\n\nif [[ \"$WAITFORIT_HOST\" == \"\" || \"$WAITFORIT_PORT\" == \"\" ]]; then\n    echoerr \"Error: you need to provide a host and port to test.\"\n    usage\nfi\n\nWAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15}\nWAITFORIT_STRICT=${WAITFORIT_STRICT:-0}\nWAITFORIT_CHILD=${WAITFORIT_CHILD:-0}\nWAITFORIT_QUIET=${WAITFORIT_QUIET:-0}\n\n# Check to see if timeout is from busybox?\nWAITFORIT_TIMEOUT_PATH=$(type -p timeout)\nWAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH)\n\nWAITFORIT_BUSYTIMEFLAG=\"\"\nif [[ $WAITFORIT_TIMEOUT_PATH =~ \"busybox\" ]]; then\n    WAITFORIT_ISBUSY=1\n    # Check if busybox timeout uses -t flag\n    # (recent Alpine versions don't support -t anymore)\n    if timeout &>/dev/stdout | grep -q -e '-t '; then\n        WAITFORIT_BUSYTIMEFLAG=\"-t\"\n    fi\nelse\n    WAITFORIT_ISBUSY=0\nfi\n\nif [[ $WAITFORIT_CHILD -gt 0 ]]; then\n    wait_for\n    WAITFORIT_RESULT=$?\n    exit $WAITFORIT_RESULT\nelse\n    if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then\n        wait_for_wrapper\n        WAITFORIT_RESULT=$?\n    else\n        wait_for\n        WAITFORIT_RESULT=$?\n    fi\nfi\n\nif [[ $WAITFORIT_CLI != \"\" ]]; then\n    if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then\n        echoerr \"$WAITFORIT_cmdname: strict mode, refusing to execute subprocess\"\n        exit $WAITFORIT_RESULT\n    fi\n    exec \"${WAITFORIT_CLI[@]}\"\nelse\n    exit $WAITFORIT_RESULT\nfi\n\n"
  },
  {
    "path": "whitelist.py",
    "content": "exc_type  # unused variable (/data/repos/redis/redis-py/redis/client.py:1045)\nexc_value  # unused variable (/data/repos/redis/redis-py/redis/client.py:1045)\ntraceback  # unused variable (/data/repos/redis/redis-py/redis/client.py:1045)\nexc_type  # unused variable (/data/repos/redis/redis-py/redis/client.py:1211)\nexc_value  # unused variable (/data/repos/redis/redis-py/redis/client.py:1211)\ntraceback  # unused variable (/data/repos/redis/redis-py/redis/client.py:1211)\nexc_type  # unused variable (/data/repos/redis/redis-py/redis/client.py:1589)\nexc_value  # unused variable (/data/repos/redis/redis-py/redis/client.py:1589)\ntraceback  # unused variable (/data/repos/redis/redis-py/redis/client.py:1589)\nexc_type  # unused variable (/data/repos/redis/redis-py/redis/lock.py:156)\nexc_value  # unused variable (/data/repos/redis/redis-py/redis/lock.py:156)\ntraceback  # unused variable (/data/repos/redis/redis-py/redis/lock.py:156)\nexc_type  # unused variable (/data/repos/redis/redis-py/redis/asyncio/utils.py:26)\nexc_value  # unused variable (/data/repos/redis/redis-py/redis/asyncio/utils.py:26)\ntraceback  # unused variable (/data/repos/redis/redis-py/redis/asyncio/utils.py:26)\nAsyncConnectionPool  # unused import (//data/repos/redis/redis-py/redis/typing.py:9)\nAsyncRedis  # unused import (//data/repos/redis/redis-py/redis/commands/core.py:49)\nTargetNodesT  # unused import (//data/repos/redis/redis-py/redis/commands/cluster.py:46)\n"
  }
]