Skip to content

Support redis-py 8 - #695

Open
abrookins wants to merge 3 commits into
mainfrom
codex/issue-686-redis-py-8
Open

Support redis-py 8#695
abrookins wants to merge 3 commits into
mainfrom
codex/issue-686-redis-py-8

Conversation

@abrookins

@abrookins abrookins commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Support redis-py 8.1+ while retaining support for redis-py 6.3 and 7.x.
  • Normalize RESP2 and RESP3 FT.INFO metadata for index schema inspection, MCP configuration, and CLI output.
  • Parse RESP3 pipelined batch_search replies for synchronous and asynchronous user-provided clients.
  • Test default, explicit RESP3, and redis-py 8 native response modes.

Release Notes

redis-py 5.x and 6.0-6.2 are no longer supported. Upgrade to redis-py 6.3 or later. redis-py 8.0.0 is excluded because of its RESP3 empty-search-results defect.

Validation

  • uv sync --all-extras --frozen
  • make format
  • make check-sort-imports
  • make check-types
  • Focused sync and async integration coverage on redis-py 6.3.0: 4 passed, 2 skipped (redis-py 8-only mode)
  • Focused sync and async integration coverage on redis-py 8.1.0: 6 passed
  • Focused unit coverage for FT.INFO conversion, CLI, MCP configuration, and storage
  • git diff --check

Closes #686


Note

Medium Risk
Changes touch core search parsing, index introspection, and default Redis protocol behavior across many redis-py versions; regressions could affect batch search and schema reconstruction when users supply redis-py 8 clients.

Overview
Adds redis-py 8.1+ support while tightening the supported floor to redis-py ≥6.3 (drops 5.x and blocks 8.0.0). CI now matrix-tests 6.x / 7.x / 8.x, and the lockfile moves to redis 8.1.0.

Because redis-py 8 defaults to RESP3, factory-created clients now setdefault(protocol=2) so existing Search reply shapes stay stable unless callers opt into RESP3 or redis-py 8’s native response modes.

FT.INFO handling is unified via normalize_index_definition / normalize_index_fields, so schema introspection, MCP binding inspection, and rvl index info work with both alternating-list (RESP2) and dictionary-shaped (RESP3) metadata—including flags like WITHSUFFIXTRIE.

Pipelined batch_search no longer assumes raw RESP2 lists; _parse_batch_search_result bridges redis-py 6/7/8 RESP3 maps and legacy parsers for sync and async paths. Related cleanups drop redis 5 import shims, tighten hash hset mappings for stricter typing, and coerce embedding cache hgetall results through dict(...).

New unit and integration tests cover RESP3 FT.INFO conversion, default RESP2 connections, and user-supplied clients under default, protocol=3, and legacy_responses=False (redis-py 8).

Reviewed by Cursor Bugbot for commit 83215fd. Bugbot is set up for automated code reviews on this repo. Configure here.

@abrookins
abrookins marked this pull request as ready for review August 31, 2026 17:14
@abrookins
abrookins requested a review from vishal-bala August 31, 2026 17:14
@vishal-bala vishal-bala added the auto:minor Increment the minor version when merged label Sep 1, 2026

@vishal-bala vishal-bala left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review, generated with Claude Code.

Reproduced against Redis 8.4.6 (Docker) on redis-py 6.0.0 / 6.1.0 / 6.2.0 / 6.3.0 / 7.4.0 / 8.0.0 / 8.1.0, running this branch and origin/main side by side to separate regressions from pre-existing bugs.

convert_index_info_to_schema is one of three FT.INFO consumers. The other two still parse positionally and raise on dict-shaped replies. The protocol=2 default that would otherwise hide that is bypassable via the URL (see the inline comment on connection.py:612), so both are reachable.

Shared context: the reply shape is mixed, not uniformly dict

Measured on redis-py 8.1.0. Several findings below depend on this:

client mode index_definition attributes entries
default (8.x) dict flat list
protocol=2 list flat list
protocol=3 dict dict
legacy_responses=False dict dict

Note also that _info() runs convert_bytes first, and it recurses into dict keys — so post-conversion keys are always str even though protocol=3 puts bytes on the wire. The str-keyed unit fixtures are right; worth a comment saying why, since "should these be bytes?" is the obvious wrong correction.

Findings in files this PR does not touch

A. redisvl/mcp/config.py:445 — MCP schema inspection still parses positionally (blocking)

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]
    ...
    "type": str(raw_field[5]).lower(),

inspected_schema_from_index_info consumes the same _info() output as convert_index_info_to_schema. Every subscript above raises when that output is dict-shaped:

=== protocol=2 (factory default) : index_definition=list attrs[0]=list
  MCP inspected_schema ... OK  fields: ['cat', 'emb']
=== ?protocol=3 in URL : index_definition=dict attrs[0]=dict
  MCP inspected_schema ... FAIL KeyError: 1

This runs during MCP server startup, so the failure is "server does not boot".

B. redisvl/cli/index.pyrvl index info table mode still parses positionally (blocking)

_display_in_table calls make_dict(index_info.get("index_definition")); make_dict indexes values[i], which raises on a dict. Same for make_dict(attrs) in the per-field loop. Reproduced end to end:

=== URL carries ?protocol=3 =====
  from_existing ......................... OK
  rvl index info (table) ................ FAIL KeyError: 1

--json mode survives; only the table path breaks.

Suggested fix for A and B

Extract one shape-tolerant accessor pair (index_definition(info), field_entries(info)) in redisvl/redis/connection.py, and route convert_index_info_to_schema, inspected_schema_from_index_info and _display_in_table through it. Four independent positional readers is how this recurs. Doing this also means the protocol=2 default no longer has to be load-bearing.

C. PR body — no ## Release Notes section for a breaking floor bump

Raising the floor from >=5.0 to >=6.3.0 breaks downstreams pinned to 5.x or 6.0–6.2. .autorc hoists ## Release Notes into the changelog with _From #N_ attribution; that is the only place users will see this. (Title correctly omits ! — keep it that way, conventional-commits would resolve a major bump.)

Inline comments

# Anchor Severity Finding
1 pyproject.toml:28 blocking range admits redis-py 8.0.0, which returns zero results; 6.3.0 floor unexplained
2 connection.py:467 blocking dict branch silently drops WITHSUFFIXTRIE
3 connection.py:482 blocking legacy path raises IndexError, and this PR makes it the default
4 connection.py:612 important protocol=2 default is bypassable via the URL
5 index.py:1802 important PR body overstates what RESP3 support lands
6 storage.py:12 important dead 5.x shims left behind by the floor bump
7 test_search_index.py:623 important new tests do not reach the new dict branches
8 test.yml:115 important 8.x cell cannot install the one broken 8.x release
9 connection.py:473 minor blanket passthrough silently drops unmodelled keys
10 storage.py:617 minor cast asserts rather than checks
11 test_transport.py:250 minor unrelated scope, fragile hand-rolled HTTP client

Out of scope

Vector index_missing=True fails index creation with DataError: Invalid input of type: 'bool' — identically on 6.3.0, 7.4.0 and 8.1.0. Pre-existing, unrelated to this PR, worth a separate issue.

Comment thread pyproject.toml Outdated
"numpy>=1.26.0,<3",
"pyyaml>=5.4,<7.0",
"redis>=5.0,<8.0",
"redis>=6.3.0,<9.0",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking. Two things on this line.

The range admits redis-py 8.0.0, which returns zero results. 8.0.0 returns zero results for every query on the RESP3 wire, with no exception. 8.1.0 is unaffected. On this branch:

=== redis-py 8.0.0 / PR #695 ===
  default (RESP3 wire)   filter=0 vector=0 batch_search=0
  protocol=2             filter=5 vector=5 batch_search=5
=== redis-py 8.1.0 / PR #695 ===
  default (RESP3 wire)   filter=5 vector=5 batch_search=5
  protocol=2             filter=5 vector=5 batch_search=5

protocol=2 masks it for factory-built clients. Since this PR's stated purpose is support for user-supplied clients, and those default to RESP3, it reaches users as a silent wrong-answer bug.

The 6.3.0 floor has no stated reason. redis-py's VectorField rejects SVS-VAMANA through 6.2.0 (DataError: Realtime vector indexing supporting 2 Indexing Methods: 'FLAT' and 'HNSW') and accepts it from 6.3.0. RedisVL exposes that algorithm via SVSVectorFieldAttributes, so 6.3.0 is correct — but undocumented it reads as arbitrary and invites being lowered.

Suggested change
"redis>=6.3.0,<9.0",
# 6.3.0 is the first redis-py whose VectorField accepts SVS-VAMANA, which
# RedisVL exposes. 8.0.0 silently returns zero results for every search on
# RESP3 connections (redis-py #4107); 8.1 is the supported 8.x floor.
"redis>=6.3.0,!=8.0.0,<9.0",

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 83215fd: the dependency now excludes redis-py 8.0.0, documents the 6.3.0 floor, and CI installs redis-py 8.1 or later for the 8.x job.

}

if isinstance(attrs, dict):
flags = attrs.get("flags", []).copy()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — silent data loss.

boolean_attrs has no WITHSUFFIXTRIE entry, so it stays in flags, and line 477 excludes flags from the passthrough. TextFieldAttributes sets no extra= policy, so pydantic's default ignore swallows the loss.

A field created with withsuffixtrie=True round-trips through from_existing as withsuffixtrie: False. Schema comparison then reports two on-disk-different indices as equal.

The TODO on line 453 — 'WITHSUFFIXTRIE' is another boolean attr, but is not returned by ft.info — is stale. Redis 8.4.6 returns it: in flags under RESP3, in the tail under RESP2. Please delete it in the same commit.

One line fixes this and the IndexError in the comment on line 482:

             "NOINDEX": "no_index",
+            "WITHSUFFIXTRIE": "withsuffixtrie",
         }

Before:

  protocol=2   FAIL IndexError: Error parsing index attributes [...]
  default(8.x) FAIL IndexError: Error parsing index attributes [...]
  protocol=3   OK   {..., 'withsuffixtrie': False, ...}   <-- silent loss

After:

  protocol=2   OK   {..., 'withsuffixtrie': True, ...}
  default(8.x) OK   {..., 'withsuffixtrie': True, ...}
  protocol=3   OK   {..., 'withsuffixtrie': True, ...}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 83215fd: WITHSUFFIXTRIE is preserved from FT.INFO flags in both alternating-list and mapping response forms, with RESP2 and RESP3 coverage.

)
return parsed_attrs

original = attrs.copy()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking. The legacy branch starting here is broken for withsuffixtrie fields, and this PR makes it the default path.

Because WITHSUFFIXTRIE is not in boolean_attrs it is never .remove()d, so it leaves an odd-length tail and the range(6, len(attrs), 2) comprehension on line 501 raises IndexError (re-raised at 504).

Pre-existing on main, so not a regression — but protocol=2 is now RedisVL's default, so the two readers disagree and the one users get by default is the broken one. Same one-line fix as the comment on line 467.

(Anchored here because line 501 itself falls between diff hunks.)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 83215fd: WITHSUFFIXTRIE is treated as a boolean flag before key-value parsing, and the stale TODO was removed.

Comment thread redisvl/index/index.py Outdated
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important — scope claim, not the code. This call is correct and _parse_results exists at the 6.3.0 floor, so no compatibility issue here.

batch_search is fixed. query and paginate are not, on this branch or on main:

##### PR #695, redis-py 8.1.0, protocol=3 client #####
  query(FilterQuery)   FAIL AttributeError: 'dict' object has no attribute 'docs'  @ index.py:457
  query(VectorQuery)   FAIL AttributeError: 'dict' object has no attribute 'docs'  @ index.py:457
  search('@cat:{a}')   OK  -> dict len=5      <-- returns dict, annotated -> Result
  batch_search         OK  -> list len=1      <-- fixed by this PR
  paginate             FAIL AttributeError: 'dict' object has no attribute 'docs'  @ index.py:457

SQLQuery breaks in the default mode too, because sql-redis 0.7.1 parses raw FT.SEARCH replies positionally:

  protocol=2 (#695 factory default)  -> 5 rows
  default (8.x wire RESP3)           -> FAIL KeyError: 2
  protocol=3                         -> FAIL KeyError: 2

Scoping the fixes out is fine. Accepting a client configuration that breaks query() without saying so is not. Either narrow the PR body claim to batch_search plus index introspection, or reject protocol=3 at connection validation with an actionable error.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 83215fd: batch_search now parses explicit RESP3 pipeline results on redis-py 6.3 through 8.1. The PR summary is narrowed to FT.INFO and batch_search behavior.

Comment thread redisvl/index/storage.py Outdated
from redis.asyncio.cluster import ClusterPipeline as AsyncClusterPipeline
from redis.typing import EncodableT

# Redis 5.x compatibility (6 fixed the import path)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important. Unreachable now that the floor is 6.3.0. Three sites, only this one is in the diff:

  • redisvl/index/storage.py:12-20 (here)
  • redisvl/index/index.py:56
  • redisvl/redis/utils.py:27, plus stale 5.x TODOs at :269 and :293

Each imports redis.__version__ solely to test startswith("5"). The floor bump in this PR is what makes them dead; leaving them implies 5.x is still supported.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 83215fd: removed the redis-py 5 compatibility imports and stale 5.x-specific TODOs.

Comment thread tests/integration/test_search_index.py Outdated
assert results[1].docs[0]["id"] == "rvl:2"


def test_default_client_from_existing_and_batch_search(redis_url, redis_test_name):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important — the new tests do not reach the new code.

Redis.from_url(redis_url) gives the default mode, which per the table in the review body returns a dict index_definition but flat-list attributes. So this covers the index_definition dict branch and none of the dict branches in parse_attrs / parse_vector_attrs — most of the new code has no integration coverage. The resp3_index name and the RESP3 docstring claim coverage the test does not have.

Rename to default_client_index and parametrize:

@pytest.mark.parametrize("client_kwargs", [{}, {"protocol": 3}, {"legacy_responses": False}])

That parametrization would have caught the findings on connection.py:467 and index.py:1802.

Same comment applies to test_async_search_index.py:546.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 83215fd: sync and async integration tests now cover default, explicit protocol=3, and redis-py 8 native response modes.

Comment thread .github/workflows/test.yml Outdated
case "$REDIS_PY_VERSION" in
"6.x") spec="redis>=6,<7" ;;
"7.x") spec="redis>=7,<8" ;;
"8.x") spec="redis>=8,<9" ;;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important. redis>=8,<9 resolves to the newest 8.x, so CI never installs the one broken 8.x release — this cell cannot catch the 8.0.0 zero-results bug described in the pyproject.toml comment. Pin to redis>=8.1,<9 to make the supported floor explicit.

Separately: this row takes the matrix from 30 to 45 jobs (5 python × 3 redis-py × 3 images). If that is a problem, restricting the 8.x row to the ends of the Python range halves the added cost.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 83215fd: the 8.x CI install range is now redis>=8.1,<9 to avoid 8.0.0.

parsed_attrs[python_attr] = True
if "UNF" in flags and field_type == "TEXT":
parsed_attrs["unf"] = True
parsed_attrs.update(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor. This forwards every non-excluded key, lowercased, into field attrs, and the attrs models ignore unknown keys. So anything Redis adds to FT.INFO that RedisVL does not model disappears silently — WITHSUFFIXTRIE (comment on line 467) is the instance already in the tree. A logger.debug on unmapped flags would make the next one findable.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 83215fd: unmapped FT.INFO flags are retained as ignored behavior with a debug log for visibility.

Comment thread redisvl/index/storage.py Outdated
client.hset(name=key, mapping=obj)
client.hset(
name=key,
mapping=cast(Mapping[EncodableT, EncodableT], obj),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor. No runtime effect; silences mypy whether or not obj is encodable. Narrow where obj is built instead.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 83215fd: the broad cast was replaced with explicit validation of hash values that redis-py can encode.

_GUARD_ORIGIN_REASON = "Origin not allowed"


async def _post_mcp(port: int, headers: dict) -> httpx.Response:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor — unrelated scope. Replacing httpx with a hand-rolled socket client is unrelated to redis-py 8 and would be easier to review as its own PR. Two issues in the implementation:

  • int(status_line.split()[1]) raises IndexError on an empty or malformed status line, so a dropped connection surfaces as a parse error rather than the real cause.
  • body = await reader.read() does not handle Transfer-Encoding: chunked, so .text would include chunk framing. The assertions compare .text against exact guard strings, so this fails confusingly if the ASGI server ever chunks these bodies.

httpx sets an explicit Host header directly via headers={"Host": ...} on an absolute-URL request.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 83215fd: reverted the complete MCP transport-test hunk to the origin/main version.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto:minor Increment the minor version when merged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support latest redis-py

2 participants