diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 312a4e0f..071e8a09 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -75,7 +75,7 @@ jobs: fail-fast: false matrix: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] - redis-py-version: ["6.x", "7.x"] + redis-py-version: ["6.x", "7.x", "8.x"] redis-image: ["redis:8.2", "redis:8.4", "redis:latest"] steps: - name: Check out repository @@ -112,6 +112,7 @@ jobs: case "$REDIS_PY_VERSION" in "6.x") spec="redis>=6,<7" ;; "7.x") spec="redis>=7,<8" ;; + "8.x") spec="redis>=8.1,<9" ;; *) echo "::error title=Unhandled redis-py-version::Matrix value '${REDIS_PY_VERSION}' has no install rule -- add a case branch in .github/workflows/test.yml." exit 1 diff --git a/pyproject.toml b/pyproject.toml index f7bac51c..cb55fa4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,9 @@ classifiers = [ dependencies = [ "numpy>=1.26.0,<3", "pyyaml>=5.4,<7.0", - "redis>=5.0,<8.0", + # 6.3.0 first accepts SVS-VAMANA. redis-py 8.0.0 returns empty RESP3 + # search results (redis-py #4107), so 8.1.0 is the supported 8.x floor. + "redis>=6.3.0,!=8.0.0,<9.0", "pydantic>=2,<3", "tenacity>=8.2.2", "ml-dtypes>=0.4.0,<1.0.0", diff --git a/redisvl/cli/index.py b/redisvl/cli/index.py index e7ac9bbc..f4ce8971 100644 --- a/redisvl/cli/index.py +++ b/redisvl/cli/index.py @@ -13,8 +13,12 @@ ) from redisvl.exceptions import RedisSearchError from redisvl.index import SearchIndex -from redisvl.redis.connection import RedisConnectionFactory -from redisvl.redis.utils import convert_bytes, make_dict +from redisvl.redis.connection import ( + RedisConnectionFactory, + normalize_index_definition, + normalize_index_fields, +) +from redisvl.redis.utils import convert_bytes from redisvl.schema.schema import IndexSchema from redisvl.utils.log import get_logger @@ -275,27 +279,11 @@ def _connect_to_index(self, args: Namespace) -> SearchIndex: def _index_info_for_json(index_info: dict) -> dict: """Build the JSON payload from the same fields shown in table mode.""" - definition_src = index_info.get("index_definition") - if isinstance(definition_src, list): - definition = convert_bytes(make_dict(definition_src)) - elif isinstance(definition_src, tuple): - definition = convert_bytes(make_dict(list(definition_src))) - elif isinstance(definition_src, dict): - definition = convert_bytes(dict(definition_src)) - else: - definition = {} - attributes = index_info.get("attributes", []) + definition = normalize_index_definition(index_info) + attributes = normalize_index_fields(index_info) index_fields = [] - for attrs in attributes: - if isinstance(attrs, list): - attr = convert_bytes(make_dict(attrs)) - elif isinstance(attrs, tuple): - attr = convert_bytes(make_dict(list(attrs))) - elif isinstance(attrs, dict): - attr = convert_bytes(dict(attrs)) - else: - attr = {} + for attr in attributes: field = { "name": attr.get("identifier"), "attribute": attr.get("attribute"), @@ -325,8 +313,8 @@ def _index_info_for_json(index_info: dict) -> dict: def _display_in_table(index_info): print("\n") - attributes = index_info.get("attributes", []) - definition = make_dict(index_info.get("index_definition")) + attributes = normalize_index_fields(index_info) + definition = normalize_index_definition(index_info) index_info = [ index_info.get("index_name"), definition.get("key_type"), @@ -370,18 +358,14 @@ def print_table_edge(length, col_width, start, mid, stop): "Type", ] - for attrs in attributes: - attr = make_dict(attrs) - + for attr in attributes: values = [attr.get("identifier"), attr.get("attribute"), attr.get("type")] - if len(attrs) > 5: - options = make_dict(attrs) - for k, v in options.items(): - if k not in ["identifier", "attribute", "type"]: - headers.append("Field Option") - headers.append("Option Value") - values.append(k) - values.append(v) + for k, v in attr.items(): + if k not in ["identifier", "attribute", "type"]: + headers.append("Field Option") + headers.append("Option Value") + values.append(k) + values.append(v) attr_values.append(values) # Display the attributes in tabular format diff --git a/redisvl/extensions/cache/embeddings/embeddings.py b/redisvl/extensions/cache/embeddings/embeddings.py index 77201c7a..20ebab7e 100644 --- a/redisvl/extensions/cache/embeddings/embeddings.py +++ b/redisvl/extensions/cache/embeddings/embeddings.py @@ -1,5 +1,6 @@ """Embeddings cache implementation for RedisVL.""" +from collections.abc import Mapping from typing import Any, Iterable from redisvl.extensions.cache.base import BaseCache @@ -112,7 +113,9 @@ def _prepare_entry_data( ) return key, entry.to_dict() - def _process_cache_data(self, data: dict[str, Any] | None) -> dict[str, Any] | None: + def _process_cache_data( + self, data: Mapping[bytes | str, Any] | None + ) -> dict[str, Any] | None: """Process Redis hash data into a cache entry response. Args: @@ -124,7 +127,7 @@ def _process_cache_data(self, data: dict[str, Any] | None) -> dict[str, Any] | N if not data: return None - cache_hit = CacheEntry(**convert_bytes(data)) + cache_hit = CacheEntry(**convert_bytes(dict(data))) return cache_hit.model_dump(exclude_none=True) def _should_warn_for_async_only(self) -> bool: diff --git a/redisvl/index/index.py b/redisvl/index/index.py index 3daa69d3..5a34c03f 100644 --- a/redisvl/index/index.py +++ b/redisvl/index/index.py @@ -47,21 +47,9 @@ from redis.commands.search.result import Result from redisvl.query.query import BaseQuery -from redis import __version__ as redis_version from redis.client import NEVER_DECODE from redis.commands.search.aggregation import AggregateRequest, Cursor - -from redisvl.utils.redis_protocol import get_protocol_version - -# Redis 5.x compatibility (6 fixed the import path) -if redis_version.startswith("5"): - from redis.commands.search.indexDefinition import ( - IndexDefinition, # type: ignore[import-untyped] - ) -else: - from redis.commands.search.index_definition import ( - IndexDefinition, # type: ignore[no-redef] - ) +from redis.commands.search.index_definition import IndexDefinition # Need Result outside TYPE_CHECKING for cast from redis.commands.search.result import Result @@ -101,9 +89,43 @@ VectorIndexAlgorithm, ) from redisvl.utils.log import get_logger +from redisvl.utils.redis_protocol import get_protocol_version logger = get_logger(__name__) + +def _parse_batch_search_result( + search: Any, result: Any, query: Any, duration: float +) -> Result: + """Parse a pipelined FT.SEARCH response across supported redis-py versions.""" + parsed_result = search._parse_results( # type: ignore + "FT.SEARCH", result, query=query, duration=duration + ) + if not isinstance(parsed_result, dict): + return parsed_result + + resp3_parser = getattr(search, "_parse_search_resp3", None) + if resp3_parser is not None: + return resp3_parser(parsed_result, query=query, duration=duration) + + # redis-py 6.x returns the raw RESP3 map from _parse_results. Convert it to + # the RESP2 shape expected by its private _parse_search callback. + def value(mapping: dict[Any, Any], key: str, default: Any = None) -> Any: + return mapping.get(key, mapping.get(key.encode(), default)) + + response: list[Any] = [value(parsed_result, "total_results", 0)] + for document in value(parsed_result, "results", []): + response.append(value(document, "id", "")) + if query._with_scores: + response.append(value(document, "score", 0)) + if query._with_payloads: + response.append(value(document, "payload")) + if not query._no_content: + fields = value(document, "extra_attributes", {}) + response.append([item for pair in fields.items() for item in pair]) + return search._parse_search(response, query=query, duration=duration) + + _HYBRID_SEARCH_ERROR_MESSAGE = "Hybrid search is not available in this version of redis-py. Please upgrade to redis-py >= 7.1.0." @@ -1799,10 +1821,8 @@ def batch_search( for j, query_results in enumerate(results): _built_query = batch_built_queries[j] - parsed_result = search._parse_search( # type: ignore - query_results, - query=_built_query, - duration=duration, + parsed_result = _parse_batch_search_result( + search, query_results, _built_query, duration ) # Return a parsed Result object for each query all_results.append(parsed_result) @@ -2998,10 +3018,8 @@ async def batch_search( for j, query_results in enumerate(results): _built_query = batch_built_queries[j] - parsed_result = search._parse_search( # type: ignore - query_results, - query=_built_query, - duration=duration, + parsed_result = _parse_batch_search_result( + search, query_results, _built_query, duration ) # Return a parsed Result object for each query all_results.append(parsed_result) diff --git a/redisvl/index/storage.py b/redisvl/index/storage.py index 9a8681ac..da9290b4 100644 --- a/redisvl/index/storage.py +++ b/redisvl/index/storage.py @@ -1,24 +1,12 @@ +import json from collections.abc import Collection from typing import Any, Callable, Iterable from pydantic import BaseModel, ValidationError -from redis import __version__ as redis_version - -# Add imports for Pipeline types from redis.asyncio.client import Pipeline as AsyncPipeline from redis.asyncio.cluster import ClusterPipeline as AsyncClusterPipeline - -# Redis 5.x compatibility (6 fixed the import path) -if redis_version.startswith("5"): - from redis.commands.search.indexDefinition import ( # type: ignore[import-untyped] - IndexType, - ) -else: - from redis.commands.search.index_definition import ( # type: ignore[no-redef] - IndexType, - ) - -import json +from redis.commands.search.index_definition import IndexType +from redis.typing import EncodableT from redisvl.exceptions import SchemaValidationError from redisvl.redis.utils import convert_bytes @@ -602,6 +590,19 @@ class HashStorage(BaseStorage): type: IndexType = IndexType.HASH """Hash data type for the index""" + @staticmethod + def _mapping(obj: dict[str, Any]) -> dict[EncodableT, EncodableT]: + """Build a redis-py encodable hash mapping.""" + mapping: dict[EncodableT, EncodableT] = {} + for key, value in obj.items(): + if not isinstance(value, (bytes, bytearray, memoryview, str, int, float)): + raise TypeError( + f"Hash field {key!r} has unsupported value type " + f"{type(value).__name__}" + ) + mapping[key] = value + return mapping + @staticmethod def _set(client: RedisClientOrPipeline, key: str, obj: dict[str, Any]): """Synchronously set a hash value in Redis for the given key. @@ -611,7 +612,7 @@ def _set(client: RedisClientOrPipeline, key: str, obj: dict[str, Any]): key (str): The key under which to store the hash. obj (Dict[str, Any]): The hash to store in Redis. """ - client.hset(name=key, mapping=obj) + client.hset(name=key, mapping=HashStorage._mapping(obj)) @staticmethod async def _aset(client: AsyncRedisClientOrPipeline, key: str, obj: dict[str, Any]): @@ -622,10 +623,11 @@ async def _aset(client: AsyncRedisClientOrPipeline, key: str, obj: dict[str, Any key (str): The key under which to store the hash. obj (Dict[str, Any]): The hash to store in Redis. """ + mapping = HashStorage._mapping(obj) if isinstance(client, (AsyncPipeline, AsyncClusterPipeline)): - client.hset(name=key, mapping=obj) # type: ignore + client.hset(name=key, mapping=mapping) else: - await client.hset(name=key, mapping=obj) # type: ignore + await client.hset(name=key, mapping=mapping) @staticmethod def _get(client: SyncRedisClient, key: str) -> dict[str, Any]: diff --git a/redisvl/mcp/config.py b/redisvl/mcp/config.py index f40d7f10..043706bd 100644 --- a/redisvl/mcp/config.py +++ b/redisvl/mcp/config.py @@ -433,7 +433,11 @@ def inspected_schema_from_index_info( incomplete on older Redis versions. MCP needs those field identities to survive so schema overrides can patch the missing attrs during startup. """ - from redisvl.redis.connection import convert_index_info_to_schema + from redisvl.redis.connection import ( + convert_index_info_to_schema, + normalize_index_definition, + normalize_index_fields, + ) schema_dict = convert_index_info_to_schema(index_info) discovered_fields = { @@ -442,18 +446,23 @@ def inspected_schema_from_index_info( if isinstance(field, dict) and "name" in field } - storage_type = index_info["index_definition"][1].lower() - for raw_field in index_info.get("attributes", []): - name = raw_field[1] if storage_type == "hash" else raw_field[3] + definition = normalize_index_definition(index_info) + storage_type = definition["key_type"].lower() + for raw_field in normalize_index_fields(index_info): + name = ( + raw_field["identifier"] + if storage_type == "hash" + else raw_field["attribute"] + ) if name in discovered_fields: continue field = { "name": name, - "type": str(raw_field[5]).lower(), + "type": str(raw_field["type"]).lower(), } if storage_type == "json": - field["path"] = raw_field[1] + field["path"] = raw_field["identifier"] # Keep discovered field identity even when FT.INFO omitted attrs. schema_dict.setdefault("fields", []).append(field) diff --git a/redisvl/redis/connection.py b/redisvl/redis/connection.py index 20f22832..53edec80 100644 --- a/redisvl/redis/connection.py +++ b/redisvl/redis/connection.py @@ -21,7 +21,7 @@ SVS_MIN_REDIS_VERSION, SVS_MIN_SEARCH_VERSION, ) -from redisvl.redis.utils import convert_bytes, is_cluster_url +from redisvl.redis.utils import convert_bytes, is_cluster_url, make_dict from redisvl.types import AsyncRedisClient, RedisClient, SyncRedisClient from redisvl.utils.log import get_logger from redisvl.utils.utils import deprecated_argument, deprecated_function @@ -249,6 +249,30 @@ async def _aidentify_client( logger.debug(f"CLIENT SETINFO was not applied, continuing without it: {e}") +def _normalize_index_info_mapping(value: Any) -> dict[str, Any]: + """Normalize a mapping-shaped or alternating-list FT.INFO section.""" + if isinstance(value, dict): + normalized = convert_bytes(dict(value)) + elif isinstance(value, (list, tuple)): + normalized = convert_bytes(make_dict(list(value))) + else: + normalized = {} + return normalized if isinstance(normalized, dict) else {} + + +def normalize_index_definition(index_info: dict[str, Any]) -> dict[str, Any]: + """Return the FT.INFO index definition in mapping form.""" + return _normalize_index_info_mapping(index_info.get("index_definition")) + + +def normalize_index_fields(index_info: dict[str, Any]) -> list[dict[str, Any]]: + """Return FT.INFO field entries in mapping form.""" + return [ + _normalize_index_info_mapping(field) + for field in index_info.get("attributes", []) + ] + + def convert_index_info_to_schema(index_info: dict[str, Any]) -> dict[str, Any]: """Convert the output of FT.INFO into a schema-ready dictionary. @@ -259,11 +283,12 @@ def convert_index_info_to_schema(index_info: dict[str, Any]) -> dict[str, Any]: Dict[str, Any]: Schema dictionary suitable for ``IndexSchema.from_dict()``. """ index_name = index_info["index_name"] - prefixes = index_info["index_definition"][3] + index_definition = normalize_index_definition(index_info) + prefixes = index_definition["prefixes"] + storage_type = index_definition["key_type"].lower() # Normalize single-element prefix lists to string for backward compatibility if isinstance(prefixes, list) and len(prefixes) == 1: prefixes = prefixes[0] - storage_type = index_info["index_definition"][1].lower() # Parse stopwords if present in FT.INFO output # stopwords_list is only present when explicitly set (STOPWORDS 0 or custom list) @@ -289,35 +314,42 @@ def parse_vector_attrs(attrs): # - Redis 7.x+: [... "VECTOR", "ALGORITHM", "FLAT", "TYPE", "FLOAT32", "DIM", "3", ...] # Position 6+: all key-value pairs - # Check if we have any attributes beyond the type declaration - if len(attrs) <= 6: - # Redis 6.2.6-v9 or similar: no vector params in FT.INFO - # Return None to signal we can't parse this field properly - return None - - vector_attrs = {} - start_pos = 6 - - # Detect format: if position 6 looks like an algorithm value (not a key), - # we're dealing with the older format - if len(attrs) > 6: - pos6_str = str(attrs[6]).upper() - # Check if position 6 is an algorithm value (FLAT, HNSW) vs a key (ALGORITHM, TYPE, DIM) - if pos6_str in ("FLAT", "HNSW"): - # Old format (Redis 6.2.x): position 6 is algorithm value, position 7 is param count - # Store the algorithm - vector_attrs["algorithm"] = pos6_str - # Skip to position 8 where key-value pairs start - start_pos = 8 + if isinstance(attrs, dict): + vector_attrs = { + str(key).lower(): value + for key, value in attrs.items() + if key not in {"identifier", "attribute", "type", "flags"} + } + else: + # Check if we have any attributes beyond the type declaration + if len(attrs) <= 6: + # Redis 6.2.6-v9 or similar: no vector params in FT.INFO + # Return None to signal we can't parse this field properly + return None + + vector_attrs = {} + start_pos = 6 + + # Detect format: if position 6 looks like an algorithm value (not a key), + # we're dealing with the older format + if len(attrs) > 6: + pos6_str = str(attrs[6]).upper() + # Check if position 6 is an algorithm value (FLAT, HNSW) vs a key (ALGORITHM, TYPE, DIM) + if pos6_str in ("FLAT", "HNSW"): + # Old format (Redis 6.2.x): position 6 is algorithm value, position 7 is param count + # Store the algorithm + vector_attrs["algorithm"] = pos6_str + # Skip to position 8 where key-value pairs start + start_pos = 8 - try: - for i in range(start_pos, len(attrs), 2): - if i + 1 < len(attrs): - key = str(attrs[i]).lower() - vector_attrs[key] = attrs[i + 1] - except (IndexError, TypeError, ValueError): - # Silently continue - we'll validate required fields below - pass + try: + for i in range(start_pos, len(attrs), 2): + if i + 1 < len(attrs): + key = str(attrs[i]).lower() + vector_attrs[key] = attrs[i + 1] + except (IndexError, TypeError, ValueError): + # Silently continue - we'll validate required fields below + pass # Normalize to expected field names normalized = {} @@ -438,8 +470,6 @@ def parse_vector_attrs(attrs): def parse_attrs(attrs, field_type=None): # 'SORTABLE', 'NOSTEM' don't have corresponding values. # Their presence indicates boolean True - # TODO 'WITHSUFFIXTRIE' is another boolean attr, but is not returned by ft.info - original = attrs.copy() parsed_attrs = {} # Handle all boolean attributes first, regardless of position @@ -450,8 +480,32 @@ def parse_attrs(attrs, field_type=None): "INDEXMISSING": "index_missing", "INDEXEMPTY": "index_empty", "NOINDEX": "no_index", + "WITHSUFFIXTRIE": "withsuffixtrie", } + if isinstance(attrs, dict): + flags = attrs.get("flags", []).copy() + for redis_attr, python_attr in boolean_attrs.items(): + if redis_attr in flags: + parsed_attrs[python_attr] = True + if "UNF" in flags and field_type == "TEXT": + parsed_attrs["unf"] = True + unknown_flags = sorted( + set(flags).difference(boolean_attrs).difference({"UNF"}) + ) + if unknown_flags: + logger.debug("Ignoring unrecognized FT.INFO flags: %s", unknown_flags) + parsed_attrs.update( + { + str(key).lower(): value + for key, value in attrs.items() + if key not in {"identifier", "attribute", "type", "flags"} + } + ) + return parsed_attrs + + original = attrs.copy() + # Special handling for UNF: # - For NUMERIC fields, Redis always adds UNF when SORTABLE is present # - For TEXT fields, UNF is only present when explicitly set @@ -477,22 +531,33 @@ def parse_attrs(attrs, field_type=None): schema_fields = [] - for field_attrs in index_fields: + normalized_fields = normalize_index_fields(index_info) + for field_attrs, normalized_field in zip(index_fields, normalized_fields): # parse field info - name = field_attrs[1] if storage_type == "hash" else field_attrs[3] - field = {"name": name, "type": field_attrs[5].lower()} + name = ( + normalized_field["identifier"] + if storage_type == "hash" + else normalized_field["attribute"] + ) + field_type = normalized_field["type"] + field = {"name": name, "type": field_type.lower()} if storage_type == "json": - field["path"] = field_attrs[1] + field["path"] = normalized_field["identifier"] # parse field attrs - if field_attrs[5] == "VECTOR": - attrs = parse_vector_attrs(field_attrs) + if field_type == "VECTOR": + attrs = parse_vector_attrs( + normalized_field if isinstance(field_attrs, dict) else field_attrs + ) if attrs is None: # Vector field attributes cannot be parsed on this Redis version # Skip this field - it cannot be properly reconstructed continue field["attrs"] = attrs else: - field["attrs"] = parse_attrs(field_attrs, field_type=field_attrs[5]) + field["attrs"] = parse_attrs( + normalized_field if isinstance(field_attrs, dict) else field_attrs, + field_type=field_type, + ) # append field schema_fields.append(field) @@ -570,6 +635,9 @@ def get_redis_connection( variable is not set. """ url = redis_url or get_address_from_env() + # redis-py 8 defaults to RESP3, which changes raw Search command reply + # shapes. Keep RedisVL's existing RESP2 behavior unless requested. + kwargs.setdefault("protocol", 2) client: SyncRedisClient if url.startswith("redis+sentinel"): client = RedisConnectionFactory._redis_sentinel_client(url, Redis, **kwargs) @@ -609,6 +677,8 @@ async def _get_aredis_connection( """ _deprecated_url = kwargs.pop("url", None) url = _deprecated_url or redis_url or get_address_from_env() + # Keep sync and async clients on the same backward-compatible default. + kwargs.setdefault("protocol", 2) client: AsyncRedisClient if url.startswith("redis+sentinel"): @@ -661,6 +731,7 @@ def get_async_redis_connection( ) _deprecated_url = kwargs.pop("url", None) url = _deprecated_url or redis_url or get_address_from_env() + kwargs.setdefault("protocol", 2) if url.startswith("redis+sentinel"): return RedisConnectionFactory._redis_sentinel_client( @@ -686,6 +757,7 @@ def get_redis_cluster_connection( ) -> RedisCluster: """Creates and returns a synchronous Redis client for a Redis cluster.""" url = redis_url or get_address_from_env() + kwargs.setdefault("protocol", 2) return RedisCluster.from_url(url, **kwargs) @staticmethod @@ -695,6 +767,7 @@ def get_async_redis_cluster_connection( ) -> AsyncRedisCluster: """Creates and returns an asynchronous Redis client for a Redis cluster.""" url = redis_url or get_address_from_env() + kwargs.setdefault("protocol", 2) # Strip 'cluster' parameter as AsyncRedisCluster doesn't accept it cleaned_url, cleaned_kwargs = _strip_cluster_from_url_and_kwargs(url, **kwargs) return AsyncRedisCluster.from_url(cleaned_url, **cleaned_kwargs) diff --git a/redisvl/redis/utils.py b/redisvl/redis/utils.py index dfb92c7c..ad45047c 100644 --- a/redisvl/redis/utils.py +++ b/redisvl/redis/utils.py @@ -4,7 +4,6 @@ from typing import Any from redis import RedisCluster -from redis import __version__ as redis_version from redis.asyncio.cluster import RedisCluster as AsyncRedisCluster from redis.client import NEVER_DECODE, Pipeline from redis.commands.search import AsyncSearch, Search @@ -21,22 +20,11 @@ TEMPORARY, ) from redis.commands.search.field import Field - -from redisvl.utils.redis_protocol import get_protocol_version - -# Redis 5.x compatibility (6 fixed the import path) -if redis_version.startswith("5"): - from redis.commands.search.indexDefinition import ( # type: ignore[import-untyped] - IndexDefinition, - ) -else: - from redis.commands.search.index_definition import ( # type: ignore[no-redef] - IndexDefinition, - ) - +from redis.commands.search.index_definition import IndexDefinition from redis.commands.search.query import Query from redis.commands.search.result import Result +from redisvl.utils.redis_protocol import get_protocol_version from redisvl.utils.utils import lazy_import # Lazy import numpy @@ -266,7 +254,6 @@ async def async_cluster_create_index( return await default_node.execute_command(*args) -# TODO: The return type is incorrect because 5.x doesn't have "ProfileInformation" def cluster_search( client: Search, query: str | Query, @@ -290,7 +277,6 @@ def cluster_search( ) -# TODO: The return type is incorrect because 5.x doesn't have "ProfileInformation" async def async_cluster_search( client: AsyncSearch, query: str | Query, diff --git a/tests/integration/test_async_search_index.py b/tests/integration/test_async_search_index.py index 68a95999..c52387e6 100644 --- a/tests/integration/test_async_search_index.py +++ b/tests/integration/test_async_search_index.py @@ -3,6 +3,7 @@ from unittest import mock import pytest +import redis from redis import Redis as SyncRedis from redis.asyncio import Redis as AsyncRedis @@ -542,6 +543,72 @@ async def test_batch_search(async_index): assert results[1].docs[0]["id"] == "rvl:2" +@pytest.mark.asyncio +@pytest.mark.parametrize( + "client_kwargs", + [ + pytest.param({}, id="default"), + pytest.param({"protocol": 3}, id="protocol-3"), + pytest.param( + {"legacy_responses": False}, + id="new-response-format", + marks=pytest.mark.skipif( + int(redis.__version__.split(".")[0]) < 8, + reason="legacy_responses requires redis-py 8", + ), + ), + ], +) +async def test_client_from_existing_and_batch_search( + redis_url, redis_test_name, client_kwargs +): + """User-provided async redis-py clients support introspection and batch search.""" + name = redis_test_name("async_client_index") + prefix = f"{name}:" + client = AsyncRedis.from_url(redis_url, **client_kwargs) + index = AsyncSearchIndex.from_dict( + { + "index": {"name": name, "prefix": prefix}, + "fields": [ + {"name": "test", "type": "tag"}, + { + "name": "title", + "type": "text", + "attrs": {"withsuffixtrie": True}, + }, + { + "name": "embedding", + "type": "vector", + "attrs": { + "dims": 3, + "distance_metric": "cosine", + "algorithm": "flat", + "datatype": "float32", + }, + }, + ], + }, + redis_client=client, + ) + + try: + await index.create() + await index.load( + [{"id": "1", "test": "foo", "title": "suffix trie"}], + id_field="id", + ) + + reopened = await AsyncSearchIndex.from_existing(name, redis_client=client) + results = await reopened.batch_search(["@test:{foo}"]) + + assert reopened.schema == index.schema + assert results[0].total == 1 + assert results[0].docs[0]["id"] == f"{prefix}1" + finally: + await index.delete(drop=True) + await client.aclose() + + @pytest.mark.parametrize( "queries", [ diff --git a/tests/integration/test_search_index.py b/tests/integration/test_search_index.py index 2c374004..f1c6c829 100644 --- a/tests/integration/test_search_index.py +++ b/tests/integration/test_search_index.py @@ -3,6 +3,7 @@ from unittest import mock import pytest +import redis from redis import Redis from redis.exceptions import NoPermissionError @@ -620,6 +621,71 @@ def test_batch_search(index): assert results[1].docs[0]["id"] == "rvl:2" +@pytest.mark.parametrize( + "client_kwargs", + [ + pytest.param({}, id="default"), + pytest.param({"protocol": 3}, id="protocol-3"), + pytest.param( + {"legacy_responses": False}, + id="new-response-format", + marks=pytest.mark.skipif( + int(redis.__version__.split(".")[0]) < 8, + reason="legacy_responses requires redis-py 8", + ), + ), + ], +) +def test_client_from_existing_and_batch_search( + redis_url, redis_test_name, client_kwargs +): + """User-provided redis-py clients support introspection and batch search.""" + name = redis_test_name("client_index") + prefix = f"{name}:" + client = Redis.from_url(redis_url, **client_kwargs) + index = SearchIndex.from_dict( + { + "index": {"name": name, "prefix": prefix}, + "fields": [ + {"name": "test", "type": "tag"}, + { + "name": "title", + "type": "text", + "attrs": {"withsuffixtrie": True}, + }, + { + "name": "embedding", + "type": "vector", + "attrs": { + "dims": 3, + "distance_metric": "cosine", + "algorithm": "flat", + "datatype": "float32", + }, + }, + ], + }, + redis_client=client, + ) + + try: + index.create() + index.load( + [{"id": "1", "test": "foo", "title": "suffix trie"}], + id_field="id", + ) + + reopened = SearchIndex.from_existing(name, redis_client=client) + results = reopened.batch_search(["@test:{foo}"]) + + assert reopened.schema == index.schema + assert results[0].total == 1 + assert results[0].docs[0]["id"] == f"{prefix}1" + finally: + index.delete(drop=True) + client.close() + + @pytest.mark.parametrize( "queries", [ diff --git a/tests/unit/test_cli_index.py b/tests/unit/test_cli_index.py index 2fbfb30c..5833bd7a 100644 --- a/tests/unit/test_cli_index.py +++ b/tests/unit/test_cli_index.py @@ -7,7 +7,7 @@ import pytest -from redisvl.cli.index import Index, _index_info_for_json +from redisvl.cli.index import Index, _display_in_table, _index_info_for_json from redisvl.exceptions import RedisSearchError from redisvl.index import SearchIndex @@ -221,6 +221,33 @@ def test_info_json_normalize(): } +def test_info_table_normalizes_resp3(capsys): + """Tests that human output accepts dictionary-shaped FT.INFO sections.""" + raw = { + "index_name": "test_index", + "index_definition": { + "key_type": "HASH", + "prefixes": ["prefix_a"], + }, + "attributes": [ + { + "identifier": "user", + "attribute": "user", + "type": "TAG", + "flags": ["SORTABLE"], + } + ], + } + + _display_in_table(raw) + + output = capsys.readouterr().out + assert "test_index" in output + assert "HASH" in output + assert "user" in output + assert "SORTABLE" in output + + def test_info_json(monkeypatch, capsys, redis_url, cli_index): """Tests that ``rvl index info --json`` prints the documented JSON contract. diff --git a/tests/unit/test_connection_protocol.py b/tests/unit/test_connection_protocol.py new file mode 100644 index 00000000..55d00050 --- /dev/null +++ b/tests/unit/test_connection_protocol.py @@ -0,0 +1,111 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from redisvl.redis.connection import RedisConnectionFactory + + +def test_sync_connection_defaults_to_resp2(): + client = MagicMock() + + with patch( + "redisvl.redis.connection.Redis.from_url", return_value=client + ) as from_url: + RedisConnectionFactory.get_redis_connection("redis://localhost:6379") + + from_url.assert_called_once_with("redis://localhost:6379", protocol=2) + + +def test_sync_connection_preserves_explicit_protocol(): + client = MagicMock() + + with patch( + "redisvl.redis.connection.Redis.from_url", return_value=client + ) as from_url: + RedisConnectionFactory.get_redis_connection( + "redis://localhost:6379", protocol=3 + ) + + from_url.assert_called_once_with("redis://localhost:6379", protocol=3) + + +def test_sync_cluster_connection_defaults_to_resp2(): + with patch("redisvl.redis.connection.RedisCluster.from_url") as from_url: + RedisConnectionFactory.get_redis_cluster_connection("redis://localhost:6379") + + from_url.assert_called_once_with("redis://localhost:6379", protocol=2) + + +def test_sync_cluster_connection_preserves_explicit_protocol(): + with patch("redisvl.redis.connection.RedisCluster.from_url") as from_url: + RedisConnectionFactory.get_redis_cluster_connection( + "redis://localhost:6379", protocol=3 + ) + + from_url.assert_called_once_with("redis://localhost:6379", protocol=3) + + +@pytest.mark.asyncio +async def test_async_connection_defaults_to_resp2(): + client = AsyncMock() + + with patch( + "redisvl.redis.connection.AsyncRedis.from_url", return_value=client + ) as from_url: + await RedisConnectionFactory._get_aredis_connection("redis://localhost:6379") + + from_url.assert_called_once_with("redis://localhost:6379", protocol=2) + + +@pytest.mark.asyncio +async def test_async_connection_preserves_explicit_protocol(): + client = AsyncMock() + + with patch( + "redisvl.redis.connection.AsyncRedis.from_url", return_value=client + ) as from_url: + await RedisConnectionFactory._get_aredis_connection( + "redis://localhost:6379", protocol=3 + ) + + from_url.assert_called_once_with("redis://localhost:6379", protocol=3) + + +def test_deprecated_async_connection_defaults_to_resp2(): + with ( + pytest.warns(DeprecationWarning), + patch("redisvl.redis.connection.AsyncRedis.from_url") as from_url, + ): + RedisConnectionFactory.get_async_redis_connection("redis://localhost:6379") + + from_url.assert_called_once_with("redis://localhost:6379", protocol=2) + + +def test_deprecated_async_connection_preserves_explicit_protocol(): + with ( + pytest.warns(DeprecationWarning), + patch("redisvl.redis.connection.AsyncRedis.from_url") as from_url, + ): + RedisConnectionFactory.get_async_redis_connection( + "redis://localhost:6379", protocol=3 + ) + + from_url.assert_called_once_with("redis://localhost:6379", protocol=3) + + +def test_async_cluster_connection_defaults_to_resp2(): + with patch("redisvl.redis.connection.AsyncRedisCluster.from_url") as from_url: + RedisConnectionFactory.get_async_redis_cluster_connection( + "redis://localhost:6379" + ) + + from_url.assert_called_once_with("redis://localhost:6379", protocol=2) + + +def test_async_cluster_connection_preserves_explicit_protocol(): + with patch("redisvl.redis.connection.AsyncRedisCluster.from_url") as from_url: + RedisConnectionFactory.get_async_redis_cluster_connection( + "redis://localhost:6379", protocol=3 + ) + + from_url.assert_called_once_with("redis://localhost:6379", protocol=3) diff --git a/tests/unit/test_convert_index_info.py b/tests/unit/test_convert_index_info.py index 2a4dc36d..53f0eeba 100644 --- a/tests/unit/test_convert_index_info.py +++ b/tests/unit/test_convert_index_info.py @@ -69,6 +69,75 @@ def test_convert_index_info_json_storage(): assert result["index"]["storage_type"] == "json" +def test_convert_index_info_resp3_definition(): + """Test converting the RESP3 dictionary form returned by redis-py 8.""" + index_info = { + "index_name": "test_resp3_index", + "index_definition": { + "key_type": "HASH", + "prefixes": ["resp3_prefix"], + "default_score": 1.0, + "indexes_all": "false", + }, + "attributes": [ + { + "identifier": "category", + "attribute": "category", + "type": "TAG", + "SEPARATOR": "|", + "flags": ["CASESENSITIVE", "SORTABLE", "WITHSUFFIXTRIE"], + } + ], + } + + result = convert_index_info_to_schema(index_info) + + assert result["index"]["name"] == "test_resp3_index" + assert result["index"]["prefix"] == "resp3_prefix" + assert result["index"]["storage_type"] == "hash" + assert result["fields"] == [ + { + "name": "category", + "type": "tag", + "attrs": { + "case_sensitive": True, + "sortable": True, + "withsuffixtrie": True, + "separator": "|", + }, + } + ] + + +def test_convert_index_info_resp2_withsuffixtrie(): + """Test the flag in the alternating-list FT.INFO form.""" + index_info = { + "index_name": "test_resp2_index", + "index_definition": ["key_type", "HASH", "prefixes", ["resp2_prefix"]], + "attributes": [ + [ + "identifier", + "title", + "attribute", + "title", + "type", + "TEXT", + "WITHSUFFIXTRIE", + ] + ], + } + + result = convert_index_info_to_schema(index_info) + + assert result["fields"] == [ + { + "name": "title", + "type": "text", + "attrs": {"withsuffixtrie": True}, + } + ] + + def test_convert_index_info_with_fields(): """Test converting index info with field definitions.""" index_info = { diff --git a/tests/unit/test_mcp/test_config.py b/tests/unit/test_mcp/test_config.py index 57cdd3bf..6560ae34 100644 --- a/tests/unit/test_mcp/test_config.py +++ b/tests/unit/test_mcp/test_config.py @@ -4,7 +4,12 @@ import pytest import yaml -from redisvl.mcp.config import MCPConfig, builtin_tool_names, load_mcp_config +from redisvl.mcp.config import ( + MCPConfig, + MCPIndexBindingConfig, + builtin_tool_names, + load_mcp_config, +) from redisvl.schema import IndexSchema @@ -49,6 +54,26 @@ def _inspected_schema() -> dict: } +def test_mcp_inspection_normalizes_resp3_fields(): + """Dictionary-shaped FT.INFO preserves incomplete field identities.""" + index_info = { + "index_name": "docs-index", + "index_definition": {"key_type": "HASH", "prefixes": ["doc"]}, + "attributes": [ + { + "identifier": "embedding", + "attribute": "embedding", + "type": "VECTOR", + "flags": [], + } + ], + } + + inspected = MCPIndexBindingConfig.inspected_schema_from_index_info(index_info) + + assert inspected["fields"] == [{"name": "embedding", "type": "vector"}] + + def test_load_mcp_config_file_not_found(): with pytest.raises(FileNotFoundError): load_mcp_config("/tmp/does-not-exist.yaml") diff --git a/tests/unit/test_storage.py b/tests/unit/test_storage.py index 11f51e73..e79c9650 100644 --- a/tests/unit/test_storage.py +++ b/tests/unit/test_storage.py @@ -1,3 +1,5 @@ +from unittest.mock import MagicMock + import pytest from pydantic import ValidationError @@ -6,6 +8,20 @@ from redisvl.schema import IndexSchema +def test_hash_storage_builds_an_encodable_mapping(): + client = MagicMock() + obj = {"text": "value", "count": 1, "vector": b"data"} + + HashStorage._set(client, "key", obj) + + client.hset.assert_called_once_with(name="key", mapping=obj) + + +def test_hash_storage_rejects_unencodable_values(): + with pytest.raises(TypeError, match="unsupported value type object"): + HashStorage._mapping({"field": object()}) + + @pytest.fixture def sample_hash_schema(): """Create a sample schema with HASH storage for testing.""" diff --git a/uv.lock b/uv.lock index 14321d42..1240aab3 100644 --- a/uv.lock +++ b/uv.lock @@ -4831,14 +4831,14 @@ wheels = [ [[package]] name = "redis" -version = "7.4.0" +version = "8.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/99/604f0b666d4c616d891cf77ebb9db6bb21601344c051aebf1b72b9ff915f/redis-8.1.0.tar.gz", hash = "sha256:6e1a19beef9225c83efd689c7e6b7da2d5215b1f42cd13b7fc3714d0a09c7b25", size = 5254356, upload-time = "2026-07-30T08:51:00.269Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" }, + { url = "https://files.pythonhosted.org/packages/66/9d/c5731f6e3608663d4d3656fd8d3aecee8b509c3082818f5a13eae925baea/redis-8.1.0-py3-none-any.whl", hash = "sha256:a4fe1aac3d3b3cc791d4b3d5931c5a956045dc951ee74d1c913ee3ac4d2ee9fb", size = 560618, upload-time = "2026-07-30T08:50:58.497Z" }, ] [[package]] @@ -4981,7 +4981,7 @@ requires-dist = [ { name = "pydantic-settings", marker = "extra == 'mcp'", specifier = ">=2.0,<3" }, { name = "python-ulid", specifier = ">=3.0.0" }, { name = "pyyaml", specifier = ">=5.4,<7.0" }, - { name = "redis", specifier = ">=5.0,<8.0" }, + { name = "redis", specifier = ">=6.3.0,!=8.0.0,<9.0" }, { name = "sentence-transformers", marker = "extra == 'all'", specifier = ">=5.2.0,<6" }, { name = "sentence-transformers", marker = "extra == 'sentence-transformers'", specifier = ">=5.2.0,<6" }, { name = "sql-redis", marker = "extra == 'all'", specifier = ">=0.7.1" },