Repository: redis/redis-py
Pull request #2739 (2023-05-03 12:10:44)

Fix `xadd` allow non negative maxlen
### Pull Request check-list

_Please make sure to review and check all of these items:_

- [ ] Does `$ tox` pass with this change (including linting)?
- [ ] Do the CI tests pass with this change (enable it first in your forked repo and wait for the github action build to finish)?
- [ ] Is the new or changed code fully tested?
- [ ] Is a documentation update included (if this change modifies existing APIs, or introduces new ones)?
- [ ] Is there an example added to the examples folder (if applicable)?
- [x] Was the change added to CHANGES file?

_NOTE: these things are not required to open a PR and can be done
afterwards / while the PR is open._

### Description of change

Modify the limit of the maxlen parameter that can be passed when using the xadd command.

AS-IS:

```python
if maxlen is not None:
  if not isinstance(maxlen, int) or maxlen < 1:
    raise DataError("XADD maxlen must be a positive integer")
```

TO-BE:

```python
if maxlen is not None:
  if not isinstance(maxlen, int) or maxlen < 0:
    raise DataError("XADD maxlen must be non-negative integer")
```

How do we create an empty 'STREAM'?

While using stream, I felt the need to empty the contents of the stream once.
Since xadd's `maxlen` **did not allow 0** at the time,
I solved the problem using `execute_command`,

```python
import redis

r = redis.Redis(host='localhost', port=6379, db=0)

# not working
# r.xadd("my-stream", {"hello": "world"}, maxlen=0)

# it works
r.execute_command("XADD", "my-stream", "MAXLEN", "0", "*", "data", "{}")
```

`execute_command` is preety good.
but someone might want to implement these logic with xadd.

redis 5.0 and unstable version as well
Since stream was added, it accepts integers up to and including 0.

- from redis/redis `5.0` branch
https://github.com/redis/redis/blob/05827336b0efcb454bcc5fe77ceacc6f226b6bb2/src/t_stream.c#L1269

- from redis/redis `unstable` branch
https://github.com/redis/redis/blob/5e3be1be09c947810732e7be2a4bb1b0ed75de4a/src/t_stream.c#LL941C30-L941C30

- from redis/redis issue-5739
> In general given that you can reach an empty stream condition, the fact XADD can create it looks natural to me.
https://github.com/redis/redis/issues/5739#issuecomment-452626046



Patch:
diff --git a/CHANGES b/CHANGES
index ea171f6b..49179801 100644
--- a/CHANGES
+++ b/CHANGES
@@ -1,3 +1,4 @@
+    * Fix `xadd` command to accept non-negative `maxlen` including 0
     * Revert #2104, #2673, add `disconnect_on_error` option to `read_response()` (issues #2506, #2624)
     * Add `address_remap` parameter to `RedisCluster`
     * Fix incorrect usage of once flag in async Sentinel
diff --git a/redis/asyncio/client.py b/redis/asyncio/client.py
index a7b888ee..7479b742 100644
--- a/redis/asyncio/client.py
+++ b/redis/asyncio/client.py
@@ -139,8 +139,12 @@ class Redis(
         arguments always win.
 
         """
+        single_connection_client = kwargs.pop("single_connection_client", False)
         connection_pool = ConnectionPool.from_url(url, **kwargs)
-        return cls(connection_pool=connection_pool)
+        return cls(
+            connection_pool=connection_pool,
+            single_connection_client=single_connection_client,
+        )
 
     def __init__(
         self,
diff --git a/redis/client.py b/redis/client.py
index 65d0cec9..9fd5b7ca 100755
--- a/redis/client.py
+++ b/redis/client.py
@@ -906,8 +906,12 @@ class Redis(AbstractRedis, RedisModuleCommands, CoreCommands, SentinelCommands):
         arguments always win.
 
         """
+        single_connection_client = kwargs.pop("single_connection_client", False)
         connection_pool = ConnectionPool.from_url(url, **kwargs)
-        return cls(connection_pool=connection_pool)
+        return cls(
+            connection_pool=connection_pool,
+            single_connection_client=single_connection_client,
+        )
 
     def __init__(
         self,
diff --git a/redis/commands/core.py b/redis/commands/core.py
index d67291b3..f2d7bf2e 100644
--- a/redis/commands/core.py
+++ b/redis/commands/core.py
@@ -761,6 +761,17 @@ class ManagementCommands(CommandsProtocol):
         """
         return self.execute_command("CLIENT NO-EVICT", mode)
 
+    def client_no_touch(self, mode: str) -> Union[Awaitable[str], str]:
+        """
+        # The command controls whether commands sent by the client will alter
+        # the LRU/LFU of the keys they access.
+        # When turned on, the current client will not change LFU/LRU stats,
+        # unless it sends the TOUCH command.
+
+        For more information see https://redis.io/commands/client-no-touch
+        """
+        return self.execute_command("CLIENT NO-TOUCH", mode)
+
     def command(self, **kwargs):
         """
         Returns dict reply of details about all Redis commands.
@@ -3485,8 +3496,8 @@ class StreamCommands(CommandsProtocol):
             raise DataError("Only one of ```maxlen``` or ```minid``` may be specified")
 
         if maxlen is not None:
-            if not isinstance(maxlen, int) or maxlen < 1:
-                raise DataError("XADD maxlen must be a positive integer")
+            if not isinstance(maxlen, int) or maxlen < 0:
+                raise DataError("XADD maxlen must be non-negative integer")
             pieces.append(b"MAXLEN")
             if approximate:
                 pieces.append(b"~")


Test patch:
diff --git a/tests/test_asyncio/test_commands.py b/tests/test_asyncio/test_commands.py
index ac3537db..955b9d42 100644
--- a/tests/test_asyncio/test_commands.py
+++ b/tests/test_asyncio/test_commands.py
@@ -453,6 +453,14 @@ class TestRedisCommands:
         with pytest.raises(exceptions.RedisError):
             await r.client_pause(timeout="not an integer")
 
+    @skip_if_server_version_lt("7.2.0")
+    @pytest.mark.onlynoncluster
+    async def test_client_no_touch(self, r: redis.Redis):
+        assert await r.client_no_touch("ON") == b"OK"
+        assert await r.client_no_touch("OFF") == b"OK"
+        with pytest.raises(TypeError):
+            await r.client_no_touch()
+
     async def test_config_get(self, r: redis.Redis):
         data = await r.config_get()
         assert "maxmemory" in data
diff --git a/tests/test_asyncio/test_connection.py b/tests/test_asyncio/test_connection.py
index e49dd422..158b8545 100644
--- a/tests/test_asyncio/test_connection.py
+++ b/tests/test_asyncio/test_connection.py
@@ -271,3 +271,9 @@ async def test_connection_disconect_race(parser_class):
 
     vals = await asyncio.gather(do_read(), do_close())
     assert vals == [b"Hello, World!", None]
+
+
+@pytest.mark.onlynoncluster
+def test_create_single_connection_client_from_url():
+    client = Redis.from_url("redis://localhost:6379/0?", single_connection_client=True)
+    assert client.single_connection_client is True
diff --git a/tests/test_commands.py b/tests/test_commands.py
index cb896690..c71e347f 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -696,6 +696,14 @@ class TestRedisCommands:
         with pytest.raises(TypeError):
             r.client_no_evict()
 
+    @pytest.mark.onlynoncluster
+    @skip_if_server_version_lt("7.2.0")
+    def test_client_no_touch(self, r):
+        assert r.client_no_touch("ON") == b"OK"
+        assert r.client_no_touch("OFF") == b"OK"
+        with pytest.raises(TypeError):
+            r.client_no_touch()
+
     @pytest.mark.onlynoncluster
     @skip_if_server_version_lt("3.2.0")
     def test_client_reply(self, r, r_timeout):
diff --git a/tests/test_connection.py b/tests/test_connection.py
index 75ba7380..31268a9e 100644
--- a/tests/test_connection.py
+++ b/tests/test_connection.py
@@ -205,3 +205,11 @@ def test_pack_command(Class):
 
     actual = Class().pack_command(*cmd)[0]
     assert actual == expected, f"actual = {actual}, expected = {expected}"
+
+
+@pytest.mark.onlynoncluster
+def test_create_single_connection_client_from_url():
+    client = redis.Redis.from_url(
+        "redis://localhost:6379/0?", single_connection_client=True
+    )
+    assert client.connection is not None