Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@ jobs:
os: [ ubuntu-latest ]
pyver: [ "3.10", "3.11", "3.12", "3.13", "pypy-3.10" ]
redisstack: [ "redis/redis-stack:latest", "redis:8.4.0" ]
redis-py-version: [ "7.x", "8.x" ]
include:
- os: ubuntu-latest
pyver: "3.12"
redisstack: "redis:8.4.0"
redis-py-version: "6.x"
- os: ubuntu-latest
pyver: "3.12"
redisstack: "redis/redis-stack:latest"
redis-py-version: "6.x"
fail-fast: false
services:
redis:
Expand All @@ -68,6 +78,10 @@ jobs:
env:
OS: ${{ matrix.os }}
INSTALL_DIR: ${{ github.workspace }}/redis
REDIS_PY_VERSION: ${{ matrix.redis-py-version }}
# Required: without it `uv run` re-syncs the venv to uv.lock and reverts
# the redis-py pin set below.
UV_NO_SYNC: "1"
steps:
- name: Checkout
uses: actions/checkout@v6
Expand All @@ -78,7 +92,27 @@ jobs:
with:
python-version: ${{ matrix.pyver }}
- name: Install dependencies
run: uv sync
run: |
set -euo pipefail

uv sync

# Must cover every redis-py-version matrix value; unhandled values fail.
case "$REDIS_PY_VERSION" in
"6.x") spec="redis>=6.3,<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/ci.yml."
exit 1
;;
esac

uv pip install "$spec"
uv run python -c 'import redis; print("redis-py:", redis.__version__)'
# Prevent `make sync` / `make test` from running `uv sync` again and
# reverting the redis-py pin.
touch .install.stamp
- name: Make sync version of library (redis_om)
run: make sync
- name: Run unittests (redisstack:${{ matrix.redisstack }}, ${{ matrix.os }})
Expand Down
34 changes: 4 additions & 30 deletions aredis_om/model/migrations/data/builtin/datetime_migration.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
from enum import Enum
from typing import Any, Dict, List, Optional, Set, Tuple

from aredis_om.search_reply import index_field_schema

from ..base import BaseMigration, DataMigrationError


Expand Down Expand Up @@ -92,37 +94,9 @@ async def check_for_schema_mismatches(self, models: List[Any]) -> Dict[str, Any]
"recommendation": self._get_recommendation(mismatches),
}

def _parse_index_schema(self, index_info: List) -> Dict[str, Dict[str, Any]]:
def _parse_index_schema(self, index_info: Any) -> Dict[str, Dict[str, Any]]:
"""Parse FT.INFO output to extract field schema information."""
schema = {}

# FT.INFO returns a list of key-value pairs
info_dict = {}
for i in range(0, len(index_info), 2):
if i + 1 < len(index_info):
key = (
index_info[i].decode()
if isinstance(index_info[i], bytes)
else str(index_info[i])
)
value = index_info[i + 1]
info_dict[key] = value

# Extract attributes (field definitions)
attributes = info_dict.get("attributes", [])

for attr in attributes:
if isinstance(attr, list) and len(attr) >= 4:
field_name = (
attr[0].decode() if isinstance(attr[0], bytes) else str(attr[0])
)
field_type = (
attr[2].decode() if isinstance(attr[2], bytes) else str(attr[2])
)

schema[field_name] = {"type": field_type, "raw_attr": attr}

return schema
return index_field_schema(index_info)

def _get_datetime_fields(self, model) -> Dict[str, Dict[str, Any]]:
"""Get datetime fields from a model and their expected types."""
Expand Down
60 changes: 6 additions & 54 deletions aredis_om/model/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
from .. import redis
from ..checks import has_redis_json, has_redisearch
from ..connections import get_redis_connection
from ..search_reply import search_document_keys, search_documents, search_total
from ..util import ASYNC_MODE, has_numeric_inner_type, is_numeric_type
from .encoders import jsonable_encoder
from .render_tree import render_tree
Expand Down Expand Up @@ -1134,30 +1135,8 @@ def _validate_deep_field_path(self, field_path: str):

def _parse_projected_results(self, res: Any) -> List[Dict[str, Any]]:
"""Parse results when using RETURN clause with specific fields."""

def to_string(s):
if isinstance(s, (str,)):
return s
elif isinstance(s, bytes):
return s.decode(errors="ignore")
else:
return s

docs = []
step = 2 # Because the result has content
offset = 1 # The first item is the count of total matches.

for i in range(1, len(res), step):
if res[i + offset] is None:
continue
# When using RETURN, we get flat key-value pairs
raw_fields: Dict[str, str] = dict(
zip(
map(to_string, res[i + offset][::2]),
map(to_string, res[i + offset][1::2]),
)
)
# Convert raw Redis strings to properly typed values
Comment thread
abrookins marked this conversation as resolved.
for raw_fields in search_documents(res):
converted_fields = self._convert_projected_fields(raw_fields)
docs.append(converted_fields)
return docs
Expand Down Expand Up @@ -1295,16 +1274,7 @@ async def _parse_json_path_projection_as_dict(
self, res: Any
) -> List[Dict[str, Any]]:
"""Use JSON.GET with JSONPath to efficiently extract deep fields."""
# Extract document keys from search results
doc_keys = []
step = 2 # Because the result has content

for i in range(1, len(res), step):
if i < len(res):
doc_key = res[i] # Document key
if isinstance(doc_key, bytes):
doc_key = doc_key.decode("utf-8")
doc_keys.append(doc_key)
doc_keys = search_document_keys(res)

if not doc_keys:
return []
Expand Down Expand Up @@ -1920,7 +1890,7 @@ async def execute(
raise
if return_raw_result:
return raw_result
count = raw_result[0]
count = search_total(raw_result)

# Handle different result processing based on what was requested
if self.projected_fields and use_full_document_fallback:
Expand Down Expand Up @@ -1980,7 +1950,7 @@ async def first(self):
async def count(self):
query = self.copy(offset=0, limit=0, nocontent=True)
result = await query.execute(exhaust_results=True, return_raw_result=True)
return result[0]
return search_total(result)

async def all(self, batch_size=DEFAULT_PAGE_SIZE):
if batch_size != self.page_size:
Expand Down Expand Up @@ -2889,27 +2859,9 @@ def find(
@classmethod
def from_redis(cls, res: Any, knn: Optional[KNNExpression] = None):
# TODO: Parsing logic copied from redisearch-py. Evaluate.
def to_string(s):
if isinstance(s, (str,)):
return s
elif isinstance(s, bytes):
return s.decode(errors="ignore")
else:
return s # Not a string we care about

docs = []
step = 2 # Because the result has content
offset = 1 # The first item is the count of total matches.

for i in range(1, len(res), step):
if res[i + offset] is None:
continue
fields: Dict[str, str] = dict(
zip(
map(to_string, res[i + offset][::2]),
map(to_string, res[i + offset][1::2]),
)
)
for fields in search_documents(res):
# $ means a json entry
if fields.get("$"):
json_fields = json.loads(fields.pop("$"))
Expand Down
142 changes: 142 additions & 0 deletions aredis_om/search_reply.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"""Normalize RediSearch replies across redis-py 6/7 (RESP2 lists) and 8 (dicts)."""

from typing import Any, Dict, Iterable, List, Mapping, Sequence, Tuple


def to_string(value: Any) -> Any:
if isinstance(value, bytes):
return value.decode(errors="ignore")
return value


def string_keyed(value: Any) -> Dict[str, Any]:
"""Copy a Redis map to string keys.

redis-py 8 returns RESP3 maps with ``bytes`` keys when
``decode_responses`` is false.
"""
if not isinstance(value, Mapping):
return {}
return {str(to_string(key)): item for key, item in value.items()}


def pairs_to_dict(pairs: Sequence[Any]) -> Dict[str, Any]:
return dict(
zip(
map(to_string, pairs[::2]),
map(to_string, pairs[1::2]),
)
)


def search_total(res: Any) -> int:
"""Return the match count from an FT.SEARCH reply."""
if isinstance(res, Mapping):
return int(string_keyed(res).get("total_results") or 0)
if not res:
return 0
return int(res[0])
Comment thread
abrookins marked this conversation as resolved.


def search_document_keys(res: Any) -> List[str]:
"""Return document ids from an FT.SEARCH reply that includes content.

RESP2 list replies look like ``[count, key, fields, ...]``.
redis-py 8 dict replies look like
``{"results": [{"id": key, ...}], ...}``.
"""
if isinstance(res, Mapping):
keys: List[str] = []
for item in string_keyed(res).get("results") or []:
if not isinstance(item, Mapping):
continue
key = to_string(string_keyed(item).get("id"))
if isinstance(key, str) and key:
keys.append(key)
return keys
if not res:
return []
keys = []
for index in range(1, len(res), 2):
key = to_string(res[index])
if isinstance(key, str) and key:
keys.append(key)
return keys


def search_documents(res: Any) -> List[Dict[str, Any]]:
"""Return per-document field maps from an FT.SEARCH reply.

RESP2 list replies look like ``[count, key, [field, value, ...], ...]``.
redis-py 8 dict replies look like
``{"total_results": N, "results": [{"extra_attributes": {...}}, ...]}``.
"""
if isinstance(res, Mapping):
docs: List[Dict[str, Any]] = []
for item in string_keyed(res).get("results") or []:
if not isinstance(item, Mapping):
continue
fields = string_keyed(item).get("extra_attributes")
# Missing or null payloads match the RESP2 parser, which skips them.
if not isinstance(fields, Mapping):
continue
docs.append({to_string(key): to_string(val) for key, val in fields.items()})
return docs
Comment thread
abrookins marked this conversation as resolved.

docs = []
if not res:
return docs
for i in range(1, len(res), 2):
payload_index = i + 1
if payload_index >= len(res):
break
payload = res[payload_index]
if payload is None:
continue
if isinstance(payload, (str, bytes)):
# NOCONTENT replies are [count, key, key, ...].
break
docs.append(pairs_to_dict(payload))
return docs


def index_info_as_dict(index_info: Any) -> Dict[str, Any]:
"""Return FT.INFO as a string-keyed dict."""
if isinstance(index_info, Mapping):
return string_keyed(index_info)
info: Dict[str, Any] = {}
if not index_info:
return info
for i in range(0, len(index_info), 2):
if i + 1 >= len(index_info):
break
info[str(to_string(index_info[i]))] = index_info[i + 1]
return info


def _attribute_name_and_type(attr: Any) -> Tuple[Any, Any]:
if isinstance(attr, Mapping):
parsed = string_keyed(attr)
name = parsed.get("attribute") or parsed.get("identifier")
return to_string(name), to_string(parsed.get("type"))
if isinstance(attr, Sequence) and not isinstance(attr, (str, bytes)):
if attr and str(to_string(attr[0])) in {"identifier", "attribute"}:
parsed = pairs_to_dict(attr)
name = parsed.get("attribute") or parsed.get("identifier")
return to_string(name), to_string(parsed.get("type"))
if len(attr) >= 3:
return to_string(attr[0]), to_string(attr[2])
return None, None


def index_field_schema(index_info: Any) -> Dict[str, Dict[str, Any]]:
"""Map field name -> {type, raw_attr} from FT.INFO."""
info = index_info_as_dict(index_info)
schema: Dict[str, Dict[str, Any]] = {}
attributes: Iterable[Any] = info.get("attributes") or []
for attr in attributes:
name, field_type = _attribute_name_and_type(attr)
if not name:
continue
schema[str(name)] = {"type": field_type, "raw_attr": attr}
return schema
2 changes: 1 addition & 1 deletion docs/getting_started.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Python 3.10.0

If you don't have Python installed, you can download it from [Python.org](https://www.python.org/downloads/), use [pyenv](https://github.com/pyenv/pyenv), or install Python with your operating system's package manager.

This library requires [redis-py](https://pypi.org/project/redis) version 4.2.0 or higher.
This library requires [redis-py](https://pypi.org/project/redis) version 6.3.0 or higher. redis-py 8.0.0 is excluded because of a search-results defect.

## Redis

Expand Down
9 changes: 6 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "redis-om"
version = "1.1.0"
version = "1.2.0"
description = "Object mappings, and more, for Redis."
authors = [{ name = "Redis OSS", email = "oss@redis.com" }]
maintainers = [{ name = "Redis OSS", email = "oss@redis.com" }]
Expand All @@ -21,7 +21,10 @@ classifiers = [
"Programming Language :: Python",
]
dependencies = [
"redis>=4.2.0,<8.0.0",
# 6.3.0 matches RedisVL. redis-py 8.0.0 returns empty RESP3 search
# results (redis-py #4107), so exclude it. RedisVL 0.27+ is the first
# release that accepts redis-py 8.1+.
"redis>=6.3.0,!=8.0.0,<9.0.0",
"pydantic>=2.0.0,<3.0.0",
"click>=8.0.1",
"types-redis>=3.5.9,<5.0.0",
Expand All @@ -31,7 +34,7 @@ dependencies = [
"more-itertools>=8.14,<12.0",
"setuptools>=70.0",
"pydantic-extra-types>=2.10.5",
"redisvl>=0.16.0",
"redisvl>=0.27.0",
]

[project.urls]
Expand Down
Loading
Loading