Repository: redis/redis-py
Pull request #3106 (2024-01-10 12:26:16)

Return a copy of the response from cache
Without this change a program can get a cache result, modify it and therefore modify the cache.

codecov-commenter: ## [Codecov](https://app.codecov.io/gh/redis/redis-py/pull/3106?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=redis) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Comparison is base [(`4a66c1e`)](https://app.codecov.io/gh/redis/redis-py/commit/4a66c1e2b06bce1fc2a05febe00ad46e5d78e2e2?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=redis) 92.05% compared to head [(`51e44ea`)](https://app.codecov.io/gh/redis/redis-py/pull/3106?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=redis) 77.87%.


:exclamation: Your organization needs to install the [Codecov GitHub app](https://github.com/apps/codecov/installations/select_target) to enable full functionality.

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##           master    #3106       +/-   ##
===========================================
- Coverage   92.05%   77.87%   -14.18%     
===========================================
  Files         128      128               
  Lines       33195    33191        -4     
===========================================
- Hits        30556    25846     -4710     
- Misses       2639     7345     +4706     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/redis/redis-py/pull/3106?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=redis).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=redis).


Patch:
diff --git a/redis/_cache.py b/redis/_cache.py
index 4255afb7..f8cc7d21 100644
--- a/redis/_cache.py
+++ b/redis/_cache.py
@@ -1,3 +1,4 @@
+import copy
 import random
 import time
 from collections import OrderedDict, defaultdict
@@ -226,7 +227,7 @@ class _LocalCache:
                 self.delete(command)
                 return
             self._update_access(command)
-            return self.cache[command]["response"]
+            return copy.deepcopy(self.cache[command]["response"])
 
     def delete(self, command: str):
         """
diff --git a/redis/commands/core.py b/redis/commands/core.py
index fa48a04b..6d81d760 100644
--- a/redis/commands/core.py
+++ b/redis/commands/core.py
@@ -5017,14 +5017,16 @@ class HashCommands(CommandsProtocol):
         """
         if key is None and not mapping and not items:
             raise DataError("'hset' with no key value pairs")
-        items = items or []
+        pieces = []
+        if items:
+            pieces.extend(items)
         if key is not None:
-            items.extend((key, value))
+            pieces.extend((key, value))
         if mapping:
             for pair in mapping.items():
-                items.extend(pair)
+                pieces.extend(pair)
 
-        return self.execute_command("HSET", name, *items)
+        return self.execute_command("HSET", name, *pieces)
 
     def hsetnx(self, name: str, key: str, value: str) -> Union[Awaitable[bool], bool]:
         """


Test patch:
diff --git a/tests/test_asyncio/test_cache.py b/tests/test_asyncio/test_cache.py
index 098ede8d..bf20337d 100644
--- a/tests/test_asyncio/test_cache.py
+++ b/tests/test_asyncio/test_cache.py
@@ -131,6 +131,17 @@ class TestLocalCache:
         assert cache.get(("LLEN", "mylist")) is None
         assert cache.get(("LINDEX", "mylist", 1)) == b"bar"
 
+    @pytest.mark.parametrize("r", [{"cache": _LocalCache()}], indirect=True)
+    async def test_cache_return_copy(self, r):
+        r, cache = r
+        await r.lpush("mylist", "foo", "bar", "baz")
+        assert await r.lrange("mylist", 0, -1) == [b"baz", b"bar", b"foo"]
+        res = cache.get(("LRANGE", "mylist", 0, -1))
+        assert res == [b"baz", b"bar", b"foo"]
+        res.append(b"new")
+        check = cache.get(("LRANGE", "mylist", 0, -1))
+        assert check == [b"baz", b"bar", b"foo"]
+
 
 @pytest.mark.skipif(HIREDIS_AVAILABLE, reason="PythonParser only")
 @pytest.mark.onlycluster
diff --git a/tests/test_cache.py b/tests/test_cache.py
index 570385a4..4eb5160e 100644
--- a/tests/test_cache.py
+++ b/tests/test_cache.py
@@ -15,6 +15,7 @@ def r(request):
         redis.Redis, request, protocol=3, client_cache=cache, **kwargs
     ) as client:
         yield client, cache
+        # client.flushdb()
 
 
 @pytest.mark.skipif(HIREDIS_AVAILABLE, reason="PythonParser only")
@@ -134,6 +135,17 @@ class TestLocalCache:
         assert cache.get(("LLEN", "mylist")) is None
         assert cache.get(("LINDEX", "mylist", 1)) == b"bar"
 
+    @pytest.mark.parametrize("r", [{"cache": _LocalCache()}], indirect=True)
+    def test_cache_return_copy(self, r):
+        r, cache = r
+        r.lpush("mylist", "foo", "bar", "baz")
+        assert r.lrange("mylist", 0, -1) == [b"baz", b"bar", b"foo"]
+        res = cache.get(("LRANGE", "mylist", 0, -1))
+        assert res == [b"baz", b"bar", b"foo"]
+        res.append(b"new")
+        check = cache.get(("LRANGE", "mylist", 0, -1))
+        assert check == [b"baz", b"bar", b"foo"]
+
 
 @pytest.mark.skipif(HIREDIS_AVAILABLE, reason="PythonParser only")
 @pytest.mark.onlycluster