diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index 68823d99519..1667d6e786f 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -669,6 +669,7 @@ jobs: - shard: caching-local test-path: >- tests/unit/caching + tests/unit/redis python-version: "3.12" workers: 2 reruns: 0 diff --git a/litellm/redis/transport/redis_py.py b/litellm/redis/transport/redis_py.py index 9ddbac14d2b..8a65413be6e 100644 --- a/litellm/redis/transport/redis_py.py +++ b/litellm/redis/transport/redis_py.py @@ -1,5 +1,5 @@ import hashlib -from collections.abc import Awaitable, Callable, Sequence +from collections.abc import Awaitable, Callable, Iterator, Sequence from dataclasses import dataclass from datetime import timedelta from typing import Final, Protocol, TypeVar @@ -128,6 +128,27 @@ def _int_reply(reply: object) -> int | RedisFailure: def _eval_reply(reply: object) -> EvalReply | RedisFailure: + frames: Final[list[tuple[Iterator[object], list[EvalReply]]]] = [ # mutable-ok: linear-time traversal stack + (iter((reply,)), []) + ] + while frames: + pending, values = frames[-1] + for item in pending: + if (array := _array(item)) is not None: + frames.append((iter(array), [])) + break + if isinstance(scalar := _eval_scalar(item), RedisFailure): + return scalar + values.append(scalar) + else: + frames.pop() + if not frames: + return values[0] + frames[-1][1].append(tuple(values)) + return _decode_failure(reply) + + +def _eval_scalar(reply: object) -> int | bytes | None | RedisFailure: if isinstance(reply, RedisFailure) or reply is None or isinstance(reply, bytes): return reply if isinstance(reply, bool): @@ -136,9 +157,4 @@ def _eval_reply(reply: object) -> EvalReply | RedisFailure: return reply if isinstance(reply, str): return reply.encode() - array: Final = _array(reply) - if array is None: - return _decode_failure(reply) - items: Final = tuple(_eval_reply(item) for item in array) - failure: Final = next((item for item in items if isinstance(item, RedisFailure)), None) - return failure if failure is not None else tuple(item for item in items if not isinstance(item, RedisFailure)) + return _decode_failure(reply) diff --git a/tests/unit/redis/transport/test_redis_py.py b/tests/unit/redis/transport/test_redis_py.py index 2c302b6563a..ee289745f92 100644 --- a/tests/unit/redis/transport/test_redis_py.py +++ b/tests/unit/redis/transport/test_redis_py.py @@ -153,6 +153,22 @@ async def test_eval_namespaces_keys_and_returns_nested_replies( assert await raw.get("ns:k") == b"w" +@pytest.mark.asyncio +@pytest.mark.parametrize("decode_responses", (False, True)) +async def test_eval_preserves_empty_arrays_and_sibling_order( + server: fakeredis.FakeServer, decode_responses: bool +) -> None: + raw = fakeredis.FakeAsyncRedis(server=server, decode_responses=decode_responses) + transport = fake_redis_transport(raw, failure_threshold=1) + + assert await transport.eval('return {{}, {1, {"hello", {}}}, {}, 0}', (), ()) == ( + (), + (1, (b"hello", ())), + (), + 0, + ) + + @pytest.mark.asyncio async def test_eval_survives_a_script_flush(server: fakeredis.FakeServer, raw: fakeredis.FakeAsyncRedis) -> None: transport = fake_redis_cache(server).transport() diff --git a/tests/unit/test_internal_context.py b/tests/unit/test_internal_context.py index 3336cce9aaf..6003a21ff57 100644 --- a/tests/unit/test_internal_context.py +++ b/tests/unit/test_internal_context.py @@ -25,7 +25,7 @@ _REPO: Final = Path(__file__).resolve().parents[2] _REDIS_PRODUCER_ROOTS: Final = ("litellm", "enterprise") # The cache implementations and facades: they emit the service events, their callers declare the family. -_CACHE_LAYER_DIRS: Final = ("litellm/caching", "litellm/_v2/cache") +_CACHE_LAYER_DIRS: Final = ("litellm/caching", "litellm/_v2/cache", "litellm/redis/transport") # Helpers that act on a cache handed in by the declaring caller, or forward to the response-cache facade. _CACHE_PARAMETER_HELPERS: Final = frozenset( {