diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b5ca251c4..d4d5af198 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -69,7 +69,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 @@ -106,6 +106,7 @@ jobs: case "$REDIS_PY_VERSION" in "6.x") spec="redis>=6,<7" ;; "7.x") spec="redis>=7,<8" ;; + "8.x") spec="redis>=8,<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 7a2645653..95c51ee7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ dependencies = [ "numpy>=1.26.0,<3", "pyyaml>=5.4,<7.0", - "redis>=5.0,<8.0", + "redis>=6.3.0,<9.0", "pydantic>=2,<3", "tenacity>=8.2.2", "ml-dtypes>=0.4.0,<1.0.0", diff --git a/redisvl/extensions/cache/embeddings/embeddings.py b/redisvl/extensions/cache/embeddings/embeddings.py index 7218de5c4..034a77a6c 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 @@ -111,7 +112,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: @@ -123,7 +126,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 3daa69d3e..6f8d7f64a 100644 --- a/redisvl/index/index.py +++ b/redisvl/index/index.py @@ -1799,7 +1799,8 @@ def batch_search( for j, query_results in enumerate(results): _built_query = batch_built_queries[j] - parsed_result = search._parse_search( # type: ignore + parsed_result = search._parse_results( # type: ignore + "FT.SEARCH", query_results, query=_built_query, duration=duration, @@ -2998,7 +2999,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 + parsed_result = search._parse_results( # type: ignore + "FT.SEARCH", query_results, query=_built_query, duration=duration, diff --git a/redisvl/index/storage.py b/redisvl/index/storage.py index 9a8681ace..b539a2c0c 100644 --- a/redisvl/index/storage.py +++ b/redisvl/index/storage.py @@ -1,5 +1,5 @@ -from collections.abc import Collection -from typing import Any, Callable, Iterable +from collections.abc import Collection, Mapping +from typing import Any, Callable, Iterable, cast from pydantic import BaseModel, ValidationError from redis import __version__ as redis_version @@ -7,6 +7,7 @@ # Add imports for Pipeline types from redis.asyncio.client import Pipeline as AsyncPipeline from redis.asyncio.cluster import ClusterPipeline as AsyncClusterPipeline +from redis.typing import EncodableT # Redis 5.x compatibility (6 fixed the import path) if redis_version.startswith("5"): @@ -611,7 +612,10 @@ 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=cast(Mapping[EncodableT, EncodableT], obj), + ) @staticmethod async def _aset(client: AsyncRedisClientOrPipeline, key: str, obj: dict[str, Any]): diff --git a/redisvl/redis/connection.py b/redisvl/redis/connection.py index 44247d1f6..feb6472b5 100644 --- a/redisvl/redis/connection.py +++ b/redisvl/redis/connection.py @@ -222,11 +222,16 @@ 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 = index_info["index_definition"] + if isinstance(index_definition, dict): + prefixes = index_definition["prefixes"] + storage_type = index_definition["key_type"].lower() + else: + prefixes = index_definition[3] + storage_type = index_definition[1].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) @@ -252,35 +257,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 = {} @@ -402,7 +414,6 @@ 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 @@ -415,6 +426,24 @@ def parse_attrs(attrs, field_type=None): "NOINDEX": "no_index", } + 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 + 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 @@ -442,12 +471,20 @@ def parse_attrs(attrs, field_type=None): for field_attrs in index_fields: # parse field info - name = field_attrs[1] if storage_type == "hash" else field_attrs[3] - field = {"name": name, "type": field_attrs[5].lower()} - if storage_type == "json": - field["path"] = field_attrs[1] + if isinstance(field_attrs, dict): + name = field_attrs["attribute"] + field_type = field_attrs["type"] + field = {"name": name, "type": field_type.lower()} + if storage_type == "json": + field["path"] = field_attrs["identifier"] + else: + name = field_attrs[1] if storage_type == "hash" else field_attrs[3] + field_type = field_attrs[5] + field = {"name": name, "type": field_type.lower()} + if storage_type == "json": + field["path"] = field_attrs[1] # parse field attrs - if field_attrs[5] == "VECTOR": + if field_type == "VECTOR": attrs = parse_vector_attrs(field_attrs) if attrs is None: # Vector field attributes cannot be parsed on this Redis version @@ -455,7 +492,7 @@ def parse_attrs(attrs, field_type=None): continue field["attrs"] = attrs else: - field["attrs"] = parse_attrs(field_attrs, field_type=field_attrs[5]) + field["attrs"] = parse_attrs(field_attrs, field_type=field_type) # append field schema_fields.append(field) @@ -533,6 +570,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) @@ -580,6 +620,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"): @@ -640,6 +682,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( @@ -665,6 +708,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 @@ -674,6 +718,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/tests/integration/test_async_search_index.py b/tests/integration/test_async_search_index.py index 68a95999f..55d353dae 100644 --- a/tests/integration/test_async_search_index.py +++ b/tests/integration/test_async_search_index.py @@ -542,6 +542,49 @@ async def test_batch_search(async_index): assert results[1].docs[0]["id"] == "rvl:2" +@pytest.mark.asyncio +async def test_default_client_from_existing_and_batch_search( + redis_url, redis_test_name +): + """User-provided async redis-py clients support introspection and batch search.""" + name = redis_test_name("async_resp3_index") + prefix = f"{name}:" + client = AsyncRedis.from_url(redis_url) + index = AsyncSearchIndex.from_dict( + { + "index": {"name": name, "prefix": prefix}, + "fields": [ + {"name": "test", "type": "tag"}, + { + "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"}], 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_mcp/test_transport.py b/tests/integration/test_mcp/test_transport.py index a8b9f4762..23da89f87 100644 --- a/tests/integration/test_mcp/test_transport.py +++ b/tests/integration/test_mcp/test_transport.py @@ -249,16 +249,30 @@ async def test_server_read_only_mode_hides_upsert_over_http( async def _post_mcp(port: int, headers: dict) -> httpx.Response: """POST a minimal payload to /mcp with custom headers; return the response.""" - async with httpx.AsyncClient(timeout=5.0) as client: - return await client.post( - f"http://127.0.0.1:{port}/mcp", - headers={ - "content-type": "application/json", - "accept": "application/json, text/event-stream", - **headers, - }, - json={"jsonrpc": "2.0", "id": 1, "method": "ping"}, - ) + reader, writer = await asyncio.open_connection("127.0.0.1", port) + payload = b'{"jsonrpc":"2.0","id":1,"method":"ping"}' + request_headers = { + "host": f"127.0.0.1:{port}", + "content-type": "application/json", + "accept": "application/json, text/event-stream", + "content-length": str(len(payload)), + "connection": "close", + **headers, + } + request = "POST /mcp HTTP/1.1\r\n" + "".join( + f"{name}: {value}\r\n" for name, value in request_headers.items() + ) + writer.write(request.encode("ascii") + b"\r\n" + payload) + await writer.drain() + + status_line = await reader.readline() + status_code = int(status_line.split()[1]) + while await reader.readline() not in (b"\r\n", b""): + pass + body = await reader.read() + writer.close() + await writer.wait_closed() + return httpx.Response(status_code, content=body) @pytest.mark.asyncio diff --git a/tests/integration/test_search_index.py b/tests/integration/test_search_index.py index bac05612e..c9ac8ae57 100644 --- a/tests/integration/test_search_index.py +++ b/tests/integration/test_search_index.py @@ -634,6 +634,46 @@ def test_batch_search(index): assert results[1].docs[0]["id"] == "rvl:2" +def test_default_client_from_existing_and_batch_search(redis_url, redis_test_name): + """User-provided redis-py clients support introspection and batch search.""" + name = redis_test_name("resp3_index") + prefix = f"{name}:" + client = Redis.from_url(redis_url) + index = SearchIndex.from_dict( + { + "index": {"name": name, "prefix": prefix}, + "fields": [ + {"name": "test", "type": "tag"}, + { + "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"}], 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_connection_protocol.py b/tests/unit/test_connection_protocol.py new file mode 100644 index 000000000..55d00050c --- /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 2a4dc36d7..e41a93ba1 100644 --- a/tests/unit/test_convert_index_info.py +++ b/tests/unit/test_convert_index_info.py @@ -69,6 +69,45 @@ 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"], + } + ], + } + + 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, + "separator": "|", + }, + } + ] + + def test_convert_index_info_with_fields(): """Test converting index info with field definitions.""" index_info = { diff --git a/uv.lock b/uv.lock index 4ac27b663..c5071ad19 100644 --- a/uv.lock +++ b/uv.lock @@ -4843,14 +4843,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]] @@ -4993,7 +4993,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,<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" },