diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a48551de..53801781 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: @@ -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 @@ -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 }}) diff --git a/aredis_om/model/migrations/data/builtin/datetime_migration.py b/aredis_om/model/migrations/data/builtin/datetime_migration.py index e4c0607c..be95349d 100644 --- a/aredis_om/model/migrations/data/builtin/datetime_migration.py +++ b/aredis_om/model/migrations/data/builtin/datetime_migration.py @@ -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 @@ -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.""" diff --git a/aredis_om/model/model.py b/aredis_om/model/model.py index a8869b78..35636c26 100644 --- a/aredis_om/model/model.py +++ b/aredis_om/model/model.py @@ -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 @@ -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 + for raw_fields in search_documents(res): converted_fields = self._convert_projected_fields(raw_fields) docs.append(converted_fields) return docs @@ -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 [] @@ -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: @@ -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: @@ -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("$")) diff --git a/aredis_om/search_reply.py b/aredis_om/search_reply.py new file mode 100644 index 00000000..f2d48aba --- /dev/null +++ b/aredis_om/search_reply.py @@ -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]) + + +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 + + 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 diff --git a/docs/getting_started.md b/docs/getting_started.md index d56142fe..9c88d59e 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index d820814b..7bc0aafc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" }] @@ -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", @@ -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] diff --git a/tests/test_json_model.py b/tests/test_json_model.py index b439f6ec..c8a2a5aa 100644 --- a/tests/test_json_model.py +++ b/tests/test_json_model.py @@ -979,6 +979,27 @@ async def test_values_method_with_specific_fields(members, m): ] +@py_test_mark_asyncio +async def test_deep_and_complex_projection(members, m): + member1 = members[0] + values = await ( + m.Member.find(m.Member.first_name == "Andrew") + .sort_by("last_name") + .values("address__city") + .all() + ) + assert values == [ + {"address__city": "Portland"}, + {"address__city": "Portland"}, + ] + + only = await m.Member.find(m.Member.pk == member1.pk).only("address__city").all() + assert only[0].address.city == "Portland" + + embedded = await m.Member.find(m.Member.pk == member1.pk).values("address").all() + assert embedded[0]["address"]["city"] == "Portland" + + @py_test_mark_asyncio async def test_values_method_all_fields(members, m): member1, member2, member3 = members diff --git a/tests/test_redis_client_compat.py b/tests/test_redis_client_compat.py new file mode 100644 index 00000000..5b715a56 --- /dev/null +++ b/tests/test_redis_client_compat.py @@ -0,0 +1,95 @@ +# type: ignore +"""Compatibility checks for redis-py 6.3+/7.x/8.x search reply shapes.""" + +import abc +from collections import namedtuple + +import pytest +import pytest_asyncio +import redis as redis_lib + +from aredis_om import Field, HashModel, Migrator +from aredis_om.model.migrations.data.builtin.datetime_migration import ( + DatetimeFieldDetector, +) +from redis_om import has_redisearch + +from .conftest import py_test_mark_asyncio + + +if not has_redisearch(): + pytestmark = pytest.mark.skip + + +def _redis_py_version_tuple(): + return tuple(int(part) for part in redis_lib.__version__.split(".")[:3]) + + +@pytest_asyncio.fixture +async def compat_model(key_prefix, redis): + class BaseHashModel(HashModel, abc.ABC): + class Meta: + global_key_prefix = key_prefix + database = redis + + class Person(BaseHashModel, index=True): + first_name: str = Field(index=True) + age: int = Field(index=True, sortable=True) + + await Migrator(conn=redis).run() + return namedtuple("Models", ["Person"])(Person) + + +@py_test_mark_asyncio +async def test_redis_py_version_is_supported(): + version = _redis_py_version_tuple() + assert version >= (6, 3, 0) + assert version != (8, 0, 0) + + +@py_test_mark_asyncio +async def test_hash_find_and_count_roundtrip(compat_model): + person = compat_model.Person(first_name="Andrew", age=38) + await person.save() + + results = await compat_model.Person.find( + compat_model.Person.first_name == "Andrew" + ).all() + assert len(results) == 1 + assert results[0].first_name == "Andrew" + assert results[0].age == 38 + assert ( + await compat_model.Person.find( + compat_model.Person.first_name == "Andrew" + ).count() + == 1 + ) + + +@py_test_mark_asyncio +async def test_raw_search_reply_is_parseable(compat_model, redis): + person = compat_model.Person(first_name="Andrew", age=38) + await person.save() + + raw_result = await redis.execute_command( + "FT.SEARCH", + compat_model.Person.Meta.index_name, + "@first_name:{Andrew}", + "LIMIT", + "0", + "10", + ) + + parsed = compat_model.Person.from_redis(raw_result) + assert [doc.first_name for doc in parsed] == ["Andrew"] + + +@py_test_mark_asyncio +async def test_ft_info_schema_parse(compat_model, redis): + detector = DatetimeFieldDetector(redis) + index_info = await redis.execute_command( + "FT.INFO", compat_model.Person.Meta.index_name + ) + schema = detector._parse_index_schema(index_info) + assert schema["first_name"]["type"] == "TAG" + assert schema["age"]["type"] == "NUMERIC" diff --git a/tests/test_search_reply.py b/tests/test_search_reply.py new file mode 100644 index 00000000..f57906dd --- /dev/null +++ b/tests/test_search_reply.py @@ -0,0 +1,186 @@ +from aredis_om.search_reply import ( + index_field_schema, + search_document_keys, + search_documents, + search_total, +) + + +RESP2_HASH_SEARCH = [1, "probe-hash:1", ["first_name", "Andrew", "age", "38"]] +RESP2_JSON_SEARCH = [1, "probe-json:1", ["$", '{"first_name":"Andrew","age":38}']] +RESP2_NOCONTENT = [2, "probe-hash:1", "probe-hash:2"] +RESP2_EMPTY = [0] + +RESP3_HASH_SEARCH = { + "attributes": [], + "format": "STRING", + "results": [ + { + "id": "probe-hash:1", + "extra_attributes": {"first_name": "Andrew", "age": "38"}, + "values": [], + } + ], + "total_results": 1, + "warning": [], +} +RESP3_JSON_SEARCH = { + "attributes": [], + "format": "STRING", + "results": [ + { + "id": "probe-json:1", + "extra_attributes": {"$": '{"first_name":"Andrew","age":38}'}, + "values": [], + } + ], + "total_results": 1, + "warning": [], +} +RESP3_NOCONTENT = { + "attributes": [], + "format": "STRING", + "results": [{"id": "probe-hash:1", "values": []}], + "total_results": 2, + "warning": [], +} +RESP3_EMPTY = { + "attributes": [], + "format": "STRING", + "results": [], + "total_results": 0, + "warning": [], +} + +RESP2_INFO = [ + "index_name", + "compat-probe", + "attributes", + [ + [ + "identifier", + "first_name", + "attribute", + "first_name", + "type", + "TAG", + "SEPARATOR", + ",", + ], + ["identifier", "age", "attribute", "age", "type", "NUMERIC"], + ], +] +RESP3_INFO = { + "index_name": "compat-probe", + "attributes": [ + { + "identifier": "first_name", + "attribute": "first_name", + "type": "TAG", + "SEPARATOR": ",", + "flags": [], + }, + { + "identifier": "age", + "attribute": "age", + "type": "NUMERIC", + }, + ], +} + + +def test_search_total_resp2_and_resp3(): + assert search_total(RESP2_HASH_SEARCH) == 1 + assert search_total(RESP3_HASH_SEARCH) == 1 + assert search_total(RESP2_EMPTY) == 0 + assert search_total(RESP3_EMPTY) == 0 + assert search_total(RESP2_NOCONTENT) == 2 + assert search_total(RESP3_NOCONTENT) == 2 + + +def test_search_document_keys_resp2_and_resp3(): + assert search_document_keys(RESP2_HASH_SEARCH) == ["probe-hash:1"] + assert search_document_keys(RESP3_HASH_SEARCH) == ["probe-hash:1"] + assert search_document_keys(RESP2_JSON_SEARCH) == ["probe-json:1"] + assert search_document_keys(RESP3_JSON_SEARCH) == ["probe-json:1"] + assert search_document_keys(RESP2_EMPTY) == [] + assert search_document_keys(RESP3_EMPTY) == [] + assert search_document_keys( + [2, b"probe-hash:1", ["first_name", "Andrew"], "probe-hash:2", []] + ) == ["probe-hash:1", "probe-hash:2"] + + +def test_search_documents_hash_fields(): + assert search_documents(RESP2_HASH_SEARCH) == [ + {"first_name": "Andrew", "age": "38"} + ] + assert search_documents(RESP3_HASH_SEARCH) == [ + {"first_name": "Andrew", "age": "38"} + ] + + +def test_search_documents_json_payload(): + assert search_documents(RESP2_JSON_SEARCH) == [ + {"$": '{"first_name":"Andrew","age":38}'} + ] + assert search_documents(RESP3_JSON_SEARCH) == [ + {"$": '{"first_name":"Andrew","age":38}'} + ] + + +def test_search_documents_empty_and_nocontent(): + assert search_documents(RESP2_EMPTY) == [] + assert search_documents(RESP3_EMPTY) == [] + assert search_documents(RESP2_NOCONTENT) == [] + assert search_documents(RESP3_NOCONTENT) == [] + + +def test_search_reply_bytes_keys_and_null_payload(): + raw = { + b"total_results": 1, + b"results": [ + { + b"id": b"probe-json:1", + b"extra_attributes": {b"$": b'{"first_name":"Andrew","age":38}'}, + }, + {b"id": b"probe-json:2", b"extra_attributes": None}, + ], + } + assert search_total(raw) == 1 + assert search_document_keys(raw) == ["probe-json:1", "probe-json:2"] + assert search_documents(raw) == [{"$": '{"first_name":"Andrew","age":38}'}] + assert ( + index_field_schema( + {b"attributes": [{b"attribute": b"age", b"type": b"NUMERIC"}]} + )["age"]["type"] + == "NUMERIC" + ) + + +def test_index_field_schema_resp2_and_resp3(): + for info in (RESP2_INFO, RESP3_INFO): + schema = index_field_schema(info) + assert schema["first_name"]["type"] == "TAG" + assert schema["age"]["type"] == "NUMERIC" + + +def test_from_redis_hash_and_json_shapes(): + from aredis_om import Field, HashModel, JsonModel + + class Person(HashModel): + first_name: str = Field(index=True) + age: int = Field(index=True) + + for raw in (RESP2_HASH_SEARCH, RESP3_HASH_SEARCH): + docs = Person.from_redis(raw) + assert docs[0].first_name == "Andrew" + assert docs[0].age == 38 + + class Document(JsonModel): + first_name: str = Field(index=True) + age: int = Field(index=True) + + for raw in (RESP2_JSON_SEARCH, RESP3_JSON_SEARCH): + docs = Document.from_redis(raw) + assert docs[0].first_name == "Andrew" + assert docs[0].age == 38 diff --git a/uv.lock b/uv.lock index c90794ff..3158ec0c 100644 --- a/uv.lock +++ b/uv.lock @@ -1962,19 +1962,19 @@ wheels = [ [[package]] name = "redis" -version = "7.1.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/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" } +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/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" }, + { 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]] name = "redis-om" -version = "1.1.0" +version = "1.2.0" source = { editable = "." } dependencies = [ { name = "click" }, @@ -2033,8 +2033,8 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.0.0,<3.0.0" }, { name = "pydantic-extra-types", specifier = ">=2.10.5" }, { name = "python-ulid", specifier = ">=1.0.3,<=3.1.0" }, - { name = "redis", specifier = ">=4.2.0,<8.0.0" }, - { name = "redisvl", specifier = ">=0.16.0" }, + { name = "redis", specifier = ">=6.3.0,!=8.0.0,<9.0.0" }, + { name = "redisvl", specifier = ">=0.27.0" }, { name = "setuptools", specifier = ">=70.0" }, { name = "types-redis", specifier = ">=3.5.9,<5.0.0" }, { name = "typing-extensions", specifier = ">=4.4.0" }, @@ -2066,7 +2066,7 @@ dev = [ [[package]] name = "redisvl" -version = "0.16.0" +version = "0.27.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonpath-ng" }, @@ -2079,9 +2079,9 @@ dependencies = [ { name = "redis" }, { name = "tenacity" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/cf/a41c9b547d46a1945b3a154c304efdb0e10df8c8117a333382522f915b6a/redisvl-0.16.0.tar.gz", hash = "sha256:992c0b8c305fbcd89f6b39fb75f1cd708ac7f8d2cf332bd08a2ad49f67f610c4", size = 874578, upload-time = "2026-03-13T12:56:31.137Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/a9/e1b9e23febbb3c2c5ab2ee3a3072d657fb39c6e35d18514a414ff19c31e9/redisvl-0.27.2.tar.gz", hash = "sha256:9a08c7ce107056d7aa9cc8e390d049350b1c7acc2801e34bd731f4dd014a1d09", size = 1354907, upload-time = "2026-09-10T10:51:36.614Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/7d/6685ad622785f9b78772ab36ffd00cf9b957b19a4ec846dd72dc420e1b89/redisvl-0.16.0-py3-none-any.whl", hash = "sha256:af11e4b3bd2c23241f715d6979afeb8a738bee83551b538bed35cca025006114", size = 199510, upload-time = "2026-03-13T12:56:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/2f/f0/ad05c7f9b1830301ac90858f5b2da1f090119c736cbac748b6043546a056/redisvl-0.27.2-py3-none-any.whl", hash = "sha256:d6fb9138b17f01baaa7fd9c2f1476e3f30078dec1baff01867ad996683985843", size = 424647, upload-time = "2026-09-10T10:51:35.148Z" }, ] [[package]]