From d65e8d2ce86464db57d78a012031ec7a2169106c Mon Sep 17 00:00:00 2001 From: Joshua Date: Fri, 7 Nov 2025 11:31:07 +0100 Subject: [PATCH 1/4] Add hsetex and tests Signed-off-by: Joshua --- tests/test_commands.py | 21 +++++++++++++++ valkey/commands/core.py | 58 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/tests/test_commands.py b/tests/test_commands.py index 2e33ac58..34826865 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -3236,6 +3236,27 @@ def test_hstrlen(self, r): assert r.hstrlen("a", "1") == 2 assert r.hstrlen("a", "2") == 3 + @skip_if_server_version_lt("9.0.0") + def test_hsetex(self, r): + assert r.hsetex("a", "field1", "value1", ex=5) == 1 + assert r.hget("a", "field1") == b"value1" + assert r.hsetex("a", "field1", "value2", ex=5) == 1 + assert r.hget("a", "field1") == b"value2" + + @skip_if_server_version_lt("9.0.0") + def test_hsetex_px(self, r): + assert r.hsetex("a", "field1", "value1", px=5000) == 1 + assert r.hget("a", "field1") == b"value1" + assert r.hsetex("a", "field1", "value2", px=5000) == 1 + assert r.hget("a", "field1") == b"value2" + + @skip_if_server_version_lt("9.0.0") + def test_hsetex_mapping(self, r): + mapping = {"field1": "value1", "field2": "value2"} + assert r.hsetex("a", mapping=mapping, ex=5) == 1 + assert r.hget("a", "field1") == b"value1" + assert r.hget("a", "field2") == b"value2" + # SORT def test_sort_basic(self, r): r.rpush("a", "3", "2", "1", "4") diff --git a/valkey/commands/core.py b/valkey/commands/core.py index 80a71be6..4f22b528 100644 --- a/valkey/commands/core.py +++ b/valkey/commands/core.py @@ -5055,6 +5055,64 @@ def hsetnx(self, name: str, key: str, value: str) -> Union[Awaitable[bool], bool """ return self.execute_command("HSETNX", name, key, value) + def hsetex( + self, + name: str, + key: Optional[str] = None, + value: Optional[str] = None, + mapping: Optional[dict] = None, + items: Optional[list] = None, + ex: Union[ExpiryT, None] = None, + px: Union[ExpiryT, None] = None, + exat: Union[AbsExpiryT, None] = None, + pxat: Union[AbsExpiryT, None] = None, + nx: bool = False, + xx: bool = False, + fnx: bool = False, + fxx: bool = False, + ) -> Union[Awaitable[bool], bool]: + """ + Set key to value within hash ``name``, + ``mapping`` accepts a dict of key/value pairs to be added to hash ``name``. + ``items`` accepts a list of key/value pairs to be added to hash ``name``. + Set expiration options for the hash fields. + """ + + if key is None and not mapping and not items: + raise DataError("'hsetex' with no key value pairs") + pieces = [] + if ex is not None: + pieces.extend(["EX", ex]) + if px is not None: + pieces.extend(["PX", px]) + if exat is not None: + pieces.extend(["EXAT", exat]) + if pxat is not None: + pieces.extend(["PXAT", pxat]) + if nx: + pieces.append("NX") + if xx: + pieces.append("XX") + if fnx: + pieces.append("FNX") + if fxx: + pieces.append("FXX") + pieces.append("FIELDS") + if key is not None and value is not None: + pieces.append(1) # for one field + pieces.append(key) + pieces.append(value) + if mapping: + pieces.append(len(mapping)) + for key, value in mapping.items(): + pieces.append(key) + pieces.append(value) + if items: + pieces.append(len(items) // 2) + pieces.extend(items) + + return self.execute_command("HSETEX", name, *pieces) + def hmset(self, name: str, mapping: dict) -> Union[Awaitable[str], str]: """ Set key to value within hash ``name`` for each corresponding From b97c408e7834a83f7f3d26db9b6715df4fcdeb2e Mon Sep 17 00:00:00 2001 From: Joshua Date: Fri, 7 Nov 2025 11:31:07 +0100 Subject: [PATCH 2/4] Add hsetex and tests Signed-off-by: Joshua --- valkey/commands/core.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/valkey/commands/core.py b/valkey/commands/core.py index 4f22b528..75b93042 100644 --- a/valkey/commands/core.py +++ b/valkey/commands/core.py @@ -5076,6 +5076,8 @@ def hsetex( ``mapping`` accepts a dict of key/value pairs to be added to hash ``name``. ``items`` accepts a list of key/value pairs to be added to hash ``name``. Set expiration options for the hash fields. + + For more information see https://valkey.io/commands/hsetex """ if key is None and not mapping and not items: From e27fbbd8baa95f498dd132138bfceb245c76481b Mon Sep 17 00:00:00 2001 From: Joshua Date: Sat, 8 Nov 2025 09:47:05 +0100 Subject: [PATCH 3/4] Make params in HSETEX mutual exclusive Signed-off-by: Joshua --- tests/test_commands.py | 5 +++++ valkey/commands/core.py | 12 ++++++++++++ 2 files changed, 17 insertions(+) diff --git a/tests/test_commands.py b/tests/test_commands.py index 34826865..f40c9292 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -3243,6 +3243,11 @@ def test_hsetex(self, r): assert r.hsetex("a", "field1", "value2", ex=5) == 1 assert r.hget("a", "field1") == b"value2" + @skip_if_server_version_lt("9.0.0") + def test_hset_ex_invalid_params(self, r): + with pytest.raises(exceptions.DataError): + r.hsetex("a", "field1", "value1", ex=5, px=5000) # Both ex and px provided + @skip_if_server_version_lt("9.0.0") def test_hsetex_px(self, r): assert r.hsetex("a", "field1", "value1", px=5000) == 1 diff --git a/valkey/commands/core.py b/valkey/commands/core.py index 75b93042..a14a4555 100644 --- a/valkey/commands/core.py +++ b/valkey/commands/core.py @@ -5066,6 +5066,7 @@ def hsetex( px: Union[ExpiryT, None] = None, exat: Union[AbsExpiryT, None] = None, pxat: Union[AbsExpiryT, None] = None, + keepttl: bool = False, nx: bool = False, xx: bool = False, fnx: bool = False, @@ -5082,6 +5083,15 @@ def hsetex( if key is None and not mapping and not items: raise DataError("'hsetex' with no key value pairs") + + if int(keepttl) + sum(arg is not None for arg in [ex, px, exat, pxat]) > 1: + raise DataError( + "Only one of 'ex', 'px', 'exat', 'pxat', or 'keepttl' can be specified." + ) + if nx and xx: + raise DataError("Only one of 'nx' or 'xx' can be specified.") + if fnx and fxx: + raise DataError("Only one of 'fnx' or 'fxx' can be specified.") pieces = [] if ex is not None: pieces.extend(["EX", ex]) @@ -5107,6 +5117,8 @@ def hsetex( if mapping: pieces.append(len(mapping)) for key, value in mapping.items(): + if key is None or value is None: + raise DataError("'hsetex' mapping contains None key or value") pieces.append(key) pieces.append(value) if items: From d7d8d5a0c064193c124cc6738a5e213f86950ae6 Mon Sep 17 00:00:00 2001 From: Joshua Date: Wed, 12 Nov 2025 16:40:39 +0100 Subject: [PATCH 4/4] Add async tests for hsetex Signed-off-by: Joshua --- tests/test_asyncio/test_commands.py | 26 ++++++++++++++++++++++++++ tests/test_commands.py | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/tests/test_asyncio/test_commands.py b/tests/test_asyncio/test_commands.py index d91cab71..ed42c509 100644 --- a/tests/test_asyncio/test_commands.py +++ b/tests/test_asyncio/test_commands.py @@ -2151,6 +2151,32 @@ async def test_hstrlen(self, r: valkey.Valkey): assert await r.hstrlen("a", "1") == 2 assert await r.hstrlen("a", "2") == 3 + @skip_if_server_version_lt("9.0.0") + async def test_hsetex(self, r): + assert await r.hsetex("a", "field1", "value1", ex=5) == 1 + assert await r.hget("a", "field1") == b"value1" + assert await r.hsetex("a", "field1", "value2", ex=5) == 1 + assert await r.hget("a", "field1") == b"value2" + + @skip_if_server_version_lt("9.0.0") + async def test_hsetex_invalid_params(self, r): + with pytest.raises(exceptions.DataError): + await r.hsetex("a", "field1", "value1", ex=5, px=5000) + + @skip_if_server_version_lt("9.0.0") + async def test_hsetex_px(self, r): + assert await r.hsetex("a", "field1", "value1", px=5000) == 1 + assert await r.hget("a", "field1") == b"value1" + assert await r.hsetex("a", "field1", "value2", px=5000) == 1 + assert await r.hget("a", "field1") == b"value2" + + @skip_if_server_version_lt("9.0.0") + async def test_hsetex_mapping(self, r): + mapping = {"field1": "value1", "field2": "value2"} + assert await r.hsetex("a", mapping=mapping, ex=5) == 1 + assert await r.hget("a", "field1") == b"value1" + assert await r.hget("a", "field2") == b"value2" + # SORT async def test_sort_basic(self, r: valkey.Valkey): await r.rpush("a", "3", "2", "1", "4") diff --git a/tests/test_commands.py b/tests/test_commands.py index f40c9292..c7b6ae54 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -3244,7 +3244,7 @@ def test_hsetex(self, r): assert r.hget("a", "field1") == b"value2" @skip_if_server_version_lt("9.0.0") - def test_hset_ex_invalid_params(self, r): + def test_hsetex_invalid_params(self, r): with pytest.raises(exceptions.DataError): r.hsetex("a", "field1", "value1", ex=5, px=5000) # Both ex and px provided