diff --git a/README.md b/README.md index b766bd2..839c0b8 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ assert c.get("name") == "Fabian" ``` > [!NOTE] -> ZnSocket does not decode strings automatically. Using it is equivalent to using `Redis.from_url(storage, decode_responses=True)` in the Redis client. +> ZnSocket does not encode/decode strings. Using it is equivalent to using `Redis.from_url(storage, decode_responses=True)` in the Redis client. ## Lists diff --git a/poetry.lock b/poetry.lock index 5b7266b..8c600a5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -412,6 +412,51 @@ files = [ {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, ] +[[package]] +name = "numpy" +version = "1.26.4" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.9" +files = [ + {file = "numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0"}, + {file = "numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a"}, + {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4"}, + {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f"}, + {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a"}, + {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2"}, + {file = "numpy-1.26.4-cp310-cp310-win32.whl", hash = "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07"}, + {file = "numpy-1.26.4-cp310-cp310-win_amd64.whl", hash = "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5"}, + {file = "numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71"}, + {file = "numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef"}, + {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e"}, + {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5"}, + {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a"}, + {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a"}, + {file = "numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20"}, + {file = "numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2"}, + {file = "numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218"}, + {file = "numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b"}, + {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b"}, + {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed"}, + {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a"}, + {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0"}, + {file = "numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110"}, + {file = "numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818"}, + {file = "numpy-1.26.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7349ab0fa0c429c82442a27a9673fc802ffdb7c7775fad780226cb234965e53c"}, + {file = "numpy-1.26.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:52b8b60467cd7dd1e9ed082188b4e6bb35aa5cdd01777621a1658910745b90be"}, + {file = "numpy-1.26.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5241e0a80d808d70546c697135da2c613f30e28251ff8307eb72ba696945764"}, + {file = "numpy-1.26.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f870204a840a60da0b12273ef34f7051e98c3b5961b61b0c2c1be6dfd64fbcd3"}, + {file = "numpy-1.26.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:679b0076f67ecc0138fd2ede3a8fd196dddc2ad3254069bcb9faf9a79b1cebcd"}, + {file = "numpy-1.26.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:47711010ad8555514b434df65f7d7b076bb8261df1ca9bb78f53d3b2db02e95c"}, + {file = "numpy-1.26.4-cp39-cp39-win32.whl", hash = "sha256:a354325ee03388678242a4d7ebcd08b5c727033fcff3b2f536aea978e15ee9e6"}, + {file = "numpy-1.26.4-cp39-cp39-win_amd64.whl", hash = "sha256:3373d5d70a5fe74a2c1bb6d2cfd9609ecf686d47a2d7b1d37a8f3b6bf6003aea"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:afedb719a9dcfc7eaf2287b839d8198e06dcd4cb5d276a3df279231138e83d30"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95a7476c59002f2f6c590b9b7b998306fba6a5aa646b1e22ddfeaf8f78c3a29c"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7e50d0a0cc3189f9cb0aeb3a6a6af18c16f59f004b866cd2be1c14b36134a4a0"}, + {file = "numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010"}, +] + [[package]] name = "packaging" version = "24.0" @@ -535,13 +580,13 @@ ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)" [[package]] name = "requests" -version = "2.32.2" +version = "2.32.3" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" files = [ - {file = "requests-2.32.2-py3-none-any.whl", hash = "sha256:fc06670dd0ed212426dfeb94fc1b983d917c4f9847c863f313c9dfaaffb7c23c"}, - {file = "requests-2.32.2.tar.gz", hash = "sha256:dd951ff5ecf3e3b3aa26b40703ba77495dab41da839ae72ef3c8e5d8e2433289"}, + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, ] [package.dependencies] @@ -712,7 +757,18 @@ files = [ [package.dependencies] h11 = ">=0.9.0,<1" +[[package]] +name = "znjson" +version = "0.2.3" +description = "A Python Package to Encode/Decode some common file formats to json" +optional = false +python-versions = "<4.0,>=3.9" +files = [ + {file = "znjson-0.2.3-py3-none-any.whl", hash = "sha256:a23d81f39e3fd663c4c27361a03858ba3d5295f337c73d917e2ac1422016817b"}, + {file = "znjson-0.2.3.tar.gz", hash = "sha256:5de937e46b10e128157e8fe8d424dc28b5dbbf43b6b9d6a7dc8f6cdc61036cef"}, +] + [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "a01ff8445d92410ce9f646d1f60f4ca16ab0c867513f6b4f86c114d279c00c2a" +content-hash = "aec703189d675c2a9e13b20d985751e4a46aaa0d611f394a7d58c896892304f0" diff --git a/pyproject.toml b/pyproject.toml index b5e787c..78c4866 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "znsocket" -version = "0.1.2" +version = "0.1.3" description = "Python implementation of a Redis-compatible API using websockets." authors = ["Fabian Zills "] license = "Apache-2.0" @@ -11,12 +11,14 @@ python = "^3.10" python-socketio = {extras = ["client"], version = "^5"} eventlet = "^0" typer = "^0" +znjson = "^0.2.3" [tool.poetry.group.dev.dependencies] ruff = "^0.4" pytest = "^8.2" coverage = "^7.5.1" redis = "^5" +numpy = "^1" [build-system] requires = ["poetry-core"] diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..aa7f8a5 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,16 @@ +import pytest + +from znsocket import Client, exceptions + + +def test_client_from_url(eventlet_memory_server): + r = Client.from_url(eventlet_memory_server) + r.set("name", "Alice") + assert r.get("name") == "Alice" + + +def test_client_connection_error(): + with pytest.raises( + exceptions.ConnectionError, match="Could not connect to http://127.0.0.1:5000" + ): + Client.from_url("znsocket://127.0.0.1:5000") diff --git a/tests/test_list.py b/tests/test_list.py index 409c539..793171e 100644 --- a/tests/test_list.py +++ b/tests/test_list.py @@ -1,3 +1,5 @@ +import numpy as np +import numpy.testing as npt import pytest import znsocket @@ -21,6 +23,13 @@ def test_list_extend(client, request): assert lst == ["1", "2", "3", "4"] assert lst[:] == ["1", "2", "3", "4"] + lst.clear() + lst.extend([1, 2, 3, 4]) + assert lst == [1, 2, 3, 4] + + lst.extend([5, 6.28, "7"]) + assert lst == [1, 2, 3, 4, 5, 6.28, "7"] + @pytest.mark.parametrize("client", ["znsclient", "redisclient", "empty"]) def test_list_setitem(client, request): @@ -125,6 +134,11 @@ def test_list_iter(client, request): lst = [] lst.extend(["1", "2", "3", "4"]) + assert lst[0] == "1" + assert lst[1] == "2" + assert lst[2] == "3" + assert lst[3] == "4" + for a, b in zip(lst, ["1", "2", "3", "4"]): assert a == b @@ -182,3 +196,21 @@ def test_list_getitem(client, request): with pytest.raises(IndexError): lst[10] + + +@pytest.mark.parametrize("client", ["znsclient", "redisclient", "empty"]) +def test_list_numpy(client, request): + """Test ZnSocket with numpy arrays through znjson.""" + c = request.getfixturevalue(client) + if c is not None: + lst = znsocket.List(r=c, key="list:test") + else: + lst = [] + + lst.extend([np.array([1, 2, 3]), np.array([4, 5, 6])]) + npt.assert_array_equal(lst[0], np.array([1, 2, 3])) + npt.assert_array_equal(lst[1], np.array([4, 5, 6])) + + lst[1] = np.array([7, 8, 9]) + npt.assert_array_equal(lst[0], np.array([1, 2, 3])) + npt.assert_array_equal(lst[1], np.array([7, 8, 9])) diff --git a/znsocket/client.py b/znsocket/client.py index 3907f5c..fbd597b 100644 --- a/znsocket/client.py +++ b/znsocket/client.py @@ -1,17 +1,20 @@ import dataclasses -import socketio +import socketio.exceptions from znsocket import exceptions -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class Client: address: str - sio: socketio.SimpleClient = dataclasses.field(default=None, repr=False, init=False) + decode_responses: bool = True + sio: socketio.SimpleClient = dataclasses.field( + default_factory=socketio.SimpleClient, repr=False, init=False + ) @classmethod - def from_url(cls, url): + def from_url(cls, url, **kwargs) -> "Client": """Connect to a znsocket server using a URL. Parameters @@ -20,11 +23,16 @@ def from_url(cls, url): The URL of the znsocket server. Should be in the format "znsocket://127.0.0.1:5000". """ - return cls(address=url.replace("znsocket://", "http://")) + return cls(address=url.replace("znsocket://", "http://"), **kwargs) def __post_init__(self): - self.sio = socketio.SimpleClient() - self.sio.connect(self.address) + try: + self.sio.connect(self.address) + except socketio.exceptions.ConnectionError as err: + raise exceptions.ConnectionError(self.address) from err + + if not self.decode_responses: + raise NotImplementedError("decode_responses=False is not supported yet") def delete(self, name): return self.sio.call("delete", {"name": name}) diff --git a/znsocket/exceptions.py b/znsocket/exceptions.py index b39e441..fd0095f 100644 --- a/znsocket/exceptions.py +++ b/znsocket/exceptions.py @@ -4,3 +4,14 @@ def __init__(self, response: str): def __str__(self): return self.response + + +class ConnectionError(Exception): + def __init__(self, address: str): + self.address = address + + def __str__(self): + response = f"Could not connect to {self.address}. " + response += "Is the 'znsocket' server running? " + response += "You can start it using the CLI 'znsocket'." + return response diff --git a/znsocket/utils.py b/znsocket/utils.py index 56a75cc..dcde3be 100644 --- a/znsocket/utils.py +++ b/znsocket/utils.py @@ -1,6 +1,9 @@ +import json import typing as t from collections.abc import MutableSequence +import znjson + from .client import Client @@ -38,7 +41,11 @@ def __getitem__(self, index: int | list | slice): items = [] for i in index: - item = self.redis.lindex(self.key, i) + value = self.redis.lindex(self.key, i) + try: + item = znjson.loads(value) + except TypeError: + item = value if item is None: raise IndexError("list index out of range") items.append(item) @@ -48,7 +55,6 @@ def __setitem__(self, index: int | list | slice, value: str | list[str]): single_item = isinstance(index, int) if single_item: index = [index] - assert isinstance(value, str), "single index requires single value" value = [value] if isinstance(index, slice): @@ -64,7 +70,7 @@ def __setitem__(self, index: int | list | slice, value: str | list[str]): for i, v in zip(index, value): if i >= self.__len__() or i < -self.__len__(): raise IndexError("list index out of range") - self.redis.lset(self.key, i, v) + self.redis.lset(self.key, i, znjson.dumps(v)) def __delitem__(self, index: int | list | slice): single_item = isinstance(index, int) @@ -79,15 +85,12 @@ def __delitem__(self, index: int | list | slice): def insert(self, index, value): if index >= self.__len__(): - self.redis.rpush(self.key, value) + self.redis.rpush(self.key, znjson.dumps(value)) elif index == 0: - self.redis.lpush(self.key, value) + self.redis.lpush(self.key, znjson.dumps(value)) else: pivot = self.redis.lindex(self.key, index) - self.redis.linsert(self.key, "BEFORE", pivot, value) - - def __iter__(self): - return (item for item in self.redis.lrange(self.key, 0, -1)) + self.redis.linsert(self.key, "BEFORE", pivot, znjson.dumps(value)) def __eq__(self, value: object) -> bool: if isinstance(value, List): @@ -97,4 +100,7 @@ def __eq__(self, value: object) -> bool: return False def __repr__(self): - return f"List({self.redis.lrange(self.key, 0, -1)})" + data = self.redis.lrange(self.key, 0, -1) + data = [znjson.loads(i) for i in data] + + return f"List({data})"