From c207c0b73fd6797a25edfeca8b779d529273670e Mon Sep 17 00:00:00 2001 From: Stephan Schultchen Date: Fri, 26 Jun 2026 12:19:41 +0200 Subject: [PATCH] Migrate to pyproject.toml, enable Geo commands, and clean up code --- .github/workflows/tests.yml | 25 ++++++++ pyproject.toml | 29 +++++++++ pyredis/client.py | 6 ++ pyredis/commands.py | 2 + pyredis/connection.py | 21 ++++--- pyredis/helper.py | 5 +- pyredis/pool.py | 84 +++++--------------------- pyredis/protocol.py | 15 +++-- requirements.txt | 1 + setup.cfg | 2 - setup.py | 34 ----------- tests/unit/test_client.py | 56 +++++++++++++++++ tests/unit/test_connection.py | 109 ++++++++++++---------------------- 13 files changed, 195 insertions(+), 194 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 pyproject.toml create mode 100644 requirements.txt delete mode 100644 setup.cfg delete mode 100644 setup.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..02aff42 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,25 @@ +name: Tests + +on: + pull_request: + branches: + - master + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Run tests + run: python -m unittest diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..cc963b7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["hatchling", "hatch-requirements-txt"] +build-backend = "hatchling.build" + +[project] +name = "python_redis" +version = "0.4.1" +requires-python = ">=3.9" +authors = [ + {name = "Stephan Schultchen", email = "stephan.schultchen@gmail.com"}, +] +description = "Redis Client" +dynamic = ["dependencies"] +license = {file = "LICENSE.txt"} +keywords = ["redis"] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License" +] + +[tool.hatch.build.targets.wheel] +packages = ["pyredis"] + +[tool.hatch.metadata.hooks.requirements_txt] +files = ["requirements.txt"] + +[project.urls] +Repository = "https://github.com/schlitzered/pyredis" +Issues = "https://github.com/schlitzered/pyredis/issues" diff --git a/pyredis/client.py b/pyredis/client.py index 3c72d3a..964c7fe 100644 --- a/pyredis/client.py +++ b/pyredis/client.py @@ -13,6 +13,7 @@ class Client( commands.Connection, + commands.Geo, commands.Hash, commands.HyperLogLog, commands.Key, @@ -28,6 +29,7 @@ class Client( Inherits the following Command classes: - commands.Connection, + - commands.Geo, - commands.Hash, - commands.HyperLogLog, - commands.Key, @@ -157,6 +159,7 @@ def execute(self, *args): class ClusterClient( commands.Connection, + commands.Geo, commands.Hash, commands.HyperLogLog, commands.Key, @@ -171,6 +174,7 @@ class ClusterClient( Inherits the following Commmand classes: - commands.Connection, + - commands.Geo, - commands.Hash, - commands.HyperLogLog, - commands.Key, @@ -346,6 +350,7 @@ def execute(self, *args, shard_key=None, sock=None, asking=False, retries=3): class HashClient( commands.Connection, + commands.Geo, commands.Hash, commands.HyperLogLog, commands.Key, @@ -375,6 +380,7 @@ class HashClient( Inherits the following Commmand classes: - commands.Connection, + - commands.Geo, - commands.Hash, - commands.HyperLogLog, - commands.Key, diff --git a/pyredis/commands.py b/pyredis/commands.py index 1c64e73..64b5a23 100644 --- a/pyredis/commands.py +++ b/pyredis/commands.py @@ -2,6 +2,7 @@ __all__ = [ "Connection", + "Geo", "Hash", "HyperLogLog", "Key", @@ -16,6 +17,7 @@ ] + class BaseCommand(object): def __init__(self): self._cluster = False diff --git a/pyredis/connection.py b/pyredis/connection.py index 3c57b07..0117d18 100644 --- a/pyredis/connection.py +++ b/pyredis/connection.py @@ -129,16 +129,10 @@ def _connect(self): def _connect_inet46(self): try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(self._conn_timeout) - sock.connect((self.host, self.port)) - except socket.gaierror: - try: - sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) - sock.settimeout(self._conn_timeout) - sock.connect((self.host, self.port)) - except socket.gaierror: - raise PyRedisConnError("Host is neither a IPv4 or IPv6 address") + sock = socket.create_connection( + address=(self.host, self.port), + timeout=self._conn_timeout + ) except ( ConnectionAbortedError, ConnectionRefusedError, @@ -148,10 +142,15 @@ def _connect_inet46(self): ) as err: self.close() raise PyRedisConnError( - "Could not Connect to {0}:{1}: {2}".format(self.host, self.port, err) + "Could not Connect to {0}:{1}: {2}".format( + self.host, + self.port, + err + ) ) return sock + def _connect_unix(self): try: sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) diff --git a/pyredis/helper.py b/pyredis/helper.py index c000eb2..dd1e170 100644 --- a/pyredis/helper.py +++ b/pyredis/helper.py @@ -1,4 +1,4 @@ -import crc +import binascii import random from collections import deque from threading import Lock @@ -9,7 +9,6 @@ from pyredis.protocol import to_bytes -crc16 = crc.Calculator(crc.Crc16.CCITT) def dict_from_list(source): @@ -37,7 +36,7 @@ def tag_from_key(key): def slot_from_key(key): - return crc16.checksum(tag_from_key(key)) % 16384 + return binascii.crc_hqx(tag_from_key(key), 0) % 16384 class ClusterMap(object): diff --git a/pyredis/pool.py b/pyredis/pool.py index 8dd153d..dd6799c 100644 --- a/pyredis/pool.py +++ b/pyredis/pool.py @@ -195,10 +195,21 @@ def release(self, conn): finally: self._lock.release() + def execute(self, *args, **kwargs): + conn = self.acquire() + try: + return conn.execute( + *args, + **kwargs + ) + finally: + self.release(conn) + class ClusterPool( BasePool, commands.Connection, + commands.Geo, commands.Hash, commands.HyperLogLog, commands.Key, @@ -252,24 +263,11 @@ def _connect(self): username=self.username, ) - def execute(self, *args, **kwargs): - """Execute arbitrary redis command. - - :param args: - :type args: list, int, float - - :return: result, exception - """ - conn = self.acquire() - try: - return conn.execute(*args, **kwargs) - finally: - self.release(conn) - class HashPool( BasePool, commands.Connection, + commands.Geo, commands.Hash, commands.HyperLogLog, commands.Key, @@ -329,24 +327,11 @@ def _connect(self): username=self.username, ) - def execute(self, *args, **kwargs): - """Execute arbitrary redis command. - - :param args: - :type args: list, int, float - - :return: result, exception - """ - conn = self.acquire() - try: - return conn.execute(*args, **kwargs) - finally: - self.release(conn) - class Pool( BasePool, commands.Connection, + commands.Geo, commands.Hash, commands.HyperLogLog, commands.Key, @@ -422,24 +407,11 @@ def _connect(self): username=self.username, ) - def execute(self, *args): - """Execute arbitrary redis command. - - :param args: - :type args: list, int, float - - :return: result, exception - """ - conn = self.acquire() - try: - return conn.execute(*args) - finally: - self.release(conn) - class SentinelHashPool( BasePool, commands.Connection, + commands.Geo, commands.Hash, commands.HyperLogLog, commands.Key, @@ -599,24 +571,11 @@ def _get_slaves(self): ) return self._get_hash_client(buckets=buckets) - def execute(self, *args, **kwargs): - """Execute arbitrary redis command. - - :param args: - :type args: list, int, float - - :return: result, exception - """ - conn = self.acquire() - try: - return conn.execute(*args, **kwargs) - finally: - self.release(conn) - class SentinelPool( BasePool, commands.Connection, + commands.Geo, commands.Hash, commands.HyperLogLog, commands.Key, @@ -748,16 +707,3 @@ def _get_slave(self): client = self._get_client(host, port) return client - def execute(self, *args): - """Execute arbitrary redis command. - - :param args: - :type args: list, int, float - - :return: result, exception - """ - conn = self.acquire() - try: - return conn.execute(*args) - finally: - self.release(conn) diff --git a/pyredis/protocol.py b/pyredis/protocol.py index e4759ff..0f9a067 100644 --- a/pyredis/protocol.py +++ b/pyredis/protocol.py @@ -18,13 +18,20 @@ def is_exception(inst, classinfo): try: if issubclass(inst, classinfo): return True - else: - raise TypeError() except TypeError: + pass + try: if isinstance(inst("test"), classinfo): return True - else: - raise TypeError("{0} is not a subclass of {1}".format(inst, classinfo)) + except TypeError: + pass + raise TypeError( + "{0} is not a subclass of {1}".format( + inst, + classinfo + ) + ) + class ReplyParser(object): diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ + diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 3480374..0000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[bdist_wheel] -universal=1 \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 741a951..0000000 --- a/setup.py +++ /dev/null @@ -1,34 +0,0 @@ -from setuptools import setup - -setup( - name='python_redis', - version='0.4.0', - description='Redis Client', - long_description=""" -Redis Client implementation for Python 3. - -Copyright (c) 2021, Stephan Schultchen. - -License: MIT (see LICENSE for details) - """, - packages=['pyredis'], - url='https://github.com/schlitzered/pyredis', - license='MIT', - author='schlitzer', - author_email='stephan.schultchen@gmail.com', - test_suite='test', - platforms='posix', - classifiers=[ - 'License :: OSI Approved :: MIT License', - 'Programming Language :: Python :: 3' - ], - setup_requires=[ - 'crc' - ], - install_requires=[ - 'crc' - ], - keywords=[ - 'redis' - ] -) diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 0816ae6..1f038e4 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -169,6 +169,39 @@ def test_execute_bulk(self): client.execute(b'PING') client._execute_bulk.assert_called_with(b'PING') + def test_geo_commands(self): + client = pyredis.client.Client(host='127.0.0.1') + client.execute = Mock(return_value=b'OK') + args_geoadd = ( + 'sicily', + 13.361389, + 38.115556, + 'Palermo', + ) + result_geoadd = client.geoadd(*args_geoadd) + self.assertEqual( + first=client.execute.call_args[0], + second=(b'GEOADD',) + args_geoadd + ) + self.assertEqual( + first=result_geoadd, + second=b'OK' + ) + args_geodist = ( + 'sicily', + 'Palermo', + 'Catania', + ) + result_geodist = client.geodist(*args_geodist) + self.assertEqual( + first=client.execute.call_args[0], + second=(b'GEODIST',) + args_geodist + ) + self.assertEqual( + first=result_geodist, + second=b'OK' + ) + class TestHashClientUnit(TestCase): def setUp(self): @@ -905,3 +938,26 @@ def test_execute_PyRedisConnReadTimeout(self): self.assertTrue(conn1.close.called) self.assertNotIn(conn1, self.client._conns) self.clustermap_inst.update.assert_called_with(self.client._map_id) + + def test_geo_commands(self): + self.client.execute = Mock(return_value=b'OK') + args_geoadd = ( + 'sicily', + 13.361389, + 38.115556, + 'Palermo', + ) + result_geoadd = self.client.geoadd(*args_geoadd) + self.assertEqual( + first=self.client.execute.call_args[0], + second=(b'GEOADD',) + args_geoadd + ) + self.assertEqual( + first=self.client.execute.call_args[1], + second={'shard_key': 'sicily'} + ) + self.assertEqual( + first=result_geoadd, + second=b'OK' + ) + diff --git a/tests/unit/test_connection.py b/tests/unit/test_connection.py index 7e2c08f..d7e0cf4 100644 --- a/tests/unit/test_connection.py +++ b/tests/unit/test_connection.py @@ -15,6 +15,9 @@ def setUp(self): socket_patcher = patch('pyredis.connection.socket', autospec=True) self.socket_mock = socket_patcher.start() self.socket_mock.socket.return_value = Mock() + self.socket_mock.timeout = socket.timeout + self.socket_mock.gaierror = socket.gaierror + self.socket_mock.create_connection.side_effect = lambda *args, **kwargs: self.socket_mock.socket.return_value reader_patcher = patch('pyredis.connection.Reader', autospec=True) self.reader_mock = reader_patcher.start() @@ -94,93 +97,57 @@ def test__authenticate_exception(self): self.assertRaises(ReplyError, connection._authenticate) connection.write.assert_called_with('AUTH', 'testpass') - def test__connect_inet46_ipv4(self): + def test__connect_inet46_ok(self): connection = pyredis.connection.Connection(host='127.0.0.1') sock = connection._connect_inet46() - self.socket_mock.socket.assert_called_with( - self.socket_mock.AF_INET, - self.socket_mock.SOCK_STREAM + self.socket_mock.create_connection.assert_called_with( + address=('127.0.0.1', 6379), + timeout=2 + ) + self.assertEqual( + first=sock, + second=self.socket_mock.socket.return_value ) - sock.settimeout.assert_called_with(2) - sock.connect.assert_called_with(('127.0.0.1', 6379)) - self.assertEqual(sock, self.socket_mock.socket()) - - def test__connect_inet46_ipv6(self): - sock_mock = Mock() - self.socket_mock.socket.side_effect = [socket.gaierror, sock_mock] - self.socket_mock.gaierror = socket.gaierror - - connection = pyredis.connection.Connection(host='::1') - sock = connection._connect_inet46() - - self.socket_mock.socket.assert_has_calls([ - call( - self.socket_mock.AF_INET, - self.socket_mock.SOCK_STREAM - ), - call( - self.socket_mock.AF_INET6, - self.socket_mock.SOCK_STREAM - ) - ]) - - sock.settimeout.assert_called_with(2) - sock.connect.assert_called_with(('::1', 6379)) - self.assertEqual(sock, sock_mock) - - def test__connect_inet46_no_ipv4_or_ipv6(self): - self.socket_mock.socket.side_effect = [socket.gaierror, socket.gaierror] - self.socket_mock.gaierror = socket.gaierror - connection = pyredis.connection.Connection(host='blarg') - self.assertRaises(PyRedisConnError, connection._connect_inet46) + def test__connect_inet46_OSError(self): + self.socket_mock.create_connection.side_effect = OSError + connection = pyredis.connection.Connection(host='127.0.0.1') + self.assertRaises( + PyRedisConnError, + connection._connect_inet46 + ) def test__connect_inet46_socket_timeout(self): - sock_mock = Mock() - sock_mock.connect.side_effect = socket.timeout - - self.socket_mock.socket.return_value = sock_mock - self.socket_mock.gaierror = socket.gaierror - self.socket_mock.timeout = socket.timeout - + self.socket_mock.create_connection.side_effect = socket.timeout connection = pyredis.connection.Connection(host='127.0.0.1') - self.assertRaises(PyRedisConnError, connection._connect_inet46) + self.assertRaises( + PyRedisConnError, + connection._connect_inet46 + ) def test__connect_inet46_OverflowError(self): - sock_mock = Mock() - sock_mock.connect.side_effect = OverflowError - - self.socket_mock.gaierror = socket.gaierror - self.socket_mock.timeout = socket.timeout - - self.socket_mock.socket.return_value = sock_mock - + self.socket_mock.create_connection.side_effect = OverflowError connection = pyredis.connection.Connection(host='127.0.0.1') - self.assertRaises(PyRedisConnError, connection._connect_inet46) + self.assertRaises( + PyRedisConnError, + connection._connect_inet46 + ) def test__connect_inet46_ConnectionRefusedError(self): - sock_mock = Mock() - sock_mock.connect.side_effect = ConnectionRefusedError - - self.socket_mock.gaierror = socket.gaierror - self.socket_mock.timeout = socket.timeout - - self.socket_mock.socket.return_value = sock_mock - + self.socket_mock.create_connection.side_effect = ConnectionRefusedError connection = pyredis.connection.Connection(host='127.0.0.1') - self.assertRaises(PyRedisConnError, connection._connect_inet46) + self.assertRaises( + PyRedisConnError, + connection._connect_inet46 + ) def test__connect_inet46_ConnectionAbortedError(self): - sock_mock = Mock() - sock_mock.connect.side_effect = ConnectionAbortedError - - self.socket_mock.gaierror = socket.gaierror - self.socket_mock.timeout = socket.timeout - - self.socket_mock.socket.return_value = sock_mock - + self.socket_mock.create_connection.side_effect = ConnectionAbortedError connection = pyredis.connection.Connection(host='127.0.0.1') - self.assertRaises(PyRedisConnError, connection._connect_inet46) + self.assertRaises( + PyRedisConnError, + connection._connect_inet46 + ) def test__connect_unix(self): connection = pyredis.connection.Connection(unix_sock='/tmp/test.sock')