Repository: cunla/fakeredis-py

Issue:
Implement support for `GETEX` (string command)
Implement support for command `GETEX` in string_mixin.py.
        
        Get the value of a key and optionally set its expiration. 
        
        Here is the [Official documentation](https://redis.io/commands/getex/)

Discussion:

Patch:
diff --git a/REDIS_COMMANDS.md b/REDIS_COMMANDS.md
index db7a154..df16b3e 100644
--- a/REDIS_COMMANDS.md
+++ b/REDIS_COMMANDS.md
@@ -14,6 +14,8 @@ list of [unimplemented commands](#unimplemented-commands).
    Get the value of a key
  * [GETDEL](https://redis.io/commands/getdel/)
    Get the value of a key and delete the key
+ * [GETEX](https://redis.io/commands/getex/)
+   Get the value of a key and optionally set its expiration
  * [GETRANGE](https://redis.io/commands/getrange/)
    Get a substring of the string stored at a key
  * [GETSET](https://redis.io/commands/getset/)
@@ -730,16 +732,14 @@ All the redis commands are implemented in fakeredis with these exceptions:
  * [GEOSEARCHSTORE](https://redis.io/commands/geosearchstore/)
    Query a sorted set representing a geospatial index to fetch members inside an area of a box or a circle, and store the result in another key.
 
-### string
- * [GETEX](https://redis.io/commands/getex/)
-   Get the value of a key and optionally set its expiration
- * [LCS](https://redis.io/commands/lcs/)
-   Find longest common substring
-
 ### hash
  * [HRANDFIELD](https://redis.io/commands/hrandfield/)
    Get one or multiple random fields from a hash
 
+### string
+ * [LCS](https://redis.io/commands/lcs/)
+   Find longest common substring
+
 ### hyperloglog
  * [PFDEBUG](https://redis.io/commands/pfdebug/)
    Internal commands for debugging HyperLogLog values
diff --git a/fakeredis/commands_mixins/string_mixin.py b/fakeredis/commands_mixins/string_mixin.py
index ac35280..7b39605 100644
--- a/fakeredis/commands_mixins/string_mixin.py
+++ b/fakeredis/commands_mixins/string_mixin.py
@@ -193,3 +193,36 @@ class StringCommandsMixin:
     @command((Key(bytes), Int, Int))
     def substr(self, key, start, end):
         return self.getrange(key, start, end)
+
+    @command((Key(bytes),), (bytes,))
+    def getex(self, key, *args):
+        i, count_options, expire_time, diff = 0, 0, None, None
+        while i < len(args):
+            count_options += 1
+            if casematch(args[i], b'ex') and i + 1 < len(args):
+                diff = Int.decode(args[i + 1])
+                expire_time = self._db.time + diff
+                i += 2
+            elif casematch(args[i], b'px') and i + 1 < len(args):
+                diff = Int.decode(args[i + 1])
+                expire_time = (self._db.time * 1000 + diff) / 1000.0
+                i += 2
+            elif casematch(args[i], b'exat') and i + 1 < len(args):
+                expire_time = Int.decode(args[i + 1])
+                i += 2
+            elif casematch(args[i], b'pxat') and i + 1 < len(args):
+                expire_time = Int.decode(args[i + 1]) / 1000.0
+                i += 2
+            elif casematch(args[i], b'persist'):
+                expire_time = None
+                i += 1
+            else:
+                raise SimpleError(msgs.SYNTAX_ERROR_MSG)
+        if ((expire_time is not None and (expire_time <= 0 or expire_time * 1000 >= 2 ** 63))
+                or (diff is not None and (diff <= 0 or diff * 1000 >= 2 ** 63))):
+            raise SimpleError(msgs.INVALID_EXPIRE_MSG.format('getex'))
+        if count_options > 1:
+            raise SimpleError(msgs.SYNTAX_ERROR_MSG)
+
+        key.expireat = expire_time
+        return key.get(None)
diff --git a/scripts/create_issues.py b/scripts/create_issues.py
index 45d21c6..b2c43e4 100644
--- a/scripts/create_issues.py
+++ b/scripts/create_issues.py
@@ -64,11 +64,12 @@ class GithubData:
         link = f"https://redis.io/commands/{cmd.replace(' ', '-')}/"
         title = f"Implement support for `{cmd.upper()}` ({group} command)"
         filename = f'{group}_mixin.py'
-        body = f"""Implement support for command `{cmd.upper()}` in {filename}.
+        body = f"""
+Implement support for command `{cmd.upper()}` in {filename}.
         
-        {summary}. 
+{summary}. 
         
-        Here is the [Official documentation]({link})"""
+Here is the [Official documentation]({link})"""
         labels = [f'{group}-commands', 'enhancement', 'help wanted']
         for label in labels:
             if label not in self.labels:


Test patch:
diff --git a/test/test_mixins/test_server_commands.py b/test/test_mixins/test_server_commands.py
index c92bc8d..9859a22 100644
--- a/test/test_mixins/test_server_commands.py
+++ b/test/test_mixins/test_server_commands.py
@@ -4,8 +4,6 @@ from time import sleep
 import pytest
 from redis.exceptions import ResponseError
 
-from .. import testtools
-
 
 def test_swapdb(r, create_redis):
     r1 = create_redis(1)
diff --git a/test/test_mixins/test_string_commands.py b/test/test_mixins/test_string_commands.py
index 8bc351a..03754e3 100644
--- a/test/test_mixins/test_string_commands.py
+++ b/test/test_mixins/test_string_commands.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import time
 from datetime import timedelta
 
 import pytest
@@ -476,3 +477,38 @@ def test_setitem_getitem(r):
 def test_getitem_non_existent_key(r):
     assert r.keys() == []
     assert 'noexists' not in r.keys()
+
+
+@pytest.mark.slow
+def test_getex(r: redis.Redis):
+    # Exceptions
+    with pytest.raises(redis.ResponseError):
+        raw_command(r, 'getex', 'foo', 'px', 1000, 'ex', 1)
+    with pytest.raises(redis.ResponseError):
+        raw_command(r, 'getex', 'foo', 'dsac', 1000, 'ex', 1)
+
+    r.set('foo', 'val')
+    assert r.getex('foo', ex=1) == b'val'
+    time.sleep(1.5)
+    assert r.get('foo') is None
+
+    r.set('foo2', 'val')
+    assert r.getex('foo2', px=1000) == b'val'
+    time.sleep(1.5)
+    assert r.get('foo2') is None
+
+    r.set('foo4', 'val')
+    r.getex('foo4', exat=int(time.time() + 1))
+    time.sleep(1.5)
+    assert r.get('foo4') is None
+
+    r.set('foo2', 'val')
+    r.getex('foo2', pxat=int(time.time() + 1) * 1000)
+    time.sleep(1.5)
+    assert r.get('foo2') is None
+
+    r.setex('foo5', 1, 'val')
+    r.getex('foo5', persist=True)
+    assert r.ttl('foo5') == -1
+    time.sleep(1.5)
+    assert r.get('foo5') == b'val'