Repository: redis/redis-py
Pull request #3278 (2024-06-13 15:34:05)

Handle lists in the response of INFO
### Pull Request check-list

_Please make sure to review and check all of these items:_

- [ ] Do tests and lints pass with this change?
- [ ] 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)?
- [ ] 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

Parse lists in the response of INFO, and even lines where list items are mixed with key=value items, in which case the overall structure will be a dict, and the items without value get `True` as their value.


Patch:
diff --git a/.github/workflows/stale-issues.yml b/.github/workflows/stale-issues.yml
index 32fd9e81..445af1c8 100644
--- a/.github/workflows/stale-issues.yml
+++ b/.github/workflows/stale-issues.yml
@@ -12,7 +12,7 @@ jobs:
 
     runs-on: ubuntu-latest
     steps:
-    - uses: actions/stale@v3
+    - uses: actions/stale@v9
       with:
         repo-token: ${{ secrets.GITHUB_TOKEN }}
         stale-issue-message: 'This issue is marked stale. It will be closed in 30 days if it is not updated.'
diff --git a/redis/_parsers/helpers.py b/redis/_parsers/helpers.py
index 57b12ab8..56d912c1 100644
--- a/redis/_parsers/helpers.py
+++ b/redis/_parsers/helpers.py
@@ -46,11 +46,18 @@ def parse_info(response):
                     return int(value)
             except ValueError:
                 return value
+        elif "=" not in value:
+            return [get_value(v) for v in value.split(",") if v]
         else:
             sub_dict = {}
             for item in value.split(","):
-                k, v = item.rsplit("=", 1)
-                sub_dict[k] = get_value(v)
+                if not item:
+                    continue
+                if "=" in item:
+                    k, v = item.rsplit("=", 1)
+                    sub_dict[k] = get_value(v)
+                else:
+                    sub_dict[item] = True
             return sub_dict
 
     for line in response.splitlines():


Test patch:
diff --git a/tests/test_asyncio/test_json.py b/tests/test_asyncio/test_json.py
index c713f1ac..cc98a553 100644
--- a/tests/test_asyncio/test_json.py
+++ b/tests/test_asyncio/test_json.py
@@ -103,7 +103,7 @@ async def test_jsonsetexistentialmodifiersshouldsucceed(decoded_r: redis.Redis):
     assert await decoded_r.json().set("obj", Path("foo"), "baz", xx=True)
     assert await decoded_r.json().set("obj", Path("qaz"), "baz", nx=True)
 
-    # Test that flags are mutually exlusive
+    # Test that flags are mutually exclusive
     with pytest.raises(Exception):
         await decoded_r.json().set("obj", Path("foo"), "baz", nx=True, xx=True)
 
@@ -483,7 +483,7 @@ async def test_json_mget_dollar(decoded_r: redis.Redis):
     )
 
     # Test mget with single path
-    await decoded_r.json().mget("doc1", "$..a") == [1, 3, None]
+    assert await decoded_r.json().mget(["doc1"], "$..a") == [[1, 3, None]]
     # Test mget with multi path
     res = await decoded_r.json().mget(["doc1", "doc2"], "$..a")
     assert res == [[1, 3, None], [4, 6, [None]]]
@@ -539,7 +539,7 @@ async def test_numby_commands_dollar(decoded_r: redis.Redis):
     await decoded_r.json().set(
         "doc1", "$", {"a": "b", "b": [{"a": 2}, {"a": 5.0}, {"a": "c"}]}
     )
-    await decoded_r.json().numincrby("doc1", ".b[0].a", 3) == 5
+    assert await decoded_r.json().numincrby("doc1", ".b[0].a", 3) == 5
 
     # Test legacy NUMMULTBY
     await decoded_r.json().set(
@@ -547,7 +547,7 @@ async def test_numby_commands_dollar(decoded_r: redis.Redis):
     )
 
     with pytest.deprecated_call():
-        await decoded_r.json().nummultby("doc1", ".b[0].a", 3) == 6
+        assert await decoded_r.json().nummultby("doc1", ".b[0].a", 3) == 6
 
 
 @pytest.mark.redismod
@@ -556,13 +556,13 @@ async def test_strappend_dollar(decoded_r: redis.Redis):
         "doc1", "$", {"a": "foo", "nested1": {"a": "hello"}, "nested2": {"a": 31}}
     )
     # Test multi
-    await decoded_r.json().strappend("doc1", "bar", "$..a") == [6, 8, None]
+    assert await decoded_r.json().strappend("doc1", "bar", "$..a") == [6, 8, None]
 
     res = [{"a": "foobar", "nested1": {"a": "hellobar"}, "nested2": {"a": 31}}]
     assert_resp_response(decoded_r, await decoded_r.json().get("doc1", "$"), res, [res])
 
     # Test single
-    await decoded_r.json().strappend("doc1", "baz", "$.nested1.a") == [11]
+    assert await decoded_r.json().strappend("doc1", "baz", "$.nested1.a") == [11]
 
     res = [{"a": "foobar", "nested1": {"a": "hellobarbaz"}, "nested2": {"a": 31}}]
     assert_resp_response(decoded_r, await decoded_r.json().get("doc1", "$"), res, [res])
@@ -572,7 +572,7 @@ async def test_strappend_dollar(decoded_r: redis.Redis):
         await decoded_r.json().strappend("non_existing_doc", "$..a", "err")
 
     # Test multi
-    await decoded_r.json().strappend("doc1", "bar", ".*.a") == 8
+    assert await decoded_r.json().strappend("doc1", "bar", ".*.a") == 14
     res = [{"a": "foobar", "nested1": {"a": "hellobarbazbar"}, "nested2": {"a": 31}}]
     assert_resp_response(decoded_r, await decoded_r.json().get("doc1", "$"), res, [res])
 
@@ -594,8 +594,8 @@ async def test_strlen_dollar(decoded_r: redis.Redis):
     assert res1 == res2
 
     # Test single
-    await decoded_r.json().strlen("doc1", "$.nested1.a") == [8]
-    await decoded_r.json().strlen("doc1", "$.nested2.a") == [None]
+    assert await decoded_r.json().strlen("doc1", "$.nested1.a") == [8]
+    assert await decoded_r.json().strlen("doc1", "$.nested2.a") == [None]
 
     # Test missing key
     with pytest.raises(exceptions.ResponseError):
@@ -614,7 +614,8 @@ async def test_arrappend_dollar(decoded_r: redis.Redis):
         },
     )
     # Test multi
-    await decoded_r.json().arrappend("doc1", "$..a", "bar", "racuda") == [3, 5, None]
+    res = [3, 5, None]
+    assert await decoded_r.json().arrappend("doc1", "$..a", "bar", "racuda") == res
     res = [
         {
             "a": ["foo", "bar", "racuda"],
@@ -794,7 +795,7 @@ async def test_arrpop_dollar(decoded_r: redis.Redis):
         },
     )
     # Test multi (all paths are updated, but return result of last path)
-    await decoded_r.json().arrpop("doc1", "..a", "1") is None
+    assert await decoded_r.json().arrpop("doc1", "..a", "1") == "null"
     res = [{"a": [], "nested1": {"a": ["hello", "world"]}, "nested2": {"a": 31}}]
     assert_resp_response(decoded_r, await decoded_r.json().get("doc1", "$"), res, [res])
 
diff --git a/tests/test_json.py b/tests/test_json.py
index 5cf9b11e..9996c898 100644
--- a/tests/test_json.py
+++ b/tests/test_json.py
@@ -104,7 +104,7 @@ def test_jsonsetexistentialmodifiersshouldsucceed(client):
     assert client.json().set("obj", Path("foo"), "baz", xx=True)
     assert client.json().set("obj", Path("qaz"), "baz", nx=True)
 
-    # Test that flags are mutually exlusive
+    # Test that flags are mutually exclusive
     with pytest.raises(Exception):
         client.json().set("obj", Path("foo"), "baz", nx=True, xx=True)
 
@@ -453,6 +453,7 @@ def test_json_forget_with_dollar(client):
     client.json().forget("not_a_document", "..a")
 
 
+@pytest.mark.onlynoncluster
 @pytest.mark.redismod
 def test_json_mget_dollar(client):
     # Test mget with multi paths
@@ -473,14 +474,14 @@ def test_json_mget_dollar(client):
     assert_resp_response(client, client.json().get("doc2", "$..a"), res, [res])
 
     # Test mget with single path
-    client.json().mget("doc1", "$..a") == [1, 3, None]
+    assert client.json().mget(["doc1"], "$..a") == [[1, 3, None]]
     # Test mget with multi path
-    client.json().mget(["doc1", "doc2"], "$..a") == [[1, 3, None], [4, 6, [None]]]
+    res = [[1, 3, None], [4, 6, [None]]]
+    assert client.json().mget(["doc1", "doc2"], "$..a") == res
 
     # Test missing key
-    client.json().mget(["doc1", "missing_doc"], "$..a") == [[1, 3, None], None]
-    res = client.json().mget(["missing_doc1", "missing_doc2"], "$..a")
-    assert res == [None, None]
+    assert client.json().mget(["doc1", "missing_doc"], "$..a") == [[1, 3, None], None]
+    assert client.json().mget(["missing_doc1", "missing_doc2"], "$..a") == [None, None]
 
 
 @pytest.mark.redismod
@@ -518,13 +519,13 @@ def test_numby_commands_dollar(client):
 
     # Test legacy NUMINCRBY
     client.json().set("doc1", "$", {"a": "b", "b": [{"a": 2}, {"a": 5.0}, {"a": "c"}]})
-    client.json().numincrby("doc1", ".b[0].a", 3) == 5
+    assert client.json().numincrby("doc1", ".b[0].a", 3) == 5
 
     # Test legacy NUMMULTBY
     client.json().set("doc1", "$", {"a": "b", "b": [{"a": 2}, {"a": 5.0}, {"a": "c"}]})
 
     with pytest.deprecated_call():
-        client.json().nummultby("doc1", ".b[0].a", 3) == 6
+        assert client.json().nummultby("doc1", ".b[0].a", 3) == 6
 
 
 @pytest.mark.redismod
@@ -533,25 +534,25 @@ def test_strappend_dollar(client):
         "doc1", "$", {"a": "foo", "nested1": {"a": "hello"}, "nested2": {"a": 31}}
     )
     # Test multi
-    client.json().strappend("doc1", "bar", "$..a") == [6, 8, None]
+    assert client.json().strappend("doc1", "bar", "$..a") == [6, 8, None]
 
-    # res = [{"a": "foobar", "nested1": {"a": "hellobar"}, "nested2": {"a": 31}}]
-    # assert_resp_response(client, client.json().get("doc1", "$"), res, [res])
+    res = [{"a": "foobar", "nested1": {"a": "hellobar"}, "nested2": {"a": 31}}]
+    assert_resp_response(client, client.json().get("doc1", "$"), res, [res])
 
     # Test single
-    client.json().strappend("doc1", "baz", "$.nested1.a") == [11]
+    assert client.json().strappend("doc1", "baz", "$.nested1.a") == [11]
 
-    # res = [{"a": "foobar", "nested1": {"a": "hellobarbaz"}, "nested2": {"a": 31}}]
-    # assert_resp_response(client, client.json().get("doc1", "$"), res, [res])
+    res = [{"a": "foobar", "nested1": {"a": "hellobarbaz"}, "nested2": {"a": 31}}]
+    assert_resp_response(client, client.json().get("doc1", "$"), res, [res])
 
     # Test missing key
     with pytest.raises(exceptions.ResponseError):
         client.json().strappend("non_existing_doc", "$..a", "err")
 
     # Test multi
-    client.json().strappend("doc1", "bar", ".*.a") == 8
-    # res = [{"a": "foo", "nested1": {"a": "hellobar"}, "nested2": {"a": 31}}]
-    # assert_resp_response(client, client.json().get("doc1", "$"), res, [res])
+    assert client.json().strappend("doc1", "bar", ".*.a") == 14
+    res = [{"a": "foobar", "nested1": {"a": "hellobarbazbar"}, "nested2": {"a": 31}}]
+    assert_resp_response(client, client.json().get("doc1", "$"), res, [res])
 
     # Test missing path
     with pytest.raises(exceptions.ResponseError):
@@ -571,8 +572,8 @@ def test_strlen_dollar(client):
     assert res1 == res2
 
     # Test single
-    client.json().strlen("doc1", "$.nested1.a") == [8]
-    client.json().strlen("doc1", "$.nested2.a") == [None]
+    assert client.json().strlen("doc1", "$.nested1.a") == [8]
+    assert client.json().strlen("doc1", "$.nested2.a") == [None]
 
     # Test missing key
     with pytest.raises(exceptions.ResponseError):
@@ -591,7 +592,7 @@ def test_arrappend_dollar(client):
         },
     )
     # Test multi
-    client.json().arrappend("doc1", "$..a", "bar", "racuda") == [3, 5, None]
+    assert client.json().arrappend("doc1", "$..a", "bar", "racuda") == [3, 5, None]
     res = [
         {
             "a": ["foo", "bar", "racuda"],
@@ -775,7 +776,7 @@ def test_arrpop_dollar(client):
         },
     )
     # Test multi (all paths are updated, but return result of last path)
-    client.json().arrpop("doc1", "..a", "1") is None
+    assert client.json().arrpop("doc1", "..a", "1") == "null"
     res = [{"a": [], "nested1": {"a": ["hello", "world"]}, "nested2": {"a": 31}}]
     assert_resp_response(client, client.json().get("doc1", "$"), res, [res])
 
diff --git a/tests/test_parsers/test_helpers.py b/tests/test_parsers/test_helpers.py
index 6430a237..383b9de7 100644
--- a/tests/test_parsers/test_helpers.py
+++ b/tests/test_parsers/test_helpers.py
@@ -33,3 +33,31 @@ search_query_timeout_ms:500
     assert info["search_version"] == "99.99.99"
     assert info["search_redis_version"] == "7.2.2 - oss"
     assert info["search_query_timeout_ms"] == 500
+
+
+def test_parse_info_list():
+    info_output = """
+list_one:a,
+list_two:a b,,c,10,1.1
+    """
+    info = parse_info(info_output)
+
+    assert isinstance(info["list_one"], list)
+    assert info["list_one"] == ["a"]
+
+    assert isinstance(info["list_two"], list)
+    assert info["list_two"] == ["a b", "c", 10, 1.1]
+
+
+def test_parse_info_list_dict_mixed():
+    info_output = """
+list_one:a,b=1
+list_two:a b=foo,,c,d=bar,e,
+    """
+    info = parse_info(info_output)
+
+    assert isinstance(info["list_one"], dict)
+    assert info["list_one"] == {"a": True, "b": 1}
+
+    assert isinstance(info["list_two"], dict)
+    assert info["list_two"] == {"a b": "foo", "c": True, "d": "bar", "e": True}